@antelopejs/dms-mailing 0.5.1 → 0.6.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 (71) hide show
  1. package/README.md +1 -1
  2. package/dist/db/models/send-events.model.d.ts +1 -0
  3. package/dist/db/models/send-events.model.js +11 -0
  4. package/dist/db/models/send-events.model.js.map +1 -1
  5. package/dist/db/models/sends.model.d.ts +2 -0
  6. package/dist/db/models/sends.model.js +13 -0
  7. package/dist/db/models/sends.model.js.map +1 -1
  8. package/dist/db/models/template-versions.model.d.ts +3 -1
  9. package/dist/db/models/template-versions.model.js +35 -2
  10. package/dist/db/models/template-versions.model.js.map +1 -1
  11. package/dist/db/models/templates.model.d.ts +1 -0
  12. package/dist/db/models/templates.model.js +22 -0
  13. package/dist/db/models/templates.model.js.map +1 -1
  14. package/dist/db/tables/settings.table.d.ts +1 -0
  15. package/dist/db/tables/settings.table.js +4 -0
  16. package/dist/db/tables/settings.table.js.map +1 -1
  17. package/dist/db/tables/templates.table.d.ts +5 -0
  18. package/dist/db/tables/templates.table.js +27 -0
  19. package/dist/db/tables/templates.table.js.map +1 -1
  20. package/dist/hooks/database-ready.d.ts +7 -0
  21. package/dist/hooks/database-ready.js +26 -0
  22. package/dist/hooks/database-ready.js.map +1 -0
  23. package/dist/hooks/index.d.ts +1 -0
  24. package/dist/hooks/index.js +1 -0
  25. package/dist/hooks/index.js.map +1 -1
  26. package/dist/implementations/dms-mailing/index.d.ts +3 -0
  27. package/dist/implementations/dms-mailing/index.js +7 -1
  28. package/dist/implementations/dms-mailing/index.js.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/services/declaration-error.d.ts +6 -0
  32. package/dist/services/declaration-error.js +26 -0
  33. package/dist/services/declaration-error.js.map +1 -0
  34. package/dist/services/declarations.d.ts +3 -0
  35. package/dist/services/declarations.js +244 -0
  36. package/dist/services/declarations.js.map +1 -0
  37. package/dist/services/events.d.ts +4 -1
  38. package/dist/services/events.js.map +1 -1
  39. package/dist/services/fingerprint.d.ts +1 -0
  40. package/dist/services/fingerprint.js +20 -0
  41. package/dist/services/fingerprint.js.map +1 -0
  42. package/dist/services/send-import.d.ts +2 -0
  43. package/dist/services/send-import.js +136 -0
  44. package/dist/services/send-import.js.map +1 -0
  45. package/dist/services/templates.d.ts +1 -0
  46. package/dist/services/templates.js +49 -48
  47. package/dist/services/templates.js.map +1 -1
  48. package/dist/validation/declarations.schema.d.ts +299 -0
  49. package/dist/validation/declarations.schema.js +55 -0
  50. package/dist/validation/declarations.schema.js.map +1 -0
  51. package/frontend-vue/package.json +1 -1
  52. package/package.json +2 -2
  53. package/skills/dms-mailing/SKILL.md +41 -3
  54. package/src/db/models/send-events.model.ts +13 -0
  55. package/src/db/models/sends.model.ts +22 -0
  56. package/src/db/models/template-versions.model.ts +48 -2
  57. package/src/db/models/templates.model.ts +31 -0
  58. package/src/db/tables/settings.table.ts +8 -0
  59. package/src/db/tables/templates.table.ts +38 -0
  60. package/src/hooks/database-ready.ts +47 -0
  61. package/src/hooks/index.ts +1 -0
  62. package/src/implementations/dms-mailing/index.ts +17 -0
  63. package/src/index.ts +6 -1
  64. package/src/services/declaration-error.ts +39 -0
  65. package/src/services/declarations.ts +450 -0
  66. package/src/services/events.ts +8 -2
  67. package/src/services/fingerprint.ts +22 -0
  68. package/src/services/send-import.ts +235 -0
  69. package/src/services/templates.ts +64 -72
  70. package/src/validation/declarations.schema.ts +77 -0
  71. package/frontend-vue/pnpm-workspace.yaml +0 -2
@@ -1,5 +1,7 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { BasicDataModel } from "@antelopejs/interface-database-decorators";
2
3
  import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
4
+ import { TemplateSlugModel } from "./template-slugs.model";
3
5
 
4
6
  /** The attention fields of a template, as the refresh writes them. */
5
7
  export type TemplateAttentionFields = Pick<
@@ -11,8 +13,37 @@ export class TemplateModel extends BasicDataModel(
11
13
  MailingTemplate,
12
14
  MAILING_TEMPLATES_TABLE_NAME,
13
15
  ) {
16
+ /** Publishes only over the exact semantic revision that was inspected. */
17
+ async publishIfCurrent(
18
+ template: MailingTemplate,
19
+ patch: Partial<MailingTemplate>,
20
+ ): Promise<boolean> {
21
+ const outcome = await this.table
22
+ .atomicMutation(template._id, {
23
+ type: "update",
24
+ revisionField: "revision",
25
+ expectedRevision: template.revision ?? { kind: "missing" },
26
+ nextRevision: randomUUID(),
27
+ patch: { ...patch, updatedAt: new Date() },
28
+ })
29
+ .run();
30
+ if (outcome === "unknown")
31
+ throw new Error("Mailing publication outcome is unknown; not retrying");
32
+ return outcome === "applied";
33
+ }
34
+
14
35
  async getBySlug(slug: string): Promise<MailingTemplate | undefined> {
15
36
  const rows = await this.getBy("slug", slug);
37
+ // A losing creation can still be cleaning up (or its process can crash).
38
+ // Senders must see the claimed template, not an earlier draft candidate.
39
+ if (rows.length > 1) {
40
+ const claims = await new TemplateSlugModel(this.database).getBy(
41
+ "templateId",
42
+ ...rows.map((row) => row._id),
43
+ );
44
+ const claim = claims.find((candidate) => candidate.slug === slug);
45
+ if (claim) return rows.find((row) => row._id === claim.templateId);
46
+ }
16
47
  return rows[0];
17
48
  }
18
49
 
@@ -46,4 +46,12 @@ export class MailingSettings extends Table {
46
46
 
47
47
  @Field("string")
48
48
  declare webhookSecret: string;
49
+
50
+ /**
51
+ * JSON-serialized fingerprint of each category `DeclareTemplateCategory`
52
+ * applied, by its id: what tells a category untouched since from one a
53
+ * user relabelled or removed.
54
+ */
55
+ @Field("string")
56
+ declare json_declared_categories?: string;
49
57
  }
@@ -1,7 +1,10 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import {
3
+ attachModifier,
2
4
  CreationTime,
3
5
  Field,
4
6
  Index,
7
+ Modifier,
5
8
  RegisterTable,
6
9
  Table,
7
10
  UpdateTime,
@@ -128,4 +131,39 @@ export class MailingTemplate extends Table {
128
131
  /** When the template last left `live` by an unpublish. */
129
132
  @Field("date")
130
133
  declare unpublishedAt?: Date;
134
+
135
+ /**
136
+ * Fingerprint of the last declaration `DeclareTemplate` applied; blank for
137
+ * a template made in the dashboard. A template still matching it is
138
+ * untouched since, and the next declaration may refresh it.
139
+ */
140
+ @Field("string")
141
+ declare declaredHash?: string;
142
+
143
+ /** Every semantic edit invalidates a publication's optimistic snapshot. */
144
+ @Field("string")
145
+ declare revision?: string;
146
+
147
+ /** An unmodified declaration seed can be completed after a crashed creator. */
148
+ @Field("string")
149
+ declare declarationSeedRevision?: string;
150
+
151
+ @Field("string")
152
+ declare pendingDeclaredHash?: string;
153
+
154
+ /**
155
+ * Complete latest version row, committed atomically with its content. Its
156
+ * history row can be recovered after an interrupted publication.
157
+ */
158
+ @Field("string")
159
+ declare json_published_version?: string;
160
+ }
161
+
162
+ /** Covers model updates and generated data-api edits in the same database write. */
163
+ class TemplateRevisionModifier extends Modifier {
164
+ update(object: Record<string, unknown>, field: string): void {
165
+ object[field] = randomUUID();
166
+ }
131
167
  }
168
+
169
+ attachModifier(MailingTemplate, TemplateRevisionModifier, "revision", {});
@@ -0,0 +1,47 @@
1
+ import { Hook, RegisterHook } from "@antelopejs/interface-dms/hooks";
2
+
3
+ /** A promise opened once, and the calls that wait for it. */
4
+ export interface ReadyGate {
5
+ open(): void;
6
+ /** `task`, run once the gate is open. */
7
+ after<Args extends unknown[], Result>(
8
+ task: (...args: Args) => Promise<Result>,
9
+ ): (...args: Args) => Promise<Result>;
10
+ }
11
+
12
+ export function createReadyGate(): ReadyGate {
13
+ let open: () => void = () => undefined;
14
+ const opened = new Promise<void>((resolve) => {
15
+ open = resolve;
16
+ });
17
+ return {
18
+ open: () => open(),
19
+ after:
20
+ (task) =>
21
+ async (...args) => {
22
+ await opened;
23
+ return task(...args);
24
+ },
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Opens once the DMS reports its schemas registered, which it does from its
30
+ * own `start()`. Modules construct and start in parallel, so a project
31
+ * declaring its content early waits here instead of failing.
32
+ */
33
+ const databaseGate = createReadyGate();
34
+
35
+ /**
36
+ * The DMS fires the hook on every start and runs a handler registered after
37
+ * that at once: whatever the load order, the first firing opens the gate.
38
+ * Its handlers run in series, so this one is registered first thing in
39
+ * `construct()`, ahead of handlers that may wait on the gate.
40
+ */
41
+ export function registerDatabaseReady(): void {
42
+ RegisterHook(Hook.DATABASE_INITIALIZED, () => databaseGate.open());
43
+ }
44
+
45
+ /** `task`, run once the database is ready. */
46
+ export const afterDatabaseReady: ReadyGate["after"] = (task) =>
47
+ databaseGate.after(task);
@@ -1 +1,2 @@
1
+ export * from "./database-ready";
1
2
  export * from "./tenant-export";
@@ -1,2 +1,19 @@
1
+ import { afterDatabaseReady } from "../../hooks/database-ready";
2
+ import {
3
+ declareTemplate,
4
+ declareTemplateCategory,
5
+ } from "../../services/declarations";
6
+ import { importSends } from "../../services/send-import";
7
+
1
8
  export { recordEmailEvent as RecordEmailEvent } from "../../services/events";
2
9
  export { sendUnlessSuppressed as SendTemplate } from "../../services/checked-send";
10
+
11
+ /*
12
+ * Projects call these from their `start()`, which runs in parallel with the
13
+ * DMS's: they wait for the database the DMS opens there rather than fail.
14
+ */
15
+ export const DeclareTemplate = afterDatabaseReady(declareTemplate);
16
+ export const DeclareTemplateCategory = afterDatabaseReady(
17
+ declareTemplateCategory,
18
+ );
19
+ export const ImportSends = afterDatabaseReady(importSends);
package/src/index.ts CHANGED
@@ -16,7 +16,11 @@ import {
16
16
  scheduleJob,
17
17
  stopJob,
18
18
  } from "./crons";
19
- import { registerTenantExport, unregisterTenantExport } from "./hooks";
19
+ import {
20
+ registerDatabaseReady,
21
+ registerTenantExport,
22
+ unregisterTenantExport,
23
+ } from "./hooks";
20
24
  import { registerRealtimeTopics } from "./realtime";
21
25
  import { WEBHOOK_SECRET_HEADER } from "./services/webhook-status";
22
26
 
@@ -44,6 +48,7 @@ const PROVIDER_WEBHOOK_ENDPOINT = {
44
48
  let scheduledJobs: ScheduledJob[] = [];
45
49
 
46
50
  export async function construct(config: DmsMailingConfig = {}): Promise<void> {
51
+ registerDatabaseReady();
47
52
  setConfig(config);
48
53
  ImplementInterface(
49
54
  await import("@antelopejs/interface-dms-mailing"),
@@ -0,0 +1,39 @@
1
+ import type { ZodError, ZodType, ZodTypeDef } from "zod";
2
+
3
+ /**
4
+ * Why a call made from code (a declaration, an import) was refused: `code`
5
+ * names the rule, like `SendError` does for a send, and the message says
6
+ * which field broke it.
7
+ */
8
+ export class DeclarationError extends Error {
9
+ constructor(
10
+ public readonly code: string,
11
+ message: string,
12
+ ) {
13
+ super(message);
14
+ }
15
+ }
16
+
17
+ const ISSUE_SEPARATOR = "; ";
18
+ const ROOT_PATH = "(root)";
19
+
20
+ function describeIssues(error: ZodError): string {
21
+ return error.issues
22
+ .map((issue) => `${issue.path.join(".") || ROOT_PATH}: ${issue.message}`)
23
+ .join(ISSUE_SEPARATOR);
24
+ }
25
+
26
+ /** `input` parsed by `schema`, or a `DeclarationError` under `code`. */
27
+ export function parseDeclared<Output, Input>(
28
+ schema: ZodType<Output, ZodTypeDef, Input>,
29
+ input: unknown,
30
+ code: string,
31
+ subject: string,
32
+ ): Output {
33
+ const parsed = schema.safeParse(input);
34
+ if (parsed.success) return parsed.data;
35
+ throw new DeclarationError(
36
+ code,
37
+ `Invalid ${subject}: ${describeIssues(parsed.error)}`,
38
+ );
39
+ }
@@ -0,0 +1,450 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ DeclarationOutcome,
4
+ DeclareTemplateCategoryResult,
5
+ DeclareTemplateResult,
6
+ TemplateCategoryDeclaration,
7
+ TemplateDeclaration,
8
+ } from "@antelopejs/interface-dms-mailing";
9
+ import { Logging } from "@antelopejs/interface-core/logging";
10
+ import { GetModel } from "@antelopejs/interface-database-decorators";
11
+ import {
12
+ type MailingTemplate,
13
+ SettingsModel,
14
+ TemplateModel,
15
+ TemplateSlugModel,
16
+ TemplateVersionModel,
17
+ templateSlugIdOf,
18
+ } from "../db";
19
+ import type {
20
+ TemplateCategory,
21
+ TemplateContent,
22
+ VariableDefinition,
23
+ } from "../types";
24
+ import {
25
+ categoryDeclarationSchema,
26
+ type ParsedCategoryDeclaration,
27
+ type ParsedTemplateDeclaration,
28
+ templateDeclarationSchema,
29
+ } from "../validation/declarations.schema";
30
+ import { categoriesSchema } from "../validation/settings.schema";
31
+ import { DeclarationError, parseDeclared } from "./declaration-error";
32
+ import { fingerprintOf } from "./fingerprint";
33
+ import { findCategory, getSettings, saveSettings } from "./settings";
34
+ import {
35
+ assertSlugClaimed,
36
+ blankFields,
37
+ hasDraft,
38
+ localesWithoutSubject,
39
+ parseContent,
40
+ parseTestData,
41
+ parseVariables,
42
+ publishedVersionOf,
43
+ publishSnapshot,
44
+ requireTemplate,
45
+ workingContent,
46
+ workingVariables,
47
+ } from "./templates";
48
+
49
+ /** Who a declaration's writes are shown as by default. */
50
+ const DECLARATION_AUTHOR = "system";
51
+
52
+ const LIVE_STATUS = "live";
53
+ const LOG_PREFIX = "[dms-mailing]";
54
+ const MAX_DECLARATION_ATTEMPTS = 5;
55
+
56
+ /** What a declaration fingerprints: everything a user may change after it. */
57
+ interface DeclaredTemplateState {
58
+ name: string;
59
+ category: string;
60
+ content: TemplateContent;
61
+ variables: VariableDefinition[];
62
+ testData: Record<string, unknown>;
63
+ }
64
+
65
+ /** A declaration ready to write: validated, defaulted and fingerprinted. */
66
+ interface TemplatePlan {
67
+ tenantId: string;
68
+ slug: string;
69
+ state: DeclaredTemplateState;
70
+ author: string;
71
+ hash: string;
72
+ }
73
+
74
+ /**
75
+ * The values as the database gives them back: a `Date` in the test data is
76
+ * stored as its ISO string, and the fingerprint must compare alike.
77
+ */
78
+ const asStored = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
79
+
80
+ function assertPublishable(content: TemplateContent, slug: string): void {
81
+ if (!Object.keys(content.locales).length)
82
+ throw new DeclarationError(
83
+ "nothing_to_publish",
84
+ `Template "${slug}" declares no locale`,
85
+ );
86
+ const blank = localesWithoutSubject(content);
87
+ if (blank.length)
88
+ throw new DeclarationError(
89
+ "empty_subject",
90
+ `Template "${slug}" has no subject in: ${blank.join(", ")}`,
91
+ );
92
+ }
93
+
94
+ /** A category is left empty or one the workspace settings declare. */
95
+ function assertKnownCategory(
96
+ categories: TemplateCategory[],
97
+ category: string,
98
+ slug: string,
99
+ ): void {
100
+ if (!category || findCategory(categories, category)) return;
101
+ throw new DeclarationError(
102
+ "unknown_category",
103
+ `Template "${slug}" names category "${category}", which the workspace settings do not declare`,
104
+ );
105
+ }
106
+
107
+ function planTemplate(declaration: ParsedTemplateDeclaration): TemplatePlan {
108
+ const { tenantId } = declaration;
109
+ const { slug } = declaration;
110
+ const state = asStored<DeclaredTemplateState>({
111
+ name: declaration.name,
112
+ category: declaration.category,
113
+ content: declaration.content as TemplateContent,
114
+ variables: declaration.variables,
115
+ testData: declaration.testData,
116
+ });
117
+ assertPublishable(state.content, slug);
118
+ return {
119
+ tenantId,
120
+ slug,
121
+ state,
122
+ author: declaration.author ?? DECLARATION_AUTHOR,
123
+ hash: fingerprintOf(state),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The fingerprint of what the template holds now, `null` when a user took it
129
+ * out of the state a declaration leaves it in: live, with no draft waiting.
130
+ */
131
+ function currentFingerprint(template: MailingTemplate): string | null {
132
+ if (template.status !== LIVE_STATUS || hasDraft(template)) return null;
133
+ return fingerprintOf({
134
+ name: template.name,
135
+ category: template.category ?? "",
136
+ content: parseContent(template),
137
+ variables: parseVariables(template),
138
+ testData: parseTestData(template),
139
+ });
140
+ }
141
+
142
+ function resultOf(
143
+ template: MailingTemplate,
144
+ outcome: DeclarationOutcome,
145
+ version = publishedVersionOf(template),
146
+ ): DeclareTemplateResult {
147
+ return {
148
+ templateId: template._id,
149
+ slug: template.slug,
150
+ outcome,
151
+ version,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Inserts the template as a fresh draft of the declared content, claims its
157
+ * slug like the dashboard's creation does. The seed is not an applied
158
+ * declaration: any caller can finish its first publication after a crash.
159
+ */
160
+ async function createDeclared(
161
+ plan: TemplatePlan,
162
+ ): Promise<MailingTemplate | undefined> {
163
+ const { tenantId, slug, state, author } = plan;
164
+ const model = GetModel(TemplateModel, tenantId);
165
+ const settings = await getSettings(tenantId);
166
+ assertKnownCategory(settings.categories, state.category, slug);
167
+ const revision = randomUUID();
168
+ const [id] = await model.insert({
169
+ slug,
170
+ name: state.name,
171
+ category: state.category,
172
+ status: "draft",
173
+ ...blankFields(state.content),
174
+ json_variables: JSON.stringify(state.variables),
175
+ json_test_data: JSON.stringify(state.testData),
176
+ updatedBy: author,
177
+ revision,
178
+ declarationSeedRevision: revision,
179
+ pendingDeclaredHash: plan.hash,
180
+ });
181
+ const templateId = id as string;
182
+ try {
183
+ await assertSlugClaimed(tenantId, slug, templateId);
184
+ } catch (error) {
185
+ if (await model.getBySlug(slug)) return undefined;
186
+ throw error;
187
+ }
188
+ return requireTemplate(model, templateId);
189
+ }
190
+
191
+ /**
192
+ * A slug claim, not an arbitrary candidate row, decides which template won
193
+ * concurrent creation. An unclaimed seed can be claimed by its next caller.
194
+ */
195
+ async function declaredTemplate(
196
+ tenantId: string,
197
+ slug: string,
198
+ ): Promise<MailingTemplate | undefined> {
199
+ const model = GetModel(TemplateModel, tenantId);
200
+ const claim = await GetModel(TemplateSlugModel, tenantId).get(
201
+ templateSlugIdOf(tenantId, slug),
202
+ );
203
+ const winner = claim && (await model.get(claim.templateId));
204
+ return winner || model.getBySlug(slug);
205
+ }
206
+
207
+ function isUnmodifiedSeed(template: MailingTemplate): boolean {
208
+ return Boolean(
209
+ template.pendingDeclaredHash &&
210
+ template.declarationSeedRevision &&
211
+ template.revision === template.declarationSeedRevision &&
212
+ template.status === "draft" &&
213
+ template.publishedVersion === 0,
214
+ );
215
+ }
216
+
217
+ /** Finishes the stored seed, never substituting a later caller's declaration. */
218
+ async function finishSeed(
219
+ tenantId: string,
220
+ template: MailingTemplate,
221
+ ): Promise<boolean> {
222
+ try {
223
+ await assertSlugClaimed(tenantId, template.slug, template._id);
224
+ } catch (error) {
225
+ const winner = await declaredTemplate(tenantId, template.slug);
226
+ if (winner && winner._id !== template._id) return false;
227
+ throw error;
228
+ }
229
+ const settings = await getSettings(tenantId);
230
+ assertKnownCategory(settings.categories, template.category, template.slug);
231
+ return Boolean(
232
+ await publishSnapshot(
233
+ tenantId,
234
+ template,
235
+ {
236
+ content: workingContent(template),
237
+ variables: workingVariables(template),
238
+ },
239
+ template.updatedBy,
240
+ {
241
+ declaredHash: template.pendingDeclaredHash,
242
+ pendingDeclaredHash: "",
243
+ declarationSeedRevision: "",
244
+ },
245
+ ),
246
+ );
247
+ }
248
+
249
+ /** Why an existing template is left alone, `null` when it may be refreshed. */
250
+ function keepReason(template: MailingTemplate): string | null {
251
+ if (!template.declaredHash) return "it was created in the dashboard";
252
+ if (currentFingerprint(template) !== template.declaredHash)
253
+ return "a user changed it since it was declared";
254
+ return null;
255
+ }
256
+
257
+ async function applyToExisting(
258
+ plan: TemplatePlan,
259
+ template: MailingTemplate,
260
+ ): Promise<DeclareTemplateResult> {
261
+ const { tenantId, state, author } = plan;
262
+ for (let attempt = 0; attempt < MAX_DECLARATION_ATTEMPTS; attempt++) {
263
+ if (isUnmodifiedSeed(template)) {
264
+ const created = await finishSeed(tenantId, template);
265
+ if (created && template.pendingDeclaredHash === plan.hash)
266
+ return resultOf(template, "created", 1);
267
+ } else {
268
+ await GetModel(TemplateVersionModel, tenantId).materialize(template);
269
+ if (template.publishedVersion > 0 && template.declaredHash === plan.hash)
270
+ return resultOf(template, "unchanged");
271
+ const reason = keepReason(template);
272
+ if (reason) {
273
+ Logging.Info(
274
+ `${LOG_PREFIX} Kept template "${plan.slug}" as it is: ${reason}`,
275
+ );
276
+ return resultOf(template, "kept");
277
+ }
278
+ const settings = await getSettings(tenantId);
279
+ assertKnownCategory(settings.categories, state.category, plan.slug);
280
+ const outcome = await publishSnapshot(tenantId, template, state, author, {
281
+ name: state.name,
282
+ category: state.category,
283
+ json_test_data: JSON.stringify(state.testData),
284
+ declaredHash: plan.hash,
285
+ });
286
+ if (outcome) return resultOf(template, "updated", outcome.version);
287
+ }
288
+ const current = await declaredTemplate(tenantId, plan.slug);
289
+ if (!current) break;
290
+ template = current;
291
+ }
292
+ throw new DeclarationError(
293
+ "declaration_conflict",
294
+ `Template "${plan.slug}" changed repeatedly; retry the declaration`,
295
+ );
296
+ }
297
+
298
+ /**
299
+ * Creates or refreshes the template a project declares from code. The rule,
300
+ * keyed on the slug:
301
+ *
302
+ * - no template has the slug: it is created and published (`created`);
303
+ * - the declaration is the one last applied: nothing is written
304
+ * (`unchanged`), whatever a user did to the template since;
305
+ * - the declaration changed and the template is still live, with no draft,
306
+ * and holds exactly what the last declaration wrote (name, category,
307
+ * content, variables, test data): the new declaration is published
308
+ * (`updated`);
309
+ * - otherwise (edited, unpublished, archived, a draft pending, or made in the
310
+ * dashboard): it is left as it is (`kept`).
311
+ *
312
+ * A declared template a user deleted is created again by the next call.
313
+ */
314
+ export async function declareTemplate(
315
+ input: TemplateDeclaration,
316
+ ): Promise<DeclareTemplateResult> {
317
+ const declaration = parseDeclared(
318
+ templateDeclarationSchema,
319
+ input,
320
+ "invalid_template",
321
+ `template declaration "${String(input?.slug)}"`,
322
+ );
323
+ const plan = planTemplate(declaration);
324
+ const existing = await declaredTemplate(plan.tenantId, plan.slug);
325
+ if (existing) return applyToExisting(plan, existing);
326
+ const created = await createDeclared(plan);
327
+ if (created) return applyToExisting(plan, created);
328
+ const winner = (await declaredTemplate(
329
+ plan.tenantId,
330
+ plan.slug,
331
+ )) as MailingTemplate;
332
+ return applyToExisting(plan, winner);
333
+ }
334
+
335
+ type DeclaredCategories = Record<string, string>;
336
+
337
+ const categoryFingerprint = (category: Omit<TemplateCategory, "id">) =>
338
+ fingerprintOf({ label: category.label, icon: category.icon });
339
+
340
+ /** Category declarations of one tenant run one after the other. */
341
+ const categoryQueues = new Map<string, Promise<unknown>>();
342
+
343
+ /**
344
+ * Runs `task` after the tenant's previous category declaration: each one
345
+ * rewrites the whole category list, and a project declaring several at once
346
+ * must not lose any to a concurrent write.
347
+ */
348
+ function inTenantQueue<T>(
349
+ tenantId: string,
350
+ task: () => Promise<T>,
351
+ ): Promise<T> {
352
+ const previous = categoryQueues.get(tenantId) ?? Promise.resolve();
353
+ const next = previous.then(task, task);
354
+ categoryQueues.set(
355
+ tenantId,
356
+ next.catch(() => undefined),
357
+ );
358
+ return next;
359
+ }
360
+
361
+ async function readDeclaredCategories(
362
+ tenantId: string,
363
+ ): Promise<DeclaredCategories> {
364
+ const row = await GetModel(SettingsModel, tenantId).get(tenantId);
365
+ return JSON.parse(
366
+ row?.json_declared_categories || "{}",
367
+ ) as DeclaredCategories;
368
+ }
369
+
370
+ /** The category list once the declared category is added or relabelled. */
371
+ function withCategory(
372
+ categories: TemplateCategory[],
373
+ declared: TemplateCategory,
374
+ ): TemplateCategory[] {
375
+ const isPresent = Boolean(findCategory(categories, declared.id));
376
+ const next = isPresent
377
+ ? categories.map((category) =>
378
+ category.id === declared.id ? declared : category,
379
+ )
380
+ : [...categories, declared];
381
+ return parseDeclared(
382
+ categoriesSchema,
383
+ next,
384
+ "invalid_category",
385
+ `category declaration "${declared.id}"`,
386
+ ).map(({ id, label, icon }) => ({ id, label, icon }));
387
+ }
388
+
389
+ /** What a category declaration does to the stored list, by its state. */
390
+ function categoryOutcome(
391
+ current: TemplateCategory | undefined,
392
+ previousHash: string | undefined,
393
+ hash: string,
394
+ ): DeclarationOutcome {
395
+ if (!current) return previousHash ? "kept" : "created";
396
+ if (!previousHash) return "kept";
397
+ if (previousHash === hash) return "unchanged";
398
+ return categoryFingerprint(current) === previousHash ? "updated" : "kept";
399
+ }
400
+
401
+ const WRITING_OUTCOMES = new Set<DeclarationOutcome>(["created", "updated"]);
402
+
403
+ async function applyCategory(
404
+ declaration: ParsedCategoryDeclaration,
405
+ ): Promise<DeclareTemplateCategoryResult> {
406
+ const { tenantId } = declaration;
407
+ const category = {
408
+ id: declaration.id,
409
+ label: declaration.label,
410
+ icon: declaration.icon,
411
+ };
412
+ const hash = categoryFingerprint(category);
413
+ const settings = await getSettings(tenantId);
414
+ const declared = await readDeclaredCategories(tenantId);
415
+ const outcome = categoryOutcome(
416
+ findCategory(settings.categories, category.id),
417
+ declared[category.id],
418
+ hash,
419
+ );
420
+ if (!WRITING_OUTCOMES.has(outcome)) return { id: category.id, outcome };
421
+ const categories = withCategory(settings.categories, category);
422
+ await saveSettings(tenantId, { ...settings, categories });
423
+ await GetModel(SettingsModel, tenantId).update(tenantId, {
424
+ json_declared_categories: JSON.stringify({
425
+ ...declared,
426
+ [category.id]: hash,
427
+ }),
428
+ });
429
+ return { id: category.id, outcome };
430
+ }
431
+
432
+ /**
433
+ * Adds or refreshes a template category a project declares from code, keyed
434
+ * on its id: a new id is added (`created`); the declaration last applied is
435
+ * left alone (`unchanged`); a changed declaration relabels a category still
436
+ * as last declared (`updated`); a category a user relabelled or removed
437
+ * since, or one that existed before any declaration (the defaults among
438
+ * them), is left as it is (`kept`).
439
+ */
440
+ export function declareTemplateCategory(
441
+ input: TemplateCategoryDeclaration,
442
+ ): Promise<DeclareTemplateCategoryResult> {
443
+ const declaration = parseDeclared(
444
+ categoryDeclarationSchema,
445
+ input,
446
+ "invalid_category",
447
+ `category declaration "${String(input?.id)}"`,
448
+ );
449
+ return inTenantQueue(declaration.tenantId, () => applyCategory(declaration));
450
+ }