@antelopejs/dms-mailing 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +28 -10
  2. package/dist/constants.d.ts +0 -2
  3. package/dist/constants.js +1 -3
  4. package/dist/constants.js.map +1 -1
  5. package/dist/crons/jobs.d.ts +1 -1
  6. package/dist/data/body.js +2 -1
  7. package/dist/data/body.js.map +1 -1
  8. package/dist/data/templates-table.js +5 -11
  9. package/dist/data/templates-table.js.map +1 -1
  10. package/dist/db/models/index.d.ts +0 -1
  11. package/dist/db/models/index.js +0 -1
  12. package/dist/db/models/index.js.map +1 -1
  13. package/dist/db/models/send-events.model.js +2 -3
  14. package/dist/db/models/send-events.model.js.map +1 -1
  15. package/dist/db/models/sends.model.js +2 -3
  16. package/dist/db/models/sends.model.js.map +1 -1
  17. package/dist/db/models/template-versions.model.js.map +1 -1
  18. package/dist/db/models/templates.model.js +4 -10
  19. package/dist/db/models/templates.model.js.map +1 -1
  20. package/dist/db/tables/index.d.ts +0 -1
  21. package/dist/db/tables/index.js +0 -1
  22. package/dist/db/tables/index.js.map +1 -1
  23. package/dist/db/tables/settings.table.d.ts +0 -1
  24. package/dist/db/tables/settings.table.js +0 -4
  25. package/dist/db/tables/settings.table.js.map +1 -1
  26. package/dist/db/tables/templates.table.d.ts +0 -3
  27. package/dist/db/tables/templates.table.js +1 -13
  28. package/dist/db/tables/templates.table.js.map +1 -1
  29. package/dist/engine/changes.js +2 -1
  30. package/dist/engine/changes.js.map +1 -1
  31. package/dist/engine/conditions.js +2 -3
  32. package/dist/engine/conditions.js.map +1 -1
  33. package/dist/engine/fallback-html.js +8 -8
  34. package/dist/engine/fallback-html.js.map +1 -1
  35. package/dist/engine/formats.js +6 -6
  36. package/dist/engine/formats.js.map +1 -1
  37. package/dist/engine/paths.js +2 -3
  38. package/dist/engine/paths.js.map +1 -1
  39. package/dist/engine/system-variables.js +1 -1
  40. package/dist/engine/system-variables.js.map +1 -1
  41. package/dist/engine/tokens.js.map +1 -1
  42. package/dist/hooks/index.d.ts +0 -1
  43. package/dist/hooks/index.js +0 -1
  44. package/dist/hooks/index.js.map +1 -1
  45. package/dist/implementations/dms-mailing/index.d.ts +3 -4
  46. package/dist/implementations/dms-mailing/index.js +8 -9
  47. package/dist/implementations/dms-mailing/index.js.map +1 -1
  48. package/dist/index.js +0 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/pages/sends.js.map +1 -1
  51. package/dist/pages/settings/form.js +8 -0
  52. package/dist/pages/settings/form.js.map +1 -1
  53. package/dist/pages/toolbar.js.map +1 -1
  54. package/dist/routes/events.js +2 -2
  55. package/dist/routes/events.js.map +1 -1
  56. package/dist/routes/metrics.js +6 -5
  57. package/dist/routes/metrics.js.map +1 -1
  58. package/dist/routes/sends.js +1 -1
  59. package/dist/routes/sends.js.map +1 -1
  60. package/dist/routes/settings.js +6 -6
  61. package/dist/routes/settings.js.map +1 -1
  62. package/dist/routes/templates.js +6 -10
  63. package/dist/routes/templates.js.map +1 -1
  64. package/dist/services/attention.js +2 -1
  65. package/dist/services/attention.js.map +1 -1
  66. package/dist/services/categories.js +6 -6
  67. package/dist/services/categories.js.map +1 -1
  68. package/dist/services/checked-send.js +16 -9
  69. package/dist/services/checked-send.js.map +1 -1
  70. package/dist/services/declarations.js +42 -184
  71. package/dist/services/declarations.js.map +1 -1
  72. package/dist/services/events.js +11 -7
  73. package/dist/services/events.js.map +1 -1
  74. package/dist/services/localized-text.d.ts +2 -1
  75. package/dist/services/localized-text.js +2 -1
  76. package/dist/services/localized-text.js.map +1 -1
  77. package/dist/services/metrics.js.map +1 -1
  78. package/dist/services/render.js.map +1 -1
  79. package/dist/services/send-import.js +7 -5
  80. package/dist/services/send-import.js.map +1 -1
  81. package/dist/services/send.js +4 -3
  82. package/dist/services/send.js.map +1 -1
  83. package/dist/services/settings.d.ts +1 -1
  84. package/dist/services/settings.js +1 -5
  85. package/dist/services/settings.js.map +1 -1
  86. package/dist/services/starters.js +5 -5
  87. package/dist/services/starters.js.map +1 -1
  88. package/dist/services/template-stats.js +5 -2
  89. package/dist/services/template-stats.js.map +1 -1
  90. package/dist/services/templates.d.ts +2 -2
  91. package/dist/services/templates.js +54 -29
  92. package/dist/services/templates.js.map +1 -1
  93. package/dist/types/settings.d.ts +2 -0
  94. package/dist/types/settings.js +8 -1
  95. package/dist/types/settings.js.map +1 -1
  96. package/dist/validation/declarations.schema.d.ts +16 -16
  97. package/dist/validation/declarations.schema.js +2 -2
  98. package/dist/validation/declarations.schema.js.map +1 -1
  99. package/dist/validation/guards.d.ts +4 -0
  100. package/dist/validation/guards.js +19 -0
  101. package/dist/validation/guards.js.map +1 -0
  102. package/dist/validation/send-event-type.schema.d.ts +2 -0
  103. package/dist/validation/send-event-type.schema.js +11 -0
  104. package/dist/validation/send-event-type.schema.js.map +1 -0
  105. package/dist/validation/settings.schema.d.ts +16 -16
  106. package/dist/validation/settings.schema.js +1 -1
  107. package/dist/validation/settings.schema.js.map +1 -1
  108. package/dist/validation/templates.schema.d.ts +19 -18
  109. package/dist/validation/templates.schema.js.map +1 -1
  110. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  111. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  112. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  113. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  114. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  115. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  116. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  117. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  118. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  119. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  120. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  121. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  122. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  123. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  124. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  125. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  126. package/frontend-vue/build/backend-boundary.ts +122 -0
  127. package/frontend-vue/dms.frontend.build.ts +7 -0
  128. package/frontend-vue/eslint.config.mjs +13 -0
  129. package/frontend-vue/package.json +4 -2
  130. package/frontend-vue/pnpm-lock.yaml +210 -61
  131. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  132. package/frontend-vue/tests/dnd.test.ts +1 -2
  133. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  134. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  135. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  136. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  137. package/frontend-vue/tests/page-reads.test.ts +26 -6
  138. package/frontend-vue/tests/send-actions.test.ts +0 -1
  139. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  140. package/frontend-vue/tests/send-modals.test.ts +8 -5
  141. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  142. package/frontend-vue/tests/suppression.test.ts +1 -2
  143. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  144. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  145. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  146. package/package.json +8 -13
  147. package/skills/dms-mailing/SKILL.md +11 -17
  148. package/skills/dms-mailing/cases.json +49 -0
  149. package/src/constants.ts +0 -2
  150. package/src/crons/jobs.ts +1 -1
  151. package/src/data/body.ts +2 -1
  152. package/src/data/templates-table.ts +15 -22
  153. package/src/db/models/index.ts +0 -1
  154. package/src/db/models/send-events.model.ts +2 -2
  155. package/src/db/models/sends.model.ts +7 -8
  156. package/src/db/models/template-versions.model.ts +5 -1
  157. package/src/db/models/templates.model.ts +9 -16
  158. package/src/db/tables/index.ts +0 -1
  159. package/src/db/tables/settings.table.ts +0 -8
  160. package/src/db/tables/templates.table.ts +2 -16
  161. package/src/engine/changes.ts +3 -2
  162. package/src/engine/conditions.ts +2 -4
  163. package/src/engine/fallback-html.ts +13 -13
  164. package/src/engine/formats.ts +6 -6
  165. package/src/engine/paths.ts +4 -3
  166. package/src/engine/system-variables.ts +4 -3
  167. package/src/engine/tokens.ts +1 -2
  168. package/src/hooks/index.ts +0 -1
  169. package/src/implementations/dms-mailing/index.ts +5 -18
  170. package/src/index.ts +1 -6
  171. package/src/pages/sends.ts +5 -3
  172. package/src/pages/settings/form.ts +16 -0
  173. package/src/pages/templates.ts +1 -1
  174. package/src/pages/toolbar.ts +4 -2
  175. package/src/routes/events.ts +3 -6
  176. package/src/routes/metrics.ts +7 -5
  177. package/src/routes/sends.ts +10 -18
  178. package/src/routes/settings.ts +8 -9
  179. package/src/routes/suppressions.ts +1 -1
  180. package/src/routes/templates.ts +16 -18
  181. package/src/services/attention.ts +4 -3
  182. package/src/services/categories.ts +6 -6
  183. package/src/services/checked-send.ts +17 -19
  184. package/src/services/declarations.ts +62 -318
  185. package/src/services/events.ts +16 -13
  186. package/src/services/localized-text.ts +4 -2
  187. package/src/services/metrics.ts +2 -2
  188. package/src/services/notify.ts +1 -1
  189. package/src/services/render.ts +5 -1
  190. package/src/services/send-import.ts +18 -13
  191. package/src/services/send.ts +7 -7
  192. package/src/services/settings.ts +3 -10
  193. package/src/services/starters.ts +6 -6
  194. package/src/services/template-stats.ts +11 -4
  195. package/src/services/templates.ts +69 -52
  196. package/src/types/settings.ts +20 -0
  197. package/src/validation/declarations.schema.ts +2 -2
  198. package/src/validation/guards.ts +25 -0
  199. package/src/validation/send-event-type.schema.ts +10 -0
  200. package/src/validation/settings.schema.ts +7 -2
  201. package/src/validation/templates.schema.ts +2 -1
  202. package/dist/db/models/template-slugs.model.d.ts +0 -27
  203. package/dist/db/models/template-slugs.model.js +0 -43
  204. package/dist/db/models/template-slugs.model.js.map +0 -1
  205. package/dist/db/tables/template-slugs.table.d.ts +0 -7
  206. package/dist/db/tables/template-slugs.table.js +0 -35
  207. package/dist/db/tables/template-slugs.table.js.map +0 -1
  208. package/dist/hooks/database-ready.d.ts +0 -7
  209. package/dist/hooks/database-ready.js +0 -26
  210. package/dist/hooks/database-ready.js.map +0 -1
  211. package/dist/services/fingerprint.d.ts +0 -1
  212. package/dist/services/fingerprint.js +0 -20
  213. package/dist/services/fingerprint.js.map +0 -1
  214. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  215. package/frontend-vue/tests/i18n.test.ts +0 -138
  216. package/src/db/models/template-slugs.model.ts +0 -61
  217. package/src/db/tables/template-slugs.table.ts +0 -29
  218. package/src/hooks/database-ready.ts +0 -47
  219. package/src/services/fingerprint.ts +0 -22
@@ -23,9 +23,9 @@ https://dms.antelopejs.com/docs/modules/mailing (sending from code and the webho
23
23
 
24
24
  - `ajs project modules add @antelopejs/dms-mailing`, plus a module implementing `@antelopejs/interface-email`
25
25
  (`@antelopejs/nodemailer`, `config: { ethereal: true }` in dev).
26
- - Mailing 0.6 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
27
- 0.4 is the DMS 0.8 line. Mailing 0.6 implements `@antelopejs/interface-dms-mailing` `>=0.8.0 <0.9.0`, adding declaration and import calls;
28
- interface 0.9 adds `DeclareSender`; `SendTemplate` / `RecordEmailEvent` callers do not change.
26
+ - Mailing 0.7.1 requires `@antelopejs/dms` `>=0.11.0 <1.0.0`, so DMS 0.11 or later (interface-dms 0.9, renderer `@antelopejs/dms-frontend` `>=0.9.1 <0.10.0`);
27
+ 0.7.0 is the DMS 0.10 line. It implements `@antelopejs/interface-dms-mailing` `>=0.9.0 <0.10.0`: declaration and import calls,
28
+ and `DeclareSender` since interface 0.9; `SendTemplate` / `RecordEmailEvent` callers do not change.
29
29
  - Module option: `useHtmlRender` (default `true`, DMS html-render chain; `false` = built-in standalone HTML).
30
30
  - Set the DMS `clientBaseUrl`: the webhook URL and the e-mail logo are built on it.
31
31
  - Per-workspace settings (sender, missing-variable guard, fallback locale, webhook secret, retention,
@@ -68,23 +68,17 @@ export async function declareMailingContent(tenantId: string) {
68
68
  content: { locales: { en: { subject: "Your {{plan.name}} plan is renewed", preheader: "", blocks: [] } } },
69
69
  variables: [{ path: "plan.name", type: "string", required: true }],
70
70
  testData: { plan: { name: "Pro" } },
71
- }); // created | updated | unchanged | kept
71
+ }); // created | kept
72
72
  }
73
73
  ```
74
74
 
75
- - Require explicit non-blank `tenantId` for declarations and imports, including an explicitly selected default tenant.
76
- The application must authorize the target and establish provisioning readiness.
77
- - For SaaS initialization, follow the consumer reference's **Initialize new SaaS workspaces** section:
78
- register the durable `created` consumer synchronously in `construct()`, without awaiting declarations,
79
- before startup reconciliation; deduplicate `operationId`, bind resources
80
- durably, then return a receipt. Mailing registers no consumer. `start()` is process-global, not per tenant;
81
- `TENANT_BEING_PROVISIONED` is prepayment and is not the durable `created` transition.
82
- Deployment updates/backfills require an authorized inventory and `IsWorkspaceProvisioningCommitted`, failing closed.
83
- - Idempotent on the tenant's slug, published at once (v1, version rows, slug claim, attention refresh: a normal template).
84
- - Rule: same declaration → `unchanged`; changed declaration → `updated` only if the template is live, has no
85
- draft and still holds exactly the last declaration (fingerprint on `declaredHash`); otherwise `kept`.
86
- A dashboard-made template under that slug is never taken over. Deleted → recreated next boot; archive instead.
87
- - Categories: added once; a category a user relabelled/removed, or a default one, is `kept`.
75
+ - Require explicit non-blank `tenantId` for declarations and imports. Call them once per tenant, from a
76
+ `Hook.TENANT_BEING_PROVISIONED` handler (the DMS fires it for `default` on the start that creates it, after
77
+ `DATABASE_INITIALIZED`; dms-saas for each workspace). Never from `start()` or `DATABASE_INITIALIZED`.
78
+ - Written once: a missing slug is created and published (v1, version rows, unique slug, attention refresh: a normal
79
+ template) → `created`; a slug the tenant already has (declared or made in the dashboard) is left as it is → `kept`.
80
+ No fingerprint, no refresh: from its creation on, the template belongs to the tenant.
81
+ - Categories: a missing id is added → `created`; an existing one, a default included, is `kept`.
88
82
  - `name` / `label` are localized texts `{ en, fr, … }` (stored `@Localized`, `en` required as fallback), never `$`
89
83
  i18n keys; each user reads, searches and sorts them in their language (`x-content-language`).
90
84
  - `DeclareSender({ tenantId, email, name?, replyTo? })` writes the settings' sender (resolves `void`); call it once
@@ -0,0 +1,49 @@
1
+ {
2
+ "version": 1,
3
+ "cases": [
4
+ {
5
+ "id": "mailing-send-template",
6
+ "area": "modules",
7
+ "style": "clean",
8
+ "prompt": "Send the welcome e-mail template when a user signs up.",
9
+ "expect": {
10
+ "skills": ["dms-mailing"],
11
+ "acceptable": ["dms", "dms-auth"],
12
+ "mustMention": ["SendTemplate"]
13
+ }
14
+ },
15
+ {
16
+ "id": "mailing-bounces",
17
+ "area": "modules",
18
+ "style": "clean",
19
+ "prompt": "Our e-mail provider reports bounces through a webhook. How do we record them?",
20
+ "expect": {
21
+ "skills": ["dms-mailing"],
22
+ "acceptable": ["dms"],
23
+ "mustMention": ["RecordEmailEvent"]
24
+ }
25
+ },
26
+ {
27
+ "id": "mailing-missing-variable-casual",
28
+ "area": "modules",
29
+ "style": "casual",
30
+ "prompt": "mail send says missing variable firstName??",
31
+ "expect": {
32
+ "skills": ["dms-mailing"],
33
+ "acceptable": ["dms"]
34
+ }
35
+ },
36
+ {
37
+ "id": "dms-mailing-avoid-tracker",
38
+ "area": "modules",
39
+ "style": "clean",
40
+ "prompt": "Put the tracking script on our public website and count how many visitors click the signup button.",
41
+ "expect": {
42
+ "skills": ["dms-marketing"],
43
+ "acceptable": ["dms"],
44
+ "mustMention": ["data-website-id"],
45
+ "avoid": ["dms-mailing"]
46
+ }
47
+ }
48
+ ]
49
+ }
package/src/constants.ts CHANGED
@@ -23,8 +23,6 @@ export const GALLERY_DISPLAY_ID = `${MODULE_ID}:gallery`;
23
23
  export const TEMPLATE_CATEGORIES_TYPE_ID = "mailing_template_categories";
24
24
  export const TEMPLATE_CATEGORY_TYPE_ID = "mailing_template_category";
25
25
 
26
- export const DEFAULT_FALLBACK_LOCALE = "en";
27
- export const DEFAULT_LOG_RETENTION_DAYS = 90;
28
26
  export const MAX_SLUG_LENGTH = 64;
29
27
  export const MAX_NAME_LENGTH = 120;
30
28
  export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
package/src/crons/jobs.ts CHANGED
@@ -5,7 +5,7 @@ import cron, { type ScheduledTask } from "node-cron";
5
5
  export interface CronJob {
6
6
  name: string;
7
7
  schedule: string;
8
- run: () => Promise<unknown>;
8
+ run: () => Promise<number | void>;
9
9
  }
10
10
 
11
11
  /** A job as it runs once scheduled. */
package/src/data/body.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { HTTPResult } from "@antelopejs/interface-api";
2
2
  import { HTTP_BAD_REQUEST } from "../constants";
3
+ import { isText } from "../validation/guards";
3
4
 
4
5
  const INVALID_BODY = "$dms_mailing.errors.invalid_body";
5
6
 
6
7
  export function parseBody(body: Buffer | string): Record<string, unknown> {
7
- const raw = typeof body === "string" ? body : body.toString("utf8");
8
+ const raw = isText(body) ? body : body.toString("utf8");
8
9
  if (!raw) return {};
9
10
  try {
10
11
  return JSON.parse(raw) as Record<string, unknown>;
@@ -71,13 +71,12 @@ import { findCategory, getSettings } from "../services/settings";
71
71
  import { findStarter, starterFields } from "../services/starters";
72
72
  import { refreshTemplateAttention } from "../services/template-attention";
73
73
  import {
74
- assertSlugClaimed,
75
74
  blankContent,
76
75
  blankFields,
77
76
  contentFieldsOf,
78
77
  displayName,
79
78
  initializeTemplate,
80
- releaseTemplateSlugs,
79
+ refuseTakenSlug,
81
80
  } from "../services/templates";
82
81
  import type { TemplateCategory } from "../types";
83
82
 
@@ -85,10 +84,10 @@ import {
85
84
  createTemplateSchema,
86
85
  templateDetailsSchema,
87
86
  } from "../validation/templates.schema";
87
+ import { isObject, isText } from "../validation/guards";
88
88
  import { parseBody } from "./body";
89
89
  import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
90
90
 
91
- const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
92
91
  const SLUG_IMMUTABLE = "$dms_mailing.errors.slug_immutable";
93
92
  const SOURCE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
94
93
  const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
@@ -157,7 +156,7 @@ function assertKnownCategory(
157
156
  category: unknown,
158
157
  ): void {
159
158
  if (isBlank(category)) return;
160
- if (typeof category === "string" && findCategory(categories, category)) {
159
+ if (isText(category) && findCategory(categories, category)) {
161
160
  return;
162
161
  }
163
162
  throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
@@ -173,7 +172,8 @@ interface CreatedRow {
173
172
  * The creation's seed, and the body to store: slug trimmed, and the name
174
173
  * typed in the reader's language as every language of the localized field,
175
174
  * the fallback one included until someone translates it. A body a guard
176
- * already parsed arrives as an object.
175
+ * already parsed arrives as an object. The body to store is handed on as an
176
+ * object: interface-data-api 0.3 refuses a JSON string.
177
177
  */
178
178
  function parseCreation(body: Validation.WrittenBody, locale: string) {
179
179
  const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
@@ -183,7 +183,6 @@ function parseCreation(body: Validation.WrittenBody, locale: string) {
183
183
  () => INVALID_TEMPLATE,
184
184
  HTTP_BAD_REQUEST,
185
185
  );
186
- // Handed as an object: interface-data-api 0.3 refuses a JSON string.
187
186
  const normalized = {
188
187
  ...raw,
189
188
  slug: seed.slug,
@@ -200,6 +199,12 @@ interface LocalizedController {
200
199
  /** The key that writes every language of a localized field at once. */
201
200
  const ALL_LANGUAGES = "*";
202
201
 
202
+ /**
203
+ * The data API's `new` callback, seeding the template it creates. The
204
+ * module's creation dialogs send one typed name, and the stored name holds
205
+ * every language, written at once: the controller's language key is set to
206
+ * all languages before the creation.
207
+ */
203
208
  function withSeeding(base: DataControllerCallback): DataControllerCallback {
204
209
  return {
205
210
  ...base,
@@ -212,25 +217,14 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
212
217
  ) {
213
218
  const { seed, normalized } = parseCreation(body, localeOfRequest(ctx));
214
219
  const tenantId = getRequestTenantId(ctx);
215
- const model = GetModel(TemplateModel, tenantId);
216
- if (await model.getBySlug(seed.slug)) {
217
- throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
218
- }
219
220
  const settings = await getSettings(tenantId);
220
221
  assertKnownCategory(settings.categories, seed.category);
221
222
  const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
222
- // The module's creation dialogs send one typed name; the stored name
223
- // holds every language, written at once.
224
223
  (this as LocalizedController).language = ALL_LANGUAGES;
225
- const created = (await base.func.call(
226
- this,
227
- ctx,
228
- params,
229
- normalized,
230
- ...rest,
224
+ const created = (await refuseTakenSlug(
225
+ base.func.call(this, ctx, params, normalized, ...rest),
231
226
  )) as CreatedRow;
232
227
  const templateId = created._id;
233
- await assertSlugClaimed(tenantId, seed.slug, templateId);
234
228
  const user = await authenticateTenantRequest(ctx);
235
229
  await initializeTemplate(tenantId, templateId, displayName(user), fields);
236
230
  return created;
@@ -291,7 +285,6 @@ export function afterDelete(tenantId: string, ids: string[]): AfterWrite {
291
285
  await Promise.all(
292
286
  ids.map((id) => versions.getAll(id, "templateId").delete().run()),
293
287
  );
294
- await releaseTemplateSlugs(tenantId, ids);
295
288
  await refreshTemplateAttention(tenantId);
296
289
  };
297
290
  }
@@ -346,8 +339,8 @@ export async function saveDetails(
346
339
  * or, from a form editing every language, all of them.
347
340
  */
348
341
  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());
342
+ const texts = isObject(name) ? Object.values(name) : [name];
343
+ return !texts.some((text) => isText(text) && text.trim());
351
344
  }
352
345
 
353
346
  /** The names trimmed, blank ones left out, the fallback language filled. */
@@ -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
  }
@@ -45,8 +45,12 @@ export class TemplateVersionModel extends BasicDataModel(
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * The published versions of a template, newest first. It reads the
50
+ * template's publication before the version rows: any later publication
51
+ * must have materialized the version that read names.
52
+ */
48
53
  async listForTemplate(templateId: string): Promise<MailingTemplateVersion[]> {
49
- // Read the publication first: any later publication must have materialized it.
50
54
  const template = await new TemplateModel(this.database).get(templateId);
51
55
  if (!template) return [];
52
56
  const rows = await this.getBy("templateId", templateId);
@@ -5,7 +5,7 @@ import {
5
5
  } from "@antelopejs/interface-database-decorators";
6
6
  import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
7
7
  import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
8
- import { TemplateSlugModel } from "./template-slugs.model";
8
+ import { isText } from "../../validation/guards";
9
9
 
10
10
  /** The attention fields of a template, as the refresh writes them. */
11
11
  export type TemplateAttentionFields = Pick<
@@ -16,7 +16,7 @@ export type TemplateAttentionFields = Pick<
16
16
  const isLocalizedText = (value: unknown): value is LocalizedText =>
17
17
  typeof value === "object" &&
18
18
  value !== null &&
19
- Object.values(value).every((text) => typeof text === "string");
19
+ Object.values(value).every(isText);
20
20
 
21
21
  /** Where a row keeps its modifier-held fields, the localized name among them. */
22
22
  const MODIFIER_CONTAINER = "_internal";
@@ -38,9 +38,11 @@ export class TemplateModel extends BasicDataModel(
38
38
  return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
39
39
  }
40
40
 
41
- /** The names of `template`, one per language. */
41
+ /**
42
+ * The names of `template`, one per language: unlocked with `"*"`, the
43
+ * localized field holds the whole per-language record.
44
+ */
42
45
  static namesOf(template: MailingTemplate): LocalizedText {
43
- // Unlocked with "*", the localized field holds the whole per-language record.
44
46
  const names: unknown = template.localize("*", ["name"]).name;
45
47
  return isLocalizedText(names) ? names : {};
46
48
  }
@@ -90,19 +92,10 @@ export class TemplateModel extends BasicDataModel(
90
92
  return outcome === "applied";
91
93
  }
92
94
 
95
+ /** The template holding `slug`: the unique index keeps it to one. */
93
96
  async getBySlug(slug: string): Promise<MailingTemplate | undefined> {
94
- const rows = await this.getBy("slug", slug);
95
- // A losing creation can still be cleaning up (or its process can crash).
96
- // Senders must see the claimed template, not an earlier draft candidate.
97
- if (rows.length > 1) {
98
- const claims = await new TemplateSlugModel(this.database).getBy(
99
- "templateId",
100
- ...rows.map((row) => row._id),
101
- );
102
- const claim = claims.find((candidate) => candidate.slug === slug);
103
- if (claim) return rows.find((row) => row._id === claim.templateId);
104
- }
105
- return rows[0];
97
+ const [template] = await this.getBy("slug", slug);
98
+ return template;
106
99
  }
107
100
 
108
101
  /**
@@ -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
  }
@@ -31,7 +31,8 @@ export class MailingTemplate extends Table.with(LocalizationModifier) {
31
31
  @Field("date")
32
32
  declare updatedAt: Date;
33
33
 
34
- @Index()
34
+ /** What sends name the template by: one template per slug in a tenant. */
35
+ @Index({ unique: true })
35
36
  @Field("string")
36
37
  declare slug: string;
37
38
 
@@ -141,25 +142,10 @@ export class MailingTemplate extends Table.with(LocalizationModifier) {
141
142
  @Field("date")
142
143
  declare unpublishedAt?: Date;
143
144
 
144
- /**
145
- * Fingerprint of the last declaration `DeclareTemplate` applied; blank for
146
- * a template made in the dashboard. A template still matching it is
147
- * untouched since, and the next declaration may refresh it.
148
- */
149
- @Field("string")
150
- declare declaredHash?: string;
151
-
152
145
  /** Every semantic edit invalidates a publication's optimistic snapshot. */
153
146
  @Field("string")
154
147
  declare revision?: string;
155
148
 
156
- /** An unmodified declaration seed can be completed after a crashed creator. */
157
- @Field("string")
158
- declare declarationSeedRevision?: string;
159
-
160
- @Field("string")
161
- declare pendingDeclaredHash?: string;
162
-
163
149
  /**
164
150
  * Complete latest version row, committed atomically with its content. Its
165
151
  * history row can be recovered after an interrupted publication.
@@ -6,6 +6,7 @@ import type {
6
6
  VariableDefinition,
7
7
  } from "../types";
8
8
  import { walkBlocks } from "./walk";
9
+ import { isObject } from "../validation/guards";
9
10
 
10
11
  export type TemplateChangeKind =
11
12
  | "locale_added"
@@ -51,11 +52,11 @@ const NESTED_KEYS = new Set(["children", "elseChildren"]);
51
52
  */
52
53
  function canonical(value: unknown): unknown {
53
54
  if (Array.isArray(value)) return value.map(canonical);
54
- if (!value || typeof value !== "object") return value;
55
+ if (!isObject(value)) return value;
55
56
  return Object.fromEntries(
56
57
  Object.keys(value)
57
58
  .sort()
58
- .map((key) => [key, canonical((value as Record<string, unknown>)[key])]),
59
+ .map((key) => [key, canonical(value[key])]),
59
60
  );
60
61
  }
61
62
 
@@ -3,15 +3,13 @@ import { getPath } from "./paths";
3
3
 
4
4
  type Evaluator = (actual: unknown, expected: string) => boolean;
5
5
 
6
- const asNumber = (value: unknown): number => Number(value);
7
-
8
6
  const EVALUATORS: Record<ConditionOperator, Evaluator> = {
9
7
  truthy: (actual) => Boolean(actual),
10
8
  falsy: (actual) => !actual,
11
9
  eq: (actual, expected) => String(actual) === expected,
12
10
  ne: (actual, expected) => String(actual) !== expected,
13
- gt: (actual, expected) => asNumber(actual) > asNumber(expected),
14
- lt: (actual, expected) => asNumber(actual) < asNumber(expected),
11
+ gt: (actual, expected) => Number(actual) > Number(expected),
12
+ lt: (actual, expected) => Number(actual) < Number(expected),
15
13
  };
16
14
 
17
15
  /** Whether `data` passes the condition a block is shown under. */
@@ -1,15 +1,15 @@
1
1
  import type { ResolvedBlock, ResolvedEmail } from "../types";
2
2
 
3
- const ESCAPES: Record<string, string> = {
4
- "&": "&amp;",
5
- "<": "&lt;",
6
- ">": "&gt;",
7
- '"': "&quot;",
8
- "'": "&#39;",
9
- };
3
+ const ESCAPES = new Map([
4
+ ["&", "&amp;"],
5
+ ["<", "&lt;"],
6
+ [">", "&gt;"],
7
+ ['"', "&quot;"],
8
+ ["'", "&#39;"],
9
+ ]);
10
10
 
11
11
  export const escapeHtml = (value: string): string =>
12
- value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
12
+ value.replace(/[&<>"']/g, (char) => ESCAPES.get(char) ?? char);
13
13
 
14
14
  const LINE_BREAK = /\r?\n/g;
15
15
 
@@ -77,7 +77,7 @@ const row = (label: string, value: string, weight: string): string =>
77
77
  const table = (rows: string): string =>
78
78
  `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BLOCK_SPACING}">${rows}</table>`;
79
79
 
80
- const TEXT_RENDERERS: Record<string, BlockHtml> = {
80
+ const TEXT_RENDERERS = {
81
81
  heading: (block) =>
82
82
  block.type === "heading"
83
83
  ? `<h1 style="${FONT};font-size:${block.size}px;font-weight:600;color:${HEADING_COLOR};text-align:${block.align};margin:0 0 24px;${WRAP}">${escapeLines(block.text)}</h1>`
@@ -94,7 +94,7 @@ const TEXT_RENDERERS: Record<string, BlockHtml> = {
94
94
  block.type === "button"
95
95
  ? `<div style="text-align:${block.align};margin:24px 0"><a href="${escapeHtml(safeUrl(block.href))}" style="${FONT};${BUTTON_STYLE}">${escapeHtml(block.text)}</a></div>`
96
96
  : "",
97
- };
97
+ } satisfies Record<string, BlockHtml>;
98
98
 
99
99
  const footerHtml = (block: ResolvedBlock): string => {
100
100
  if (block.type !== "footer") return "";
@@ -109,7 +109,7 @@ const footerHtml = (block: ResolvedBlock): string => {
109
109
  return `${DIVIDER}<p style="${FOOTER_STYLE}">${escapeLines(block.text)}${linkLine}</p>`;
110
110
  };
111
111
 
112
- const LAYOUT_RENDERERS: Record<string, BlockHtml> = {
112
+ const LAYOUT_RENDERERS = {
113
113
  hero: (block) =>
114
114
  block.type === "hero" && block.imageUrl
115
115
  ? `<img src="${escapeHtml(safeUrl(block.imageUrl))}" alt="${escapeHtml(block.alt)}" style="display:block;width:100%;border-radius:8px;margin:0 0 20px">`
@@ -124,12 +124,12 @@ const LAYOUT_RENDERERS: Record<string, BlockHtml> = {
124
124
  block.type === "total" ? table(row(block.label, block.value, "600")) : "",
125
125
  divider: () => DIVIDER,
126
126
  footer: footerHtml,
127
- };
127
+ } satisfies Record<string, BlockHtml>;
128
128
 
129
129
  const RENDERERS: Record<ResolvedBlock["type"], BlockHtml> = {
130
130
  ...TEXT_RENDERERS,
131
131
  ...LAYOUT_RENDERERS,
132
- } as Record<ResolvedBlock["type"], BlockHtml>;
132
+ };
133
133
 
134
134
  const FOOTER_TYPE = "footer";
135
135
 
@@ -1,4 +1,5 @@
1
1
  import type { VariableDefinition } from "../types";
2
+ import { isObject, isText } from "../validation/guards";
2
3
 
3
4
  /**
4
5
  * An amount in the minor unit of its currency (cents for `EUR`), like the
@@ -55,12 +56,11 @@ export function valueFormatOf(
55
56
  }
56
57
 
57
58
  export function isMoneyValue(value: unknown): value is MoneyValue {
58
- if (!value || typeof value !== "object") return false;
59
- const candidate = value as Partial<MoneyValue>;
59
+ if (!isObject(value)) return false;
60
60
  return (
61
- typeof candidate.value === "number" &&
62
- Number.isFinite(candidate.value) &&
63
- typeof candidate.currency === "string"
61
+ typeof value.value === "number" &&
62
+ Number.isFinite(value.value) &&
63
+ typeof value.currency === "string"
64
64
  );
65
65
  }
66
66
 
@@ -118,7 +118,7 @@ export function formatValue(
118
118
  return Number.isNaN(value.getTime())
119
119
  ? String(value)
120
120
  : formatDate(value, DATE_STYLE, locale);
121
- if (typeof value === "string" && format?.datePaths.has(path))
121
+ if (isText(value) && format?.datePaths.has(path))
122
122
  return formatIsoDate(value, locale) ?? value;
123
123
  return String(value);
124
124
  }
@@ -1,3 +1,5 @@
1
+ import { isObject } from "../validation/guards";
2
+
1
3
  const PATH_SEPARATOR = ".";
2
4
  const ARRAY_SUFFIX = "[]";
3
5
 
@@ -16,10 +18,9 @@ export function normalizePath(path: string): string {
16
18
  const RESERVED_SEGMENTS = ["__proto__", "constructor", "prototype"];
17
19
 
18
20
  function readSegment(current: unknown, segment: string): unknown {
19
- if (current === null || current === undefined) return undefined;
20
- if (typeof current !== "object") return undefined;
21
+ if (!isObject(current)) return undefined;
21
22
  if (RESERVED_SEGMENTS.includes(segment)) return undefined;
22
- return (current as Record<string, unknown>)[segment];
23
+ return current[segment];
23
24
  }
24
25
 
25
26
  export function getPath(data: unknown, path: string): unknown {
@@ -22,7 +22,10 @@ export const MARKETING_CATEGORY = "marketing";
22
22
 
23
23
  const SYSTEM_VARIABLE_TYPE = "url";
24
24
 
25
- type FooterLabelKey = "unsubscribeLabel" | "preferencesLabel";
25
+ /** The footer fields that label a link, in the order the links show. */
26
+ const LABEL_KEYS = ["unsubscribeLabel", "preferencesLabel"] as const;
27
+
28
+ type FooterLabelKey = (typeof LABEL_KEYS)[number];
26
29
 
27
30
  /** Each footer link: the label that shows it and the URL the caller passes. */
28
31
  const FOOTER_LINKS: Record<FooterLabelKey, string> = {
@@ -30,8 +33,6 @@ const FOOTER_LINKS: Record<FooterLabelKey, string> = {
30
33
  preferencesLabel: PREFERENCES_URL_PATH,
31
34
  };
32
35
 
33
- const LABEL_KEYS = Object.keys(FOOTER_LINKS) as FooterLabelKey[];
34
-
35
36
  export function isMarketingCategory(category?: string | null): boolean {
36
37
  return category === MARKETING_CATEGORY;
37
38
  }
@@ -21,8 +21,7 @@ const MISSING_TOKEN: Record<InterpolationMode, (token: string) => string> = {
21
21
 
22
22
  export function extractTokens(text: string): string[] {
23
23
  const found = new Set<string>();
24
- for (const match of text.matchAll(TOKEN_PATTERN))
25
- found.add(match[1] as string);
24
+ for (const match of text.matchAll(TOKEN_PATTERN)) found.add(match[1]);
26
25
  return [...found];
27
26
  }
28
27
 
@@ -1,2 +1 @@
1
- export * from "./database-ready";
2
1
  export * from "./tenant-export";