@antelopejs/dms-mailing 0.7.0 → 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 (88) hide show
  1. package/README.md +8 -7
  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/data/templates-table.js +1 -8
  6. package/dist/data/templates-table.js.map +1 -1
  7. package/dist/db/models/index.d.ts +0 -1
  8. package/dist/db/models/index.js +0 -1
  9. package/dist/db/models/index.js.map +1 -1
  10. package/dist/db/models/send-events.model.js +2 -3
  11. package/dist/db/models/send-events.model.js.map +1 -1
  12. package/dist/db/models/sends.model.js +2 -3
  13. package/dist/db/models/sends.model.js.map +1 -1
  14. package/dist/db/models/templates.model.js +2 -9
  15. package/dist/db/models/templates.model.js.map +1 -1
  16. package/dist/db/tables/index.d.ts +0 -1
  17. package/dist/db/tables/index.js +0 -1
  18. package/dist/db/tables/index.js.map +1 -1
  19. package/dist/db/tables/settings.table.d.ts +0 -1
  20. package/dist/db/tables/settings.table.js +0 -4
  21. package/dist/db/tables/settings.table.js.map +1 -1
  22. package/dist/db/tables/templates.table.d.ts +0 -3
  23. package/dist/db/tables/templates.table.js +1 -13
  24. package/dist/db/tables/templates.table.js.map +1 -1
  25. package/dist/hooks/index.d.ts +0 -1
  26. package/dist/hooks/index.js +0 -1
  27. package/dist/hooks/index.js.map +1 -1
  28. package/dist/implementations/dms-mailing/index.d.ts +3 -4
  29. package/dist/implementations/dms-mailing/index.js +8 -9
  30. package/dist/implementations/dms-mailing/index.js.map +1 -1
  31. package/dist/index.js +0 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/pages/settings/form.js +8 -0
  34. package/dist/pages/settings/form.js.map +1 -1
  35. package/dist/routes/templates.js +2 -6
  36. package/dist/routes/templates.js.map +1 -1
  37. package/dist/services/declarations.js +42 -184
  38. package/dist/services/declarations.js.map +1 -1
  39. package/dist/services/send-import.js +0 -2
  40. package/dist/services/send-import.js.map +1 -1
  41. package/dist/services/settings.d.ts +1 -1
  42. package/dist/services/settings.js +1 -5
  43. package/dist/services/settings.js.map +1 -1
  44. package/dist/services/templates.d.ts +2 -2
  45. package/dist/services/templates.js +48 -27
  46. package/dist/services/templates.js.map +1 -1
  47. package/dist/types/settings.d.ts +2 -0
  48. package/dist/types/settings.js +8 -1
  49. package/dist/types/settings.js.map +1 -1
  50. package/dist/validation/settings.schema.d.ts +16 -16
  51. package/frontend-vue/package.json +1 -1
  52. package/package.json +6 -6
  53. package/skills/dms-mailing/SKILL.md +8 -14
  54. package/src/constants.ts +0 -2
  55. package/src/data/templates-table.ts +3 -15
  56. package/src/db/models/index.ts +0 -1
  57. package/src/db/models/send-events.model.ts +2 -2
  58. package/src/db/models/sends.model.ts +7 -8
  59. package/src/db/models/templates.model.ts +3 -13
  60. package/src/db/tables/index.ts +0 -1
  61. package/src/db/tables/settings.table.ts +0 -8
  62. package/src/db/tables/templates.table.ts +2 -16
  63. package/src/hooks/index.ts +0 -1
  64. package/src/implementations/dms-mailing/index.ts +5 -18
  65. package/src/index.ts +1 -6
  66. package/src/pages/settings/form.ts +16 -0
  67. package/src/routes/templates.ts +11 -13
  68. package/src/services/declarations.ts +64 -318
  69. package/src/services/send-import.ts +2 -2
  70. package/src/services/settings.ts +3 -10
  71. package/src/services/templates.ts +59 -44
  72. package/src/types/settings.ts +20 -0
  73. package/dist/db/models/template-slugs.model.d.ts +0 -27
  74. package/dist/db/models/template-slugs.model.js +0 -43
  75. package/dist/db/models/template-slugs.model.js.map +0 -1
  76. package/dist/db/tables/template-slugs.table.d.ts +0 -7
  77. package/dist/db/tables/template-slugs.table.js +0 -35
  78. package/dist/db/tables/template-slugs.table.js.map +0 -1
  79. package/dist/hooks/database-ready.d.ts +0 -7
  80. package/dist/hooks/database-ready.js +0 -26
  81. package/dist/hooks/database-ready.js.map +0 -1
  82. package/dist/services/fingerprint.d.ts +0 -1
  83. package/dist/services/fingerprint.js +0 -20
  84. package/dist/services/fingerprint.js.map +0 -1
  85. package/src/db/models/template-slugs.model.ts +0 -61
  86. package/src/db/tables/template-slugs.table.ts +0 -29
  87. package/src/hooks/database-ready.ts +0 -47
  88. package/src/services/fingerprint.ts +0 -22
@@ -1,27 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type {
3
- DeclarationOutcome,
4
- LocalizedText,
5
3
  DeclareTemplateCategoryResult,
6
4
  DeclareTemplateResult,
7
5
  TemplateCategoryDeclaration,
8
6
  TemplateDeclaration,
9
7
  } from "@antelopejs/interface-dms-mailing";
10
- import { Logging } from "@antelopejs/interface-core/logging";
8
+ import { IsDuplicateKeyError } from "@antelopejs/interface-database";
11
9
  import { GetModel } from "@antelopejs/interface-database-decorators";
12
10
  import {
13
11
  type MailingTemplate,
14
- SettingsModel,
15
12
  TemplateModel,
16
- TemplateSlugModel,
17
13
  TemplateVersionModel,
18
- templateSlugIdOf,
19
14
  } from "../db";
20
- import type {
21
- TemplateCategory,
22
- TemplateContent,
23
- VariableDefinition,
24
- } from "../types";
15
+ import type { TemplateCategory, TemplateContent } from "../types";
25
16
  import {
26
17
  categoryDeclarationSchema,
27
18
  type ParsedCategoryDeclaration,
@@ -30,54 +21,18 @@ import {
30
21
  } from "../validation/declarations.schema";
31
22
  import { categoriesSchema } from "../validation/settings.schema";
32
23
  import { DeclarationError, parseDeclared } from "./declaration-error";
33
- import { fingerprintOf } from "./fingerprint";
34
24
  import { findCategory, getSettings, saveSettings } from "./settings";
25
+ import { refreshTemplateAttention } from "./template-attention";
35
26
  import {
36
- assertSlugClaimed,
37
- blankFields,
38
- hasDraft,
39
27
  localesWithoutSubject,
40
- parseContent,
41
- parseTestData,
42
- parseVariables,
43
28
  publishedVersionOf,
44
- publishSnapshot,
29
+ publishedAtCreation,
45
30
  requireTemplate,
46
- workingContent,
47
- workingVariables,
48
31
  } from "./templates";
49
32
 
50
33
  /** Who a declaration's writes are shown as by default. */
51
34
  const DECLARATION_AUTHOR = "system";
52
35
 
53
- const LIVE_STATUS = "live";
54
- const LOG_PREFIX = "[dms-mailing]";
55
- const MAX_DECLARATION_ATTEMPTS = 5;
56
-
57
- /** What a declaration fingerprints: everything a user may change after it. */
58
- interface DeclaredTemplateState {
59
- name: LocalizedText;
60
- category: string;
61
- content: TemplateContent;
62
- variables: VariableDefinition[];
63
- testData: Record<string, unknown>;
64
- }
65
-
66
- /** A declaration ready to write: validated, defaulted and fingerprinted. */
67
- interface TemplatePlan {
68
- tenantId: string;
69
- slug: string;
70
- state: DeclaredTemplateState;
71
- author: string;
72
- hash: string;
73
- }
74
-
75
- /**
76
- * The values as the database gives them back: a `Date` in the test data is
77
- * stored as its ISO string, and the fingerprint must compare alike.
78
- */
79
- const asStored = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
80
-
81
36
  function assertPublishable(content: TemplateContent, slug: string): void {
82
37
  if (!Object.keys(content.locales).length)
83
38
  throw new DeclarationError(
@@ -105,212 +60,62 @@ function assertKnownCategory(
105
60
  );
106
61
  }
107
62
 
108
- function planTemplate(declaration: ParsedTemplateDeclaration): TemplatePlan {
109
- const { tenantId } = declaration;
110
- const { slug } = declaration;
111
- const state = asStored<DeclaredTemplateState>({
112
- name: declaration.name,
113
- category: declaration.category,
114
- content: declaration.content as TemplateContent,
115
- variables: declaration.variables,
116
- testData: declaration.testData,
117
- });
118
- assertPublishable(state.content, slug);
119
- return {
120
- tenantId,
121
- slug,
122
- state,
123
- author: declaration.author ?? DECLARATION_AUTHOR,
124
- hash: fingerprintOf(state),
125
- };
126
- }
127
-
128
- /**
129
- * The fingerprint of what the template holds now, `null` when a user took it
130
- * out of the state a declaration leaves it in: live, with no draft waiting.
131
- */
132
- function currentFingerprint(template: MailingTemplate): string | null {
133
- if (template.status !== LIVE_STATUS || hasDraft(template)) return null;
134
- return fingerprintOf({
135
- name: TemplateModel.namesOf(template),
136
- category: template.category ?? "",
137
- content: parseContent(template),
138
- variables: parseVariables(template),
139
- testData: parseTestData(template),
140
- });
141
- }
142
-
143
63
  function resultOf(
144
64
  template: MailingTemplate,
145
- outcome: DeclarationOutcome,
146
- version = publishedVersionOf(template),
65
+ outcome: DeclareTemplateResult["outcome"],
147
66
  ): DeclareTemplateResult {
148
67
  return {
149
68
  templateId: template._id,
150
69
  slug: template.slug,
151
70
  outcome,
152
- version,
71
+ version: publishedVersionOf(template),
153
72
  };
154
73
  }
155
74
 
156
75
  /**
157
- * Inserts the template as a fresh draft of the declared content, claims its
158
- * slug like the dashboard's creation does. The seed is not an applied
159
- * 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.
160
79
  */
161
80
  async function createDeclared(
162
- plan: TemplatePlan,
81
+ declaration: ParsedTemplateDeclaration,
163
82
  ): Promise<MailingTemplate | undefined> {
164
- const { tenantId, slug, state, author } = plan;
83
+ const { tenantId, slug } = declaration;
165
84
  const model = GetModel(TemplateModel, tenantId);
166
- const settings = await getSettings(tenantId);
167
- assertKnownCategory(settings.categories, state.category, slug);
168
- const revision = randomUUID();
169
- const [id] = await model.insertFields({
170
- slug,
171
- name: state.name,
172
- category: state.category,
173
- status: "draft",
174
- ...blankFields(state.content),
175
- json_variables: JSON.stringify(state.variables),
176
- json_test_data: JSON.stringify(state.testData),
177
- updatedBy: author,
178
- revision,
179
- declarationSeedRevision: revision,
180
- pendingDeclaredHash: plan.hash,
181
- });
182
- const templateId = id as string;
85
+ const author = declaration.author ?? DECLARATION_AUTHOR;
86
+ let template: MailingTemplate;
183
87
  try {
184
- 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);
185
103
  } catch (error) {
186
- 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;
187
107
  throw error;
188
108
  }
189
- return requireTemplate(model, templateId);
109
+ await GetModel(TemplateVersionModel, tenantId).materialize(template);
110
+ await refreshTemplateAttention(tenantId);
111
+ return template;
190
112
  }
191
113
 
192
114
  /**
193
- * A slug claim, not an arbitrary candidate row, decides which template won
194
- * concurrent creation. An unclaimed seed can be claimed by its next caller.
195
- */
196
- async function declaredTemplate(
197
- tenantId: string,
198
- slug: string,
199
- ): Promise<MailingTemplate | undefined> {
200
- const model = GetModel(TemplateModel, tenantId);
201
- const claim = await GetModel(TemplateSlugModel, tenantId).get(
202
- templateSlugIdOf(tenantId, slug),
203
- );
204
- const winner = claim && (await model.get(claim.templateId));
205
- return winner || model.getBySlug(slug);
206
- }
207
-
208
- function isUnmodifiedSeed(template: MailingTemplate): boolean {
209
- return Boolean(
210
- template.pendingDeclaredHash &&
211
- template.declarationSeedRevision &&
212
- template.revision === template.declarationSeedRevision &&
213
- template.status === "draft" &&
214
- template.publishedVersion === 0,
215
- );
216
- }
217
-
218
- /** Finishes the stored seed, never substituting a later caller's declaration. */
219
- async function finishSeed(
220
- tenantId: string,
221
- template: MailingTemplate,
222
- ): Promise<boolean> {
223
- try {
224
- await assertSlugClaimed(tenantId, template.slug, template._id);
225
- } catch (error) {
226
- const winner = await declaredTemplate(tenantId, template.slug);
227
- if (winner && winner._id !== template._id) return false;
228
- throw error;
229
- }
230
- const settings = await getSettings(tenantId);
231
- assertKnownCategory(settings.categories, template.category, template.slug);
232
- return Boolean(
233
- await publishSnapshot(
234
- tenantId,
235
- template,
236
- {
237
- content: workingContent(template),
238
- variables: workingVariables(template),
239
- },
240
- template.updatedBy,
241
- {
242
- declaredHash: template.pendingDeclaredHash,
243
- pendingDeclaredHash: "",
244
- declarationSeedRevision: "",
245
- },
246
- ),
247
- );
248
- }
249
-
250
- /** Why an existing template is left alone, `null` when it may be refreshed. */
251
- function keepReason(template: MailingTemplate): string | null {
252
- if (!template.declaredHash) return "it was created in the dashboard";
253
- if (currentFingerprint(template) !== template.declaredHash)
254
- return "a user changed it since it was declared";
255
- return null;
256
- }
257
-
258
- async function applyToExisting(
259
- plan: TemplatePlan,
260
- template: MailingTemplate,
261
- ): Promise<DeclareTemplateResult> {
262
- const { tenantId, state, author } = plan;
263
- for (let attempt = 0; attempt < MAX_DECLARATION_ATTEMPTS; attempt++) {
264
- if (isUnmodifiedSeed(template)) {
265
- const created = await finishSeed(tenantId, template);
266
- if (created && template.pendingDeclaredHash === plan.hash)
267
- return resultOf(template, "created", 1);
268
- } else {
269
- await GetModel(TemplateVersionModel, tenantId).materialize(template);
270
- if (template.publishedVersion > 0 && template.declaredHash === plan.hash)
271
- return resultOf(template, "unchanged");
272
- const reason = keepReason(template);
273
- if (reason) {
274
- Logging.Info(
275
- `${LOG_PREFIX} Kept template "${plan.slug}" as it is: ${reason}`,
276
- );
277
- return resultOf(template, "kept");
278
- }
279
- const settings = await getSettings(tenantId);
280
- assertKnownCategory(settings.categories, state.category, plan.slug);
281
- const outcome = await publishSnapshot(tenantId, template, state, author, {
282
- name: state.name,
283
- category: state.category,
284
- json_test_data: JSON.stringify(state.testData),
285
- declaredHash: plan.hash,
286
- });
287
- if (outcome) return resultOf(template, "updated", outcome.version);
288
- }
289
- const current = await declaredTemplate(tenantId, plan.slug);
290
- if (!current) break;
291
- template = current;
292
- }
293
- throw new DeclarationError(
294
- "declaration_conflict",
295
- `Template "${plan.slug}" changed repeatedly; retry the declaration`,
296
- );
297
- }
298
-
299
- /**
300
- * Creates or refreshes the template a project declares from code. The rule,
301
- * keyed on the slug:
302
- *
303
- * - no template has the slug: it is created and published (`created`);
304
- * - the declaration is the one last applied: nothing is written
305
- * (`unchanged`), whatever a user did to the template since;
306
- * - the declaration changed and the template is still live, with no draft,
307
- * and holds exactly what the last declaration wrote (name, category,
308
- * content, variables, test data): the new declaration is published
309
- * (`updated`);
310
- * - otherwise (edited, unpublished, archived, a draft pending, or made in the
311
- * dashboard): it is left as it is (`kept`).
312
- *
313
- * 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.
314
119
  */
315
120
  export async function declareTemplate(
316
121
  input: TemplateDeclaration,
@@ -321,23 +126,18 @@ export async function declareTemplate(
321
126
  "invalid_template",
322
127
  `template declaration "${String(input?.slug)}"`,
323
128
  );
324
- const plan = planTemplate(declaration);
325
- const existing = await declaredTemplate(plan.tenantId, plan.slug);
326
- if (existing) return applyToExisting(plan, existing);
327
- const created = await createDeclared(plan);
328
- if (created) return applyToExisting(plan, created);
329
- const winner = (await declaredTemplate(
330
- plan.tenantId,
331
- plan.slug,
332
- )) as MailingTemplate;
333
- 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");
334
139
  }
335
140
 
336
- type DeclaredCategories = Record<string, string>;
337
-
338
- const categoryFingerprint = (category: Omit<TemplateCategory, "id">) =>
339
- fingerprintOf({ label: category.label, icon: category.icon });
340
-
341
141
  /** Category declarations of one tenant run one after the other. */
342
142
  const categoryQueues = new Map<string, Promise<unknown>>();
343
143
 
@@ -359,84 +159,30 @@ function inTenantQueue<T>(
359
159
  return next;
360
160
  }
361
161
 
362
- async function readDeclaredCategories(
363
- tenantId: string,
364
- ): Promise<DeclaredCategories> {
365
- const row = await GetModel(SettingsModel, tenantId).get(tenantId);
366
- return JSON.parse(
367
- row?.json_declared_categories || "{}",
368
- ) as DeclaredCategories;
369
- }
370
-
371
- /** The category list once the declared category is added or relabelled. */
372
- function withCategory(
373
- categories: TemplateCategory[],
374
- declared: TemplateCategory,
375
- ): TemplateCategory[] {
376
- const isPresent = Boolean(findCategory(categories, declared.id));
377
- const next = isPresent
378
- ? categories.map((category) =>
379
- category.id === declared.id ? declared : category,
380
- )
381
- : [...categories, declared];
382
- return parseDeclared(
383
- categoriesSchema,
384
- next,
385
- "invalid_category",
386
- `category declaration "${declared.id}"`,
387
- ).map(({ id, label, icon }) => ({ id, label, icon }));
388
- }
389
-
390
- /** What a category declaration does to the stored list, by its state. */
391
- function categoryOutcome(
392
- current: TemplateCategory | undefined,
393
- previousHash: string | undefined,
394
- hash: string,
395
- ): DeclarationOutcome {
396
- if (!current) return previousHash ? "kept" : "created";
397
- if (!previousHash) return "kept";
398
- if (previousHash === hash) return "unchanged";
399
- return categoryFingerprint(current) === previousHash ? "updated" : "kept";
400
- }
401
-
402
- const WRITING_OUTCOMES = new Set<DeclarationOutcome>(["created", "updated"]);
403
-
404
162
  async function applyCategory(
405
163
  declaration: ParsedCategoryDeclaration,
406
164
  ): Promise<DeclareTemplateCategoryResult> {
407
- const { tenantId } = declaration;
408
- const category = {
409
- id: declaration.id,
410
- label: declaration.label,
411
- icon: declaration.icon,
412
- };
413
- const hash = categoryFingerprint(category);
165
+ const { tenantId, id, label, icon } = declaration;
414
166
  const settings = await getSettings(tenantId);
415
- const declared = await readDeclaredCategories(tenantId);
416
- const outcome = categoryOutcome(
417
- findCategory(settings.categories, category.id),
418
- declared[category.id],
419
- hash,
420
- );
421
- if (!WRITING_OUTCOMES.has(outcome)) return { id: category.id, outcome };
422
- 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
+ }));
423
178
  await saveSettings(tenantId, { ...settings, categories });
424
- await GetModel(SettingsModel, tenantId).update(tenantId, {
425
- json_declared_categories: JSON.stringify({
426
- ...declared,
427
- [category.id]: hash,
428
- }),
429
- });
430
- return { id: category.id, outcome };
179
+ return { id, outcome: "created" };
431
180
  }
432
181
 
433
182
  /**
434
- * Adds or refreshes a template category a project declares from code, keyed
435
- * on its id: a new id is added (`created`); the declaration last applied is
436
- * left alone (`unchanged`); a changed declaration relabels a category still
437
- * as last declared (`updated`); a category a user relabelled or removed
438
- * since, or one that existed before any declaration (the defaults among
439
- * 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`).
440
186
  */
441
187
  export function declareTemplateCategory(
442
188
  input: TemplateCategoryDeclaration,
@@ -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()) {
@@ -1,12 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { GetModel } from "@antelopejs/interface-database-decorators";
3
- import {
4
- DEFAULT_FALLBACK_LOCALE,
5
- DEFAULT_LOG_RETENTION_DAYS,
6
- } from "../constants";
7
3
  import { type MailingSettings, SettingsModel } from "../db";
8
4
  import {
9
- DEFAULT_CATEGORIES,
5
+ MODULE_DEFAULT_SETTINGS,
10
6
  type MailingSettingsValues,
11
7
  type TemplateCategory,
12
8
  } from "../types";
@@ -15,14 +11,11 @@ export const WEBHOOK_SECRET_BYTES = 24;
15
11
 
16
12
  function defaults(): MailingSettingsValues {
17
13
  return {
18
- fallbackLocale: DEFAULT_FALLBACK_LOCALE,
19
- logRetentionDays: DEFAULT_LOG_RETENTION_DAYS,
20
- blockOnMissingVariables: false,
14
+ ...MODULE_DEFAULT_SETTINGS,
21
15
  senderName: "",
22
16
  senderEmail: "",
23
17
  replyTo: "",
24
18
  webhookSecret: randomBytes(WEBHOOK_SECRET_BYTES).toString("hex"),
25
- categories: DEFAULT_CATEGORIES,
26
19
  };
27
20
  }
28
21
 
@@ -106,7 +99,7 @@ const REFUSE_POLICY: MissingVariablesPolicy = "refuse";
106
99
  const SEND_POLICY: MissingVariablesPolicy = "send";
107
100
 
108
101
  export function missingVariablesPolicy(
109
- values: MailingSettingsValues,
102
+ values: Pick<MailingSettingsValues, "blockOnMissingVariables">,
110
103
  ): MissingVariablesPolicy {
111
104
  return values.blockOnMissingVariables ? REFUSE_POLICY : SEND_POLICY;
112
105
  }
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { HTTPResult } from "@antelopejs/interface-api";
3
+ import { IsDuplicateKeyError } from "@antelopejs/interface-database";
3
4
  import { GetModel } from "@antelopejs/interface-database-decorators";
4
5
  import {
5
- DEFAULT_FALLBACK_LOCALE,
6
6
  HTTP_CONFLICT,
7
7
  HTTP_NOT_FOUND,
8
8
  LOCALE_LIST_SEPARATOR,
@@ -12,9 +12,7 @@ import {
12
12
  type MailingTemplateVersion,
13
13
  type TemplateFields,
14
14
  TemplateModel,
15
- TemplateSlugModel,
16
15
  TemplateVersionModel,
17
- templateSlugIdOf,
18
16
  } from "../db";
19
17
  import { diffContents, diffVariables, type TemplateChange } from "../engine";
20
18
  import {
@@ -28,6 +26,7 @@ import type {
28
26
  TemplateContent,
29
27
  VariableDefinition,
30
28
  } from "../types";
29
+ import { DEFAULT_FALLBACK_LOCALE } from "../types";
31
30
 
32
31
  export interface TemplateAuthor {
33
32
  name?: string;
@@ -45,6 +44,7 @@ const STALE_DRAFT: TemplateAttentionReason = "stale_draft";
45
44
  const VERSION_NOT_FOUND = "$dms_mailing.errors.version_not_found";
46
45
  const TEMPLATE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
47
46
  const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
47
+ const SLUG_FIELD = "slug";
48
48
 
49
49
  /** The template `id` names; 404 when there is none. */
50
50
  export async function requireTemplate(
@@ -57,49 +57,18 @@ export async function requireTemplate(
57
57
  }
58
58
 
59
59
  /**
60
- * Gives `slug` to the template just inserted; false when another template
61
- * holds it. A claim whose template is gone (deleted before its claim was
62
- * freed) is taken over, through the same insert that settles a race.
60
+ * Runs a write that gives a template its slug, refused with a 409 when
61
+ * another template of the tenant holds it: the slug's unique index settles
62
+ * concurrent creations, so nothing reads the table before writing.
63
63
  */
64
- async function claimTemplateSlug(
65
- tenantId: string,
66
- slug: string,
67
- templateId: string,
68
- ): Promise<boolean> {
69
- const claims = GetModel(TemplateSlugModel, tenantId);
70
- const id = templateSlugIdOf(tenantId, slug);
71
- if (await claims.insertClaim(id, slug, templateId)) return true;
72
- const holder = await claims.get(id);
73
- if (holder?.templateId === templateId) return true;
74
- if (holder) {
75
- const templates = GetModel(TemplateModel, tenantId);
76
- if (await templates.get(holder.templateId)) return false;
77
- await claims.dropClaimOf(id, holder.templateId);
64
+ export async function refuseTakenSlug<T>(write: Promise<T>): Promise<T> {
65
+ try {
66
+ return await write;
67
+ } catch (error) {
68
+ if (IsDuplicateKeyError(error) && error.fields.includes(SLUG_FIELD))
69
+ throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
70
+ throw error;
78
71
  }
79
- return claims.insertClaim(id, slug, templateId);
80
- }
81
-
82
- /**
83
- * Keeps the template just inserted if it got its slug, or removes it again
84
- * and refuses it like any taken slug: two creations racing past the free-slug
85
- * check both insert, and only the claim's primary key tells them apart.
86
- */
87
- export async function assertSlugClaimed(
88
- tenantId: string,
89
- slug: string,
90
- templateId: string,
91
- ): Promise<void> {
92
- if (await claimTemplateSlug(tenantId, slug, templateId)) return;
93
- await GetModel(TemplateModel, tenantId).delete(templateId);
94
- throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
95
- }
96
-
97
- /** Frees the slugs of deleted templates for new ones. */
98
- export async function releaseTemplateSlugs(
99
- tenantId: string,
100
- templateIds: string[],
101
- ): Promise<void> {
102
- await GetModel(TemplateSlugModel, tenantId).releaseTemplates(templateIds);
103
72
  }
104
73
 
105
74
  /** The content customers receive: the last published version. */
@@ -528,6 +497,52 @@ export async function initializeTemplate(
528
497
  await refreshTemplateAttention(tenantId);
529
498
  }
530
499
 
500
+ /** The first version a template publishes. */
501
+ const FIRST_VERSION = 1;
502
+
503
+ /**
504
+ * A new template, published at once as version 1 of `draft`: its content
505
+ * and its history entry land in the one write that creates it, so no reader
506
+ * ever sees it unpublished. Materialize its version row afterwards, as a
507
+ * publication does.
508
+ */
509
+ export function publishedAtCreation(
510
+ id: string,
511
+ draft: TemplateDraft,
512
+ author: string,
513
+ ): TemplateFields {
514
+ const shipped: TemplateFields = {
515
+ json_content: JSON.stringify(draft.content),
516
+ json_variables: JSON.stringify(draft.variables),
517
+ locales: localesOf(draft.content),
518
+ publishedAt: new Date(),
519
+ publishedBy: author,
520
+ };
521
+ const changes = [
522
+ ...diffContents({ locales: {} }, draft.content),
523
+ ...diffVariables([], draft.variables),
524
+ ];
525
+ return {
526
+ _id: id,
527
+ ...shipped,
528
+ json_published_version: JSON.stringify({
529
+ ...shipped,
530
+ _id: randomUUID(),
531
+ templateId: id,
532
+ version: FIRST_VERSION,
533
+ json_changes: JSON.stringify(changes),
534
+ }),
535
+ json_draft: "",
536
+ json_variables_draft: "",
537
+ isDraftPending: false,
538
+ draftRevision: FIRST_DRAFT_REVISION,
539
+ status: "live",
540
+ publishedVersion: FIRST_VERSION,
541
+ updatedBy: author,
542
+ ...NO_ATTENTION,
543
+ };
544
+ }
545
+
531
546
  export function blankFields(content: TemplateContent): TemplateFields {
532
547
  return {
533
548
  json_content: "",