@antelopejs/dms-mailing 0.7.0 → 0.8.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 (219) hide show
  1. package/README.md +28 -10
  2. package/dist/constants.d.ts +0 -2
  3. package/dist/constants.js +1 -3
  4. package/dist/constants.js.map +1 -1
  5. package/dist/crons/jobs.d.ts +1 -1
  6. package/dist/data/body.js +2 -1
  7. package/dist/data/body.js.map +1 -1
  8. package/dist/data/templates-table.js +5 -11
  9. package/dist/data/templates-table.js.map +1 -1
  10. package/dist/db/models/index.d.ts +0 -1
  11. package/dist/db/models/index.js +0 -1
  12. package/dist/db/models/index.js.map +1 -1
  13. package/dist/db/models/send-events.model.js +2 -3
  14. package/dist/db/models/send-events.model.js.map +1 -1
  15. package/dist/db/models/sends.model.js +2 -3
  16. package/dist/db/models/sends.model.js.map +1 -1
  17. package/dist/db/models/template-versions.model.js.map +1 -1
  18. package/dist/db/models/templates.model.js +4 -10
  19. package/dist/db/models/templates.model.js.map +1 -1
  20. package/dist/db/tables/index.d.ts +0 -1
  21. package/dist/db/tables/index.js +0 -1
  22. package/dist/db/tables/index.js.map +1 -1
  23. package/dist/db/tables/settings.table.d.ts +0 -1
  24. package/dist/db/tables/settings.table.js +0 -4
  25. package/dist/db/tables/settings.table.js.map +1 -1
  26. package/dist/db/tables/templates.table.d.ts +0 -3
  27. package/dist/db/tables/templates.table.js +1 -13
  28. package/dist/db/tables/templates.table.js.map +1 -1
  29. package/dist/engine/changes.js +2 -1
  30. package/dist/engine/changes.js.map +1 -1
  31. package/dist/engine/conditions.js +2 -3
  32. package/dist/engine/conditions.js.map +1 -1
  33. package/dist/engine/fallback-html.js +8 -8
  34. package/dist/engine/fallback-html.js.map +1 -1
  35. package/dist/engine/formats.js +6 -6
  36. package/dist/engine/formats.js.map +1 -1
  37. package/dist/engine/paths.js +2 -3
  38. package/dist/engine/paths.js.map +1 -1
  39. package/dist/engine/system-variables.js +1 -1
  40. package/dist/engine/system-variables.js.map +1 -1
  41. package/dist/engine/tokens.js.map +1 -1
  42. package/dist/hooks/index.d.ts +0 -1
  43. package/dist/hooks/index.js +0 -1
  44. package/dist/hooks/index.js.map +1 -1
  45. package/dist/implementations/dms-mailing/index.d.ts +3 -4
  46. package/dist/implementations/dms-mailing/index.js +8 -9
  47. package/dist/implementations/dms-mailing/index.js.map +1 -1
  48. package/dist/index.js +0 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/pages/sends.js.map +1 -1
  51. package/dist/pages/settings/form.js +8 -0
  52. package/dist/pages/settings/form.js.map +1 -1
  53. package/dist/pages/toolbar.js.map +1 -1
  54. package/dist/routes/events.js +2 -2
  55. package/dist/routes/events.js.map +1 -1
  56. package/dist/routes/metrics.js +6 -5
  57. package/dist/routes/metrics.js.map +1 -1
  58. package/dist/routes/sends.js +1 -1
  59. package/dist/routes/sends.js.map +1 -1
  60. package/dist/routes/settings.js +6 -6
  61. package/dist/routes/settings.js.map +1 -1
  62. package/dist/routes/templates.js +6 -10
  63. package/dist/routes/templates.js.map +1 -1
  64. package/dist/services/attention.js +2 -1
  65. package/dist/services/attention.js.map +1 -1
  66. package/dist/services/categories.js +6 -6
  67. package/dist/services/categories.js.map +1 -1
  68. package/dist/services/checked-send.js +16 -9
  69. package/dist/services/checked-send.js.map +1 -1
  70. package/dist/services/declarations.js +42 -184
  71. package/dist/services/declarations.js.map +1 -1
  72. package/dist/services/events.js +11 -7
  73. package/dist/services/events.js.map +1 -1
  74. package/dist/services/localized-text.d.ts +2 -1
  75. package/dist/services/localized-text.js +2 -1
  76. package/dist/services/localized-text.js.map +1 -1
  77. package/dist/services/metrics.js.map +1 -1
  78. package/dist/services/render.js.map +1 -1
  79. package/dist/services/send-import.js +7 -5
  80. package/dist/services/send-import.js.map +1 -1
  81. package/dist/services/send.js +4 -3
  82. package/dist/services/send.js.map +1 -1
  83. package/dist/services/settings.d.ts +1 -1
  84. package/dist/services/settings.js +1 -5
  85. package/dist/services/settings.js.map +1 -1
  86. package/dist/services/starters.js +5 -5
  87. package/dist/services/starters.js.map +1 -1
  88. package/dist/services/template-stats.js +5 -2
  89. package/dist/services/template-stats.js.map +1 -1
  90. package/dist/services/templates.d.ts +2 -2
  91. package/dist/services/templates.js +54 -29
  92. package/dist/services/templates.js.map +1 -1
  93. package/dist/types/settings.d.ts +2 -0
  94. package/dist/types/settings.js +8 -1
  95. package/dist/types/settings.js.map +1 -1
  96. package/dist/validation/declarations.schema.d.ts +16 -16
  97. package/dist/validation/declarations.schema.js +2 -2
  98. package/dist/validation/declarations.schema.js.map +1 -1
  99. package/dist/validation/guards.d.ts +4 -0
  100. package/dist/validation/guards.js +19 -0
  101. package/dist/validation/guards.js.map +1 -0
  102. package/dist/validation/send-event-type.schema.d.ts +2 -0
  103. package/dist/validation/send-event-type.schema.js +11 -0
  104. package/dist/validation/send-event-type.schema.js.map +1 -0
  105. package/dist/validation/settings.schema.d.ts +16 -16
  106. package/dist/validation/settings.schema.js +1 -1
  107. package/dist/validation/settings.schema.js.map +1 -1
  108. package/dist/validation/templates.schema.d.ts +19 -18
  109. package/dist/validation/templates.schema.js.map +1 -1
  110. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  111. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  112. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  113. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  114. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  115. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  116. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  117. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  118. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  119. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  120. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  121. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  122. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  123. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  124. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  125. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  126. package/frontend-vue/build/backend-boundary.ts +122 -0
  127. package/frontend-vue/dms.frontend.build.ts +7 -0
  128. package/frontend-vue/eslint.config.mjs +13 -0
  129. package/frontend-vue/package.json +4 -2
  130. package/frontend-vue/pnpm-lock.yaml +210 -61
  131. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  132. package/frontend-vue/tests/dnd.test.ts +1 -2
  133. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  134. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  135. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  136. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  137. package/frontend-vue/tests/page-reads.test.ts +26 -6
  138. package/frontend-vue/tests/send-actions.test.ts +0 -1
  139. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  140. package/frontend-vue/tests/send-modals.test.ts +8 -5
  141. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  142. package/frontend-vue/tests/suppression.test.ts +1 -2
  143. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  144. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  145. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  146. package/package.json +8 -13
  147. package/skills/dms-mailing/SKILL.md +11 -17
  148. package/skills/dms-mailing/cases.json +49 -0
  149. package/src/constants.ts +0 -2
  150. package/src/crons/jobs.ts +1 -1
  151. package/src/data/body.ts +2 -1
  152. package/src/data/templates-table.ts +15 -22
  153. package/src/db/models/index.ts +0 -1
  154. package/src/db/models/send-events.model.ts +2 -2
  155. package/src/db/models/sends.model.ts +7 -8
  156. package/src/db/models/template-versions.model.ts +5 -1
  157. package/src/db/models/templates.model.ts +9 -16
  158. package/src/db/tables/index.ts +0 -1
  159. package/src/db/tables/settings.table.ts +0 -8
  160. package/src/db/tables/templates.table.ts +2 -16
  161. package/src/engine/changes.ts +3 -2
  162. package/src/engine/conditions.ts +2 -4
  163. package/src/engine/fallback-html.ts +13 -13
  164. package/src/engine/formats.ts +6 -6
  165. package/src/engine/paths.ts +4 -3
  166. package/src/engine/system-variables.ts +4 -3
  167. package/src/engine/tokens.ts +1 -2
  168. package/src/hooks/index.ts +0 -1
  169. package/src/implementations/dms-mailing/index.ts +5 -18
  170. package/src/index.ts +1 -6
  171. package/src/pages/sends.ts +5 -3
  172. package/src/pages/settings/form.ts +16 -0
  173. package/src/pages/templates.ts +1 -1
  174. package/src/pages/toolbar.ts +4 -2
  175. package/src/routes/events.ts +3 -6
  176. package/src/routes/metrics.ts +7 -5
  177. package/src/routes/sends.ts +10 -18
  178. package/src/routes/settings.ts +8 -9
  179. package/src/routes/suppressions.ts +1 -1
  180. package/src/routes/templates.ts +16 -18
  181. package/src/services/attention.ts +4 -3
  182. package/src/services/categories.ts +6 -6
  183. package/src/services/checked-send.ts +17 -19
  184. package/src/services/declarations.ts +62 -318
  185. package/src/services/events.ts +16 -13
  186. package/src/services/localized-text.ts +4 -2
  187. package/src/services/metrics.ts +2 -2
  188. package/src/services/notify.ts +1 -1
  189. package/src/services/render.ts +5 -1
  190. package/src/services/send-import.ts +18 -13
  191. package/src/services/send.ts +7 -7
  192. package/src/services/settings.ts +3 -10
  193. package/src/services/starters.ts +6 -6
  194. package/src/services/template-stats.ts +11 -4
  195. package/src/services/templates.ts +69 -52
  196. package/src/types/settings.ts +20 -0
  197. package/src/validation/declarations.schema.ts +2 -2
  198. package/src/validation/guards.ts +25 -0
  199. package/src/validation/send-event-type.schema.ts +10 -0
  200. package/src/validation/settings.schema.ts +7 -2
  201. package/src/validation/templates.schema.ts +2 -1
  202. package/dist/db/models/template-slugs.model.d.ts +0 -27
  203. package/dist/db/models/template-slugs.model.js +0 -43
  204. package/dist/db/models/template-slugs.model.js.map +0 -1
  205. package/dist/db/tables/template-slugs.table.d.ts +0 -7
  206. package/dist/db/tables/template-slugs.table.js +0 -35
  207. package/dist/db/tables/template-slugs.table.js.map +0 -1
  208. package/dist/hooks/database-ready.d.ts +0 -7
  209. package/dist/hooks/database-ready.js +0 -26
  210. package/dist/hooks/database-ready.js.map +0 -1
  211. package/dist/services/fingerprint.d.ts +0 -1
  212. package/dist/services/fingerprint.js +0 -20
  213. package/dist/services/fingerprint.js.map +0 -1
  214. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  215. package/frontend-vue/tests/i18n.test.ts +0 -138
  216. package/src/db/models/template-slugs.model.ts +0 -61
  217. package/src/db/tables/template-slugs.table.ts +0 -29
  218. package/src/hooks/database-ready.ts +0 -47
  219. package/src/services/fingerprint.ts +0 -22
@@ -27,6 +27,7 @@ import {
27
27
  } from "./events";
28
28
  import { getSettings } from "./settings";
29
29
  import { refreshTemplateAttention } from "./template-attention";
30
+ import { isText } from "../validation/guards";
30
31
 
31
32
  /** Labels an imported send in the log when its caller gives no source. */
32
33
  const IMPORT_SOURCE = "import";
@@ -47,7 +48,7 @@ function importedSendIdOf(tenantId: string, importId: string): string {
47
48
  }
48
49
 
49
50
  const addressOf = (to: ParsedImportedSend["to"]) =>
50
- typeof to === "string" ? { email: to } : to;
51
+ isText(to) ? { email: to } : to;
51
52
 
52
53
  /** Chronological events, with the webhook's retry deduplication rules. */
53
54
  function timelineOf(send: ParsedImportedSend): ImportedEvent[] {
@@ -59,11 +60,11 @@ function timelineOf(send: ParsedImportedSend): ImportedEvent[] {
59
60
  const repeated = timeline.some((stored) =>
60
61
  isSameEvent(
61
62
  {
62
- type: stored.type as SendEventType,
63
+ type: stored.type,
63
64
  at: stored.at,
64
65
  json_details: JSON.stringify(stored.details ?? {}),
65
66
  },
66
- { ...entry, type: entry.type as SendEventType },
67
+ entry,
67
68
  ),
68
69
  );
69
70
  if (!repeated) timeline.push(entry);
@@ -87,7 +88,7 @@ function foldEvents(
87
88
  const event: EmailEvent = {
88
89
  provider: send.provider ?? IMPORT_SOURCE,
89
90
  messageId: send.providerMessageId ?? "",
90
- type: entry.type as SendEventType,
91
+ type: entry.type,
91
92
  at: entry.at,
92
93
  details: entry.details,
93
94
  };
@@ -141,6 +142,13 @@ function sendRow(
141
142
  };
142
143
  }
143
144
 
145
+ /** The refusal of an import that names a template the workspace lacks. */
146
+ const missingTemplate = (slug: string): DeclarationError =>
147
+ new DeclarationError(
148
+ "template_not_found",
149
+ `Imported sends name template "${slug}", which the workspace does not have`,
150
+ );
151
+
144
152
  /** The templates the sends name, by slug; refused before anything is written. */
145
153
  async function templatesOf(
146
154
  tenantId: string,
@@ -151,11 +159,7 @@ async function templatesOf(
151
159
  const templates = new Map<string, MailingTemplate>();
152
160
  for (const slug of slugs) {
153
161
  const template = await model.getBySlug(slug);
154
- if (!template)
155
- throw new DeclarationError(
156
- "template_not_found",
157
- `Imported sends name template "${slug}", which the workspace does not have`,
158
- );
162
+ if (!template) throw missingTemplate(slug);
159
163
  templates.set(slug, template);
160
164
  }
161
165
  return templates;
@@ -170,7 +174,8 @@ interface EntryOutcome {
170
174
  /**
171
175
  * Writes one send: its events first, under ids derived from the send's, then
172
176
  * the send itself. A run stopped half-way leaves events no send shows yet,
173
- * which the next run completes; a send present means it was fully imported.
177
+ * which the next run completes; each write skips the row an earlier run
178
+ * wrote, and a send present means it was fully imported.
174
179
  */
175
180
  async function importOne(
176
181
  tenantId: string,
@@ -180,14 +185,13 @@ async function importOne(
180
185
  ): Promise<EntryOutcome> {
181
186
  const id = importedSendIdOf(tenantId, send.importId);
182
187
  const sends = GetModel(SendModel, tenantId);
183
- if (await sends.get(id)) return { id, isImported: false };
184
188
  const timeline = timelineOf(send);
185
189
  const events = GetModel(SendEventModel, tenantId);
186
190
  for (const [index, entry] of timeline.entries()) {
187
191
  await events.insertImported({
188
192
  _id: `${id}-${index}`,
189
193
  sendId: id,
190
- type: entry.type as SendEventType,
194
+ type: entry.type,
191
195
  at: entry.at,
192
196
  json_details: JSON.stringify(entry.details ?? {}),
193
197
  });
@@ -219,7 +223,8 @@ export async function importSends(
219
223
  const { fallbackLocale } = await getSettings(tenantId);
220
224
  const result: ImportSendsResult = { imported: 0, skipped: 0, sendIds: [] };
221
225
  for (const send of sends) {
222
- const template = templates.get(send.templateSlug) as MailingTemplate;
226
+ const template = templates.get(send.templateSlug);
227
+ if (!template) throw missingTemplate(send.templateSlug);
223
228
  const { id, isImported } = await importOne(
224
229
  tenantId,
225
230
  send,
@@ -52,6 +52,7 @@ import {
52
52
  type VersionedSnapshot,
53
53
  workingSnapshot,
54
54
  } from "./templates";
55
+ import { isText } from "../validation/guards";
55
56
 
56
57
  export class SendError extends Error {
57
58
  constructor(
@@ -161,11 +162,10 @@ const OUTCOME_SEVERITY: Record<SendStatus, number> = {
161
162
  const describeError = (error: unknown): string =>
162
163
  error instanceof Error ? error.message : String(error);
163
164
 
164
- const addressOf = (to: EmailAddress): string =>
165
- typeof to === "string" ? to : to.email;
165
+ const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
166
166
 
167
167
  const nameOf = (to: EmailAddress): string | undefined =>
168
- typeof to === "string" ? undefined : to.name;
168
+ isText(to) ? undefined : to.name;
169
169
 
170
170
  const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
171
171
  Array.isArray(to) ? to : [to];
@@ -468,15 +468,15 @@ async function record(
468
468
  isTest: Boolean(params.isTest),
469
469
  });
470
470
  await GetModel(SendEventModel, tenantId).insertForActiveSend({
471
- sendId: sendId as string,
471
+ sendId,
472
472
  type: status,
473
473
  at: new Date(),
474
474
  json_details: JSON.stringify(
475
475
  outcome.error ? { reason: outcome.error } : {},
476
476
  ),
477
477
  });
478
- publishSendCreated(tenantId, sendId as string);
479
- return sendId as string;
478
+ publishSendCreated(tenantId, sendId);
479
+ return sendId;
480
480
  }
481
481
 
482
482
  async function recordOne(
@@ -525,7 +525,7 @@ export async function recordAll(
525
525
  export function aggregateResults(
526
526
  results: SendTemplateResult[],
527
527
  ): SendTemplateResult {
528
- const first = results[0] as SendTemplateResult;
528
+ const first = results[0];
529
529
  const worst = results.reduce((left, right) =>
530
530
  OUTCOME_SEVERITY[right.status] > OUTCOME_SEVERITY[left.status]
531
531
  ? right
@@ -1,12 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { GetModel } from "@antelopejs/interface-database-decorators";
3
- import {
4
- DEFAULT_FALLBACK_LOCALE,
5
- DEFAULT_LOG_RETENTION_DAYS,
6
- } from "../constants";
7
3
  import { type MailingSettings, SettingsModel } from "../db";
8
4
  import {
9
- DEFAULT_CATEGORIES,
5
+ MODULE_DEFAULT_SETTINGS,
10
6
  type MailingSettingsValues,
11
7
  type TemplateCategory,
12
8
  } from "../types";
@@ -15,14 +11,11 @@ export const WEBHOOK_SECRET_BYTES = 24;
15
11
 
16
12
  function defaults(): MailingSettingsValues {
17
13
  return {
18
- fallbackLocale: DEFAULT_FALLBACK_LOCALE,
19
- logRetentionDays: DEFAULT_LOG_RETENTION_DAYS,
20
- blockOnMissingVariables: false,
14
+ ...MODULE_DEFAULT_SETTINGS,
21
15
  senderName: "",
22
16
  senderEmail: "",
23
17
  replyTo: "",
24
18
  webhookSecret: randomBytes(WEBHOOK_SECRET_BYTES).toString("hex"),
25
- categories: DEFAULT_CATEGORIES,
26
19
  };
27
20
  }
28
21
 
@@ -106,7 +99,7 @@ const REFUSE_POLICY: MissingVariablesPolicy = "refuse";
106
99
  const SEND_POLICY: MissingVariablesPolicy = "send";
107
100
 
108
101
  export function missingVariablesPolicy(
109
- values: MailingSettingsValues,
102
+ values: Pick<MailingSettingsValues, "blockOnMissingVariables">,
110
103
  ): MissingVariablesPolicy {
111
104
  return values.blockOnMissingVariables ? REFUSE_POLICY : SEND_POLICY;
112
105
  }
@@ -32,16 +32,16 @@ const DEFAULT_TEST_DATA_LOCALE = "en";
32
32
  const STARTER_CURRENCY = "EUR";
33
33
 
34
34
  /** The ordered item of the order starter's test data, by language. */
35
- const ORDER_LINE_NAMES: Record<string, string> = {
36
- en: "Ergo chair · Graphite × 4",
37
- fr: "Fauteuil ergonomique · Graphite × 4",
38
- };
35
+ const ORDER_LINE_NAMES = new Map([
36
+ [DEFAULT_TEST_DATA_LOCALE, "Ergo chair · Graphite × 4"],
37
+ ["fr", "Fauteuil ergonomique · Graphite × 4"],
38
+ ]);
39
39
  const LOCALE_PARTS = /[-_]/;
40
40
 
41
41
  /** A test text in the language of `locale`, English when it has none. */
42
- function inLanguageOf(texts: Record<string, string>, locale: string): string {
42
+ function inLanguageOf(texts: ReadonlyMap<string, string>, locale: string) {
43
43
  const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
44
- return texts[language] ?? (texts[DEFAULT_TEST_DATA_LOCALE] as string);
44
+ return texts.get(language) ?? texts.get(DEFAULT_TEST_DATA_LOCALE);
45
45
  }
46
46
 
47
47
  /** An amount the way `locale` writes it: "€1,556.00", "1 556,00 €". */
@@ -66,18 +66,25 @@ export function sourcesOf(sends: SendSummary[]): SourceCount[] {
66
66
  .slice(0, MAX_SOURCES);
67
67
  }
68
68
 
69
- export function isFallback(send: SendSummary): boolean {
69
+ /** Whether `send` went out in another locale than the one it asked for. */
70
+ function isRequestedFallback(
71
+ send: SendSummary,
72
+ ): send is SendSummary & { requestedLocale: string } {
70
73
  return Boolean(send.requestedLocale && send.requestedLocale !== send.locale);
71
74
  }
72
75
 
76
+ export function isFallback(send: SendSummary): boolean {
77
+ return isRequestedFallback(send);
78
+ }
79
+
73
80
  export function fallbacksOf(sends: SendSummary[]): FallbackCount[] {
74
- const fallbacks = sends.filter(isFallback);
81
+ const fallbacks = sends.filter(isRequestedFallback);
75
82
  return [
76
83
  ...groupBy(fallbacks, (send) => `${send.requestedLocale}>${send.locale}`),
77
84
  ].map(([, rows]) => {
78
- const first = rows[0] as SendSummary;
85
+ const first = rows[0];
79
86
  return {
80
- requested: first.requestedLocale as string,
87
+ requested: first.requestedLocale,
81
88
  used: first.locale,
82
89
  count: rows.length,
83
90
  };
@@ -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,
@@ -12,9 +12,7 @@ import {
12
12
  type MailingTemplateVersion,
13
13
  type TemplateFields,
14
14
  TemplateModel,
15
- TemplateSlugModel,
16
15
  TemplateVersionModel,
17
- templateSlugIdOf,
18
16
  } from "../db";
19
17
  import { diffContents, diffVariables, type TemplateChange } from "../engine";
20
18
  import {
@@ -28,6 +26,7 @@ import type {
28
26
  TemplateContent,
29
27
  VariableDefinition,
30
28
  } from "../types";
29
+ import { DEFAULT_FALLBACK_LOCALE } from "../types";
31
30
 
32
31
  export interface TemplateAuthor {
33
32
  name?: string;
@@ -45,6 +44,7 @@ const STALE_DRAFT: TemplateAttentionReason = "stale_draft";
45
44
  const VERSION_NOT_FOUND = "$dms_mailing.errors.version_not_found";
46
45
  const TEMPLATE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
47
46
  const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
47
+ const SLUG_FIELD = "slug";
48
48
 
49
49
  /** The template `id` names; 404 when there is none. */
50
50
  export async function requireTemplate(
@@ -57,49 +57,18 @@ export async function requireTemplate(
57
57
  }
58
58
 
59
59
  /**
60
- * Gives `slug` to the template just inserted; false when another template
61
- * holds it. A claim whose template is gone (deleted before its claim was
62
- * 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.
63
63
  */
64
- async function claimTemplateSlug(
65
- tenantId: string,
66
- slug: string,
67
- templateId: string,
68
- ): Promise<boolean> {
69
- const claims = GetModel(TemplateSlugModel, tenantId);
70
- const id = templateSlugIdOf(tenantId, slug);
71
- if (await claims.insertClaim(id, slug, templateId)) return true;
72
- const holder = await claims.get(id);
73
- if (holder?.templateId === templateId) return true;
74
- if (holder) {
75
- const templates = GetModel(TemplateModel, tenantId);
76
- if (await templates.get(holder.templateId)) return false;
77
- 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;
78
71
  }
79
- return claims.insertClaim(id, slug, templateId);
80
- }
81
-
82
- /**
83
- * Keeps the template just inserted if it got its slug, or removes it again
84
- * and refuses it like any taken slug: two creations racing past the free-slug
85
- * check both insert, and only the claim's primary key tells them apart.
86
- */
87
- export async function assertSlugClaimed(
88
- tenantId: string,
89
- slug: string,
90
- templateId: string,
91
- ): Promise<void> {
92
- if (await claimTemplateSlug(tenantId, slug, templateId)) return;
93
- await GetModel(TemplateModel, tenantId).delete(templateId);
94
- throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
95
- }
96
-
97
- /** Frees the slugs of deleted templates for new ones. */
98
- export async function releaseTemplateSlugs(
99
- tenantId: string,
100
- templateIds: string[],
101
- ): Promise<void> {
102
- await GetModel(TemplateSlugModel, tenantId).releaseTemplates(templateIds);
103
72
  }
104
73
 
105
74
  /** The content customers receive: the last published version. */
@@ -211,13 +180,18 @@ interface FooterLabels {
211
180
  preferences: string;
212
181
  }
213
182
 
183
+ const ENGLISH_FOOTER_LABELS: FooterLabels = {
184
+ unsubscribe: "Unsubscribe",
185
+ preferences: "Preferences",
186
+ };
187
+
214
188
  /**
215
189
  * The one piece of content the module still writes for a new template. An
216
190
  * e-mail without an unsubscribe link is a legal problem, so a blank template
217
191
  * ships a footer — and nothing else: no invented subject, prose or address.
218
192
  */
219
193
  const FOOTER_LABELS: Record<string, FooterLabels> = {
220
- en: { unsubscribe: "Unsubscribe", preferences: "Preferences" },
194
+ en: ENGLISH_FOOTER_LABELS,
221
195
  fr: { unsubscribe: "Se désinscrire", preferences: "Préférences" },
222
196
  };
223
197
 
@@ -225,7 +199,7 @@ const FOOTER_BLOCK_ID = "footer";
225
199
 
226
200
  /** A footer in `locale`: its legal line and the labels of its two links. */
227
201
  export function footerBlock(locale: string, text = ""): Block {
228
- const labels = FOOTER_LABELS[locale] ?? (FOOTER_LABELS.en as FooterLabels);
202
+ const labels = FOOTER_LABELS[locale] ?? ENGLISH_FOOTER_LABELS;
229
203
  return {
230
204
  id: FOOTER_BLOCK_ID,
231
205
  type: "footer",
@@ -257,16 +231,13 @@ export function isStaleRevision(
257
231
  return revision !== undefined && revision !== template.draftRevision;
258
232
  }
259
233
 
260
- function draftWriteFields(
261
- template: MailingTemplate,
262
- author: string,
263
- ): TemplateFields {
234
+ function draftWriteFields(template: MailingTemplate, author: string) {
264
235
  return {
265
236
  draftRevision: template.draftRevision + 1,
266
237
  draftUpdatedAt: new Date(),
267
238
  draftUpdatedBy: author,
268
239
  updatedBy: author,
269
- };
240
+ } satisfies TemplateFields;
270
241
  }
271
242
 
272
243
  /** Whether `draft` is exactly what customers and callers already have. */
@@ -302,7 +273,7 @@ export async function saveDraft(
302
273
  ...locales,
303
274
  });
304
275
  await refreshAttentionIfFlagged(tenantId, template, STALE_DRAFT);
305
- return fields.draftRevision as number;
276
+ return fields.draftRevision;
306
277
  }
307
278
 
308
279
  export async function discardDraft(
@@ -528,6 +499,52 @@ export async function initializeTemplate(
528
499
  await refreshTemplateAttention(tenantId);
529
500
  }
530
501
 
502
+ /** The first version a template publishes. */
503
+ const FIRST_VERSION = 1;
504
+
505
+ /**
506
+ * A new template, published at once as version 1 of `draft`: its content
507
+ * and its history entry land in the one write that creates it, so no reader
508
+ * ever sees it unpublished. Materialize its version row afterwards, as a
509
+ * publication does.
510
+ */
511
+ export function publishedAtCreation(
512
+ id: string,
513
+ draft: TemplateDraft,
514
+ author: string,
515
+ ): TemplateFields {
516
+ const shipped: TemplateFields = {
517
+ json_content: JSON.stringify(draft.content),
518
+ json_variables: JSON.stringify(draft.variables),
519
+ locales: localesOf(draft.content),
520
+ publishedAt: new Date(),
521
+ publishedBy: author,
522
+ };
523
+ const changes = [
524
+ ...diffContents({ locales: {} }, draft.content),
525
+ ...diffVariables([], draft.variables),
526
+ ];
527
+ return {
528
+ _id: id,
529
+ ...shipped,
530
+ json_published_version: JSON.stringify({
531
+ ...shipped,
532
+ _id: randomUUID(),
533
+ templateId: id,
534
+ version: FIRST_VERSION,
535
+ json_changes: JSON.stringify(changes),
536
+ }),
537
+ json_draft: "",
538
+ json_variables_draft: "",
539
+ isDraftPending: false,
540
+ draftRevision: FIRST_DRAFT_REVISION,
541
+ status: "live",
542
+ publishedVersion: FIRST_VERSION,
543
+ updatedBy: author,
544
+ ...NO_ATTENTION,
545
+ };
546
+ }
547
+
531
548
  export function blankFields(content: TemplateContent): TemplateFields {
532
549
  return {
533
550
  json_content: "",
@@ -52,3 +52,23 @@ export const DEFAULT_CATEGORIES: TemplateCategory[] = [
52
52
  icon: "i-ph-textbox",
53
53
  },
54
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,7 @@
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,
@@ -76,7 +76,7 @@ const recipientSchema = z.union([
76
76
  ]);
77
77
 
78
78
  const importedEventSchema = z.object({
79
- type: z.enum(SEND_EVENT_TYPES as [string, ...string[]]),
79
+ type: sendEventTypeSchema,
80
80
  at: z.coerce.date(),
81
81
  details: z.record(z.unknown()).optional(),
82
82
  });
@@ -0,0 +1,25 @@
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 a JSON object as a parsed body holds one: an object,
8
+ * neither `null` nor an array. Its values are not checked.
9
+ */
10
+ export function isJsonObject(value: unknown): value is Record<string, unknown> {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+
14
+ /**
15
+ * Whether `value` is an object, arrays included, and not `null`: reading any
16
+ * key of it yields a value of unknown type.
17
+ */
18
+ export function isObject(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null;
20
+ }
21
+
22
+ /** Whether `value` is a number. */
23
+ export function isNumber(value: unknown): value is number {
24
+ return typeof value === "number";
25
+ }
@@ -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,6 @@
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";
3
4
 
4
5
  const MIN_HEADING_SIZE = 12;
5
6
  const MAX_HEADING_SIZE = 48;
@@ -31,7 +32,7 @@ export const conditionSchema = z.object({
31
32
  const alignSchema = z.enum(["left", "center", "right"]);
32
33
  const base = { id: z.string().min(1), visibleIf: conditionSchema.nullable() };
33
34
 
34
- export const blockSchema: z.ZodType<unknown> = z.lazy(() =>
35
+ export const blockSchema: z.ZodType<Block, z.ZodTypeDef, unknown> = z.lazy(() =>
35
36
  z.discriminatedUnion("type", [
36
37
  z.object({
37
38
  ...base,
@@ -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
- }