@antelopejs/dms-mailing 0.6.1 → 0.7.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 (236) hide show
  1. package/README.md +8 -7
  2. package/dist/constants.d.ts +1 -2
  3. package/dist/constants.js +2 -3
  4. package/dist/constants.js.map +1 -1
  5. package/dist/data/templates-table.d.ts +4 -1
  6. package/dist/data/templates-table.js +55 -20
  7. package/dist/data/templates-table.js.map +1 -1
  8. package/dist/data-types/categories-type.d.ts +5 -5
  9. package/dist/db/models/index.d.ts +0 -1
  10. package/dist/db/models/index.js +0 -1
  11. package/dist/db/models/index.js.map +1 -1
  12. package/dist/db/models/send-events.model.js +2 -3
  13. package/dist/db/models/send-events.model.js.map +1 -1
  14. package/dist/db/models/sends.model.js +2 -3
  15. package/dist/db/models/sends.model.js.map +1 -1
  16. package/dist/db/models/templates.model.d.ts +10 -1
  17. package/dist/db/models/templates.model.js +29 -10
  18. package/dist/db/models/templates.model.js.map +1 -1
  19. package/dist/db/tables/index.d.ts +0 -1
  20. package/dist/db/tables/index.js +0 -1
  21. package/dist/db/tables/index.js.map +1 -1
  22. package/dist/db/tables/settings.table.d.ts +0 -1
  23. package/dist/db/tables/settings.table.js +0 -4
  24. package/dist/db/tables/settings.table.js.map +1 -1
  25. package/dist/db/tables/templates.table.d.ts +7 -4
  26. package/dist/db/tables/templates.table.js +4 -14
  27. package/dist/db/tables/templates.table.js.map +1 -1
  28. package/dist/engine/formats.d.ts +13 -0
  29. package/dist/engine/formats.js +85 -0
  30. package/dist/engine/formats.js.map +1 -0
  31. package/dist/engine/index.d.ts +1 -0
  32. package/dist/engine/index.js +1 -0
  33. package/dist/engine/index.js.map +1 -1
  34. package/dist/engine/resolve.d.ts +2 -1
  35. package/dist/engine/resolve.js +14 -7
  36. package/dist/engine/resolve.js.map +1 -1
  37. package/dist/engine/tokens.d.ts +2 -1
  38. package/dist/engine/tokens.js +3 -5
  39. package/dist/engine/tokens.js.map +1 -1
  40. package/dist/frontend-module.d.ts +2 -0
  41. package/dist/frontend-module.js +24 -0
  42. package/dist/frontend-module.js.map +1 -0
  43. package/dist/hooks/index.d.ts +0 -1
  44. package/dist/hooks/index.js +0 -1
  45. package/dist/hooks/index.js.map +1 -1
  46. package/dist/hooks/tenant-export.js +4 -1
  47. package/dist/hooks/tenant-export.js.map +1 -1
  48. package/dist/implementations/dms-mailing/index.d.ts +3 -3
  49. package/dist/implementations/dms-mailing/index.js +8 -7
  50. package/dist/implementations/dms-mailing/index.js.map +1 -1
  51. package/dist/index.js +2 -20
  52. package/dist/index.js.map +1 -1
  53. package/dist/pages/overview.d.ts +1 -1
  54. package/dist/pages/overview.js +11 -13
  55. package/dist/pages/overview.js.map +1 -1
  56. package/dist/pages/sends.d.ts +1 -1
  57. package/dist/pages/sends.js +16 -8
  58. package/dist/pages/sends.js.map +1 -1
  59. package/dist/pages/settings/form.js +8 -0
  60. package/dist/pages/settings/form.js.map +1 -1
  61. package/dist/pages/templates.js +3 -8
  62. package/dist/pages/templates.js.map +1 -1
  63. package/dist/pages/toolbar.d.ts +12 -0
  64. package/dist/pages/toolbar.js +41 -0
  65. package/dist/pages/toolbar.js.map +1 -0
  66. package/dist/realtime.d.ts +2 -2
  67. package/dist/realtime.js +8 -8
  68. package/dist/realtime.js.map +1 -1
  69. package/dist/routes/metrics.d.ts +9 -2
  70. package/dist/routes/metrics.js +90 -35
  71. package/dist/routes/metrics.js.map +1 -1
  72. package/dist/routes/period.d.ts +1 -0
  73. package/dist/routes/period.js +19 -0
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +6 -3
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.js +6 -7
  78. package/dist/routes/settings.js.map +1 -1
  79. package/dist/routes/templates.d.ts +8 -4
  80. package/dist/routes/templates.js +41 -20
  81. package/dist/routes/templates.js.map +1 -1
  82. package/dist/services/categories.d.ts +2 -1
  83. package/dist/services/categories.js +9 -0
  84. package/dist/services/categories.js.map +1 -1
  85. package/dist/services/declarations.js +42 -184
  86. package/dist/services/declarations.js.map +1 -1
  87. package/dist/services/events.js +1 -1
  88. package/dist/services/events.js.map +1 -1
  89. package/dist/services/localized-text.d.ts +7 -0
  90. package/dist/services/localized-text.js +28 -0
  91. package/dist/services/localized-text.js.map +1 -0
  92. package/dist/services/send-import.js +0 -2
  93. package/dist/services/send-import.js.map +1 -1
  94. package/dist/services/send-stat-items.js +34 -12
  95. package/dist/services/send-stat-items.js.map +1 -1
  96. package/dist/services/send-stats.d.ts +7 -0
  97. package/dist/services/send-stats.js +17 -5
  98. package/dist/services/send-stats.js.map +1 -1
  99. package/dist/services/send.d.ts +2 -0
  100. package/dist/services/send.js +11 -12
  101. package/dist/services/send.js.map +1 -1
  102. package/dist/services/sender-declaration.d.ts +2 -0
  103. package/dist/services/sender-declaration.js +18 -0
  104. package/dist/services/sender-declaration.js.map +1 -0
  105. package/dist/services/settings.d.ts +1 -1
  106. package/dist/services/settings.js +1 -5
  107. package/dist/services/settings.js.map +1 -1
  108. package/dist/services/starters.d.ts +2 -2
  109. package/dist/services/starters.js +1 -10
  110. package/dist/services/starters.js.map +1 -1
  111. package/dist/services/templates.d.ts +13 -7
  112. package/dist/services/templates.js +72 -33
  113. package/dist/services/templates.js.map +1 -1
  114. package/dist/services/webhook-status.d.ts +2 -1
  115. package/dist/services/webhook-status.js +6 -5
  116. package/dist/services/webhook-status.js.map +1 -1
  117. package/dist/types/settings.d.ts +8 -0
  118. package/dist/types/settings.js +33 -6
  119. package/dist/types/settings.js.map +1 -1
  120. package/dist/validation/declarations.schema.d.ts +25 -7
  121. package/dist/validation/declarations.schema.js +16 -3
  122. package/dist/validation/declarations.schema.js.map +1 -1
  123. package/dist/validation/settings.schema.d.ts +41 -41
  124. package/dist/validation/settings.schema.js +30 -6
  125. package/dist/validation/settings.schema.js.map +1 -1
  126. package/dist/validation/templates.schema.d.ts +13 -0
  127. package/dist/validation/templates.schema.js +6 -1
  128. package/dist/validation/templates.schema.js.map +1 -1
  129. package/frontend-vue/app/components/CategoriesInput.vue +15 -13
  130. package/frontend-vue/app/components/FunnelCard.vue +46 -3
  131. package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
  132. package/frontend-vue/app/components/OverviewGate.vue +0 -8
  133. package/frontend-vue/app/components/ProviderPill.vue +50 -0
  134. package/frontend-vue/app/components/SendDrawer.vue +206 -183
  135. package/frontend-vue/app/components/StarterGallery.vue +1 -1
  136. package/frontend-vue/app/components/TemplateCard.vue +4 -1
  137. package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
  138. package/frontend-vue/app/components/TemplateGallery.vue +57 -17
  139. package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
  140. package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
  141. package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
  142. package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
  143. package/frontend-vue/app/composables/useMailingApi.ts +3 -3
  144. package/frontend-vue/app/composables/useMailingReads.ts +2 -2
  145. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
  146. package/frontend-vue/app/types/mailing.ts +18 -0
  147. package/frontend-vue/app/utils/categories.ts +58 -49
  148. package/frontend-vue/app/utils/funnel.ts +25 -0
  149. package/frontend-vue/app/utils/period.ts +0 -7
  150. package/frontend-vue/app/utils/starters.ts +3 -1
  151. package/frontend-vue/app/utils/tokens.ts +6 -1
  152. package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
  153. package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
  154. package/frontend-vue/package.json +1 -1
  155. package/frontend-vue/tests/categories-input.test.ts +4 -10
  156. package/frontend-vue/tests/categories.test.ts +55 -17
  157. package/frontend-vue/tests/conditions.test.ts +1 -4
  158. package/frontend-vue/tests/funnel-card.test.ts +27 -0
  159. package/frontend-vue/tests/funnel.test.ts +25 -0
  160. package/frontend-vue/tests/localized-defaults.test.ts +0 -29
  161. package/frontend-vue/tests/new-template-modal.test.ts +1 -1
  162. package/frontend-vue/tests/overview-gate.test.ts +1 -24
  163. package/frontend-vue/tests/page-reads.test.ts +9 -25
  164. package/frontend-vue/tests/period.test.ts +0 -12
  165. package/frontend-vue/tests/replay-toast.test.ts +2 -1
  166. package/frontend-vue/tests/send-drawer.test.ts +123 -0
  167. package/frontend-vue/tests/template-drawer.test.ts +1 -1
  168. package/frontend-vue/tests/template-gallery.test.ts +90 -3
  169. package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
  170. package/package.json +8 -7
  171. package/skills/dms-mailing/SKILL.md +25 -18
  172. package/src/constants.ts +2 -2
  173. package/src/data/sends-table.ts +1 -1
  174. package/src/data/templates-table.ts +125 -33
  175. package/src/db/models/index.ts +0 -1
  176. package/src/db/models/send-events.model.ts +2 -2
  177. package/src/db/models/sends.model.ts +7 -8
  178. package/src/db/models/templates.model.ts +64 -16
  179. package/src/db/tables/index.ts +0 -1
  180. package/src/db/tables/settings.table.ts +0 -8
  181. package/src/db/tables/templates.table.ts +12 -17
  182. package/src/engine/formats.ts +124 -0
  183. package/src/engine/index.ts +1 -0
  184. package/src/engine/resolve.ts +18 -7
  185. package/src/engine/tokens.ts +7 -5
  186. package/src/frontend-module.ts +36 -0
  187. package/src/hooks/index.ts +0 -1
  188. package/src/hooks/tenant-export.ts +8 -1
  189. package/src/implementations/dms-mailing/index.ts +5 -16
  190. package/src/index.ts +3 -29
  191. package/src/pages/overview.ts +20 -16
  192. package/src/pages/sends.ts +27 -11
  193. package/src/pages/settings/form.ts +16 -0
  194. package/src/pages/templates.ts +4 -8
  195. package/src/pages/toolbar.ts +77 -0
  196. package/src/realtime.ts +17 -8
  197. package/src/routes/metrics.ts +158 -45
  198. package/src/routes/period.ts +32 -0
  199. package/src/routes/sends.ts +13 -4
  200. package/src/routes/settings.ts +11 -8
  201. package/src/routes/templates.ts +81 -37
  202. package/src/services/categories.ts +14 -1
  203. package/src/services/declarations.ts +64 -317
  204. package/src/services/events.ts +1 -1
  205. package/src/services/localized-text.ts +53 -0
  206. package/src/services/send-import.ts +2 -2
  207. package/src/services/send-stat-items.ts +60 -13
  208. package/src/services/send-stats.ts +37 -5
  209. package/src/services/send.ts +19 -20
  210. package/src/services/sender-declaration.ts +27 -0
  211. package/src/services/settings.ts +3 -10
  212. package/src/services/starters.ts +3 -15
  213. package/src/services/templates.ts +109 -63
  214. package/src/services/webhook-status.ts +3 -3
  215. package/src/types/settings.ts +55 -5
  216. package/src/validation/declarations.schema.ts +29 -3
  217. package/src/validation/settings.schema.ts +56 -14
  218. package/src/validation/templates.schema.ts +10 -0
  219. package/dist/db/models/template-slugs.model.d.ts +0 -27
  220. package/dist/db/models/template-slugs.model.js +0 -43
  221. package/dist/db/models/template-slugs.model.js.map +0 -1
  222. package/dist/db/tables/template-slugs.table.d.ts +0 -7
  223. package/dist/db/tables/template-slugs.table.js +0 -35
  224. package/dist/db/tables/template-slugs.table.js.map +0 -1
  225. package/dist/hooks/database-ready.d.ts +0 -7
  226. package/dist/hooks/database-ready.js +0 -26
  227. package/dist/hooks/database-ready.js.map +0 -1
  228. package/dist/services/fingerprint.d.ts +0 -1
  229. package/dist/services/fingerprint.js +0 -20
  230. package/dist/services/fingerprint.js.map +0 -1
  231. package/frontend-vue/app/components/PageHeader.vue +0 -94
  232. package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
  233. package/src/db/models/template-slugs.model.ts +0 -61
  234. package/src/db/tables/template-slugs.table.ts +0 -29
  235. package/src/hooks/database-ready.ts +0 -47
  236. package/src/services/fingerprint.ts +0 -22
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { HTTPResult } from "@antelopejs/interface-api";
3
+ import { IsDuplicateKeyError } from "@antelopejs/interface-database";
3
4
  import { GetModel } from "@antelopejs/interface-database-decorators";
4
5
  import {
5
- DEFAULT_FALLBACK_LOCALE,
6
6
  HTTP_CONFLICT,
7
7
  HTTP_NOT_FOUND,
8
8
  LOCALE_LIST_SEPARATOR,
@@ -10,10 +10,9 @@ import {
10
10
  import {
11
11
  type MailingTemplate,
12
12
  type MailingTemplateVersion,
13
+ type TemplateFields,
13
14
  TemplateModel,
14
- TemplateSlugModel,
15
15
  TemplateVersionModel,
16
- templateSlugIdOf,
17
16
  } from "../db";
18
17
  import { diffContents, diffVariables, type TemplateChange } from "../engine";
19
18
  import {
@@ -27,6 +26,7 @@ import type {
27
26
  TemplateContent,
28
27
  VariableDefinition,
29
28
  } from "../types";
29
+ import { DEFAULT_FALLBACK_LOCALE } from "../types";
30
30
 
31
31
  export interface TemplateAuthor {
32
32
  name?: string;
@@ -44,6 +44,7 @@ const STALE_DRAFT: TemplateAttentionReason = "stale_draft";
44
44
  const VERSION_NOT_FOUND = "$dms_mailing.errors.version_not_found";
45
45
  const TEMPLATE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
46
46
  const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
47
+ const SLUG_FIELD = "slug";
47
48
 
48
49
  /** The template `id` names; 404 when there is none. */
49
50
  export async function requireTemplate(
@@ -56,49 +57,18 @@ export async function requireTemplate(
56
57
  }
57
58
 
58
59
  /**
59
- * Gives `slug` to the template just inserted; false when another template
60
- * holds it. A claim whose template is gone (deleted before its claim was
61
- * freed) is taken over, through the same insert that settles a race.
60
+ * Runs a write that gives a template its slug, refused with a 409 when
61
+ * another template of the tenant holds it: the slug's unique index settles
62
+ * concurrent creations, so nothing reads the table before writing.
62
63
  */
63
- async function claimTemplateSlug(
64
- tenantId: string,
65
- slug: string,
66
- templateId: string,
67
- ): Promise<boolean> {
68
- const claims = GetModel(TemplateSlugModel, tenantId);
69
- const id = templateSlugIdOf(tenantId, slug);
70
- if (await claims.insertClaim(id, slug, templateId)) return true;
71
- const holder = await claims.get(id);
72
- if (holder?.templateId === templateId) return true;
73
- if (holder) {
74
- const templates = GetModel(TemplateModel, tenantId);
75
- if (await templates.get(holder.templateId)) return false;
76
- await claims.dropClaimOf(id, holder.templateId);
64
+ export async function refuseTakenSlug<T>(write: Promise<T>): Promise<T> {
65
+ try {
66
+ return await write;
67
+ } catch (error) {
68
+ if (IsDuplicateKeyError(error) && error.fields.includes(SLUG_FIELD))
69
+ throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
70
+ throw error;
77
71
  }
78
- return claims.insertClaim(id, slug, templateId);
79
- }
80
-
81
- /**
82
- * Keeps the template just inserted if it got its slug, or removes it again
83
- * and refuses it like any taken slug: two creations racing past the free-slug
84
- * check both insert, and only the claim's primary key tells them apart.
85
- */
86
- export async function assertSlugClaimed(
87
- tenantId: string,
88
- slug: string,
89
- templateId: string,
90
- ): Promise<void> {
91
- if (await claimTemplateSlug(tenantId, slug, templateId)) return;
92
- await GetModel(TemplateModel, tenantId).delete(templateId);
93
- throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
94
- }
95
-
96
- /** Frees the slugs of deleted templates for new ones. */
97
- export async function releaseTemplateSlugs(
98
- tenantId: string,
99
- templateIds: string[],
100
- ): Promise<void> {
101
- await GetModel(TemplateSlugModel, tenantId).releaseTemplates(templateIds);
102
72
  }
103
73
 
104
74
  /** The content customers receive: the last published version. */
@@ -153,6 +123,25 @@ export interface TemplateDraft {
153
123
  variables: VariableDefinition[];
154
124
  }
155
125
 
126
+ /** A snapshot and the version it belongs to; 0 for a draft. */
127
+ export interface VersionedSnapshot extends TemplateDraft {
128
+ version: number;
129
+ }
130
+
131
+ /** What a publish would ship: the draft's content and declarations. */
132
+ export const workingSnapshot = (template: MailingTemplate): TemplateDraft => ({
133
+ content: workingContent(template),
134
+ variables: workingVariables(template),
135
+ });
136
+
137
+ /** What customers receive: the published content and declarations. */
138
+ export const publishedSnapshot = (
139
+ template: MailingTemplate,
140
+ ): TemplateDraft => ({
141
+ content: parseContent(template),
142
+ variables: parseVariables(template),
143
+ });
144
+
156
145
  /**
157
146
  * What `draft` would change for customers and for the callers' contract. A
158
147
  * template never published has nothing to compare against: every locale and
@@ -240,7 +229,7 @@ export function isStaleRevision(
240
229
  function draftWriteFields(
241
230
  template: MailingTemplate,
242
231
  author: string,
243
- ): Partial<MailingTemplate> {
232
+ ): TemplateFields {
244
233
  return {
245
234
  draftRevision: template.draftRevision + 1,
246
235
  draftUpdatedAt: new Date(),
@@ -274,7 +263,7 @@ export async function saveDraft(
274
263
  const locales = isPublished(template) ? {} : { locales: localesOf(content) };
275
264
  const isClean = isPublishedDraft(template, draft);
276
265
  const fields = draftWriteFields(template, author);
277
- await GetModel(TemplateModel, tenantId).update(template._id, {
266
+ await GetModel(TemplateModel, tenantId).updateFields(template._id, {
278
267
  json_draft: isClean ? "" : JSON.stringify(content),
279
268
  json_variables_draft: isClean ? "" : JSON.stringify(variables),
280
269
  isDraftPending: !isClean,
@@ -290,7 +279,7 @@ export async function discardDraft(
290
279
  template: MailingTemplate,
291
280
  author: string,
292
281
  ): Promise<void> {
293
- await GetModel(TemplateModel, tenantId).update(template._id, {
282
+ await GetModel(TemplateModel, tenantId).updateFields(template._id, {
294
283
  json_draft: "",
295
284
  json_variables_draft: "",
296
285
  isDraftPending: false,
@@ -353,7 +342,7 @@ export async function publishSnapshot(
353
342
  template: MailingTemplate,
354
343
  draft: TemplateDraft,
355
344
  author: string,
356
- metadata: Partial<MailingTemplate> = {},
345
+ metadata: TemplateFields = {},
357
346
  ): Promise<PublishOutcome | undefined> {
358
347
  const previous = publishedVersionOf(template);
359
348
  const changes = changesOf(template, draft);
@@ -361,7 +350,7 @@ export async function publishSnapshot(
361
350
  const version = isUnchanged ? previous : previous + 1;
362
351
  const versions = GetModel(TemplateVersionModel, tenantId);
363
352
  await versions.materialize(template);
364
- const shipped: Partial<MailingTemplate> = isUnchanged
353
+ const shipped: TemplateFields = isUnchanged
365
354
  ? {}
366
355
  : {
367
356
  json_content: JSON.stringify(draft.content),
@@ -381,7 +370,7 @@ export async function publishSnapshot(
381
370
  json_changes: JSON.stringify(changes),
382
371
  }),
383
372
  };
384
- const patch: Partial<MailingTemplate> = {
373
+ const patch: TemplateFields = {
385
374
  ...metadata,
386
375
  ...shipped,
387
376
  ...history,
@@ -431,19 +420,34 @@ export async function publishTemplate(
431
420
  return outcome;
432
421
  }
433
422
 
434
- /** The content a send of `version` carried: the draft's for a test of one. */
435
- export async function contentOfVersion(
423
+ /**
424
+ * The content and declarations a send of `version` carried: the draft's for
425
+ * a test of one.
426
+ */
427
+ export async function snapshotOfVersion(
436
428
  tenantId: string,
437
429
  template: MailingTemplate,
438
430
  version: number,
439
- ): Promise<TemplateContent> {
440
- if (!version) return workingContent(template);
431
+ ): Promise<TemplateDraft> {
432
+ if (!version) return workingSnapshot(template);
441
433
  const row = await GetModel(TemplateVersionModel, tenantId).getVersion(
442
434
  template._id,
443
435
  version,
444
436
  );
445
437
  if (!row) throw new HTTPResult(HTTP_NOT_FOUND, VERSION_NOT_FOUND);
446
- return parseVersionContent(row);
438
+ return {
439
+ content: parseVersionContent(row),
440
+ variables: parseVersionVariables(row),
441
+ };
442
+ }
443
+
444
+ /** The content a send of `version` carried: the draft's for a test of one. */
445
+ export async function contentOfVersion(
446
+ tenantId: string,
447
+ template: MailingTemplate,
448
+ version: number,
449
+ ): Promise<TemplateContent> {
450
+ return (await snapshotOfVersion(tenantId, template, version)).content;
447
451
  }
448
452
 
449
453
  export function parseVersionContent(
@@ -457,9 +461,7 @@ export function parseVersionContent(
457
461
  * copy starts as a draft of the source's working content: it was never
458
462
  * published, so customers receive nothing from it yet.
459
463
  */
460
- export function contentFieldsOf(
461
- source: MailingTemplate,
462
- ): Partial<MailingTemplate> {
464
+ export function contentFieldsOf(source: MailingTemplate): TemplateFields {
463
465
  const content = workingContent(source);
464
466
  return {
465
467
  json_content: "",
@@ -483,9 +485,9 @@ export async function initializeTemplate(
483
485
  tenantId: string,
484
486
  templateId: string,
485
487
  author: string,
486
- fields: Partial<MailingTemplate>,
488
+ fields: TemplateFields,
487
489
  ): Promise<void> {
488
- await GetModel(TemplateModel, tenantId).update(templateId, {
490
+ await GetModel(TemplateModel, tenantId).updateFields(templateId, {
489
491
  status: "draft",
490
492
  json_variables: "[]",
491
493
  json_test_data: "{}",
@@ -495,9 +497,53 @@ export async function initializeTemplate(
495
497
  await refreshTemplateAttention(tenantId);
496
498
  }
497
499
 
498
- export function blankFields(
499
- content: TemplateContent,
500
- ): Partial<MailingTemplate> {
500
+ /** The first version a template publishes. */
501
+ const FIRST_VERSION = 1;
502
+
503
+ /**
504
+ * A new template, published at once as version 1 of `draft`: its content
505
+ * and its history entry land in the one write that creates it, so no reader
506
+ * ever sees it unpublished. Materialize its version row afterwards, as a
507
+ * publication does.
508
+ */
509
+ export function publishedAtCreation(
510
+ id: string,
511
+ draft: TemplateDraft,
512
+ author: string,
513
+ ): TemplateFields {
514
+ const shipped: TemplateFields = {
515
+ json_content: JSON.stringify(draft.content),
516
+ json_variables: JSON.stringify(draft.variables),
517
+ locales: localesOf(draft.content),
518
+ publishedAt: new Date(),
519
+ publishedBy: author,
520
+ };
521
+ const changes = [
522
+ ...diffContents({ locales: {} }, draft.content),
523
+ ...diffVariables([], draft.variables),
524
+ ];
525
+ return {
526
+ _id: id,
527
+ ...shipped,
528
+ json_published_version: JSON.stringify({
529
+ ...shipped,
530
+ _id: randomUUID(),
531
+ templateId: id,
532
+ version: FIRST_VERSION,
533
+ json_changes: JSON.stringify(changes),
534
+ }),
535
+ json_draft: "",
536
+ json_variables_draft: "",
537
+ isDraftPending: false,
538
+ draftRevision: FIRST_DRAFT_REVISION,
539
+ status: "live",
540
+ publishedVersion: FIRST_VERSION,
541
+ updatedBy: author,
542
+ ...NO_ATTENTION,
543
+ };
544
+ }
545
+
546
+ export function blankFields(content: TemplateContent): TemplateFields {
501
547
  return {
502
548
  json_content: "",
503
549
  json_draft: JSON.stringify(content),
@@ -8,13 +8,13 @@ import type {
8
8
  WebhookStatus,
9
9
  } from "../types";
10
10
  import { webhookSecretMatches } from "./events";
11
+ import { WEBHOOK_SECRET_HEADER } from "../constants";
12
+
13
+ export { WEBHOOK_SECRET_HEADER };
11
14
 
12
15
  /** The message id of the event the settings' "Send a test event" posts. */
13
16
  export const WEBHOOK_TEST_MESSAGE_ID = "dms-mailing-webhook-test";
14
17
 
15
- /** The header a provider (or its relay) proves it knows the secret with. */
16
- export const WEBHOOK_SECRET_HEADER = "x-mailing-webhook-secret";
17
-
18
18
  const WEBHOOK_TEST_EVENT_TYPE: SendEventType = "delivered";
19
19
  const WEBHOOK_TEST_TIMEOUT_MS = 10_000;
20
20
  const JSON_CONTENT_TYPE = "application/json";
@@ -1,4 +1,14 @@
1
+ import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
2
+
3
+ /** A template category as stored: its label written once per language. */
1
4
  export interface TemplateCategory {
5
+ id: string;
6
+ label: LocalizedText;
7
+ icon: string;
8
+ }
9
+
10
+ /** A template category as one reader sees it: the label in their language. */
11
+ export interface CategoryInLocale {
2
12
  id: string;
3
13
  label: string;
4
14
  icon: string;
@@ -16,9 +26,49 @@ export interface MailingSettingsValues {
16
26
  }
17
27
 
18
28
  export const DEFAULT_CATEGORIES: TemplateCategory[] = [
19
- { id: "orders", label: "Orders", icon: "i-ph-package" },
20
- { id: "account", label: "Account & access", icon: "i-ph-lock" },
21
- { id: "billing", label: "Billing", icon: "i-ph-file-text" },
22
- { id: "marketing", label: "Marketing", icon: "i-ph-sparkle" },
23
- { id: "forms", label: "Forms", icon: "i-ph-textbox" },
29
+ {
30
+ id: "orders",
31
+ label: { en: "Orders", fr: "Commandes" },
32
+ icon: "i-ph-package",
33
+ },
34
+ {
35
+ id: "account",
36
+ label: { en: "Account & access", fr: "Compte et accès" },
37
+ icon: "i-ph-lock",
38
+ },
39
+ {
40
+ id: "billing",
41
+ label: { en: "Billing", fr: "Facturation" },
42
+ icon: "i-ph-file-text",
43
+ },
44
+ {
45
+ id: "marketing",
46
+ label: { en: "Marketing", fr: "Marketing" },
47
+ icon: "i-ph-sparkle",
48
+ },
49
+ {
50
+ id: "forms",
51
+ label: { en: "Forms", fr: "Formulaires" },
52
+ icon: "i-ph-textbox",
53
+ },
24
54
  ];
55
+
56
+ export const DEFAULT_FALLBACK_LOCALE = "en";
57
+
58
+ /**
59
+ * The module defaults: what a tenant's settings start with, and what the
60
+ * settings form offers to put back ("Use default"). The sender is empty and
61
+ * the webhook secret is drawn per tenant, so neither has one.
62
+ */
63
+ export const MODULE_DEFAULT_SETTINGS: Pick<
64
+ MailingSettingsValues,
65
+ | "fallbackLocale"
66
+ | "logRetentionDays"
67
+ | "blockOnMissingVariables"
68
+ | "categories"
69
+ > = {
70
+ fallbackLocale: DEFAULT_FALLBACK_LOCALE,
71
+ logRetentionDays: 90,
72
+ blockOnMissingVariables: false,
73
+ categories: DEFAULT_CATEGORIES,
74
+ };
@@ -1,7 +1,8 @@
1
+ import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
1
2
  import { z } from "zod";
2
3
  import { MAX_NAME_LENGTH } from "../constants";
3
4
  import { SEND_EVENT_TYPES } from "../types";
4
- import { categorySchema } from "./settings.schema";
5
+ import { categorySchema, settingsSchema } from "./settings.schema";
5
6
  import {
6
7
  slugSchema,
7
8
  templateContentSchema,
@@ -13,6 +14,17 @@ const MAX_IMPORT_ID_LENGTH = 200;
13
14
 
14
15
  const tenantIdSchema = z.string().trim().min(1);
15
16
 
17
+ /**
18
+ * A name or a label declared from code: one non-blank text per language, the
19
+ * fallback language's required, since every reader without a text of their
20
+ * own reads it.
21
+ */
22
+ export const localizedTextSchema = z
23
+ .record(z.string().trim().min(1).max(MAX_NAME_LENGTH))
24
+ .refine((text) => Boolean(text[LOCALIZED_TEXT_FALLBACK_LOCALE]), {
25
+ message: `a text in "${LOCALIZED_TEXT_FALLBACK_LOCALE}" is required`,
26
+ });
27
+
16
28
  /**
17
29
  * A template declared from code: the slug and name rules of the dashboard's
18
30
  * creation, and the content and declarations its editor saves.
@@ -20,7 +32,7 @@ const tenantIdSchema = z.string().trim().min(1);
20
32
  export const templateDeclarationSchema = z.object({
21
33
  tenantId: tenantIdSchema,
22
34
  slug: slugSchema,
23
- name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
35
+ name: localizedTextSchema,
24
36
  category: z.string().trim().default(""),
25
37
  content: templateContentSchema,
26
38
  variables: variableListSchema.default([]),
@@ -34,13 +46,27 @@ export type ParsedTemplateDeclaration = z.infer<
34
46
 
35
47
  export const categoryDeclarationSchema = categorySchema.extend({
36
48
  tenantId: tenantIdSchema,
37
- label: z.string().trim().min(1).max(MAX_NAME_LENGTH),
49
+ label: localizedTextSchema,
38
50
  });
39
51
 
40
52
  export type ParsedCategoryDeclaration = z.infer<
41
53
  typeof categoryDeclarationSchema
42
54
  >;
43
55
 
56
+ const SENDER_REQUIRED = "a sender address is required";
57
+
58
+ /** A sender declared from code: the settings form's rules, an address required. */
59
+ export const senderDeclarationSchema = z.object({
60
+ tenantId: tenantIdSchema,
61
+ email: settingsSchema.shape.senderEmail.pipe(
62
+ z.string().min(1, SENDER_REQUIRED),
63
+ ),
64
+ name: settingsSchema.shape.senderName.default(""),
65
+ replyTo: settingsSchema.shape.replyTo.default(""),
66
+ });
67
+
68
+ export type ParsedSenderDeclaration = z.infer<typeof senderDeclarationSchema>;
69
+
44
70
  const recipientSchema = z.union([
45
71
  z.string().trim().email(),
46
72
  z.object({
@@ -1,6 +1,7 @@
1
1
  import { domainToASCII } from "node:url";
2
2
  import { z } from "zod";
3
3
  import { MAX_NAME_LENGTH, SLUG_PATTERN } from "../constants";
4
+ import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
4
5
  import { MARKETING_CATEGORY } from "../engine";
5
6
 
6
7
  export const MIN_RETENTION_DAYS = 1;
@@ -31,7 +32,8 @@ const MISSING_VARIABLES_POLICIES = ["refuse", "send"] as const;
31
32
 
32
33
  export const categorySchema = z.object({
33
34
  id: z.string().max(MAX_NAME_LENGTH).regex(SLUG_PATTERN),
34
- label: z.string().trim().max(MAX_NAME_LENGTH),
35
+ /** One label per language (`{ en: "Orders", fr: "Commandes" }`). */
36
+ label: z.record(z.string().trim().max(MAX_NAME_LENGTH)),
35
37
  icon: z.string().regex(CATEGORY_ICON_PATTERN),
36
38
  });
37
39
 
@@ -45,19 +47,44 @@ const categoryEntrySchema = categorySchema.extend({
45
47
 
46
48
  export type CategoryEntry = z.infer<typeof categoryEntrySchema>;
47
49
 
50
+ /** A category entry as a reader of one language sees its label. */
51
+ interface EntryInLocale {
52
+ id: string;
53
+ label: string;
54
+ moveTo?: string;
55
+ }
56
+
57
+ /**
58
+ * Every language a list labels its categories in, the fallback one first:
59
+ * the rules below hold in each of them.
60
+ */
61
+ function languagesOf(entries: CategoryEntry[]): string[] {
62
+ const codes = entries.flatMap((entry) => Object.keys(entry.label));
63
+ return [...new Set([LOCALIZED_TEXT_FALLBACK_LOCALE, ...codes])];
64
+ }
65
+
66
+ /** The entries as a reader of `locale` reads them, the fallback filling in. */
67
+ function entriesIn(entries: CategoryEntry[], locale: string): EntryInLocale[] {
68
+ return entries.map(({ id, label, moveTo }) => ({
69
+ id,
70
+ label: label[locale] || label[LOCALIZED_TEXT_FALLBACK_LOCALE] || "",
71
+ moveTo,
72
+ }));
73
+ }
74
+
48
75
  /** Why a category list is refused, and the part of it at fault. */
49
76
  interface CategoryIssue {
50
77
  message: string;
51
78
  path: Array<string | number>;
52
79
  }
53
80
 
54
- const isKept = (entry: CategoryEntry): boolean => entry.moveTo === undefined;
81
+ const isKept = (entry: EntryInLocale): boolean => entry.moveTo === undefined;
55
82
 
56
83
  /**
57
84
  * A kept entry repeating the label (whatever its case) or the id of an
58
85
  * earlier one, under the row it belongs to.
59
86
  */
60
- function duplicateIssues(entries: CategoryEntry[]): CategoryIssue[] {
87
+ function duplicateIssues(entries: EntryInLocale[]): CategoryIssue[] {
61
88
  const labels = new Set<string>();
62
89
  const ids = new Set<string>();
63
90
  const issues: CategoryIssue[] = [];
@@ -79,7 +106,7 @@ function duplicateIssues(entries: CategoryEntry[]): CategoryIssue[] {
79
106
  * label's own minimum, which the form would turn into a browser-side check:
80
107
  * the server words it, under the row, together with the other row issues.
81
108
  */
82
- function blankLabelIssues(entries: CategoryEntry[]): CategoryIssue[] {
109
+ function blankLabelIssues(entries: EntryInLocale[]): CategoryIssue[] {
83
110
  return entries.flatMap((entry, index) =>
84
111
  entry.label ? [] : [{ message: LABEL_REQUIRED, path: [index, LABEL_PART] }],
85
112
  );
@@ -92,7 +119,7 @@ function marketingIssues(kept: Set<string>): CategoryIssue[] {
92
119
  }
93
120
 
94
121
  function moveIssues(
95
- entries: CategoryEntry[],
122
+ entries: EntryInLocale[],
96
123
  kept: Set<string>,
97
124
  ): CategoryIssue[] {
98
125
  return entries.flatMap((entry, index) =>
@@ -106,20 +133,35 @@ function refineCategoryEntries(
106
133
  entries: CategoryEntry[],
107
134
  context: z.RefinementCtx,
108
135
  ): void {
109
- const kept = new Set(entries.filter(isKept).map((entry) => entry.id));
136
+ const kept = new Set(
137
+ entries.filter((entry) => entry.moveTo === undefined).map(({ id }) => id),
138
+ );
139
+ const labelIssues = languagesOf(entries).flatMap((locale) => {
140
+ const inLocale = entriesIn(entries, locale);
141
+ return [...blankLabelIssues(inLocale), ...duplicateIssues(inLocale)];
142
+ });
143
+ const seen = new Set<string>();
110
144
  [
111
- ...blankLabelIssues(entries),
112
- ...duplicateIssues(entries),
145
+ ...labelIssues,
113
146
  ...marketingIssues(kept),
114
- ...moveIssues(entries, kept),
115
- ].forEach((issue) =>
116
- context.addIssue({ code: z.ZodIssueCode.custom, ...issue }),
117
- );
147
+ ...moveIssues(entriesIn(entries, LOCALIZED_TEXT_FALLBACK_LOCALE), kept),
148
+ ]
149
+ .filter((issue) => {
150
+ // One issue per row and part, whichever language shows it first.
151
+ const id = JSON.stringify(issue);
152
+ if (seen.has(id)) return false;
153
+ seen.add(id);
154
+ return true;
155
+ })
156
+ .forEach((issue) =>
157
+ context.addIssue({ code: z.ZodIssueCode.custom, ...issue }),
158
+ );
118
159
  }
119
160
 
120
161
  /**
121
- * The category list of the settings: kept labels and ids are unique, every
122
- * move names a kept one, and the marketing category stays.
162
+ * The category list of the settings: in every language, each kept label is
163
+ * set and unique; ids are unique, every move names a kept one, and the
164
+ * marketing category stays.
123
165
  */
124
166
  export const categoriesSchema = z
125
167
  .array(categoryEntrySchema)
@@ -182,6 +182,16 @@ export const testSendSchema = previewSchema.extend({
182
182
  to: z.array(z.string().email()).min(1).max(MAX_TEST_RECIPIENT_COUNT),
183
183
  });
184
184
 
185
+ /**
186
+ * The details a template's drawer or the editor saves: the name typed in the
187
+ * reader's language, the slug it was read with, the category (`null` clears).
188
+ */
189
+ export const templateDetailsSchema = z.object({
190
+ name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
191
+ slug: z.string().optional(),
192
+ category: z.string().nullable().optional(),
193
+ });
194
+
185
195
  export const duplicateSchema = z.object({
186
196
  slug: slugSchema,
187
197
  name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
@@ -1,27 +0,0 @@
1
- import { MailingTemplateSlug } from "../tables";
2
- export declare function templateSlugIdOf(tenantId: string, slug: string): string;
3
- declare const TemplateSlugModel_base: {
4
- new (database: import("@antelopejs/interface-database").SchemaInstance<any>): {
5
- readonly table: import("@antelopejs/interface-database").Table<MailingTemplateSlug>;
6
- readonly database: import("@antelopejs/interface-database").SchemaInstance<any>;
7
- get(id: string): PromiseLike<MailingTemplateSlug | undefined>;
8
- getBy(index: keyof MailingTemplateSlug, ...keys: any[]): PromiseLike<MailingTemplateSlug[]>;
9
- getAll(): PromiseLike<MailingTemplateSlug[]>;
10
- insert(obj: import("@antelopejs/interface-database-decorators").DeepPartial<MailingTemplateSlug> | import("@antelopejs/interface-database-decorators").DeepPartial<MailingTemplateSlug>[], options?: import("@antelopejs/interface-database-decorators").ValidateOptions): Promise<string[]>;
11
- update(id: string, obj: import("@antelopejs/interface-database-decorators").DeepPartial<MailingTemplateSlug>, options?: import("@antelopejs/interface-database-decorators").ValidateOptions): Promise<number>;
12
- update(obj: import("@antelopejs/interface-database-decorators").DeepPartial<MailingTemplateSlug>, options?: import("@antelopejs/interface-database-decorators").ValidateOptions): Promise<number>;
13
- delete(id: string): Promise<number>;
14
- };
15
- readonly schemaName: string;
16
- readonly tableClass: import("@antelopejs/interface-database-decorators").Constructible<MailingTemplateSlug>;
17
- fromPlainData(obj: any): MailingTemplateSlug;
18
- fromDatabase(obj: any): MailingTemplateSlug | undefined;
19
- toDatabase(obj: any): Record<string, any>;
20
- validate(obj: unknown, options?: import("@antelopejs/interface-database-decorators").ValidateInputOptions): import("@antelopejs/interface-database-decorators").ValidationResult;
21
- };
22
- export declare class TemplateSlugModel extends TemplateSlugModel_base {
23
- insertClaim(id: string, slug: string, templateId: string): Promise<boolean>;
24
- dropClaimOf(id: string, templateId: string): Promise<void>;
25
- releaseTemplates(templateIds: string[]): Promise<void>;
26
- }
27
- export {};
@@ -1,43 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TemplateSlugModel = void 0;
4
- exports.templateSlugIdOf = templateSlugIdOf;
5
- const node_crypto_1 = require("node:crypto");
6
- const interface_database_decorators_1 = require("@antelopejs/interface-database-decorators");
7
- const tables_1 = require("../tables");
8
- const ID_LENGTH = 32;
9
- function templateSlugIdOf(tenantId, slug) {
10
- return (0, node_crypto_1.createHash)("sha256")
11
- .update(`${tenantId}\n${slug}`)
12
- .digest("hex")
13
- .slice(0, ID_LENGTH);
14
- }
15
- class TemplateSlugModel extends (0, interface_database_decorators_1.BasicDataModel)(tables_1.MailingTemplateSlug, tables_1.MAILING_TEMPLATE_SLUGS_TABLE_NAME) {
16
- async insertClaim(id, slug, templateId) {
17
- try {
18
- await this.insert({ _id: id, slug, templateId });
19
- return true;
20
- }
21
- catch (error) {
22
- if (await this.get(id))
23
- return false;
24
- throw error;
25
- }
26
- }
27
- async dropClaimOf(id, templateId) {
28
- await this.table
29
- .atomicMutation(id, {
30
- type: "deleteIfEqual",
31
- field: "templateId",
32
- expectedValue: templateId,
33
- })
34
- .run();
35
- }
36
- async releaseTemplates(templateIds) {
37
- if (!templateIds.length)
38
- return;
39
- await this.table.getAll(templateIds, "templateId").delete().run();
40
- }
41
- }
42
- exports.TemplateSlugModel = TemplateSlugModel;
43
- //# sourceMappingURL=template-slugs.model.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"template-slugs.model.js","sourceRoot":"","sources":["../../../src/db/models/template-slugs.model.ts"],"names":[],"mappings":";;;AAcA,4CAKC;AAnBD,6CAAyC;AACzC,6FAA2E;AAC3E,sCAGmB;AAEnB,MAAM,SAAS,GAAG,EAAE,CAAC;AAOrB,SAAgB,gBAAgB,CAAC,QAAgB,EAAE,IAAY;IAC7D,OAAO,IAAA,wBAAU,EAAC,QAAQ,CAAC;SACxB,MAAM,CAAC,GAAG,QAAQ,KAAK,IAAI,EAAE,CAAC;SAC9B,MAAM,CAAC,KAAK,CAAC;SACb,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;AACzB,CAAC;AAED,MAAa,iBAAkB,SAAQ,IAAA,8CAAc,EACnD,4BAAmB,EACnB,0CAAiC,CAClC;IAMC,KAAK,CAAC,WAAW,CACf,EAAU,EACV,IAAY,EACZ,UAAkB;QAElB,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;YACjD,OAAO,IAAI,CAAC;QACd,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,MAAM,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBAAE,OAAO,KAAK,CAAC;YACrC,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAGD,KAAK,CAAC,WAAW,CAAC,EAAU,EAAE,UAAkB;QAC9C,MAAM,IAAI,CAAC,KAAK;aACb,cAAc,CAAC,EAAE,EAAE;YAClB,IAAI,EAAE,eAAe;YACrB,KAAK,EAAE,YAAY;YACnB,aAAa,EAAE,UAAU;SAC1B,CAAC;aACD,GAAG,EAAE,CAAC;IACX,CAAC;IAGD,KAAK,CAAC,gBAAgB,CAAC,WAAqB;QAC1C,IAAI,CAAC,WAAW,CAAC,MAAM;YAAE,OAAO;QAChC,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC;IACpE,CAAC;CACF;AAvCD,8CAuCC"}
@@ -1,7 +0,0 @@
1
- import { Table } from "@antelopejs/interface-database-decorators";
2
- export declare const MAILING_TEMPLATE_SLUGS_TABLE_NAME = "mailing_template_slugs";
3
- export declare class MailingTemplateSlug extends Table {
4
- _id: string;
5
- slug: string;
6
- templateId: string;
7
- }