@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
@@ -1,23 +1,69 @@
1
1
  import { BasicDataModel } from "@antelopejs/interface-database-decorators";
2
2
  import {
3
3
  MAILING_TEMPLATE_VERSIONS_TABLE_NAME,
4
+ type MailingTemplate,
4
5
  MailingTemplateVersion,
5
6
  } from "../tables";
7
+ import { TemplateModel } from "./templates.model";
8
+
9
+ /** The latest committed history row, even before its separate insert completes. */
10
+ export function committedVersionOf(
11
+ template: Pick<MailingTemplate, "json_published_version">,
12
+ ): MailingTemplateVersion | undefined {
13
+ if (!template.json_published_version) return undefined;
14
+ const row = JSON.parse(
15
+ template.json_published_version,
16
+ ) as MailingTemplateVersion;
17
+ row.publishedAt = new Date(row.publishedAt);
18
+ return row;
19
+ }
6
20
 
7
21
  export class TemplateVersionModel extends BasicDataModel(
8
22
  MailingTemplateVersion,
9
23
  MAILING_TEMPLATE_VERSIONS_TABLE_NAME,
10
24
  ) {
25
+ /** Must complete before replacing the only recoverable snapshot of this version. */
26
+ async materialize(
27
+ template: Pick<MailingTemplate, "json_published_version">,
28
+ ): Promise<void> {
29
+ const version = committedVersionOf(template);
30
+ if (!version) return;
31
+ if (await this.get(version._id)) return;
32
+ try {
33
+ await this.insert(version);
34
+ } catch (error) {
35
+ const stored = await this.get(version._id);
36
+ if (
37
+ !stored ||
38
+ Object.entries(version).some(
39
+ ([key, value]) =>
40
+ JSON.stringify(stored[key as keyof MailingTemplateVersion]) !==
41
+ JSON.stringify(value),
42
+ )
43
+ )
44
+ throw error;
45
+ }
46
+ }
47
+
11
48
  async listForTemplate(templateId: string): Promise<MailingTemplateVersion[]> {
49
+ // Read the publication first: any later publication must have materialized it.
50
+ const template = await new TemplateModel(this.database).get(templateId);
51
+ if (!template) return [];
12
52
  const rows = await this.getBy("templateId", templateId);
13
- return rows.sort((left, right) => right.version - left.version);
53
+ const current = committedVersionOf(template);
54
+ const history = rows.filter(
55
+ (row) => row.version <= template.publishedVersion,
56
+ );
57
+ if (current && !history.some((row) => row.version === current.version))
58
+ history.push(current);
59
+ return history.sort((left, right) => right.version - left.version);
14
60
  }
15
61
 
16
62
  async getVersion(
17
63
  templateId: string,
18
64
  version: number,
19
65
  ): Promise<MailingTemplateVersion | undefined> {
20
- const rows = await this.getBy("templateId", templateId);
66
+ const rows = await this.listForTemplate(templateId);
21
67
  return rows.find((row) => row.version === version);
22
68
  }
23
69
  }
@@ -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
 
@@ -2,6 +2,7 @@ export * from "./send-events.table";
2
2
  export * from "./sends.table";
3
3
  export * from "./settings.table";
4
4
  export * from "./suppressions.table";
5
+ export * from "./template-slugs.table";
5
6
  export * from "./template-versions.table";
6
7
  export * from "./templates.table";
7
8
  export * from "./webhook-status.table";
@@ -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
  }
@@ -0,0 +1,29 @@
1
+ import {
2
+ Field,
3
+ Index,
4
+ RegisterTable,
5
+ Table,
6
+ } from "@antelopejs/interface-database-decorators";
7
+ import { TENANT_SCHEMA_NAME } from "@antelopejs/interface-dms/constants";
8
+
9
+ export const MAILING_TEMPLATE_SLUGS_TABLE_NAME = "mailing_template_slugs";
10
+
11
+ /**
12
+ * The template a slug belongs to. Its id derives from the tenant and the
13
+ * slug, so the primary key is what keeps a slug to one template: the database
14
+ * interface declares no unique secondary index, and two creations racing
15
+ * past the free-slug check both insert their template, but only one claim.
16
+ */
17
+ @RegisterTable(MAILING_TEMPLATE_SLUGS_TABLE_NAME, TENANT_SCHEMA_NAME)
18
+ export class MailingTemplateSlug extends Table {
19
+ @Field("string")
20
+ declare _id: string;
21
+
22
+ @Field("string")
23
+ declare slug: string;
24
+
25
+ /** The template holding the slug; a deleted one leaves the claim free. */
26
+ @Index()
27
+ @Field("string")
28
+ declare templateId: string;
29
+ }
@@ -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"),
@@ -37,6 +37,7 @@ import {
37
37
  type TemplateStats,
38
38
  } from "../services/template-stats";
39
39
  import {
40
+ assertSlugClaimed,
40
41
  contentFieldsOf,
41
42
  contentOfVersion,
42
43
  discardDraft,
@@ -536,6 +537,7 @@ export class TemplatesController extends Controller(
536
537
  ...contentFieldsOf(source),
537
538
  updatedBy: this.author,
538
539
  });
540
+ await assertSlugClaimed(this.tenantId, slug, copyId as string);
539
541
  await refreshTemplateAttention(this.tenantId);
540
542
  return { id: copyId };
541
543
  }
@@ -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
+ }