@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.
- package/README.md +8 -7
- package/dist/constants.d.ts +0 -2
- package/dist/constants.js +1 -3
- package/dist/constants.js.map +1 -1
- package/dist/data/templates-table.js +1 -8
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +0 -1
- package/dist/db/models/index.js +0 -1
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.js +2 -3
- package/dist/db/models/send-events.model.js.map +1 -1
- package/dist/db/models/sends.model.js +2 -3
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/templates.model.js +2 -9
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/tables/index.d.ts +0 -1
- package/dist/db/tables/index.js +0 -1
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/settings.table.d.ts +0 -1
- package/dist/db/tables/settings.table.js +0 -4
- package/dist/db/tables/settings.table.js.map +1 -1
- package/dist/db/tables/templates.table.d.ts +0 -3
- package/dist/db/tables/templates.table.js +1 -13
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/hooks/index.d.ts +0 -1
- package/dist/hooks/index.js +0 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +3 -4
- package/dist/implementations/dms-mailing/index.js +8 -9
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/settings/form.js +8 -0
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/routes/templates.js +2 -6
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/declarations.js +42 -184
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/send-import.js +0 -2
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/settings.d.ts +1 -1
- package/dist/services/settings.js +1 -5
- package/dist/services/settings.js.map +1 -1
- package/dist/services/templates.d.ts +2 -2
- package/dist/services/templates.js +48 -27
- package/dist/services/templates.js.map +1 -1
- package/dist/types/settings.d.ts +2 -0
- package/dist/types/settings.js +8 -1
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/settings.schema.d.ts +16 -16
- package/frontend-vue/package.json +1 -1
- package/package.json +6 -6
- package/skills/dms-mailing/SKILL.md +8 -14
- package/src/constants.ts +0 -2
- package/src/data/templates-table.ts +3 -15
- package/src/db/models/index.ts +0 -1
- package/src/db/models/send-events.model.ts +2 -2
- package/src/db/models/sends.model.ts +7 -8
- package/src/db/models/templates.model.ts +3 -13
- package/src/db/tables/index.ts +0 -1
- package/src/db/tables/settings.table.ts +0 -8
- package/src/db/tables/templates.table.ts +2 -16
- package/src/hooks/index.ts +0 -1
- package/src/implementations/dms-mailing/index.ts +5 -18
- package/src/index.ts +1 -6
- package/src/pages/settings/form.ts +16 -0
- package/src/routes/templates.ts +11 -13
- package/src/services/declarations.ts +64 -318
- package/src/services/send-import.ts +2 -2
- package/src/services/settings.ts +3 -10
- package/src/services/templates.ts +59 -44
- package/src/types/settings.ts +20 -0
- package/dist/db/models/template-slugs.model.d.ts +0 -27
- package/dist/db/models/template-slugs.model.js +0 -43
- package/dist/db/models/template-slugs.model.js.map +0 -1
- package/dist/db/tables/template-slugs.table.d.ts +0 -7
- package/dist/db/tables/template-slugs.table.js +0 -35
- package/dist/db/tables/template-slugs.table.js.map +0 -1
- package/dist/hooks/database-ready.d.ts +0 -7
- package/dist/hooks/database-ready.js +0 -26
- package/dist/hooks/database-ready.js.map +0 -1
- package/dist/services/fingerprint.d.ts +0 -1
- package/dist/services/fingerprint.js +0 -20
- package/dist/services/fingerprint.js.map +0 -1
- package/src/db/models/template-slugs.model.ts +0 -61
- package/src/db/tables/template-slugs.table.ts +0 -29
- package/src/hooks/database-ready.ts +0 -47
- 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 {
|
|
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
|
-
|
|
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:
|
|
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
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
|
|
81
|
+
declaration: ParsedTemplateDeclaration,
|
|
163
82
|
): Promise<MailingTemplate | undefined> {
|
|
164
|
-
const { tenantId, slug
|
|
83
|
+
const { tenantId, slug } = declaration;
|
|
165
84
|
const model = GetModel(TemplateModel, tenantId);
|
|
166
|
-
const
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
await GetModel(TemplateVersionModel, tenantId).materialize(template);
|
|
110
|
+
await refreshTemplateAttention(tenantId);
|
|
111
|
+
return template;
|
|
190
112
|
}
|
|
191
113
|
|
|
192
114
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
)
|
|
333
|
-
return
|
|
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
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
|
435
|
-
*
|
|
436
|
-
*
|
|
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;
|
|
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()) {
|
package/src/services/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
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: "",
|