@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.
- package/README.md +61 -339
- package/dist/data/templates-table.js +2 -10
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +2 -0
- package/dist/db/models/send-events.model.js +11 -0
- package/dist/db/models/send-events.model.js.map +1 -1
- package/dist/db/models/sends.model.d.ts +3 -0
- package/dist/db/models/sends.model.js +13 -0
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +1 -0
- package/dist/db/models/suppressions.model.d.ts +1 -0
- package/dist/db/models/template-slugs.model.d.ts +27 -0
- package/dist/db/models/template-slugs.model.js +43 -0
- package/dist/db/models/template-slugs.model.js.map +1 -0
- package/dist/db/models/template-versions.model.d.ts +4 -1
- package/dist/db/models/template-versions.model.js +35 -2
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +2 -0
- package/dist/db/models/templates.model.js +22 -0
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/models/webhook-status.model.d.ts +1 -0
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/settings.table.d.ts +1 -0
- package/dist/db/tables/settings.table.js +4 -0
- package/dist/db/tables/settings.table.js.map +1 -1
- package/dist/db/tables/template-slugs.table.d.ts +7 -0
- package/dist/db/tables/template-slugs.table.js +35 -0
- package/dist/db/tables/template-slugs.table.js.map +1 -0
- package/dist/db/tables/templates.table.d.ts +5 -0
- package/dist/db/tables/templates.table.js +27 -0
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/hooks/database-ready.d.ts +7 -0
- package/dist/hooks/database-ready.js +26 -0
- package/dist/hooks/database-ready.js.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +3 -0
- package/dist/implementations/dms-mailing/index.js +7 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/templates.js +1 -0
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/declaration-error.d.ts +6 -0
- package/dist/services/declaration-error.js +26 -0
- package/dist/services/declaration-error.js.map +1 -0
- package/dist/services/declarations.d.ts +3 -0
- package/dist/services/declarations.js +244 -0
- package/dist/services/declarations.js.map +1 -0
- package/dist/services/events.d.ts +4 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/fingerprint.d.ts +1 -0
- package/dist/services/fingerprint.js +20 -0
- package/dist/services/fingerprint.js.map +1 -0
- package/dist/services/send-import.d.ts +2 -0
- package/dist/services/send-import.js +136 -0
- package/dist/services/send-import.js.map +1 -0
- package/dist/services/templates.d.ts +3 -0
- package/dist/services/templates.js +75 -48
- package/dist/services/templates.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +299 -0
- package/dist/validation/declarations.schema.js +55 -0
- package/dist/validation/declarations.schema.js.map +1 -0
- package/frontend-vue/app/components/CategoriesInput.vue +12 -11
- package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
- package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
- package/frontend-vue/app/composables/useSortableList.ts +159 -0
- package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +6 -13
- package/frontend-vue/tests/editor-block-list.test.ts +46 -6
- package/frontend-vue/tests/sortable-list.test.ts +253 -0
- package/package.json +31 -14
- package/skills/dms-mailing/SKILL.md +101 -0
- package/src/data/templates-table.ts +7 -27
- package/src/db/models/index.ts +1 -0
- package/src/db/models/send-events.model.ts +13 -0
- package/src/db/models/sends.model.ts +22 -0
- package/src/db/models/template-slugs.model.ts +61 -0
- package/src/db/models/template-versions.model.ts +48 -2
- package/src/db/models/templates.model.ts +31 -0
- package/src/db/tables/index.ts +1 -0
- package/src/db/tables/settings.table.ts +8 -0
- package/src/db/tables/template-slugs.table.ts +29 -0
- package/src/db/tables/templates.table.ts +38 -0
- package/src/hooks/database-ready.ts +47 -0
- package/src/hooks/index.ts +1 -0
- package/src/implementations/dms-mailing/index.ts +17 -0
- package/src/index.ts +6 -1
- package/src/routes/templates.ts +2 -0
- package/src/services/declaration-error.ts +39 -0
- package/src/services/declarations.ts +450 -0
- package/src/services/events.ts +8 -2
- package/src/services/fingerprint.ts +22 -0
- package/src/services/send-import.ts +235 -0
- package/src/services/templates.ts +113 -72
- package/src/validation/declarations.schema.ts +77 -0
- 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
|
-
*
|
|
304
|
-
*
|
|
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
|
|
351
|
+
export async function publishSnapshot(
|
|
307
352
|
tenantId: string,
|
|
308
353
|
template: MailingTemplate,
|
|
309
|
-
|
|
354
|
+
draft: TemplateDraft,
|
|
310
355
|
author: string,
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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:
|
|
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
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
+
});
|