@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,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,28 +58,35 @@ 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";
57
73
  import {
58
- assertSlugClaimed,
59
74
  blankContent,
60
75
  blankFields,
61
76
  contentFieldsOf,
62
77
  displayName,
63
78
  initializeTemplate,
64
- releaseTemplateSlugs,
79
+ refuseTakenSlug,
65
80
  } from "../services/templates";
66
81
  import type { TemplateCategory } from "../types";
67
82
 
68
- import { createTemplateSchema } from "../validation/templates.schema";
83
+ import {
84
+ createTemplateSchema,
85
+ templateDetailsSchema,
86
+ } from "../validation/templates.schema";
69
87
  import { parseBody } from "./body";
70
88
  import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
71
89
 
72
- const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
73
90
  const SLUG_IMMUTABLE = "$dms_mailing.errors.slug_immutable";
74
91
  const SOURCE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
75
92
  const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
@@ -116,7 +133,7 @@ async function seedFields(
116
133
  tenantId: string,
117
134
  seed: TemplateSeed,
118
135
  fallbackLocale: string,
119
- ): Promise<Partial<MailingTemplate>> {
136
+ ): Promise<TemplateFields> {
120
137
  const source = await resolveSource(tenantId, seed.sourceTemplateId);
121
138
  if (source) return contentFieldsOf(source);
122
139
  if (!seed.starterId) {
@@ -144,23 +161,43 @@ function assertKnownCategory(
144
161
  throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
145
162
  }
146
163
 
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);
164
+ /** The row `new` answers since interface-data-api 0.3: its key and fields. */
165
+ interface CreatedRow {
166
+ _id: string;
167
+ [field: string]: unknown;
168
+ }
169
+
170
+ /**
171
+ * The creation's seed, and the body to store: slug trimmed, and the name
172
+ * typed in the reader's language as every language of the localized field,
173
+ * the fallback one included until someone translates it. A body a guard
174
+ * already parsed arrives as an object.
175
+ */
176
+ function parseCreation(body: Validation.WrittenBody, locale: string) {
177
+ const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
150
178
  const seed = assertValidation(
151
179
  raw,
152
180
  (value) => createTemplateSchema.parse(value),
153
181
  () => INVALID_TEMPLATE,
154
182
  HTTP_BAD_REQUEST,
155
183
  );
156
- const normalized = JSON.stringify({
184
+ // Handed as an object: interface-data-api 0.3 refuses a JSON string.
185
+ const normalized = {
157
186
  ...raw,
158
187
  slug: seed.slug,
159
- name: seed.name,
160
- });
188
+ name: withTextIn(undefined, locale, seed.name),
189
+ };
161
190
  return { seed, normalized };
162
191
  }
163
192
 
193
+ /** What the data API locks a localized field with: the controller's key. */
194
+ interface LocalizedController {
195
+ language: string;
196
+ }
197
+
198
+ /** The key that writes every language of a localized field at once. */
199
+ const ALL_LANGUAGES = "*";
200
+
164
201
  function withSeeding(base: DataControllerCallback): DataControllerCallback {
165
202
  return {
166
203
  ...base,
@@ -168,30 +205,24 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
168
205
  this: unknown,
169
206
  ctx: RequestContext,
170
207
  params: unknown,
171
- body: Buffer | string,
208
+ body: Validation.WrittenBody,
172
209
  ...rest: unknown[]
173
210
  ) {
174
- const { seed, normalized } = parseCreation(body);
211
+ const { seed, normalized } = parseCreation(body, localeOfRequest(ctx));
175
212
  const tenantId = getRequestTenantId(ctx);
176
- const model = GetModel(TemplateModel, tenantId);
177
- if (await model.getBySlug(seed.slug)) {
178
- throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
179
- }
180
213
  const settings = await getSettings(tenantId);
181
214
  assertKnownCategory(settings.categories, seed.category);
182
215
  const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
183
- const ids = (await base.func.call(
184
- this,
185
- ctx,
186
- params,
187
- normalized,
188
- ...rest,
189
- )) as string[];
190
- const templateId = ids[0] as string;
191
- await assertSlugClaimed(tenantId, seed.slug, templateId);
216
+ // The module's creation dialogs send one typed name; the stored name
217
+ // holds every language, written at once.
218
+ (this as LocalizedController).language = ALL_LANGUAGES;
219
+ const created = (await refuseTakenSlug(
220
+ base.func.call(this, ctx, params, normalized, ...rest),
221
+ )) as CreatedRow;
222
+ const templateId = created._id;
192
223
  const user = await authenticateTenantRequest(ctx);
193
224
  await initializeTemplate(tenantId, templateId, displayName(user), fields);
194
- return ids;
225
+ return created;
195
226
  },
196
227
  };
197
228
  }
@@ -249,7 +280,6 @@ export function afterDelete(tenantId: string, ids: string[]): AfterWrite {
249
280
  await Promise.all(
250
281
  ids.map((id) => versions.getAll(id, "templateId").delete().run()),
251
282
  );
252
- await releaseTemplateSlugs(tenantId, ids);
253
283
  await refreshTemplateAttention(tenantId);
254
284
  };
255
285
  }
@@ -266,21 +296,78 @@ export async function assertEditable(
266
296
  ): Promise<void> {
267
297
  if (body.slug !== undefined && body.slug !== current.slug)
268
298
  throw new HTTPResult(HTTP_CONFLICT, SLUG_IMMUTABLE);
269
- if (typeof body.name === "string" && !body.name.trim())
299
+ if (body.name !== undefined && isBlankName(body.name))
270
300
  throw new HTTPResult(HTTP_BAD_REQUEST, BLANK_NAME);
271
301
  const { categories } = await getSettings(tenantId);
272
302
  assertKnownCategory(categories, body.category);
273
303
  }
274
304
 
275
- /** Stores an edited name trimmed, once the edit is written. */
305
+ /**
306
+ * Saves what the module's drawer and editor change: the name, typed in the
307
+ * reader's language (its other languages kept), and the category.
308
+ */
309
+ export async function saveDetails(
310
+ ctx: RequestContext,
311
+ template: MailingTemplate,
312
+ body: unknown,
313
+ author: string,
314
+ ): Promise<void> {
315
+ const details = assertValidation(
316
+ body,
317
+ (value) => templateDetailsSchema.parse(value),
318
+ () => INVALID_TEMPLATE,
319
+ );
320
+ const tenantId = getRequestTenantId(ctx);
321
+ await assertEditable(tenantId, details, template);
322
+ const names = TemplateModel.namesOf(template);
323
+ await GetModel(TemplateModel, tenantId).updateFields(template._id, {
324
+ name: withTextIn(names, localeOfRequest(ctx), details.name),
325
+ ...(details.category !== undefined && {
326
+ category: details.category ?? "",
327
+ }),
328
+ updatedBy: author,
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Whether an edited name holds no text: the text of the reader's language,
334
+ * or, from a form editing every language, all of them.
335
+ */
336
+ function isBlankName(name: unknown): boolean {
337
+ const texts = typeof name === "object" && name ? Object.values(name) : [name];
338
+ return !texts.some((text) => typeof text === "string" && text.trim());
339
+ }
340
+
341
+ /** The names trimmed, blank ones left out, the fallback language filled. */
342
+ function tidyNames(names: LocalizedText, locale: string): LocalizedText {
343
+ const tidy = Object.fromEntries(
344
+ Object.entries(names)
345
+ .map(([code, text]) => [code, text.trim()] as const)
346
+ .filter(([, text]) => text),
347
+ );
348
+ if (tidy[LOCALIZED_TEXT_FALLBACK_LOCALE]) return tidy;
349
+ const [first = ""] = Object.values(tidy);
350
+ return { ...tidy, [LOCALIZED_TEXT_FALLBACK_LOCALE]: tidy[locale] ?? first };
351
+ }
352
+
353
+ /**
354
+ * Once an edit of the name is written, stores it trimmed, and gives the
355
+ * fallback language a name when it has none, so every reader sees one.
356
+ */
276
357
  export function afterEdit(
277
358
  model: TemplateModel,
278
359
  id: string,
279
360
  name: unknown,
361
+ locale: string,
280
362
  ): AfterWrite | undefined {
281
- if (typeof name !== "string" || name === name.trim()) return undefined;
363
+ if (name === undefined) return undefined;
282
364
  return async () => {
283
- await model.update(id, { name: name.trim() });
365
+ const template = await model.get(id);
366
+ if (!template) return;
367
+ const names = TemplateModel.namesOf(template);
368
+ const tidy = tidyNames(names, locale);
369
+ if (JSON.stringify(tidy) !== JSON.stringify(names))
370
+ await model.updateFields(id, { name: tidy });
284
371
  };
285
372
  }
286
373
 
@@ -306,6 +393,11 @@ export class TemplatesTableAPI extends DataController(
306
393
  @TenantScopedModel(SendModel)
307
394
  declare sends: SendModel;
308
395
 
396
+ /** The language names are read, searched, sorted and written in. */
397
+ @Parameter(CONTENT_LANGUAGE_HEADER, "header")
398
+ @ModifierKey(LocalizationModifier)
399
+ declare language: string;
400
+
309
401
  @Select()
310
402
  @Listable()
311
403
  @Exported()
@@ -2,7 +2,6 @@ export * from "./send-events.model";
2
2
  export * from "./sends.model";
3
3
  export * from "./settings.model";
4
4
  export * from "./suppressions.model";
5
- export * from "./template-slugs.model";
6
5
  export * from "./template-versions.model";
7
6
  export * from "./templates.model";
8
7
  export * from "./webhook-status.model";
@@ -1,3 +1,4 @@
1
+ import { IsDuplicateKeyError } from "@antelopejs/interface-database";
1
2
  import { BasicDataModel } from "@antelopejs/interface-database-decorators";
2
3
  import { MAILING_SEND_EVENTS_TABLE_NAME, MailingSendEvent } from "../tables";
3
4
  import { SendModel } from "./sends.model";
@@ -28,11 +29,10 @@ export class SendEventModel extends BasicDataModel(
28
29
  * the same import already did.
29
30
  */
30
31
  async insertImported(event: MailingSendEvent): Promise<void> {
31
- if (await this.get(event._id)) return;
32
32
  try {
33
33
  await this.insert(event);
34
34
  } catch (error) {
35
- if (!(await this.get(event._id))) throw error;
35
+ if (!IsDuplicateKeyError(error)) throw error;
36
36
  }
37
37
  }
38
38
 
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { BasicDataModel } from "@antelopejs/interface-database-decorators";
3
- import type {
4
- AtomicMutationOutcome,
5
- ValueProxy,
3
+ import {
4
+ type AtomicMutationOutcome,
5
+ IsDuplicateKeyError,
6
+ type ValueProxy,
6
7
  } from "@antelopejs/interface-database";
7
8
  import { isResolvedStage, stageOf } from "../../types";
8
9
  import { MAILING_SENDS_TABLE_NAME, MailingSend } from "../tables";
@@ -89,17 +90,15 @@ export class SendModel extends BasicDataModel(
89
90
  }
90
91
 
91
92
  /**
92
- * Writes an imported send under its own id; false when that id is already
93
- * taken, by an earlier run of the same import. Probed by state like the
94
- * slug claims, since the database exposes no typed conflict error.
93
+ * Writes an imported send under its own id; false when an earlier run of
94
+ * the same import already wrote it (its id is derived from the import's).
95
95
  */
96
96
  async insertImported(send: ImportedSendRow): Promise<boolean> {
97
- if (await this.get(send._id)) return false;
98
97
  try {
99
98
  await this.table.insert({ ...send, revision: randomUUID() }).run();
100
99
  return true;
101
100
  } catch (error) {
102
- if (await this.get(send._id)) return false;
101
+ if (IsDuplicateKeyError(error)) return false;
103
102
  throw error;
104
103
  }
105
104
  }
@@ -1,7 +1,10 @@
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
- import { TemplateSlugModel } from "./template-slugs.model";
5
8
 
6
9
  /** The attention fields of a template, as the refresh writes them. */
7
10
  export type TemplateAttentionFields = Pick<
@@ -9,22 +12,76 @@ export type TemplateAttentionFields = Pick<
9
12
  "attention" | "needsAttention"
10
13
  >;
11
14
 
15
+ const isLocalizedText = (value: unknown): value is LocalizedText =>
16
+ typeof value === "object" &&
17
+ value !== null &&
18
+ Object.values(value).every((text) => typeof text === "string");
19
+
20
+ /** Where a row keeps its modifier-held fields, the localized name among them. */
21
+ const MODIFIER_CONTAINER = "_internal";
22
+
23
+ /**
24
+ * Template fields as code writes them: the name holds every language
25
+ * (`{ en: "Welcome", fr: "Bienvenue" }`).
26
+ */
27
+ export type TemplateFields = Omit<Partial<MailingTemplate>, "name"> & {
28
+ name?: LocalizedText;
29
+ };
30
+
12
31
  export class TemplateModel extends BasicDataModel(
13
32
  MailingTemplate,
14
33
  MAILING_TEMPLATES_TABLE_NAME,
15
34
  ) {
35
+ /** The row of `fields`, its name written in every language it holds. */
36
+ static fromFields(fields: TemplateFields): MailingTemplate {
37
+ return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
38
+ }
39
+
40
+ /** The names of `template`, one per language. */
41
+ static namesOf(template: MailingTemplate): LocalizedText {
42
+ // Unlocked with "*", the localized field holds the whole per-language record.
43
+ const names: unknown = template.localize("*", ["name"]).name;
44
+ return isLocalizedText(names) ? names : {};
45
+ }
46
+
47
+ /** The name of `template` in `locale`, else in the fallback language. */
48
+ static nameIn(template: MailingTemplate, locale: string): string {
49
+ return template.localize(locale, ["name"]).name ?? "";
50
+ }
51
+
52
+ /** Inserts a template whose name holds every language. */
53
+ insertFields(fields: TemplateFields) {
54
+ return this.insert(TemplateModel.fromFields(fields));
55
+ }
56
+
57
+ /**
58
+ * Updates a template, a name given holding every language. Every write of
59
+ * a template goes through here rather than `update`: an update event leaves
60
+ * the row's modifier container (`_internal`, where the localized name
61
+ * lives) empty when the patch has no name, and the database merges a row
62
+ * shallowly, so that empty container would wipe the stored name.
63
+ */
64
+ updateFields(id: string, fields: TemplateFields): Promise<number> {
65
+ const row = TemplateModel.fromFields(fields);
66
+ triggerEvent(row, "update");
67
+ const stored = TemplateModel.toDatabase(row);
68
+ if (fields.name === undefined) delete stored[MODIFIER_CONTAINER];
69
+ return this.table.get(id).update(stored).run();
70
+ }
71
+
16
72
  /** Publishes only over the exact semantic revision that was inspected. */
17
73
  async publishIfCurrent(
18
74
  template: MailingTemplate,
19
- patch: Partial<MailingTemplate>,
75
+ patch: TemplateFields,
20
76
  ): Promise<boolean> {
77
+ const stored = TemplateModel.toDatabase(TemplateModel.fromFields(patch));
21
78
  const outcome = await this.table
22
79
  .atomicMutation(template._id, {
23
80
  type: "update",
24
81
  revisionField: "revision",
25
82
  expectedRevision: template.revision ?? { kind: "missing" },
26
83
  nextRevision: randomUUID(),
27
- patch: { ...patch, updatedAt: new Date() },
84
+ patch: { ...stored, updatedAt: new Date() },
28
85
  })
29
86
  .run();
30
87
  if (outcome === "unknown")
@@ -32,19 +89,10 @@ export class TemplateModel extends BasicDataModel(
32
89
  return outcome === "applied";
33
90
  }
34
91
 
92
+ /** The template holding `slug`: the unique index keeps it to one. */
35
93
  async getBySlug(slug: string): Promise<MailingTemplate | undefined> {
36
- const rows = await this.getBy("slug", slug);
37
- // A losing creation can still be cleaning up (or its process can crash).
38
- // Senders must see the claimed template, not an earlier draft candidate.
39
- if (rows.length > 1) {
40
- const claims = await new TemplateSlugModel(this.database).getBy(
41
- "templateId",
42
- ...rows.map((row) => row._id),
43
- );
44
- const claim = claims.find((candidate) => candidate.slug === slug);
45
- if (claim) return rows.find((row) => row._id === claim.templateId);
46
- }
47
- return rows[0];
94
+ const [template] = await this.getBy("slug", slug);
95
+ return template;
48
96
  }
49
97
 
50
98
  /**
@@ -2,7 +2,6 @@ export * from "./send-events.table";
2
2
  export * from "./sends.table";
3
3
  export * from "./settings.table";
4
4
  export * from "./suppressions.table";
5
- export * from "./template-slugs.table";
6
5
  export * from "./template-versions.table";
7
6
  export * from "./templates.table";
8
7
  export * from "./webhook-status.table";
@@ -46,12 +46,4 @@ export class MailingSettings extends Table {
46
46
 
47
47
  @Field("string")
48
48
  declare webhookSecret: string;
49
-
50
- /**
51
- * JSON-serialized fingerprint of each category `DeclareTemplateCategory`
52
- * applied, by its id: what tells a category untouched since from one a
53
- * user relabelled or removed.
54
- */
55
- @Field("string")
56
- declare json_declared_categories?: string;
57
49
  }
@@ -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
 
@@ -28,10 +31,17 @@ export class MailingTemplate extends Table {
28
31
  @Field("date")
29
32
  declare updatedAt: Date;
30
33
 
31
- @Index()
34
+ /** What sends name the template by: one template per slug in a tenant. */
35
+ @Index({ unique: true })
32
36
  @Field("string")
33
37
  declare slug: string;
34
38
 
39
+ /**
40
+ * One name per language (`{ en: "Welcome", fr: "Bienvenue" }`): read it
41
+ * after `template.localize(locale, ["name"])`, write every language at
42
+ * once through `localize("*")`.
43
+ */
44
+ @Localized({ fallbackLocale: LOCALIZED_TEXT_FALLBACK_LOCALE })
35
45
  @Field("string")
36
46
  declare name: string;
37
47
 
@@ -132,25 +142,10 @@ export class MailingTemplate extends Table {
132
142
  @Field("date")
133
143
  declare unpublishedAt?: Date;
134
144
 
135
- /**
136
- * Fingerprint of the last declaration `DeclareTemplate` applied; blank for
137
- * a template made in the dashboard. A template still matching it is
138
- * untouched since, and the next declaration may refresh it.
139
- */
140
- @Field("string")
141
- declare declaredHash?: string;
142
-
143
145
  /** Every semantic edit invalidates a publication's optimistic snapshot. */
144
146
  @Field("string")
145
147
  declare revision?: string;
146
148
 
147
- /** An unmodified declaration seed can be completed after a crashed creator. */
148
- @Field("string")
149
- declare declarationSeedRevision?: string;
150
-
151
- @Field("string")
152
- declare pendingDeclaredHash?: string;
153
-
154
149
  /**
155
150
  * Complete latest version row, committed atomically with its content. Its
156
151
  * history row can be recovered after an interrupted publication.
@@ -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";