@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
@@ -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
+ }
@@ -1,2 +1 @@
1
- export * from "./database-ready";
2
1
  export * from "./tenant-export";
@@ -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,
@@ -1,19 +1,8 @@
1
- import { afterDatabaseReady } from "../../hooks/database-ready";
2
- import {
3
- declareTemplate,
4
- declareTemplateCategory,
1
+ export {
2
+ declareTemplate as DeclareTemplate,
3
+ declareTemplateCategory as DeclareTemplateCategory,
5
4
  } from "../../services/declarations";
6
- import { importSends } from "../../services/send-import";
7
-
5
+ export { importSends as ImportSends } from "../../services/send-import";
6
+ export { declareSender as DeclareSender } from "../../services/sender-declaration";
8
7
  export { recordEmailEvent as RecordEmailEvent } from "../../services/events";
9
8
  export { sendUnlessSuppressed as SendTemplate } from "../../services/checked-send";
10
-
11
- /*
12
- * Projects call these from their `start()`, which runs in parallel with the
13
- * DMS's: they wait for the database the DMS opens there rather than fail.
14
- */
15
- export const DeclareTemplate = afterDatabaseReady(declareTemplate);
16
- export const DeclareTemplateCategory = afterDatabaseReady(
17
- declareTemplateCategory,
18
- );
19
- export const ImportSends = afterDatabaseReady(importSends);
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import "./permissions";
2
2
  import "./db";
3
- import path from "node:path";
4
3
  import { ImplementInterface } from "@antelopejs/interface-core";
5
4
  import { AddFrontendModule } from "@antelopejs/interface-dms/page";
6
5
  import {
@@ -8,7 +7,6 @@ import {
8
7
  unregisterAutomationNodes,
9
8
  } from "./automation";
10
9
  import { type DmsMailingConfig, setConfig } from "./config";
11
- import { API_BASE_PATH, FRONTEND_MODULE_NAME } from "./constants";
12
10
  import {
13
11
  ATTENTION_JOB,
14
12
  RETENTION_JOB,
@@ -16,13 +14,9 @@ import {
16
14
  scheduleJob,
17
15
  stopJob,
18
16
  } from "./crons";
19
- import {
20
- registerDatabaseReady,
21
- registerTenantExport,
22
- unregisterTenantExport,
23
- } from "./hooks";
17
+ import { registerTenantExport, unregisterTenantExport } from "./hooks";
24
18
  import { registerRealtimeTopics } from "./realtime";
25
- import { WEBHOOK_SECRET_HEADER } from "./services/webhook-status";
19
+ import { frontendModule } from "./frontend-module";
26
20
 
27
21
  export * from "./data";
28
22
  export * from "./data-types";
@@ -33,22 +27,9 @@ export type { DmsMailingConfig } from "./config";
33
27
 
34
28
  const CRON_JOBS = [RETENTION_JOB, ATTENTION_JOB];
35
29
 
36
- /**
37
- * The provider webhook (`/events/:provider/:tenantId`), served through the
38
- * dashboard's public URL: the front proxy lets it through without an
39
- * `Origin`, never sends it a session and relays the secret header, which the
40
- * route checks itself.
41
- */
42
- const PROVIDER_WEBHOOK_ENDPOINT = {
43
- path: `${API_BASE_PATH}/events/*/*`,
44
- methods: ["POST"],
45
- headers: [WEBHOOK_SECRET_HEADER],
46
- };
47
-
48
30
  let scheduledJobs: ScheduledJob[] = [];
49
31
 
50
32
  export async function construct(config: DmsMailingConfig = {}): Promise<void> {
51
- registerDatabaseReady();
52
33
  setConfig(config);
53
34
  ImplementInterface(
54
35
  await import("@antelopejs/interface-dms-mailing"),
@@ -57,14 +38,7 @@ export async function construct(config: DmsMailingConfig = {}): Promise<void> {
57
38
  registerAutomationNodes();
58
39
  registerTenantExport();
59
40
  registerRealtimeTopics();
60
- await AddFrontendModule({
61
- name: FRONTEND_MODULE_NAME,
62
- sourcePath: path.join(__dirname, "../frontend-vue"),
63
- renderer: { name: "vue", version: "3" },
64
- options: { dmsMailing: {} },
65
- priority: 0,
66
- publicEndpoints: [PROVIDER_WEBHOOK_ENDPOINT],
67
- });
41
+ await AddFrontendModule(frontendModule());
68
42
  }
69
43
 
70
44
  export function destroy(): void {
@@ -23,6 +23,7 @@ import {
23
23
  } from "../constants";
24
24
  import { BUSINESS_AUDIENCE, SEND_AUDIENCE_QUERY_KEY } from "../types";
25
25
  import { mailingNavCategory } from "./module";
26
+ import { MAILING_LIVE, mailingToolbar } from "./toolbar";
26
27
 
27
28
  const OVERVIEW_ORDER = 0;
28
29
  const GRID_GAP = "1rem";
@@ -30,6 +31,7 @@ const CHART_HEIGHT = "260px";
30
31
  const WIDE_SPAN = 2;
31
32
  const ATTENTION_SKELETON_ROWS = 3;
32
33
 
34
+ /** A KPI card of the Overview, refreshed on each send while the page is live. */
33
35
  const kpi = (
34
36
  id: string,
35
37
  icon: string,
@@ -40,6 +42,7 @@ const kpi = (
40
42
  variant: "stat",
41
43
  fetchUrl: `${API_BASE_PATH}/metrics/kpi/${id}?${SEND_AUDIENCE_QUERY_KEY}=${BUSINESS_AUDIENCE}`,
42
44
  periodScope: OVERVIEW_PERIOD_SCOPE,
45
+ realtimeTopic: MAILING_SENDS_TOPIC,
43
46
  valueFormat: "compact",
44
47
  showSparkline: true,
45
48
  ...extra,
@@ -104,9 +107,11 @@ const activity = Grid({ gap: GRID_GAP }).child(
104
107
  * `name` carried by each series goes to ApexCharts verbatim.
105
108
  */
106
109
  primaryLabel: "$dms_mailing.metrics.sends",
107
- comparisonLabel: "$dms_mailing.metrics.previous",
110
+ /** Neutral: the toolbar compares with the previous period or year. */
111
+ comparisonLabel: "$dms_mailing.metrics.compared",
108
112
  fetchUrl: `${API_BASE_PATH}/metrics/volume`,
109
113
  periodScope: OVERVIEW_PERIOD_SCOPE,
114
+ realtimeTopic: MAILING_SENDS_TOPIC,
110
115
  chart: ChartArea({
111
116
  height: CHART_HEIGHT,
112
117
  xaxisType: "datetime",
@@ -157,11 +162,16 @@ const breakdown = Grid({ gap: GRID_GAP }).child(
157
162
  title: "$dms_mailing.metrics.ranking",
158
163
  fetchUrl: `${API_BASE_PATH}/metrics/top-templates?metric=open-rate`,
159
164
  periodScope: OVERVIEW_PERIOD_SCOPE,
165
+ realtimeTopic: MAILING_SENDS_TOPIC,
160
166
  valueFormat: "percent",
161
167
  showRank: true,
162
168
  highlightTopN: 2,
163
169
  showBar: true,
164
- showDelta: false,
170
+ /**
171
+ * Each row's change against the window the toolbar compares with;
172
+ * "No comparison" leaves the rows without one.
173
+ */
174
+ showDelta: true,
165
175
  skeletonCount: 5,
166
176
  emptyLabel: "$dms_mailing.metrics.ranking_empty",
167
177
  }),
@@ -173,9 +183,10 @@ const breakdown = Grid({ gap: GRID_GAP }).child(
173
183
  description: "$dms_mailing.metrics.domain_sends",
174
184
  fetchUrl: `${API_BASE_PATH}/metrics/domains`,
175
185
  periodScope: OVERVIEW_PERIOD_SCOPE,
186
+ realtimeTopic: MAILING_SENDS_TOPIC,
176
187
  valueFormat: "percent",
177
188
  showRank: false,
178
- showDelta: false,
189
+ showDelta: true,
179
190
  skeletonCount: 5,
180
191
  emptyLabel: "$dms_mailing.metrics.ranking_empty",
181
192
  }),
@@ -192,6 +203,7 @@ export class OverviewPageController extends PageController(
192
203
  module: MODULE_ID,
193
204
  category: mailingNavCategory,
194
205
  order: OVERVIEW_ORDER,
206
+ live: MAILING_LIVE,
195
207
  },
196
208
  DefaultLayout({
197
209
  fullWidth: true,
@@ -206,19 +218,11 @@ export class OverviewPageController extends PageController(
206
218
  ],
207
219
  }),
208
220
  ) {
209
- static header = CustomComponent("MailingPageHeader")
210
- .options({
211
- periodScope: OVERVIEW_PERIOD_SCOPE,
212
- presets: ["last-7-days", "last-30-days", "last-90-days"],
213
- defaultPreset: "last-30-days",
214
- defaultComparison: "previous-period",
215
- showProvider: true,
216
- })
217
- .meta({
218
- name: "$dms_mailing.permissions.page_header.name",
219
- description: "$dms_mailing.permissions.page_header.description",
220
- icon: "i-ph-plugs-connected",
221
- });
221
+ static header = mailingToolbar({
222
+ scope: OVERVIEW_PERIOD_SCOPE,
223
+ presets: ["last-7-days", "last-30-days", "last-90-days"],
224
+ defaultPreset: "last-30-days",
225
+ });
222
226
 
223
227
  static dashboard = CustomComponent("MailingOverviewGate")
224
228
  .meta({
@@ -23,6 +23,7 @@ import { SendModel } from "../db";
23
23
  import { SendsTableAPI } from "../data/sends-table";
24
24
  import { RESOLVED_STAGE, type SendStage } from "../types";
25
25
  import { mailingNavCategory } from "./module";
26
+ import { MAILING_LIVE, NEW_SENDS_LABEL, mailingToolbar } from "./toolbar";
26
27
 
27
28
  const SENDS_ORDER = 3;
28
29
  const DAY_MS = 86_400_000;
@@ -187,18 +188,15 @@ export class SendsPageController extends PageController(
187
188
  module: MODULE_ID,
188
189
  category: mailingNavCategory,
189
190
  order: SENDS_ORDER,
191
+ live: MAILING_LIVE,
190
192
  },
191
193
  DefaultLayout({ fullWidth: true }),
192
194
  ) {
193
- static header = CustomComponent("MailingPageHeader")
194
- .options({
195
- periodScope: SENDS_PERIOD_SCOPE,
196
- presets: ["last-24h", "last-7-days", "last-30-days"],
197
- defaultPreset: "last-24h",
198
- defaultComparison: "previous-period",
199
- showProvider: true,
200
- })
201
- .meta(permissionMeta("page_header", "i-ph-plugs-connected"));
195
+ static header = mailingToolbar({
196
+ scope: SENDS_PERIOD_SCOPE,
197
+ presets: ["last-24h", "last-7-days", "last-30-days"],
198
+ defaultPreset: "last-24h",
199
+ });
202
200
 
203
201
  static health = Banner({
204
202
  fetchUrl: `${API_BASE_PATH}/sends/banner`,
@@ -226,6 +224,16 @@ export class SendsPageController extends PageController(
226
224
  searchPlaceholder: "$dms_mailing.sends.search",
227
225
  pageSize: 50,
228
226
  rowClick: "single",
227
+ /**
228
+ * New sends wait in a "N new sends" strip while the user reads a send,
229
+ * scrolls down the log or searches it, so the rows do not move under them.
230
+ */
231
+ liveHold: {
232
+ whileRowOpen: true,
233
+ whileScrolled: true,
234
+ whileSearching: true,
235
+ missedLabel: NEW_SENDS_LABEL,
236
+ },
229
237
  views: {
230
238
  layout: "tabs",
231
239
  items: [
@@ -294,16 +302,24 @@ export class SendsPageController extends PageController(
294
302
  /**
295
303
  * A send is an append-only record: it cannot be edited, so the
296
304
  * built-in `edit`/`details` row click has nothing to open. This
297
- * action takes the row click and opens the send in the side panel.
305
+ * action takes the row click and opens the send in a drawer on the
306
+ * right, at the DMS's default width (the user's resize is kept).
298
307
  */
299
308
  isDefault: true,
300
309
  target: {
301
- type: "sidePanel",
310
+ type: "drawer",
311
+ direction: "right",
302
312
  component: CustomComponent("MailingSendDrawer").meta(
303
313
  permissionMeta("send_drawer", "i-ph-info"),
304
314
  ),
305
315
  title: "$dms_mailing.sends.actions.details",
316
+ // Names the open send: its recipient and the template it used.
317
+ rowTitle: "$dms_mailing.sends.drawer.heading",
306
318
  positionLabel: "$dms_mailing.sends.drawer.position",
319
+ // A side panel keeps the open send in the URL and words a missing
320
+ // one by default; a drawer does both only when asked.
321
+ deepLink: true,
322
+ notFound: {},
307
323
  },
308
324
  },
309
325
  {
@@ -7,6 +7,8 @@ import { ComponentBuilder } from "@antelopejs/interface-dms/component";
7
7
  import { z } from "zod";
8
8
  import { API_BASE_PATH } from "../../constants";
9
9
  import { TemplateCategoriesType } from "../../data-types";
10
+ import { missingVariablesPolicy } from "../../services/settings";
11
+ import { MODULE_DEFAULT_SETTINGS } from "../../types";
10
12
  import {
11
13
  MAX_RETENTION_DAYS,
12
14
  MIN_RETENTION_DAYS,
@@ -37,6 +39,18 @@ const field = (id: string) => ({
37
39
  description: key(`fields.${id}.description`),
38
40
  });
39
41
 
42
+ /**
43
+ * A field holding a module default offers it: "Module default · Use default"
44
+ * while it holds something else. The retention does not: one click there
45
+ * could shorten it, and lowering it deletes sends, which its input asks to
46
+ * confirm instead. Nor do the categories: putting the default list back
47
+ * would move every template of a custom category.
48
+ */
49
+ const moduleDefault = (defaultValue: string) => ({
50
+ defaultValue,
51
+ offersDefault: true,
52
+ });
53
+
40
54
  const section = (
41
55
  id: string,
42
56
  icon: string,
@@ -84,12 +98,14 @@ const guards = section("guards", "i-ph-shield-check", [
84
98
  ],
85
99
  }),
86
100
  required: true,
101
+ ...moduleDefault(missingVariablesPolicy(MODULE_DEFAULT_SETTINGS)),
87
102
  },
88
103
  {
89
104
  ...field("fallbackLocale"),
90
105
  type: new DefaultDataTypes.StringType({}),
91
106
  inputComponent: CustomComponent("MailingLocaleSelect").serializeSync(),
92
107
  required: true,
108
+ ...moduleDefault(MODULE_DEFAULT_SETTINGS.fallbackLocale),
93
109
  },
94
110
  ]);
95
111
 
@@ -20,6 +20,7 @@ import {
20
20
  assertEditable,
21
21
  TemplatesTableAPI,
22
22
  } from "../data/templates-table";
23
+ import { localeOfRequest } from "../services/localized-text";
23
24
  import { mailingNavCategory } from "./module";
24
25
 
25
26
  const TEMPLATES_ORDER = 1;
@@ -95,13 +96,6 @@ export class TemplatesPageController extends PageController(
95
96
  filter: { accessorKey: "needsAttention", value: "true", mode: "is" },
96
97
  },
97
98
  ],
98
- quickFilters: [
99
- {
100
- field: "category",
101
- label: "$dms_mailing.templates.filters.category",
102
- icon: "i-ph-tag",
103
- },
104
- ],
105
99
  footer: {
106
100
  countLabel: "$dms_mailing.templates.footer.count",
107
101
  hint: "$dms_mailing.templates.footer.hint",
@@ -115,7 +109,7 @@ export class TemplatesPageController extends PageController(
115
109
  },
116
110
  async edit(ctx, { id, body, current }) {
117
111
  await assertEditable(getRequestTenantId(ctx), body, current);
118
- return afterEdit(this.model, id, body.name);
112
+ return afterEdit(this.model, id, body.name, localeOfRequest(ctx));
119
113
  },
120
114
  },
121
115
  rowActions: {
@@ -152,6 +146,8 @@ export class TemplatesPageController extends PageController(
152
146
  deepLink: true,
153
147
  target: {
154
148
  type: "drawer",
149
+ // On the right, as the gallery opens it (useTemplateDrawer).
150
+ direction: "right",
155
151
  component: templateDrawer,
156
152
  title: "$dms_mailing.templates.actions.details",
157
153
  },
@@ -0,0 +1,77 @@
1
+ import {
2
+ type PageToolbarProps,
3
+ type PeriodComparison,
4
+ type PeriodPreset,
5
+ pageToolbar,
6
+ } from "@antelopejs/interface-dms/base";
7
+ import type { ComponentBuilder } from "@antelopejs/interface-dms/component";
8
+ import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
9
+ import type { PageLiveOptions } from "@antelopejs/interface-dms/page";
10
+ import { MODULE_ID } from "../constants";
11
+
12
+ /** Shortest interval, in seconds, between two refetches of a live block. */
13
+ const LIVE_MAX_RATE_SECONDS = 5;
14
+
15
+ /**
16
+ * Direct / Pause of the pages fed by the sends topic. One `rememberKey` for
17
+ * the module: pausing the Sends log pauses the Overview too.
18
+ */
19
+ export const MAILING_LIVE: PageLiveOptions = {
20
+ maxRate: LIVE_MAX_RATE_SECONDS,
21
+ rememberKey: MODULE_ID,
22
+ };
23
+
24
+ /** What the paused toggle and a held table call the sends they missed. */
25
+ export const NEW_SENDS_LABEL = "$dms_mailing.live.new_sends";
26
+
27
+ /**
28
+ * The comparisons every mailing toolbar offers, in its "vs …" menu: the
29
+ * previous period by default, the same window a year back, or none, which
30
+ * leaves the cards without a delta.
31
+ */
32
+ export const MAILING_COMPARISONS: PeriodComparison[] = [
33
+ "previous-period",
34
+ "previous-year",
35
+ "none",
36
+ ];
37
+
38
+ /** The period a page's toolbar offers. */
39
+ export interface MailingToolbarPeriod {
40
+ scope: string;
41
+ presets: PeriodPreset[];
42
+ defaultPreset: PeriodPreset;
43
+ }
44
+
45
+ /**
46
+ * The DMS page toolbar of a mailing page: the provider pill on the left, then
47
+ * "updated", Direct / Pause and the period with its comparison menu ("vs …").
48
+ *
49
+ * The page keeps it under its `header` static, the id the former header
50
+ * block had, so a role granting it keeps granting the bar.
51
+ */
52
+ export function mailingToolbar(
53
+ period: MailingToolbarPeriod,
54
+ ): ComponentBuilder<PageToolbarProps> {
55
+ return pageToolbar({
56
+ leading: {
57
+ provider: CustomComponent("MailingProviderPill").meta({
58
+ name: "$dms_mailing.permissions.provider_pill.name",
59
+ description: "$dms_mailing.permissions.provider_pill.description",
60
+ icon: "i-ph-plugs-connected",
61
+ }),
62
+ },
63
+ live: { missedLabel: NEW_SENDS_LABEL },
64
+ period: {
65
+ id: period.scope,
66
+ presets: period.presets,
67
+ defaultPreset: period.defaultPreset,
68
+ // More than one comparison: pageToolbar() draws them as a "vs …" menu
69
+ // on the first one, the previous period.
70
+ comparisons: MAILING_COMPARISONS,
71
+ },
72
+ }).meta({
73
+ name: "$dms_mailing.permissions.page_header.name",
74
+ description: "$dms_mailing.permissions.page_header.description",
75
+ icon: "i-ph-sliders-horizontal",
76
+ });
77
+ }
package/src/realtime.ts CHANGED
@@ -31,11 +31,20 @@ export function registerRealtimeTopics(): void {
31
31
  }
32
32
  }
33
33
 
34
- function publishSendChange(type: string, sendId: string): void {
35
- const payload = { ids: [sendId] };
34
+ /**
35
+ * A send of `tenantId` was created or changed: the sends table and the
36
+ * Overview / Sends figures of that tenant read again (AntelopeJS/dms#353
37
+ * scopes the message to it; another workspace hears nothing).
38
+ */
39
+ function publishSendChange(
40
+ type: string,
41
+ tenantId: string,
42
+ sendId: string,
43
+ ): void {
44
+ const options = { payload: { ids: [sendId] }, tenantId };
36
45
  void Promise.all([
37
- PublishMessage(SENDS_ROW_TOPIC, type, { payload }),
38
- PublishMessage(MAILING_SENDS_TOPIC, type, { payload }),
46
+ PublishMessage(SENDS_ROW_TOPIC, type, options),
47
+ PublishMessage(MAILING_SENDS_TOPIC, type, options),
39
48
  ]).catch((error: unknown) => {
40
49
  Logging.Error(
41
50
  `[dms-mailing] Failed to publish "${type}" for send "${sendId}":`,
@@ -44,10 +53,10 @@ function publishSendChange(type: string, sendId: string): void {
44
53
  });
45
54
  }
46
55
 
47
- export function publishSendCreated(sendId: string): void {
48
- publishSendChange(SEND_CREATED_TYPE, sendId);
56
+ export function publishSendCreated(tenantId: string, sendId: string): void {
57
+ publishSendChange(SEND_CREATED_TYPE, tenantId, sendId);
49
58
  }
50
59
 
51
- export function publishSendUpdated(sendId: string): void {
52
- publishSendChange(SEND_UPDATED_TYPE, sendId);
60
+ export function publishSendUpdated(tenantId: string, sendId: string): void {
61
+ publishSendChange(SEND_UPDATED_TYPE, tenantId, sendId);
53
62
  }