@antelopejs/dms-mailing 0.6.0 → 0.7.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 (187) hide show
  1. package/dist/constants.d.ts +1 -0
  2. package/dist/constants.js +2 -1
  3. package/dist/constants.js.map +1 -1
  4. package/dist/data/templates-table.d.ts +4 -1
  5. package/dist/data/templates-table.js +55 -13
  6. package/dist/data/templates-table.js.map +1 -1
  7. package/dist/data-types/categories-type.d.ts +5 -5
  8. package/dist/db/models/templates.model.d.ts +10 -1
  9. package/dist/db/models/templates.model.js +27 -1
  10. package/dist/db/models/templates.model.js.map +1 -1
  11. package/dist/db/tables/templates.table.d.ts +7 -1
  12. package/dist/db/tables/templates.table.js +3 -1
  13. package/dist/db/tables/templates.table.js.map +1 -1
  14. package/dist/engine/formats.d.ts +13 -0
  15. package/dist/engine/formats.js +85 -0
  16. package/dist/engine/formats.js.map +1 -0
  17. package/dist/engine/index.d.ts +1 -0
  18. package/dist/engine/index.js +1 -0
  19. package/dist/engine/index.js.map +1 -1
  20. package/dist/engine/resolve.d.ts +2 -1
  21. package/dist/engine/resolve.js +14 -7
  22. package/dist/engine/resolve.js.map +1 -1
  23. package/dist/engine/tokens.d.ts +2 -1
  24. package/dist/engine/tokens.js +3 -5
  25. package/dist/engine/tokens.js.map +1 -1
  26. package/dist/frontend-module.d.ts +2 -0
  27. package/dist/frontend-module.js +24 -0
  28. package/dist/frontend-module.js.map +1 -0
  29. package/dist/hooks/tenant-export.js +4 -1
  30. package/dist/hooks/tenant-export.js.map +1 -1
  31. package/dist/implementations/dms-mailing/index.d.ts +1 -0
  32. package/dist/implementations/dms-mailing/index.js +3 -1
  33. package/dist/implementations/dms-mailing/index.js.map +1 -1
  34. package/dist/index.js +2 -19
  35. package/dist/index.js.map +1 -1
  36. package/dist/pages/overview.d.ts +1 -1
  37. package/dist/pages/overview.js +11 -13
  38. package/dist/pages/overview.js.map +1 -1
  39. package/dist/pages/sends.d.ts +1 -1
  40. package/dist/pages/sends.js +16 -8
  41. package/dist/pages/sends.js.map +1 -1
  42. package/dist/pages/templates.js +3 -8
  43. package/dist/pages/templates.js.map +1 -1
  44. package/dist/pages/toolbar.d.ts +12 -0
  45. package/dist/pages/toolbar.js +41 -0
  46. package/dist/pages/toolbar.js.map +1 -0
  47. package/dist/realtime.d.ts +2 -2
  48. package/dist/realtime.js +8 -8
  49. package/dist/realtime.js.map +1 -1
  50. package/dist/routes/metrics.d.ts +9 -2
  51. package/dist/routes/metrics.js +90 -35
  52. package/dist/routes/metrics.js.map +1 -1
  53. package/dist/routes/period.d.ts +1 -0
  54. package/dist/routes/period.js +19 -0
  55. package/dist/routes/period.js.map +1 -1
  56. package/dist/routes/sends.js +6 -3
  57. package/dist/routes/sends.js.map +1 -1
  58. package/dist/routes/settings.js +6 -7
  59. package/dist/routes/settings.js.map +1 -1
  60. package/dist/routes/templates.d.ts +8 -4
  61. package/dist/routes/templates.js +40 -15
  62. package/dist/routes/templates.js.map +1 -1
  63. package/dist/services/categories.d.ts +2 -1
  64. package/dist/services/categories.js +9 -0
  65. package/dist/services/categories.js.map +1 -1
  66. package/dist/services/declarations.js +2 -2
  67. package/dist/services/declarations.js.map +1 -1
  68. package/dist/services/events.js +1 -1
  69. package/dist/services/events.js.map +1 -1
  70. package/dist/services/localized-text.d.ts +7 -0
  71. package/dist/services/localized-text.js +28 -0
  72. package/dist/services/localized-text.js.map +1 -0
  73. package/dist/services/send-stat-items.js +34 -12
  74. package/dist/services/send-stat-items.js.map +1 -1
  75. package/dist/services/send-stats.d.ts +7 -0
  76. package/dist/services/send-stats.js +17 -5
  77. package/dist/services/send-stats.js.map +1 -1
  78. package/dist/services/send.d.ts +2 -0
  79. package/dist/services/send.js +11 -12
  80. package/dist/services/send.js.map +1 -1
  81. package/dist/services/sender-declaration.d.ts +2 -0
  82. package/dist/services/sender-declaration.js +18 -0
  83. package/dist/services/sender-declaration.js.map +1 -0
  84. package/dist/services/starters.d.ts +2 -2
  85. package/dist/services/starters.js +1 -10
  86. package/dist/services/starters.js.map +1 -1
  87. package/dist/services/templates.d.ts +11 -5
  88. package/dist/services/templates.js +24 -6
  89. package/dist/services/templates.js.map +1 -1
  90. package/dist/services/webhook-status.d.ts +2 -1
  91. package/dist/services/webhook-status.js +6 -5
  92. package/dist/services/webhook-status.js.map +1 -1
  93. package/dist/types/settings.d.ts +6 -0
  94. package/dist/types/settings.js +25 -5
  95. package/dist/types/settings.js.map +1 -1
  96. package/dist/validation/declarations.schema.d.ts +25 -7
  97. package/dist/validation/declarations.schema.js +16 -3
  98. package/dist/validation/declarations.schema.js.map +1 -1
  99. package/dist/validation/settings.schema.d.ts +25 -25
  100. package/dist/validation/settings.schema.js +30 -6
  101. package/dist/validation/settings.schema.js.map +1 -1
  102. package/dist/validation/templates.schema.d.ts +13 -0
  103. package/dist/validation/templates.schema.js +6 -1
  104. package/dist/validation/templates.schema.js.map +1 -1
  105. package/frontend-vue/app/components/CategoriesInput.vue +15 -13
  106. package/frontend-vue/app/components/FunnelCard.vue +46 -3
  107. package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
  108. package/frontend-vue/app/components/OverviewGate.vue +0 -8
  109. package/frontend-vue/app/components/ProviderPill.vue +50 -0
  110. package/frontend-vue/app/components/SendDrawer.vue +206 -183
  111. package/frontend-vue/app/components/StarterGallery.vue +1 -1
  112. package/frontend-vue/app/components/TemplateCard.vue +4 -1
  113. package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
  114. package/frontend-vue/app/components/TemplateGallery.vue +57 -17
  115. package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
  116. package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
  117. package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
  118. package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
  119. package/frontend-vue/app/composables/useMailingApi.ts +3 -3
  120. package/frontend-vue/app/composables/useMailingReads.ts +2 -2
  121. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
  122. package/frontend-vue/app/types/mailing.ts +18 -0
  123. package/frontend-vue/app/utils/categories.ts +58 -49
  124. package/frontend-vue/app/utils/funnel.ts +25 -0
  125. package/frontend-vue/app/utils/period.ts +0 -7
  126. package/frontend-vue/app/utils/starters.ts +3 -1
  127. package/frontend-vue/app/utils/tokens.ts +6 -1
  128. package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
  129. package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
  130. package/frontend-vue/package.json +1 -1
  131. package/frontend-vue/tests/categories-input.test.ts +4 -10
  132. package/frontend-vue/tests/categories.test.ts +55 -17
  133. package/frontend-vue/tests/conditions.test.ts +1 -4
  134. package/frontend-vue/tests/funnel-card.test.ts +27 -0
  135. package/frontend-vue/tests/funnel.test.ts +25 -0
  136. package/frontend-vue/tests/localized-defaults.test.ts +0 -29
  137. package/frontend-vue/tests/new-template-modal.test.ts +1 -1
  138. package/frontend-vue/tests/overview-gate.test.ts +1 -24
  139. package/frontend-vue/tests/page-reads.test.ts +9 -25
  140. package/frontend-vue/tests/period.test.ts +0 -12
  141. package/frontend-vue/tests/replay-toast.test.ts +2 -1
  142. package/frontend-vue/tests/send-drawer.test.ts +123 -0
  143. package/frontend-vue/tests/template-drawer.test.ts +1 -1
  144. package/frontend-vue/tests/template-gallery.test.ts +90 -3
  145. package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
  146. package/package.json +6 -5
  147. package/skills/dms-mailing/SKILL.md +17 -4
  148. package/src/constants.ts +2 -0
  149. package/src/data/sends-table.ts +1 -1
  150. package/src/data/templates-table.ts +123 -19
  151. package/src/db/models/templates.model.ts +61 -3
  152. package/src/db/tables/templates.table.ts +10 -1
  153. package/src/engine/formats.ts +124 -0
  154. package/src/engine/index.ts +1 -0
  155. package/src/engine/resolve.ts +18 -7
  156. package/src/engine/tokens.ts +7 -5
  157. package/src/frontend-module.ts +36 -0
  158. package/src/hooks/tenant-export.ts +8 -1
  159. package/src/implementations/dms-mailing/index.ts +2 -0
  160. package/src/index.ts +2 -23
  161. package/src/pages/overview.ts +20 -16
  162. package/src/pages/sends.ts +27 -11
  163. package/src/pages/templates.ts +4 -8
  164. package/src/pages/toolbar.ts +77 -0
  165. package/src/realtime.ts +17 -8
  166. package/src/routes/metrics.ts +158 -45
  167. package/src/routes/period.ts +32 -0
  168. package/src/routes/sends.ts +13 -4
  169. package/src/routes/settings.ts +11 -8
  170. package/src/routes/templates.ts +72 -26
  171. package/src/services/categories.ts +14 -1
  172. package/src/services/declarations.ts +4 -3
  173. package/src/services/events.ts +1 -1
  174. package/src/services/localized-text.ts +53 -0
  175. package/src/services/send-stat-items.ts +60 -13
  176. package/src/services/send-stats.ts +37 -5
  177. package/src/services/send.ts +19 -20
  178. package/src/services/sender-declaration.ts +27 -0
  179. package/src/services/starters.ts +3 -15
  180. package/src/services/templates.ts +50 -19
  181. package/src/services/webhook-status.ts +3 -3
  182. package/src/types/settings.ts +35 -5
  183. package/src/validation/declarations.schema.ts +29 -3
  184. package/src/validation/settings.schema.ts +56 -14
  185. package/src/validation/templates.schema.ts +10 -0
  186. package/frontend-vue/app/components/PageHeader.vue +0 -94
  187. package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Controller,
3
3
  HTTPResult,
4
+ Parameter,
4
5
  type RequestContext,
5
6
  } from "@antelopejs/interface-api";
6
7
  import { assertValidation } from "@antelopejs/interface-api-util";
@@ -9,6 +10,7 @@ import {
9
10
  type DataControllerCallback,
10
11
  RegisterDataController,
11
12
  } from "@antelopejs/interface-data-api";
13
+ import type { Validation } from "@antelopejs/interface-data-api/components";
12
14
  import {
13
15
  Access,
14
16
  AccessMode,
@@ -17,9 +19,17 @@ import {
17
19
  Listable,
18
20
  Mandatory,
19
21
  ModelReference,
22
+ ModifierKey,
20
23
  Sortable,
21
24
  } from "@antelopejs/interface-data-api/metadata";
22
- import { GetModel } from "@antelopejs/interface-database-decorators";
25
+ import {
26
+ GetModel,
27
+ LocalizationModifier,
28
+ } from "@antelopejs/interface-database-decorators";
29
+ import {
30
+ LOCALIZED_TEXT_FALLBACK_LOCALE,
31
+ type LocalizedText,
32
+ } from "@antelopejs/interface-dms-mailing";
23
33
  import { authenticateTenantRequest } from "@antelopejs/interface-dms/guards";
24
34
  import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
25
35
  import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
@@ -48,9 +58,15 @@ import { footerForCategory } from "../engine";
48
58
  import {
49
59
  MailingTemplate,
50
60
  SendModel,
61
+ type TemplateFields,
51
62
  TemplateModel,
52
63
  TemplateVersionModel,
53
64
  } from "../db";
65
+ import {
66
+ CONTENT_LANGUAGE_HEADER,
67
+ localeOfRequest,
68
+ withTextIn,
69
+ } from "../services/localized-text";
54
70
  import { findCategory, getSettings } from "../services/settings";
55
71
  import { findStarter, starterFields } from "../services/starters";
56
72
  import { refreshTemplateAttention } from "../services/template-attention";
@@ -65,7 +81,10 @@ import {
65
81
  } from "../services/templates";
66
82
  import type { TemplateCategory } from "../types";
67
83
 
68
- import { createTemplateSchema } from "../validation/templates.schema";
84
+ import {
85
+ createTemplateSchema,
86
+ templateDetailsSchema,
87
+ } from "../validation/templates.schema";
69
88
  import { parseBody } from "./body";
70
89
  import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
71
90
 
@@ -116,7 +135,7 @@ async function seedFields(
116
135
  tenantId: string,
117
136
  seed: TemplateSeed,
118
137
  fallbackLocale: string,
119
- ): Promise<Partial<MailingTemplate>> {
138
+ ): Promise<TemplateFields> {
120
139
  const source = await resolveSource(tenantId, seed.sourceTemplateId);
121
140
  if (source) return contentFieldsOf(source);
122
141
  if (!seed.starterId) {
@@ -144,23 +163,43 @@ function assertKnownCategory(
144
163
  throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
145
164
  }
146
165
 
147
- /** The creation's seed, and the body to store: slug and name trimmed. */
148
- function parseCreation(body: Buffer | string) {
149
- const raw = parseBody(body);
166
+ /** The row `new` answers since interface-data-api 0.3: its key and fields. */
167
+ interface CreatedRow {
168
+ _id: string;
169
+ [field: string]: unknown;
170
+ }
171
+
172
+ /**
173
+ * The creation's seed, and the body to store: slug trimmed, and the name
174
+ * typed in the reader's language as every language of the localized field,
175
+ * the fallback one included until someone translates it. A body a guard
176
+ * already parsed arrives as an object.
177
+ */
178
+ function parseCreation(body: Validation.WrittenBody, locale: string) {
179
+ const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
150
180
  const seed = assertValidation(
151
181
  raw,
152
182
  (value) => createTemplateSchema.parse(value),
153
183
  () => INVALID_TEMPLATE,
154
184
  HTTP_BAD_REQUEST,
155
185
  );
156
- const normalized = JSON.stringify({
186
+ // Handed as an object: interface-data-api 0.3 refuses a JSON string.
187
+ const normalized = {
157
188
  ...raw,
158
189
  slug: seed.slug,
159
- name: seed.name,
160
- });
190
+ name: withTextIn(undefined, locale, seed.name),
191
+ };
161
192
  return { seed, normalized };
162
193
  }
163
194
 
195
+ /** What the data API locks a localized field with: the controller's key. */
196
+ interface LocalizedController {
197
+ language: string;
198
+ }
199
+
200
+ /** The key that writes every language of a localized field at once. */
201
+ const ALL_LANGUAGES = "*";
202
+
164
203
  function withSeeding(base: DataControllerCallback): DataControllerCallback {
165
204
  return {
166
205
  ...base,
@@ -168,10 +207,10 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
168
207
  this: unknown,
169
208
  ctx: RequestContext,
170
209
  params: unknown,
171
- body: Buffer | string,
210
+ body: Validation.WrittenBody,
172
211
  ...rest: unknown[]
173
212
  ) {
174
- const { seed, normalized } = parseCreation(body);
213
+ const { seed, normalized } = parseCreation(body, localeOfRequest(ctx));
175
214
  const tenantId = getRequestTenantId(ctx);
176
215
  const model = GetModel(TemplateModel, tenantId);
177
216
  if (await model.getBySlug(seed.slug)) {
@@ -180,18 +219,21 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
180
219
  const settings = await getSettings(tenantId);
181
220
  assertKnownCategory(settings.categories, seed.category);
182
221
  const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
183
- const ids = (await base.func.call(
222
+ // The module's creation dialogs send one typed name; the stored name
223
+ // holds every language, written at once.
224
+ (this as LocalizedController).language = ALL_LANGUAGES;
225
+ const created = (await base.func.call(
184
226
  this,
185
227
  ctx,
186
228
  params,
187
229
  normalized,
188
230
  ...rest,
189
- )) as string[];
190
- const templateId = ids[0] as string;
231
+ )) as CreatedRow;
232
+ const templateId = created._id;
191
233
  await assertSlugClaimed(tenantId, seed.slug, templateId);
192
234
  const user = await authenticateTenantRequest(ctx);
193
235
  await initializeTemplate(tenantId, templateId, displayName(user), fields);
194
- return ids;
236
+ return created;
195
237
  },
196
238
  };
197
239
  }
@@ -266,21 +308,78 @@ export async function assertEditable(
266
308
  ): Promise<void> {
267
309
  if (body.slug !== undefined && body.slug !== current.slug)
268
310
  throw new HTTPResult(HTTP_CONFLICT, SLUG_IMMUTABLE);
269
- if (typeof body.name === "string" && !body.name.trim())
311
+ if (body.name !== undefined && isBlankName(body.name))
270
312
  throw new HTTPResult(HTTP_BAD_REQUEST, BLANK_NAME);
271
313
  const { categories } = await getSettings(tenantId);
272
314
  assertKnownCategory(categories, body.category);
273
315
  }
274
316
 
275
- /** Stores an edited name trimmed, once the edit is written. */
317
+ /**
318
+ * Saves what the module's drawer and editor change: the name, typed in the
319
+ * reader's language (its other languages kept), and the category.
320
+ */
321
+ export async function saveDetails(
322
+ ctx: RequestContext,
323
+ template: MailingTemplate,
324
+ body: unknown,
325
+ author: string,
326
+ ): Promise<void> {
327
+ const details = assertValidation(
328
+ body,
329
+ (value) => templateDetailsSchema.parse(value),
330
+ () => INVALID_TEMPLATE,
331
+ );
332
+ const tenantId = getRequestTenantId(ctx);
333
+ await assertEditable(tenantId, details, template);
334
+ const names = TemplateModel.namesOf(template);
335
+ await GetModel(TemplateModel, tenantId).updateFields(template._id, {
336
+ name: withTextIn(names, localeOfRequest(ctx), details.name),
337
+ ...(details.category !== undefined && {
338
+ category: details.category ?? "",
339
+ }),
340
+ updatedBy: author,
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Whether an edited name holds no text: the text of the reader's language,
346
+ * or, from a form editing every language, all of them.
347
+ */
348
+ function isBlankName(name: unknown): boolean {
349
+ const texts = typeof name === "object" && name ? Object.values(name) : [name];
350
+ return !texts.some((text) => typeof text === "string" && text.trim());
351
+ }
352
+
353
+ /** The names trimmed, blank ones left out, the fallback language filled. */
354
+ function tidyNames(names: LocalizedText, locale: string): LocalizedText {
355
+ const tidy = Object.fromEntries(
356
+ Object.entries(names)
357
+ .map(([code, text]) => [code, text.trim()] as const)
358
+ .filter(([, text]) => text),
359
+ );
360
+ if (tidy[LOCALIZED_TEXT_FALLBACK_LOCALE]) return tidy;
361
+ const [first = ""] = Object.values(tidy);
362
+ return { ...tidy, [LOCALIZED_TEXT_FALLBACK_LOCALE]: tidy[locale] ?? first };
363
+ }
364
+
365
+ /**
366
+ * Once an edit of the name is written, stores it trimmed, and gives the
367
+ * fallback language a name when it has none, so every reader sees one.
368
+ */
276
369
  export function afterEdit(
277
370
  model: TemplateModel,
278
371
  id: string,
279
372
  name: unknown,
373
+ locale: string,
280
374
  ): AfterWrite | undefined {
281
- if (typeof name !== "string" || name === name.trim()) return undefined;
375
+ if (name === undefined) return undefined;
282
376
  return async () => {
283
- await model.update(id, { name: name.trim() });
377
+ const template = await model.get(id);
378
+ if (!template) return;
379
+ const names = TemplateModel.namesOf(template);
380
+ const tidy = tidyNames(names, locale);
381
+ if (JSON.stringify(tidy) !== JSON.stringify(names))
382
+ await model.updateFields(id, { name: tidy });
284
383
  };
285
384
  }
286
385
 
@@ -306,6 +405,11 @@ export class TemplatesTableAPI extends DataController(
306
405
  @TenantScopedModel(SendModel)
307
406
  declare sends: SendModel;
308
407
 
408
+ /** The language names are read, searched, sorted and written in. */
409
+ @Parameter(CONTENT_LANGUAGE_HEADER, "header")
410
+ @ModifierKey(LocalizationModifier)
411
+ declare language: string;
412
+
309
413
  @Select()
310
414
  @Listable()
311
415
  @Exported()
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { BasicDataModel } from "@antelopejs/interface-database-decorators";
2
+ import {
3
+ BasicDataModel,
4
+ triggerEvent,
5
+ } from "@antelopejs/interface-database-decorators";
6
+ import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
3
7
  import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
4
8
  import { TemplateSlugModel } from "./template-slugs.model";
5
9
 
@@ -9,22 +13,76 @@ export type TemplateAttentionFields = Pick<
9
13
  "attention" | "needsAttention"
10
14
  >;
11
15
 
16
+ const isLocalizedText = (value: unknown): value is LocalizedText =>
17
+ typeof value === "object" &&
18
+ value !== null &&
19
+ Object.values(value).every((text) => typeof text === "string");
20
+
21
+ /** Where a row keeps its modifier-held fields, the localized name among them. */
22
+ const MODIFIER_CONTAINER = "_internal";
23
+
24
+ /**
25
+ * Template fields as code writes them: the name holds every language
26
+ * (`{ en: "Welcome", fr: "Bienvenue" }`).
27
+ */
28
+ export type TemplateFields = Omit<Partial<MailingTemplate>, "name"> & {
29
+ name?: LocalizedText;
30
+ };
31
+
12
32
  export class TemplateModel extends BasicDataModel(
13
33
  MailingTemplate,
14
34
  MAILING_TEMPLATES_TABLE_NAME,
15
35
  ) {
36
+ /** The row of `fields`, its name written in every language it holds. */
37
+ static fromFields(fields: TemplateFields): MailingTemplate {
38
+ return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
39
+ }
40
+
41
+ /** The names of `template`, one per language. */
42
+ static namesOf(template: MailingTemplate): LocalizedText {
43
+ // Unlocked with "*", the localized field holds the whole per-language record.
44
+ const names: unknown = template.localize("*", ["name"]).name;
45
+ return isLocalizedText(names) ? names : {};
46
+ }
47
+
48
+ /** The name of `template` in `locale`, else in the fallback language. */
49
+ static nameIn(template: MailingTemplate, locale: string): string {
50
+ return template.localize(locale, ["name"]).name ?? "";
51
+ }
52
+
53
+ /** Inserts a template whose name holds every language. */
54
+ insertFields(fields: TemplateFields) {
55
+ return this.insert(TemplateModel.fromFields(fields));
56
+ }
57
+
58
+ /**
59
+ * Updates a template, a name given holding every language. Every write of
60
+ * a template goes through here rather than `update`: an update event leaves
61
+ * the row's modifier container (`_internal`, where the localized name
62
+ * lives) empty when the patch has no name, and the database merges a row
63
+ * shallowly, so that empty container would wipe the stored name.
64
+ */
65
+ updateFields(id: string, fields: TemplateFields): Promise<number> {
66
+ const row = TemplateModel.fromFields(fields);
67
+ triggerEvent(row, "update");
68
+ const stored = TemplateModel.toDatabase(row);
69
+ if (fields.name === undefined) delete stored[MODIFIER_CONTAINER];
70
+ return this.table.get(id).update(stored).run();
71
+ }
72
+
16
73
  /** Publishes only over the exact semantic revision that was inspected. */
17
74
  async publishIfCurrent(
18
75
  template: MailingTemplate,
19
- patch: Partial<MailingTemplate>,
76
+ patch: TemplateFields,
20
77
  ): Promise<boolean> {
78
+ const stored = TemplateModel.toDatabase(TemplateModel.fromFields(patch));
21
79
  const outcome = await this.table
22
80
  .atomicMutation(template._id, {
23
81
  type: "update",
24
82
  revisionField: "revision",
25
83
  expectedRevision: template.revision ?? { kind: "missing" },
26
84
  nextRevision: randomUUID(),
27
- patch: { ...patch, updatedAt: new Date() },
85
+ patch: { ...stored, updatedAt: new Date() },
28
86
  })
29
87
  .run();
30
88
  if (outcome === "unknown")
@@ -4,18 +4,21 @@ import {
4
4
  CreationTime,
5
5
  Field,
6
6
  Index,
7
+ LocalizationModifier,
8
+ Localized,
7
9
  Modifier,
8
10
  RegisterTable,
9
11
  Table,
10
12
  UpdateTime,
11
13
  } from "@antelopejs/interface-database-decorators";
12
14
  import { TENANT_SCHEMA_NAME } from "@antelopejs/interface-dms/constants";
15
+ import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
13
16
  import type { TemplateStatus } from "../../types";
14
17
 
15
18
  export const MAILING_TEMPLATES_TABLE_NAME = "mailing_templates";
16
19
 
17
20
  @RegisterTable(MAILING_TEMPLATES_TABLE_NAME, TENANT_SCHEMA_NAME)
18
- export class MailingTemplate extends Table {
21
+ export class MailingTemplate extends Table.with(LocalizationModifier) {
19
22
  @Field("string")
20
23
  declare _id: string;
21
24
 
@@ -32,6 +35,12 @@ export class MailingTemplate extends Table {
32
35
  @Field("string")
33
36
  declare slug: string;
34
37
 
38
+ /**
39
+ * One name per language (`{ en: "Welcome", fr: "Bienvenue" }`): read it
40
+ * after `template.localize(locale, ["name"])`, write every language at
41
+ * once through `localize("*")`.
42
+ */
43
+ @Localized({ fallbackLocale: LOCALIZED_TEXT_FALLBACK_LOCALE })
35
44
  @Field("string")
36
45
  declare name: string;
37
46
 
@@ -0,0 +1,124 @@
1
+ import type { VariableDefinition } from "../types";
2
+
3
+ /**
4
+ * An amount in the minor unit of its currency (cents for `EUR`), like the
5
+ * DMS's composed-text `money` parameter: `{ value: 987420, currency: "EUR" }`
6
+ * reads "€9,874.20" in English, "9 874,20 €" in French.
7
+ */
8
+ export interface MoneyValue {
9
+ value: number;
10
+ /** ISO 4217 code. An unknown code writes the amount as a plain number. */
11
+ currency: string;
12
+ }
13
+
14
+ /** How a send writes its typed values: its locale, and the paths typed `date`. */
15
+ export interface ValueFormat {
16
+ locale: string;
17
+ datePaths: Set<string>;
18
+ }
19
+
20
+ const FALLBACK_LOCALE = "en";
21
+ const DATE_STYLE: Intl.DateTimeFormatOptions = { dateStyle: "long" };
22
+ const UTC_DATE_STYLE: Intl.DateTimeFormatOptions = {
23
+ ...DATE_STYLE,
24
+ timeZone: "UTC",
25
+ };
26
+ /** A calendar day: written as that day, whatever the server's time zone. */
27
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
28
+ /** A moment: written as its day in the server's time zone. */
29
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
30
+ const DECIMAL_BASE = 10;
31
+ const DATE_TYPE = "date";
32
+
33
+ /** `locale` when the runtime can format for it, English otherwise. */
34
+ export function formattingLocale(locale: string): string {
35
+ try {
36
+ return Intl.NumberFormat.supportedLocalesOf(locale)[0] ?? FALLBACK_LOCALE;
37
+ } catch {
38
+ return FALLBACK_LOCALE;
39
+ }
40
+ }
41
+
42
+ /** The paths a template declares `date`: their ISO strings are written as dates. */
43
+ export function valueFormatOf(
44
+ locale: string,
45
+ variables: VariableDefinition[] = [],
46
+ ): ValueFormat {
47
+ return {
48
+ locale: formattingLocale(locale),
49
+ datePaths: new Set(
50
+ variables
51
+ .filter((variable) => variable.type === DATE_TYPE)
52
+ .map((variable) => variable.path),
53
+ ),
54
+ };
55
+ }
56
+
57
+ export function isMoneyValue(value: unknown): value is MoneyValue {
58
+ if (!value || typeof value !== "object") return false;
59
+ const candidate = value as Partial<MoneyValue>;
60
+ return (
61
+ typeof candidate.value === "number" &&
62
+ Number.isFinite(candidate.value) &&
63
+ typeof candidate.currency === "string"
64
+ );
65
+ }
66
+
67
+ function formatMoney(money: MoneyValue, locale: string): string {
68
+ try {
69
+ const currency = new Intl.NumberFormat(locale, {
70
+ style: "currency",
71
+ currency: money.currency,
72
+ });
73
+ const digits = currency.resolvedOptions().maximumFractionDigits ?? 0;
74
+ return currency.format(money.value / DECIMAL_BASE ** digits);
75
+ } catch {
76
+ return new Intl.NumberFormat(locale).format(money.value);
77
+ }
78
+ }
79
+
80
+ function formatDate(
81
+ date: Date,
82
+ options: Intl.DateTimeFormatOptions,
83
+ locale: string,
84
+ ): string {
85
+ return new Intl.DateTimeFormat(locale, options).format(date);
86
+ }
87
+
88
+ function isoDateOptions(value: string): Intl.DateTimeFormatOptions | null {
89
+ if (ISO_DAY.test(value)) return UTC_DATE_STYLE;
90
+ if (ISO_DATE_TIME.test(value)) return DATE_STYLE;
91
+ return null;
92
+ }
93
+
94
+ /** An ISO string at a `date` path as the date it names; `null` otherwise. */
95
+ function formatIsoDate(value: string, locale: string): string | null {
96
+ const options = isoDateOptions(value);
97
+ if (!options) return null;
98
+ const date = new Date(value);
99
+ return Number.isNaN(date.getTime())
100
+ ? null
101
+ : formatDate(date, options, locale);
102
+ }
103
+
104
+ /**
105
+ * A value as a recipient reads it. A money value and a `Date` format
106
+ * themselves in the send's locale wherever they appear; an ISO string is
107
+ * written as a date only at a path the template declares `date`; anything
108
+ * else, a string already formatted by the caller among them, as given.
109
+ */
110
+ export function formatValue(
111
+ value: unknown,
112
+ path = "",
113
+ format?: ValueFormat,
114
+ ): string {
115
+ const locale = format?.locale ?? FALLBACK_LOCALE;
116
+ if (isMoneyValue(value)) return formatMoney(value, locale);
117
+ if (value instanceof Date)
118
+ return Number.isNaN(value.getTime())
119
+ ? String(value)
120
+ : formatDate(value, DATE_STYLE, locale);
121
+ if (typeof value === "string" && format?.datePaths.has(path))
122
+ return formatIsoDate(value, locale) ?? value;
123
+ return String(value);
124
+ }
@@ -2,6 +2,7 @@ export * from "./changes";
2
2
  export * from "./conditions";
3
3
  export * from "./coverage";
4
4
  export * from "./fallback-html";
5
+ export * from "./formats";
5
6
  export * from "./paths";
6
7
  export * from "./plain-text";
7
8
  export * from "./resolve";
@@ -15,6 +15,7 @@ import type {
15
15
  } from "../types";
16
16
  import { evaluateCondition } from "./conditions";
17
17
  import { safeUrl } from "./fallback-html";
18
+ import { formatValue, type ValueFormat } from "./formats";
18
19
  import { getPath, isPresent } from "./paths";
19
20
  import { footerLinkPaths } from "./system-variables";
20
21
  import { type InterpolationMode, interpolate } from "./tokens";
@@ -26,6 +27,7 @@ interface ResolveState {
26
27
  mode: InterpolationMode;
27
28
  missing: Set<string>;
28
29
  hidden: string[];
30
+ format?: ValueFormat;
29
31
  }
30
32
 
31
33
  /** A blocked image source: the hero is left out rather than drawn broken. */
@@ -34,7 +36,7 @@ const BLOCKED_IMAGE = "";
34
36
  type BlockResolver = (block: Block, state: ResolveState) => ResolvedBlock[];
35
37
 
36
38
  function text(state: ResolveState, value: string): string {
37
- const result = interpolate(value, state.data, state.mode);
39
+ const result = interpolate(value, state.data, state.mode, state.format);
38
40
  for (const path of result.missing) state.missing.add(path);
39
41
  return result.text;
40
42
  }
@@ -61,9 +63,10 @@ function systemUrl(
61
63
  return isPresent(value) ? safeUrl(String(value)) : "";
62
64
  }
63
65
 
64
- function cell(item: unknown, path: string): string {
66
+ /** A list cell: a money value or a date in it is written for the locale. */
67
+ function cell(item: unknown, path: string, state: ResolveState): string {
65
68
  const value = getPath(item, path);
66
- return isPresent(value) ? String(value) : "";
69
+ return isPresent(value) ? formatValue(value, "", state.format) : "";
67
70
  }
68
71
 
69
72
  function listRows(block: ListBlock, state: ResolveState): ResolvedListRow[] {
@@ -73,8 +76,8 @@ function listRows(block: ListBlock, state: ResolveState): ResolvedListRow[] {
73
76
  return [];
74
77
  }
75
78
  return source.map((item) => ({
76
- label: cell(item, block.labelPath),
77
- value: cell(item, block.valuePath),
79
+ label: cell(item, block.labelPath, state),
80
+ value: cell(item, block.valuePath, state),
78
81
  }));
79
82
  }
80
83
 
@@ -191,14 +194,22 @@ const isUserPath = (path: string): boolean =>
191
194
 
192
195
  /**
193
196
  * Resolves `content` against `data`. A real send passes `send` so a missing
194
- * value leaves a blank; previews keep the token visible.
197
+ * value leaves a blank; previews keep the token visible. `format` writes the
198
+ * typed values (money, dates) for the e-mail's locale: see `valueFormatOf`.
195
199
  */
196
200
  export function resolveEmail(
197
201
  content: LocaleContent,
198
202
  data: Data,
199
203
  mode: InterpolationMode = "preview",
204
+ format?: ValueFormat,
200
205
  ): ResolvedEmail {
201
- const state: ResolveState = { data, mode, missing: new Set(), hidden: [] };
206
+ const state: ResolveState = {
207
+ data,
208
+ mode,
209
+ missing: new Set(),
210
+ hidden: [],
211
+ format,
212
+ };
202
213
  const subject = text(state, content.subject);
203
214
  const preheader = text(state, content.preheader);
204
215
  const blocks = resolveBlocks(content.blocks, state);
@@ -1,3 +1,4 @@
1
+ import { formatValue, type ValueFormat } from "./formats";
1
2
  import { getPath, isPresent } from "./paths";
2
3
 
3
4
  export const TOKEN_PATTERN = /\{\{\s*([A-Za-z0-9_.[\]]+)\s*\}\}/g;
@@ -25,14 +26,15 @@ export function extractTokens(text: string): string[] {
25
26
  return [...found];
26
27
  }
27
28
 
28
- function formatValue(value: unknown): string {
29
- return value instanceof Date ? value.toISOString() : String(value);
30
- }
31
-
29
+ /**
30
+ * Replaces each token of `text` with its value in `data`, typed values
31
+ * written for `format`'s locale (see {@link formatValue}).
32
+ */
32
33
  export function interpolate(
33
34
  text: string,
34
35
  data: Record<string, unknown>,
35
36
  mode: InterpolationMode = "preview",
37
+ format?: ValueFormat,
36
38
  ): InterpolationResult {
37
39
  const missing = new Set<string>();
38
40
  const output = text.replace(TOKEN_PATTERN, (token, path: string) => {
@@ -41,7 +43,7 @@ export function interpolate(
41
43
  missing.add(path);
42
44
  return MISSING_TOKEN[mode](token);
43
45
  }
44
- return formatValue(value);
46
+ return formatValue(value, path, format);
45
47
  });
46
48
  return { text: output, missing: [...missing] };
47
49
  }
@@ -0,0 +1,36 @@
1
+ import path from "node:path";
2
+ import type { AddFrontendModuleOptions } from "@antelopejs/interface-dms/page";
3
+ import {
4
+ API_BASE_PATH,
5
+ FRONTEND_MODULE_NAME,
6
+ WEBHOOK_SECRET_HEADER,
7
+ } from "./constants";
8
+
9
+ /**
10
+ * The provider webhook (`/events/:provider/:tenantId`), served through the
11
+ * dashboard's public URL: the front proxy lets it through without an
12
+ * `Origin`, never sends it a session and relays the secret header, which the
13
+ * route checks itself.
14
+ */
15
+ const PROVIDER_WEBHOOK_ENDPOINT = {
16
+ path: `${API_BASE_PATH}/events/*/*`,
17
+ methods: ["POST"],
18
+ headers: [WEBHOOK_SECRET_HEADER],
19
+ };
20
+
21
+ /**
22
+ * This module's frontend, from its configuration alone: the backend registers
23
+ * it with `AddFrontendModule`, and `@antelopejs/dms-frontend` calls this same
24
+ * function (declared as `dmsFrontend` in package.json) to build and start the
25
+ * frontend without a backend. It reads nothing but its arguments.
26
+ */
27
+ export function frontendModule(): AddFrontendModuleOptions {
28
+ return {
29
+ name: FRONTEND_MODULE_NAME,
30
+ sourcePath: path.join(__dirname, "../frontend-vue"),
31
+ renderer: { name: "vue", version: "3" },
32
+ options: { dmsMailing: {} },
33
+ priority: 0,
34
+ publicEndpoints: [PROVIDER_WEBHOOK_ENDPOINT],
35
+ };
36
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from "@antelopejs/interface-dms/hooks";
11
11
  import { MODULE_ID } from "../constants";
12
12
  import {
13
+ type MailingTemplate,
13
14
  SettingsModel,
14
15
  SuppressionModel,
15
16
  TemplateModel,
@@ -98,6 +99,12 @@ async function loadPage<T extends object>(
98
99
  .filter((row): row is T => row !== undefined);
99
100
  }
100
101
 
102
+ /** A template as exported: its name in every language. */
103
+ const templateCodec: RowCodec<MailingTemplate> = {
104
+ fromDatabase: (row) =>
105
+ TemplateModel.fromDatabase(row)?.localize("*", ["name"]),
106
+ };
107
+
101
108
  export function exportedCollections(tenantId: string): ExportedCollection[] {
102
109
  const templates = GetModel(TemplateModel, tenantId);
103
110
  const versions = GetModel(TemplateVersionModel, tenantId);
@@ -106,7 +113,7 @@ export function exportedCollections(tenantId: string): ExportedCollection[] {
106
113
  {
107
114
  entry: TEMPLATES_ENTRY,
108
115
  load: (offset, size) =>
109
- loadPage(templates.table, TemplateModel, offset, size),
116
+ loadPage(templates.table, templateCodec, offset, size),
110
117
  },
111
118
  {
112
119
  entry: TEMPLATE_VERSIONS_ENTRY,