@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,26 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type {
3
- DeclarationOutcome,
4
3
  DeclareTemplateCategoryResult,
5
4
  DeclareTemplateResult,
6
5
  TemplateCategoryDeclaration,
7
6
  TemplateDeclaration,
8
7
  } from "@antelopejs/interface-dms-mailing";
9
- import { Logging } from "@antelopejs/interface-core/logging";
8
+ import { IsDuplicateKeyError } from "@antelopejs/interface-database";
10
9
  import { GetModel } from "@antelopejs/interface-database-decorators";
11
10
  import {
12
11
  type MailingTemplate,
13
- SettingsModel,
14
12
  TemplateModel,
15
- TemplateSlugModel,
16
13
  TemplateVersionModel,
17
- templateSlugIdOf,
18
14
  } from "../db";
19
- import type {
20
- TemplateCategory,
21
- TemplateContent,
22
- VariableDefinition,
23
- } from "../types";
15
+ import type { TemplateCategory, TemplateContent } from "../types";
24
16
  import {
25
17
  categoryDeclarationSchema,
26
18
  type ParsedCategoryDeclaration,
@@ -29,54 +21,18 @@ import {
29
21
  } from "../validation/declarations.schema";
30
22
  import { categoriesSchema } from "../validation/settings.schema";
31
23
  import { DeclarationError, parseDeclared } from "./declaration-error";
32
- import { fingerprintOf } from "./fingerprint";
33
24
  import { findCategory, getSettings, saveSettings } from "./settings";
25
+ import { refreshTemplateAttention } from "./template-attention";
34
26
  import {
35
- assertSlugClaimed,
36
- blankFields,
37
- hasDraft,
38
27
  localesWithoutSubject,
39
- parseContent,
40
- parseTestData,
41
- parseVariables,
42
28
  publishedVersionOf,
43
- publishSnapshot,
29
+ publishedAtCreation,
44
30
  requireTemplate,
45
- workingContent,
46
- workingVariables,
47
31
  } from "./templates";
48
32
 
49
33
  /** Who a declaration's writes are shown as by default. */
50
34
  const DECLARATION_AUTHOR = "system";
51
35
 
52
- const LIVE_STATUS = "live";
53
- const LOG_PREFIX = "[dms-mailing]";
54
- const MAX_DECLARATION_ATTEMPTS = 5;
55
-
56
- /** What a declaration fingerprints: everything a user may change after it. */
57
- interface DeclaredTemplateState {
58
- name: string;
59
- category: string;
60
- content: TemplateContent;
61
- variables: VariableDefinition[];
62
- testData: Record<string, unknown>;
63
- }
64
-
65
- /** A declaration ready to write: validated, defaulted and fingerprinted. */
66
- interface TemplatePlan {
67
- tenantId: string;
68
- slug: string;
69
- state: DeclaredTemplateState;
70
- author: string;
71
- hash: string;
72
- }
73
-
74
- /**
75
- * The values as the database gives them back: a `Date` in the test data is
76
- * stored as its ISO string, and the fingerprint must compare alike.
77
- */
78
- const asStored = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
79
-
80
36
  function assertPublishable(content: TemplateContent, slug: string): void {
81
37
  if (!Object.keys(content.locales).length)
82
38
  throw new DeclarationError(
@@ -104,212 +60,62 @@ function assertKnownCategory(
104
60
  );
105
61
  }
106
62
 
107
- function planTemplate(declaration: ParsedTemplateDeclaration): TemplatePlan {
108
- const { tenantId } = declaration;
109
- const { slug } = declaration;
110
- const state = asStored<DeclaredTemplateState>({
111
- name: declaration.name,
112
- category: declaration.category,
113
- content: declaration.content as TemplateContent,
114
- variables: declaration.variables,
115
- testData: declaration.testData,
116
- });
117
- assertPublishable(state.content, slug);
118
- return {
119
- tenantId,
120
- slug,
121
- state,
122
- author: declaration.author ?? DECLARATION_AUTHOR,
123
- hash: fingerprintOf(state),
124
- };
125
- }
126
-
127
- /**
128
- * The fingerprint of what the template holds now, `null` when a user took it
129
- * out of the state a declaration leaves it in: live, with no draft waiting.
130
- */
131
- function currentFingerprint(template: MailingTemplate): string | null {
132
- if (template.status !== LIVE_STATUS || hasDraft(template)) return null;
133
- return fingerprintOf({
134
- name: template.name,
135
- category: template.category ?? "",
136
- content: parseContent(template),
137
- variables: parseVariables(template),
138
- testData: parseTestData(template),
139
- });
140
- }
141
-
142
63
  function resultOf(
143
64
  template: MailingTemplate,
144
- outcome: DeclarationOutcome,
145
- version = publishedVersionOf(template),
65
+ outcome: DeclareTemplateResult["outcome"],
146
66
  ): DeclareTemplateResult {
147
67
  return {
148
68
  templateId: template._id,
149
69
  slug: template.slug,
150
70
  outcome,
151
- version,
71
+ version: publishedVersionOf(template),
152
72
  };
153
73
  }
154
74
 
155
75
  /**
156
- * Inserts the template as a fresh draft of the declared content, claims its
157
- * slug like the dashboard's creation does. The seed is not an applied
158
- * declaration: any caller can finish its first publication after a crash.
76
+ * Inserts the declared template already published, as version 1, in one
77
+ * write. `undefined` when a concurrent call created the slug first (the slug
78
+ * index is unique): that call's row is published too.
159
79
  */
160
80
  async function createDeclared(
161
- plan: TemplatePlan,
81
+ declaration: ParsedTemplateDeclaration,
162
82
  ): Promise<MailingTemplate | undefined> {
163
- const { tenantId, slug, state, author } = plan;
83
+ const { tenantId, slug } = declaration;
164
84
  const model = GetModel(TemplateModel, tenantId);
165
- const settings = await getSettings(tenantId);
166
- assertKnownCategory(settings.categories, state.category, slug);
167
- const revision = randomUUID();
168
- const [id] = await model.insert({
169
- slug,
170
- name: state.name,
171
- category: state.category,
172
- status: "draft",
173
- ...blankFields(state.content),
174
- json_variables: JSON.stringify(state.variables),
175
- json_test_data: JSON.stringify(state.testData),
176
- updatedBy: author,
177
- revision,
178
- declarationSeedRevision: revision,
179
- pendingDeclaredHash: plan.hash,
180
- });
181
- const templateId = id as string;
85
+ const author = declaration.author ?? DECLARATION_AUTHOR;
86
+ let template: MailingTemplate;
182
87
  try {
183
- await assertSlugClaimed(tenantId, slug, templateId);
88
+ const [id] = await model.insertFields({
89
+ slug,
90
+ name: declaration.name,
91
+ category: declaration.category,
92
+ json_test_data: JSON.stringify(declaration.testData),
93
+ ...publishedAtCreation(
94
+ randomUUID(),
95
+ {
96
+ content: declaration.content as TemplateContent,
97
+ variables: declaration.variables,
98
+ },
99
+ author,
100
+ ),
101
+ });
102
+ template = await requireTemplate(model, id as string);
184
103
  } catch (error) {
185
- if (await model.getBySlug(slug)) return undefined;
104
+ // A concurrent declaration of the slug created it first: expected, the
105
+ // caller goes on with that template.
106
+ if (IsDuplicateKeyError(error)) return undefined;
186
107
  throw error;
187
108
  }
188
- return requireTemplate(model, templateId);
109
+ await GetModel(TemplateVersionModel, tenantId).materialize(template);
110
+ await refreshTemplateAttention(tenantId);
111
+ return template;
189
112
  }
190
113
 
191
114
  /**
192
- * A slug claim, not an arbitrary candidate row, decides which template won
193
- * concurrent creation. An unclaimed seed can be claimed by its next caller.
194
- */
195
- async function declaredTemplate(
196
- tenantId: string,
197
- slug: string,
198
- ): Promise<MailingTemplate | undefined> {
199
- const model = GetModel(TemplateModel, tenantId);
200
- const claim = await GetModel(TemplateSlugModel, tenantId).get(
201
- templateSlugIdOf(tenantId, slug),
202
- );
203
- const winner = claim && (await model.get(claim.templateId));
204
- return winner || model.getBySlug(slug);
205
- }
206
-
207
- function isUnmodifiedSeed(template: MailingTemplate): boolean {
208
- return Boolean(
209
- template.pendingDeclaredHash &&
210
- template.declarationSeedRevision &&
211
- template.revision === template.declarationSeedRevision &&
212
- template.status === "draft" &&
213
- template.publishedVersion === 0,
214
- );
215
- }
216
-
217
- /** Finishes the stored seed, never substituting a later caller's declaration. */
218
- async function finishSeed(
219
- tenantId: string,
220
- template: MailingTemplate,
221
- ): Promise<boolean> {
222
- try {
223
- await assertSlugClaimed(tenantId, template.slug, template._id);
224
- } catch (error) {
225
- const winner = await declaredTemplate(tenantId, template.slug);
226
- if (winner && winner._id !== template._id) return false;
227
- throw error;
228
- }
229
- const settings = await getSettings(tenantId);
230
- assertKnownCategory(settings.categories, template.category, template.slug);
231
- return Boolean(
232
- await publishSnapshot(
233
- tenantId,
234
- template,
235
- {
236
- content: workingContent(template),
237
- variables: workingVariables(template),
238
- },
239
- template.updatedBy,
240
- {
241
- declaredHash: template.pendingDeclaredHash,
242
- pendingDeclaredHash: "",
243
- declarationSeedRevision: "",
244
- },
245
- ),
246
- );
247
- }
248
-
249
- /** Why an existing template is left alone, `null` when it may be refreshed. */
250
- function keepReason(template: MailingTemplate): string | null {
251
- if (!template.declaredHash) return "it was created in the dashboard";
252
- if (currentFingerprint(template) !== template.declaredHash)
253
- return "a user changed it since it was declared";
254
- return null;
255
- }
256
-
257
- async function applyToExisting(
258
- plan: TemplatePlan,
259
- template: MailingTemplate,
260
- ): Promise<DeclareTemplateResult> {
261
- const { tenantId, state, author } = plan;
262
- for (let attempt = 0; attempt < MAX_DECLARATION_ATTEMPTS; attempt++) {
263
- if (isUnmodifiedSeed(template)) {
264
- const created = await finishSeed(tenantId, template);
265
- if (created && template.pendingDeclaredHash === plan.hash)
266
- return resultOf(template, "created", 1);
267
- } else {
268
- await GetModel(TemplateVersionModel, tenantId).materialize(template);
269
- if (template.publishedVersion > 0 && template.declaredHash === plan.hash)
270
- return resultOf(template, "unchanged");
271
- const reason = keepReason(template);
272
- if (reason) {
273
- Logging.Info(
274
- `${LOG_PREFIX} Kept template "${plan.slug}" as it is: ${reason}`,
275
- );
276
- return resultOf(template, "kept");
277
- }
278
- const settings = await getSettings(tenantId);
279
- assertKnownCategory(settings.categories, state.category, plan.slug);
280
- const outcome = await publishSnapshot(tenantId, template, state, author, {
281
- name: state.name,
282
- category: state.category,
283
- json_test_data: JSON.stringify(state.testData),
284
- declaredHash: plan.hash,
285
- });
286
- if (outcome) return resultOf(template, "updated", outcome.version);
287
- }
288
- const current = await declaredTemplate(tenantId, plan.slug);
289
- if (!current) break;
290
- template = current;
291
- }
292
- throw new DeclarationError(
293
- "declaration_conflict",
294
- `Template "${plan.slug}" changed repeatedly; retry the declaration`,
295
- );
296
- }
297
-
298
- /**
299
- * Creates or refreshes the template a project declares from code. The rule,
300
- * keyed on the slug:
301
- *
302
- * - no template has the slug: it is created and published (`created`);
303
- * - the declaration is the one last applied: nothing is written
304
- * (`unchanged`), whatever a user did to the template since;
305
- * - the declaration changed and the template is still live, with no draft,
306
- * and holds exactly what the last declaration wrote (name, category,
307
- * content, variables, test data): the new declaration is published
308
- * (`updated`);
309
- * - otherwise (edited, unpublished, archived, a draft pending, or made in the
310
- * dashboard): it is left as it is (`kept`).
311
- *
312
- * A declared template a user deleted is created again by the next call.
115
+ * Writes a template a project ships as code, once: call it when the tenant
116
+ * is provisioned. A new slug is created and published (`created`). A slug the
117
+ * tenant already has is left as it is (`kept`): from its creation on, the
118
+ * template belongs to the tenant, and a later declaration never changes it.
313
119
  */
314
120
  export async function declareTemplate(
315
121
  input: TemplateDeclaration,
@@ -320,23 +126,18 @@ export async function declareTemplate(
320
126
  "invalid_template",
321
127
  `template declaration "${String(input?.slug)}"`,
322
128
  );
323
- const plan = planTemplate(declaration);
324
- const existing = await declaredTemplate(plan.tenantId, plan.slug);
325
- if (existing) return applyToExisting(plan, existing);
326
- const created = await createDeclared(plan);
327
- if (created) return applyToExisting(plan, created);
328
- const winner = (await declaredTemplate(
329
- plan.tenantId,
330
- plan.slug,
331
- )) as MailingTemplate;
332
- return applyToExisting(plan, winner);
129
+ const { tenantId, slug } = declaration;
130
+ assertPublishable(declaration.content as TemplateContent, slug);
131
+ const model = GetModel(TemplateModel, tenantId);
132
+ const existing = await model.getBySlug(slug);
133
+ if (existing) return resultOf(existing, "kept");
134
+ const settings = await getSettings(tenantId);
135
+ assertKnownCategory(settings.categories, declaration.category, slug);
136
+ const created = await createDeclared(declaration);
137
+ if (created) return resultOf(created, "created");
138
+ return resultOf((await model.getBySlug(slug)) as MailingTemplate, "kept");
333
139
  }
334
140
 
335
- type DeclaredCategories = Record<string, string>;
336
-
337
- const categoryFingerprint = (category: Omit<TemplateCategory, "id">) =>
338
- fingerprintOf({ label: category.label, icon: category.icon });
339
-
340
141
  /** Category declarations of one tenant run one after the other. */
341
142
  const categoryQueues = new Map<string, Promise<unknown>>();
342
143
 
@@ -358,84 +159,30 @@ function inTenantQueue<T>(
358
159
  return next;
359
160
  }
360
161
 
361
- async function readDeclaredCategories(
362
- tenantId: string,
363
- ): Promise<DeclaredCategories> {
364
- const row = await GetModel(SettingsModel, tenantId).get(tenantId);
365
- return JSON.parse(
366
- row?.json_declared_categories || "{}",
367
- ) as DeclaredCategories;
368
- }
369
-
370
- /** The category list once the declared category is added or relabelled. */
371
- function withCategory(
372
- categories: TemplateCategory[],
373
- declared: TemplateCategory,
374
- ): TemplateCategory[] {
375
- const isPresent = Boolean(findCategory(categories, declared.id));
376
- const next = isPresent
377
- ? categories.map((category) =>
378
- category.id === declared.id ? declared : category,
379
- )
380
- : [...categories, declared];
381
- return parseDeclared(
382
- categoriesSchema,
383
- next,
384
- "invalid_category",
385
- `category declaration "${declared.id}"`,
386
- ).map(({ id, label, icon }) => ({ id, label, icon }));
387
- }
388
-
389
- /** What a category declaration does to the stored list, by its state. */
390
- function categoryOutcome(
391
- current: TemplateCategory | undefined,
392
- previousHash: string | undefined,
393
- hash: string,
394
- ): DeclarationOutcome {
395
- if (!current) return previousHash ? "kept" : "created";
396
- if (!previousHash) return "kept";
397
- if (previousHash === hash) return "unchanged";
398
- return categoryFingerprint(current) === previousHash ? "updated" : "kept";
399
- }
400
-
401
- const WRITING_OUTCOMES = new Set<DeclarationOutcome>(["created", "updated"]);
402
-
403
162
  async function applyCategory(
404
163
  declaration: ParsedCategoryDeclaration,
405
164
  ): Promise<DeclareTemplateCategoryResult> {
406
- const { tenantId } = declaration;
407
- const category = {
408
- id: declaration.id,
409
- label: declaration.label,
410
- icon: declaration.icon,
411
- };
412
- const hash = categoryFingerprint(category);
165
+ const { tenantId, id, label, icon } = declaration;
413
166
  const settings = await getSettings(tenantId);
414
- const declared = await readDeclaredCategories(tenantId);
415
- const outcome = categoryOutcome(
416
- findCategory(settings.categories, category.id),
417
- declared[category.id],
418
- hash,
419
- );
420
- if (!WRITING_OUTCOMES.has(outcome)) return { id: category.id, outcome };
421
- const categories = withCategory(settings.categories, category);
167
+ if (findCategory(settings.categories, id)) return { id, outcome: "kept" };
168
+ const categories = parseDeclared(
169
+ categoriesSchema,
170
+ [...settings.categories, { id, label, icon }],
171
+ "invalid_category",
172
+ `category declaration "${id}"`,
173
+ ).map((category) => ({
174
+ id: category.id,
175
+ label: category.label,
176
+ icon: category.icon,
177
+ }));
422
178
  await saveSettings(tenantId, { ...settings, categories });
423
- await GetModel(SettingsModel, tenantId).update(tenantId, {
424
- json_declared_categories: JSON.stringify({
425
- ...declared,
426
- [category.id]: hash,
427
- }),
428
- });
429
- return { id: category.id, outcome };
179
+ return { id, outcome: "created" };
430
180
  }
431
181
 
432
182
  /**
433
- * Adds or refreshes a template category a project declares from code, keyed
434
- * on its id: a new id is added (`created`); the declaration last applied is
435
- * left alone (`unchanged`); a changed declaration relabels a category still
436
- * as last declared (`updated`); a category a user relabelled or removed
437
- * since, or one that existed before any declaration (the defaults among
438
- * them), is left as it is (`kept`).
183
+ * Adds a template category a project ships as code, once: call it when the
184
+ * tenant is provisioned. A new id is added (`created`); an id the workspace
185
+ * settings already hold, the defaults among them, is left as it is (`kept`).
439
186
  */
440
187
  export function declareTemplateCategory(
441
188
  input: TemplateCategoryDeclaration,
@@ -135,7 +135,7 @@ async function publishEmailEvent(
135
135
  send: MailingSend,
136
136
  ): Promise<void> {
137
137
  emitEmailEvent(payload);
138
- publishSendUpdated(payload.sendId);
138
+ publishSendUpdated(payload.tenantId, payload.sendId);
139
139
  await notifyProblems(payload.tenantId, {
140
140
  sendId: send._id,
141
141
  templateSlug: send.templateSlug,
@@ -0,0 +1,53 @@
1
+ import type { RequestContext } from "@antelopejs/interface-api";
2
+ import {
3
+ LOCALIZED_TEXT_FALLBACK_LOCALE,
4
+ type LocalizedText,
5
+ } from "@antelopejs/interface-dms-mailing";
6
+
7
+ /** Header the DMS frontend states the reader's language in on every request. */
8
+ export const CONTENT_LANGUAGE_HEADER = "x-content-language";
9
+
10
+ /**
11
+ * The language to read localized texts in: the header's, else the one every
12
+ * localized text falls back to.
13
+ *
14
+ * @param header Value of {@link CONTENT_LANGUAGE_HEADER}
15
+ */
16
+ export function requestLocale(header: unknown): string {
17
+ return typeof header === "string" && header.length > 0
18
+ ? header
19
+ : LOCALIZED_TEXT_FALLBACK_LOCALE;
20
+ }
21
+
22
+ /** The language a request reads and writes localized texts in. */
23
+ export function localeOfRequest(ctx: RequestContext): string {
24
+ return requestLocale(ctx.rawRequest.headers[CONTENT_LANGUAGE_HEADER]);
25
+ }
26
+
27
+ /**
28
+ * A localized text in `locale`, else in the fallback language, as
29
+ * `@Localized({ fallbackLocale })` reads a table field.
30
+ */
31
+ export function textIn(
32
+ text: LocalizedText | undefined,
33
+ locale: string,
34
+ ): string {
35
+ return text?.[locale] ?? text?.[LOCALIZED_TEXT_FALLBACK_LOCALE] ?? "";
36
+ }
37
+
38
+ /**
39
+ * `text` with `value` written in `locale`. The fallback language gets the
40
+ * same value while it has none, so a text first written in another language
41
+ * still reads in every language.
42
+ */
43
+ export function withTextIn(
44
+ text: LocalizedText | undefined,
45
+ locale: string,
46
+ value: string,
47
+ ): LocalizedText {
48
+ return {
49
+ [LOCALIZED_TEXT_FALLBACK_LOCALE]: value,
50
+ ...text,
51
+ [locale]: value,
52
+ };
53
+ }
@@ -170,7 +170,8 @@ interface EntryOutcome {
170
170
  /**
171
171
  * Writes one send: its events first, under ids derived from the send's, then
172
172
  * the send itself. A run stopped half-way leaves events no send shows yet,
173
- * which the next run completes; a send present means it was fully imported.
173
+ * which the next run completes; each write skips the row an earlier run
174
+ * wrote, and a send present means it was fully imported.
174
175
  */
175
176
  async function importOne(
176
177
  tenantId: string,
@@ -180,7 +181,6 @@ async function importOne(
180
181
  ): Promise<EntryOutcome> {
181
182
  const id = importedSendIdOf(tenantId, send.importId);
182
183
  const sends = GetModel(SendModel, tenantId);
183
- if (await sends.get(id)) return { id, isImported: false };
184
184
  const timeline = timelineOf(send);
185
185
  const events = GetModel(SendEventModel, tenantId);
186
186
  for (const [index, entry] of timeline.entries()) {