@antelopejs/dms-mailing 0.5.0 → 0.6.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 (106) hide show
  1. package/README.md +61 -339
  2. package/dist/data/templates-table.js +2 -10
  3. package/dist/data/templates-table.js.map +1 -1
  4. package/dist/db/models/index.d.ts +1 -0
  5. package/dist/db/models/index.js +1 -0
  6. package/dist/db/models/index.js.map +1 -1
  7. package/dist/db/models/send-events.model.d.ts +2 -0
  8. package/dist/db/models/send-events.model.js +11 -0
  9. package/dist/db/models/send-events.model.js.map +1 -1
  10. package/dist/db/models/sends.model.d.ts +3 -0
  11. package/dist/db/models/sends.model.js +13 -0
  12. package/dist/db/models/sends.model.js.map +1 -1
  13. package/dist/db/models/settings.model.d.ts +1 -0
  14. package/dist/db/models/suppressions.model.d.ts +1 -0
  15. package/dist/db/models/template-slugs.model.d.ts +27 -0
  16. package/dist/db/models/template-slugs.model.js +43 -0
  17. package/dist/db/models/template-slugs.model.js.map +1 -0
  18. package/dist/db/models/template-versions.model.d.ts +4 -1
  19. package/dist/db/models/template-versions.model.js +35 -2
  20. package/dist/db/models/template-versions.model.js.map +1 -1
  21. package/dist/db/models/templates.model.d.ts +2 -0
  22. package/dist/db/models/templates.model.js +22 -0
  23. package/dist/db/models/templates.model.js.map +1 -1
  24. package/dist/db/models/webhook-status.model.d.ts +1 -0
  25. package/dist/db/tables/index.d.ts +1 -0
  26. package/dist/db/tables/index.js +1 -0
  27. package/dist/db/tables/index.js.map +1 -1
  28. package/dist/db/tables/settings.table.d.ts +1 -0
  29. package/dist/db/tables/settings.table.js +4 -0
  30. package/dist/db/tables/settings.table.js.map +1 -1
  31. package/dist/db/tables/template-slugs.table.d.ts +7 -0
  32. package/dist/db/tables/template-slugs.table.js +35 -0
  33. package/dist/db/tables/template-slugs.table.js.map +1 -0
  34. package/dist/db/tables/templates.table.d.ts +5 -0
  35. package/dist/db/tables/templates.table.js +27 -0
  36. package/dist/db/tables/templates.table.js.map +1 -1
  37. package/dist/hooks/database-ready.d.ts +7 -0
  38. package/dist/hooks/database-ready.js +26 -0
  39. package/dist/hooks/database-ready.js.map +1 -0
  40. package/dist/hooks/index.d.ts +1 -0
  41. package/dist/hooks/index.js +1 -0
  42. package/dist/hooks/index.js.map +1 -1
  43. package/dist/implementations/dms-mailing/index.d.ts +3 -0
  44. package/dist/implementations/dms-mailing/index.js +7 -1
  45. package/dist/implementations/dms-mailing/index.js.map +1 -1
  46. package/dist/index.js +1 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/routes/templates.js +1 -0
  49. package/dist/routes/templates.js.map +1 -1
  50. package/dist/services/declaration-error.d.ts +6 -0
  51. package/dist/services/declaration-error.js +26 -0
  52. package/dist/services/declaration-error.js.map +1 -0
  53. package/dist/services/declarations.d.ts +3 -0
  54. package/dist/services/declarations.js +244 -0
  55. package/dist/services/declarations.js.map +1 -0
  56. package/dist/services/events.d.ts +4 -1
  57. package/dist/services/events.js.map +1 -1
  58. package/dist/services/fingerprint.d.ts +1 -0
  59. package/dist/services/fingerprint.js +20 -0
  60. package/dist/services/fingerprint.js.map +1 -0
  61. package/dist/services/send-import.d.ts +2 -0
  62. package/dist/services/send-import.js +136 -0
  63. package/dist/services/send-import.js.map +1 -0
  64. package/dist/services/templates.d.ts +3 -0
  65. package/dist/services/templates.js +75 -48
  66. package/dist/services/templates.js.map +1 -1
  67. package/dist/validation/declarations.schema.d.ts +299 -0
  68. package/dist/validation/declarations.schema.js +55 -0
  69. package/dist/validation/declarations.schema.js.map +1 -0
  70. package/frontend-vue/app/components/CategoriesInput.vue +12 -11
  71. package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
  72. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
  73. package/frontend-vue/app/composables/useSortableList.ts +159 -0
  74. package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
  75. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
  76. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
  77. package/frontend-vue/package.json +4 -3
  78. package/frontend-vue/pnpm-lock.yaml +6 -13
  79. package/frontend-vue/tests/editor-block-list.test.ts +46 -6
  80. package/frontend-vue/tests/sortable-list.test.ts +253 -0
  81. package/package.json +31 -14
  82. package/skills/dms-mailing/SKILL.md +101 -0
  83. package/src/data/templates-table.ts +7 -27
  84. package/src/db/models/index.ts +1 -0
  85. package/src/db/models/send-events.model.ts +13 -0
  86. package/src/db/models/sends.model.ts +22 -0
  87. package/src/db/models/template-slugs.model.ts +61 -0
  88. package/src/db/models/template-versions.model.ts +48 -2
  89. package/src/db/models/templates.model.ts +31 -0
  90. package/src/db/tables/index.ts +1 -0
  91. package/src/db/tables/settings.table.ts +8 -0
  92. package/src/db/tables/template-slugs.table.ts +29 -0
  93. package/src/db/tables/templates.table.ts +38 -0
  94. package/src/hooks/database-ready.ts +47 -0
  95. package/src/hooks/index.ts +1 -0
  96. package/src/implementations/dms-mailing/index.ts +17 -0
  97. package/src/index.ts +6 -1
  98. package/src/routes/templates.ts +2 -0
  99. package/src/services/declaration-error.ts +39 -0
  100. package/src/services/declarations.ts +450 -0
  101. package/src/services/events.ts +8 -2
  102. package/src/services/fingerprint.ts +22 -0
  103. package/src/services/send-import.ts +235 -0
  104. package/src/services/templates.ts +113 -72
  105. package/src/validation/declarations.schema.ts +77 -0
  106. package/frontend-vue/pnpm-workspace.yaml +0 -2
@@ -0,0 +1,235 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ EmailEvent,
4
+ ImportSendsResult,
5
+ SendImport,
6
+ } from "@antelopejs/interface-dms-mailing";
7
+ import { GetModel } from "@antelopejs/interface-database-decorators";
8
+ import {
9
+ type ImportedSendRow,
10
+ type MailingTemplate,
11
+ SendEventModel,
12
+ SendModel,
13
+ TemplateModel,
14
+ } from "../db";
15
+ import { type SendEventType, stageOf } from "../types";
16
+ import {
17
+ type ParsedImportedSend,
18
+ sendImportSchema,
19
+ } from "../validation/declarations.schema";
20
+ import { DeclarationError, parseDeclared } from "./declaration-error";
21
+ import {
22
+ applyEvent,
23
+ errorAfter,
24
+ isSameEvent,
25
+ isSoftBounce,
26
+ type SendCounters,
27
+ } from "./events";
28
+ import { getSettings } from "./settings";
29
+ import { refreshTemplateAttention } from "./template-attention";
30
+
31
+ /** Labels an imported send in the log when its caller gives no source. */
32
+ const IMPORT_SOURCE = "import";
33
+
34
+ const ID_LENGTH = 32;
35
+ /** What an imported send without any event is taken to have reached. */
36
+ const DEFAULT_EVENT: SendEventType = "sent";
37
+ const START: SendCounters = { status: "queued", opens: 0, clicks: 0 };
38
+
39
+ type ImportedEvent = ParsedImportedSend["events"][number];
40
+
41
+ /** A send's id from its import key: the same on every run of the import. */
42
+ function importedSendIdOf(tenantId: string, importId: string): string {
43
+ return createHash("sha256")
44
+ .update(`${tenantId}\nimport\n${importId}`)
45
+ .digest("hex")
46
+ .slice(0, ID_LENGTH);
47
+ }
48
+
49
+ const addressOf = (to: ParsedImportedSend["to"]) =>
50
+ typeof to === "string" ? { email: to } : to;
51
+
52
+ /** Chronological events, with the webhook's retry deduplication rules. */
53
+ function timelineOf(send: ParsedImportedSend): ImportedEvent[] {
54
+ if (!send.events.length) return [{ type: DEFAULT_EVENT, at: send.sentAt }];
55
+ const timeline: ImportedEvent[] = [];
56
+ for (const entry of [...send.events].sort(
57
+ (left, right) => +left.at - +right.at,
58
+ )) {
59
+ const repeated = timeline.some((stored) =>
60
+ isSameEvent(
61
+ {
62
+ type: stored.type as SendEventType,
63
+ at: stored.at,
64
+ json_details: JSON.stringify(stored.details ?? {}),
65
+ },
66
+ { ...entry, type: entry.type as SendEventType },
67
+ ),
68
+ );
69
+ if (!repeated) timeline.push(entry);
70
+ }
71
+ return timeline;
72
+ }
73
+
74
+ /** The send's columns once its events are applied, as a webhook would. */
75
+ interface FoldedActivity extends SendCounters {
76
+ error?: string;
77
+ isSoftBounce?: boolean;
78
+ lastEventAt: Date;
79
+ }
80
+
81
+ function foldEvents(
82
+ send: ParsedImportedSend,
83
+ timeline: ImportedEvent[],
84
+ ): FoldedActivity {
85
+ return timeline.reduce<FoldedActivity>(
86
+ (activity, entry) => {
87
+ const event: EmailEvent = {
88
+ provider: send.provider ?? IMPORT_SOURCE,
89
+ messageId: send.providerMessageId ?? "",
90
+ type: entry.type as SendEventType,
91
+ at: entry.at,
92
+ details: entry.details,
93
+ };
94
+ const counters = applyEvent(activity, event.type);
95
+ return {
96
+ ...counters,
97
+ error:
98
+ errorAfter(event, activity.status, counters.status) ?? activity.error,
99
+ isSoftBounce:
100
+ event.type === "bounced"
101
+ ? isSoftBounce(event)
102
+ : activity.isSoftBounce,
103
+ lastEventAt: entry.at,
104
+ };
105
+ },
106
+ { ...START, lastEventAt: send.sentAt },
107
+ );
108
+ }
109
+
110
+ function sendRow(
111
+ id: string,
112
+ send: ParsedImportedSend,
113
+ template: MailingTemplate,
114
+ locale: string,
115
+ activity: FoldedActivity,
116
+ ): ImportedSendRow {
117
+ const recipient = addressOf(send.to);
118
+ return {
119
+ _id: id,
120
+ createdAt: send.sentAt,
121
+ templateId: template._id,
122
+ templateSlug: template.slug,
123
+ locale,
124
+ requestedLocale: send.locale ?? "",
125
+ templateVersion: template.publishedVersion,
126
+ recipientEmail: recipient.email,
127
+ recipientName: recipient.name,
128
+ status: activity.status,
129
+ stage: stageOf(activity.status),
130
+ provider: send.provider,
131
+ providerMessageId: send.providerMessageId,
132
+ latencyMs: null,
133
+ error: activity.error || undefined,
134
+ source: send.source ?? IMPORT_SOURCE,
135
+ json_variables: JSON.stringify(send.variables),
136
+ opens: activity.opens,
137
+ clicks: activity.clicks,
138
+ lastEventAt: activity.lastEventAt,
139
+ isTest: send.isTest,
140
+ isSoftBounce: activity.isSoftBounce,
141
+ };
142
+ }
143
+
144
+ /** The templates the sends name, by slug; refused before anything is written. */
145
+ async function templatesOf(
146
+ tenantId: string,
147
+ sends: ParsedImportedSend[],
148
+ ): Promise<Map<string, MailingTemplate>> {
149
+ const model = GetModel(TemplateModel, tenantId);
150
+ const slugs = [...new Set(sends.map((send) => send.templateSlug))];
151
+ const templates = new Map<string, MailingTemplate>();
152
+ for (const slug of slugs) {
153
+ const template = await model.getBySlug(slug);
154
+ if (!template)
155
+ throw new DeclarationError(
156
+ "template_not_found",
157
+ `Imported sends name template "${slug}", which the workspace does not have`,
158
+ );
159
+ templates.set(slug, template);
160
+ }
161
+ return templates;
162
+ }
163
+
164
+ /** The id an entry got, and whether this run wrote it. */
165
+ interface EntryOutcome {
166
+ id: string;
167
+ isImported: boolean;
168
+ }
169
+
170
+ /**
171
+ * Writes one send: its events first, under ids derived from the send's, then
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.
174
+ */
175
+ async function importOne(
176
+ tenantId: string,
177
+ send: ParsedImportedSend,
178
+ template: MailingTemplate,
179
+ fallbackLocale: string,
180
+ ): Promise<EntryOutcome> {
181
+ const id = importedSendIdOf(tenantId, send.importId);
182
+ const sends = GetModel(SendModel, tenantId);
183
+ if (await sends.get(id)) return { id, isImported: false };
184
+ const timeline = timelineOf(send);
185
+ const events = GetModel(SendEventModel, tenantId);
186
+ for (const [index, entry] of timeline.entries()) {
187
+ await events.insertImported({
188
+ _id: `${id}-${index}`,
189
+ sendId: id,
190
+ type: entry.type as SendEventType,
191
+ at: entry.at,
192
+ json_details: JSON.stringify(entry.details ?? {}),
193
+ });
194
+ }
195
+ const locale = send.locale ?? fallbackLocale;
196
+ const row = sendRow(id, send, template, locale, foldEvents(send, timeline));
197
+ return { id, isImported: await sends.insertImported(row) };
198
+ }
199
+
200
+ /**
201
+ * Writes past sends into the send log with their own dates and events: the
202
+ * Overview, the template stats and the log then count them like any other.
203
+ * An import, not a send: nothing is delivered, and no suppression, realtime
204
+ * update, automation or notification follows. A send whose `importId` was
205
+ * already imported is skipped untouched. The log retention applies to them
206
+ * like to any send: one older than the workspace's retention goes at its next
207
+ * nightly run.
208
+ */
209
+ export async function importSends(
210
+ input: SendImport,
211
+ ): Promise<ImportSendsResult> {
212
+ const { tenantId, sends } = parseDeclared(
213
+ sendImportSchema,
214
+ input,
215
+ "invalid_import",
216
+ "send import",
217
+ );
218
+ const templates = await templatesOf(tenantId, sends);
219
+ const { fallbackLocale } = await getSettings(tenantId);
220
+ const result: ImportSendsResult = { imported: 0, skipped: 0, sendIds: [] };
221
+ for (const send of sends) {
222
+ const template = templates.get(send.templateSlug) as MailingTemplate;
223
+ const { id, isImported } = await importOne(
224
+ tenantId,
225
+ send,
226
+ template,
227
+ fallbackLocale,
228
+ );
229
+ result.sendIds.push(id);
230
+ if (isImported) result.imported += 1;
231
+ else result.skipped += 1;
232
+ }
233
+ if (result.imported) await refreshTemplateAttention(tenantId);
234
+ return result;
235
+ }
@@ -1,7 +1,9 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { HTTPResult } from "@antelopejs/interface-api";
2
3
  import { GetModel } from "@antelopejs/interface-database-decorators";
3
4
  import {
4
5
  DEFAULT_FALLBACK_LOCALE,
6
+ HTTP_CONFLICT,
5
7
  HTTP_NOT_FOUND,
6
8
  LOCALE_LIST_SEPARATOR,
7
9
  } from "../constants";
@@ -9,7 +11,9 @@ import {
9
11
  type MailingTemplate,
10
12
  type MailingTemplateVersion,
11
13
  TemplateModel,
14
+ TemplateSlugModel,
12
15
  TemplateVersionModel,
16
+ templateSlugIdOf,
13
17
  } from "../db";
14
18
  import { diffContents, diffVariables, type TemplateChange } from "../engine";
15
19
  import {
@@ -39,6 +43,7 @@ const NEVER_PUBLISHED = 0;
39
43
  const STALE_DRAFT: TemplateAttentionReason = "stale_draft";
40
44
  const VERSION_NOT_FOUND = "$dms_mailing.errors.version_not_found";
41
45
  const TEMPLATE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
46
+ const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
42
47
 
43
48
  /** The template `id` names; 404 when there is none. */
44
49
  export async function requireTemplate(
@@ -50,6 +55,52 @@ export async function requireTemplate(
50
55
  return template;
51
56
  }
52
57
 
58
+ /**
59
+ * Gives `slug` to the template just inserted; false when another template
60
+ * holds it. A claim whose template is gone (deleted before its claim was
61
+ * freed) is taken over, through the same insert that settles a race.
62
+ */
63
+ async function claimTemplateSlug(
64
+ tenantId: string,
65
+ slug: string,
66
+ templateId: string,
67
+ ): Promise<boolean> {
68
+ const claims = GetModel(TemplateSlugModel, tenantId);
69
+ const id = templateSlugIdOf(tenantId, slug);
70
+ if (await claims.insertClaim(id, slug, templateId)) return true;
71
+ const holder = await claims.get(id);
72
+ if (holder?.templateId === templateId) return true;
73
+ if (holder) {
74
+ const templates = GetModel(TemplateModel, tenantId);
75
+ if (await templates.get(holder.templateId)) return false;
76
+ await claims.dropClaimOf(id, holder.templateId);
77
+ }
78
+ return claims.insertClaim(id, slug, templateId);
79
+ }
80
+
81
+ /**
82
+ * Keeps the template just inserted if it got its slug, or removes it again
83
+ * and refuses it like any taken slug: two creations racing past the free-slug
84
+ * check both insert, and only the claim's primary key tells them apart.
85
+ */
86
+ export async function assertSlugClaimed(
87
+ tenantId: string,
88
+ slug: string,
89
+ templateId: string,
90
+ ): Promise<void> {
91
+ if (await claimTemplateSlug(tenantId, slug, templateId)) return;
92
+ await GetModel(TemplateModel, tenantId).delete(templateId);
93
+ throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
94
+ }
95
+
96
+ /** Frees the slugs of deleted templates for new ones. */
97
+ export async function releaseTemplateSlugs(
98
+ tenantId: string,
99
+ templateIds: string[],
100
+ ): Promise<void> {
101
+ await GetModel(TemplateSlugModel, tenantId).releaseTemplates(templateIds);
102
+ }
103
+
53
104
  /** The content customers receive: the last published version. */
54
105
  export function parseContent(template: MailingTemplate): TemplateContent {
55
106
  return JSON.parse(template.json_content || EMPTY_CONTENT) as TemplateContent;
@@ -291,48 +342,69 @@ export interface PublishOutcome {
291
342
  changes: TemplateChange[];
292
343
  }
293
344
 
294
- /** What a publish ships: the working content and declarations. */
295
- interface Shipment {
296
- content: TemplateContent;
297
- variables: string;
298
- version: number;
299
- changes: TemplateChange[];
300
- }
301
-
302
345
  /**
303
- * Keeps `shipment` as a version row for the history and makes it what
304
- * customers receive, the draft cleared.
346
+ * Publishes a snapshot in one conditional write, including declaration
347
+ * metadata when supplied. A losing writer never inserts speculative history.
348
+ * The latest version is recoverable from the template after a failed history
349
+ * insert; before replacing it we must materialize the previous version.
305
350
  */
306
- async function ship(
351
+ export async function publishSnapshot(
307
352
  tenantId: string,
308
353
  template: MailingTemplate,
309
- shipment: Shipment,
354
+ draft: TemplateDraft,
310
355
  author: string,
311
- ): Promise<void> {
312
- const { content, variables, version, changes } = shipment;
313
- const shipped = {
314
- json_content: JSON.stringify(content),
315
- json_variables: variables,
316
- locales: localesOf(content),
317
- publishedAt: new Date(),
318
- publishedBy: author,
319
- };
320
- await GetModel(TemplateVersionModel, tenantId).insert({
321
- ...shipped,
322
- templateId: template._id,
323
- version,
324
- json_changes: JSON.stringify(changes),
325
- });
326
- await GetModel(TemplateModel, tenantId).update(template._id, {
356
+ metadata: Partial<MailingTemplate> = {},
357
+ ): Promise<PublishOutcome | undefined> {
358
+ const previous = publishedVersionOf(template);
359
+ const changes = changesOf(template, draft);
360
+ const isUnchanged = previous > NEVER_PUBLISHED && changes.length === 0;
361
+ const version = isUnchanged ? previous : previous + 1;
362
+ const versions = GetModel(TemplateVersionModel, tenantId);
363
+ await versions.materialize(template);
364
+ const shipped: Partial<MailingTemplate> = isUnchanged
365
+ ? {}
366
+ : {
367
+ json_content: JSON.stringify(draft.content),
368
+ json_variables: JSON.stringify(draft.variables),
369
+ locales: localesOf(draft.content),
370
+ publishedAt: new Date(),
371
+ publishedBy: author,
372
+ };
373
+ const history = isUnchanged
374
+ ? {}
375
+ : {
376
+ json_published_version: JSON.stringify({
377
+ ...shipped,
378
+ _id: randomUUID(),
379
+ templateId: template._id,
380
+ version,
381
+ json_changes: JSON.stringify(changes),
382
+ }),
383
+ };
384
+ const patch: Partial<MailingTemplate> = {
385
+ ...metadata,
327
386
  ...shipped,
387
+ ...history,
328
388
  json_draft: "",
329
389
  json_variables_draft: "",
330
390
  isDraftPending: false,
331
- draftRevision: template.draftRevision + 1,
391
+ draftRevision:
392
+ template.draftRevision + Number(!isUnchanged || hasDraft(template)),
332
393
  status: "live",
333
394
  publishedVersion: version,
395
+ publishedAt: shipped.publishedAt ?? template.publishedAt ?? new Date(),
334
396
  updatedBy: author,
397
+ };
398
+ if (
399
+ !(await GetModel(TemplateModel, tenantId).publishIfCurrent(template, patch))
400
+ )
401
+ return undefined;
402
+ await versions.materialize({
403
+ json_published_version:
404
+ patch.json_published_version ?? template.json_published_version,
335
405
  });
406
+ await refreshTemplateAttention(tenantId);
407
+ return { version, changes };
336
408
  }
337
409
 
338
410
  /**
@@ -345,49 +417,18 @@ export async function publishTemplate(
345
417
  template: MailingTemplate,
346
418
  author: string,
347
419
  ): Promise<PublishOutcome> {
348
- const previous = publishedVersionOf(template);
349
- const changes = pendingChanges(template);
350
- const isUnchanged = previous > NEVER_PUBLISHED && changes.length === 0;
351
- const version = isUnchanged ? previous : previous + 1;
352
- if (isUnchanged) await markLive(tenantId, template, previous, author);
353
- else
354
- await ship(
355
- tenantId,
356
- template,
357
- {
358
- content: workingContent(template),
359
- variables: JSON.stringify(workingVariables(template)),
360
- version,
361
- changes,
362
- },
363
- author,
364
- );
365
- await refreshTemplateAttention(tenantId);
366
- return { version, changes };
367
- }
368
-
369
- /** Puts `version` back live; a draft equal to it is dropped on the way. */
370
- async function markLive(
371
- tenantId: string,
372
- template: MailingTemplate,
373
- version: number,
374
- author: string,
375
- ): Promise<void> {
376
- const draft = hasDraft(template)
377
- ? {
378
- json_draft: "",
379
- json_variables_draft: "",
380
- isDraftPending: false,
381
- draftRevision: template.draftRevision + 1,
382
- }
383
- : {};
384
- await GetModel(TemplateModel, tenantId).update(template._id, {
385
- status: "live",
386
- publishedVersion: version,
387
- publishedAt: template.publishedAt ?? new Date(),
388
- updatedBy: author,
389
- ...draft,
390
- });
420
+ const outcome = await publishSnapshot(
421
+ tenantId,
422
+ template,
423
+ {
424
+ content: workingContent(template),
425
+ variables: workingVariables(template),
426
+ },
427
+ author,
428
+ );
429
+ if (!outcome)
430
+ throw new HTTPResult(HTTP_CONFLICT, "$dms_mailing.errors.draft_conflict");
431
+ return outcome;
391
432
  }
392
433
 
393
434
  /** The content a send of `version` carried: the draft's for a test of one. */
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import { MAX_NAME_LENGTH } from "../constants";
3
+ import { SEND_EVENT_TYPES } from "../types";
4
+ import { categorySchema } from "./settings.schema";
5
+ import {
6
+ slugSchema,
7
+ templateContentSchema,
8
+ variableListSchema,
9
+ } from "./templates.schema";
10
+
11
+ const MAX_AUTHOR_LENGTH = MAX_NAME_LENGTH;
12
+ const MAX_IMPORT_ID_LENGTH = 200;
13
+
14
+ const tenantIdSchema = z.string().trim().min(1);
15
+
16
+ /**
17
+ * A template declared from code: the slug and name rules of the dashboard's
18
+ * creation, and the content and declarations its editor saves.
19
+ */
20
+ export const templateDeclarationSchema = z.object({
21
+ tenantId: tenantIdSchema,
22
+ slug: slugSchema,
23
+ name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
24
+ category: z.string().trim().default(""),
25
+ content: templateContentSchema,
26
+ variables: variableListSchema.default([]),
27
+ testData: z.record(z.unknown()).default({}),
28
+ author: z.string().trim().min(1).max(MAX_AUTHOR_LENGTH).optional(),
29
+ });
30
+
31
+ export type ParsedTemplateDeclaration = z.infer<
32
+ typeof templateDeclarationSchema
33
+ >;
34
+
35
+ export const categoryDeclarationSchema = categorySchema.extend({
36
+ tenantId: tenantIdSchema,
37
+ label: z.string().trim().min(1).max(MAX_NAME_LENGTH),
38
+ });
39
+
40
+ export type ParsedCategoryDeclaration = z.infer<
41
+ typeof categoryDeclarationSchema
42
+ >;
43
+
44
+ const recipientSchema = z.union([
45
+ z.string().trim().email(),
46
+ z.object({
47
+ email: z.string().trim().email(),
48
+ name: z.string().optional(),
49
+ }),
50
+ ]);
51
+
52
+ const importedEventSchema = z.object({
53
+ type: z.enum(SEND_EVENT_TYPES as [string, ...string[]]),
54
+ at: z.coerce.date(),
55
+ details: z.record(z.unknown()).optional(),
56
+ });
57
+
58
+ const importedSendSchema = z.object({
59
+ importId: z.string().trim().min(1).max(MAX_IMPORT_ID_LENGTH),
60
+ templateSlug: slugSchema,
61
+ to: recipientSchema,
62
+ sentAt: z.coerce.date(),
63
+ locale: z.string().trim().min(1).optional(),
64
+ variables: z.record(z.unknown()).default({}),
65
+ provider: z.string().optional(),
66
+ providerMessageId: z.string().optional(),
67
+ source: z.string().optional(),
68
+ isTest: z.boolean().default(false),
69
+ events: z.array(importedEventSchema).default([]),
70
+ });
71
+
72
+ export type ParsedImportedSend = z.infer<typeof importedSendSchema>;
73
+
74
+ export const sendImportSchema = z.object({
75
+ tenantId: tenantIdSchema,
76
+ sends: z.array(importedSendSchema),
77
+ });
@@ -1,2 +0,0 @@
1
- packages:
2
- - '.'