@antelopejs/dms-mailing 0.4.1 → 0.5.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 (86) 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 +1 -0
  8. package/dist/db/models/sends.model.d.ts +1 -0
  9. package/dist/db/models/settings.model.d.ts +1 -0
  10. package/dist/db/models/suppressions.model.d.ts +1 -0
  11. package/dist/db/models/template-slugs.model.d.ts +27 -0
  12. package/dist/db/models/template-slugs.model.js +43 -0
  13. package/dist/db/models/template-slugs.model.js.map +1 -0
  14. package/dist/db/models/template-versions.model.d.ts +1 -0
  15. package/dist/db/models/templates.model.d.ts +1 -0
  16. package/dist/db/models/webhook-status.model.d.ts +1 -0
  17. package/dist/db/tables/index.d.ts +1 -0
  18. package/dist/db/tables/index.js +1 -0
  19. package/dist/db/tables/index.js.map +1 -1
  20. package/dist/db/tables/template-slugs.table.d.ts +7 -0
  21. package/dist/db/tables/template-slugs.table.js +35 -0
  22. package/dist/db/tables/template-slugs.table.js.map +1 -0
  23. package/dist/routes/templates.js +1 -0
  24. package/dist/routes/templates.js.map +1 -1
  25. package/dist/services/templates.d.ts +2 -0
  26. package/dist/services/templates.js +26 -0
  27. package/dist/services/templates.js.map +1 -1
  28. package/frontend-vue/app/components/CategoriesInput.vue +18 -24
  29. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  30. package/frontend-vue/app/components/Editor.vue +3 -3
  31. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  32. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  33. package/frontend-vue/app/components/PageHeader.vue +8 -12
  34. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  35. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  36. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  37. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  38. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  39. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  40. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  41. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  42. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  43. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  44. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  45. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  46. package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
  47. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
  48. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  49. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  50. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  51. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  52. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  53. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  54. package/frontend-vue/app/composables/useSortableList.ts +159 -0
  55. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  56. package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
  57. package/frontend-vue/app/utils/gallery.ts +19 -7
  58. package/frontend-vue/app/utils/period.ts +0 -5
  59. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
  60. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
  61. package/frontend-vue/package.json +4 -3
  62. package/frontend-vue/pnpm-lock.yaml +6 -13
  63. package/frontend-vue/tests/categories-input.test.ts +43 -13
  64. package/frontend-vue/tests/editor-block-list.test.ts +46 -6
  65. package/frontend-vue/tests/editor-session.test.ts +4 -0
  66. package/frontend-vue/tests/gallery.test.ts +16 -0
  67. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  68. package/frontend-vue/tests/new-template-modal.test.ts +17 -5
  69. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  70. package/frontend-vue/tests/overview-gate.test.ts +59 -21
  71. package/frontend-vue/tests/page-reads.test.ts +157 -0
  72. package/frontend-vue/tests/period.test.ts +0 -9
  73. package/frontend-vue/tests/retention-input.test.ts +30 -9
  74. package/frontend-vue/tests/send-modals.test.ts +8 -1
  75. package/frontend-vue/tests/sortable-list.test.ts +253 -0
  76. package/frontend-vue/tests/template-drawer.test.ts +30 -14
  77. package/frontend-vue/tests/template-gallery.test.ts +45 -24
  78. package/package.json +31 -14
  79. package/skills/dms-mailing/SKILL.md +63 -0
  80. package/src/data/templates-table.ts +7 -27
  81. package/src/db/models/index.ts +1 -0
  82. package/src/db/models/template-slugs.model.ts +61 -0
  83. package/src/db/tables/index.ts +1 -0
  84. package/src/db/tables/template-slugs.table.ts +29 -0
  85. package/src/routes/templates.ts +2 -0
  86. package/src/services/templates.ts +49 -0
@@ -2,6 +2,7 @@ export * from "./send-events.model";
2
2
  export * from "./sends.model";
3
3
  export * from "./settings.model";
4
4
  export * from "./suppressions.model";
5
+ export * from "./template-slugs.model";
5
6
  export * from "./template-versions.model";
6
7
  export * from "./templates.model";
7
8
  export * from "./webhook-status.model";
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+ import { BasicDataModel } from "@antelopejs/interface-database-decorators";
3
+ import {
4
+ MAILING_TEMPLATE_SLUGS_TABLE_NAME,
5
+ MailingTemplateSlug,
6
+ } from "../tables";
7
+
8
+ const ID_LENGTH = 32;
9
+
10
+ /**
11
+ * The id of a slug's claim, the same whoever creates the template. The tenant
12
+ * is part of it: a driver may keep every tenant's rows in one collection,
13
+ * where a key made of the slug alone would collide across tenants.
14
+ */
15
+ export function templateSlugIdOf(tenantId: string, slug: string): string {
16
+ return createHash("sha256")
17
+ .update(`${tenantId}\n${slug}`)
18
+ .digest("hex")
19
+ .slice(0, ID_LENGTH);
20
+ }
21
+
22
+ export class TemplateSlugModel extends BasicDataModel(
23
+ MailingTemplateSlug,
24
+ MAILING_TEMPLATE_SLUGS_TABLE_NAME,
25
+ ) {
26
+ /**
27
+ * Inserts the claim; false when the key is taken. Probed by state rather
28
+ * than by error, since the database interface exposes no typed conflict
29
+ * error: a claim present after a failed insert is the lost race.
30
+ */
31
+ async insertClaim(
32
+ id: string,
33
+ slug: string,
34
+ templateId: string,
35
+ ): Promise<boolean> {
36
+ try {
37
+ await this.insert({ _id: id, slug, templateId });
38
+ return true;
39
+ } catch (error) {
40
+ if (await this.get(id)) return false;
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ /** Drops the claim if `templateId` still holds it, never a newer one. */
46
+ async dropClaimOf(id: string, templateId: string): Promise<void> {
47
+ await this.table
48
+ .atomicMutation(id, {
49
+ type: "deleteIfEqual",
50
+ field: "templateId",
51
+ expectedValue: templateId,
52
+ })
53
+ .run();
54
+ }
55
+
56
+ /** Frees the slugs the templates held. */
57
+ async releaseTemplates(templateIds: string[]): Promise<void> {
58
+ if (!templateIds.length) return;
59
+ await this.table.getAll(templateIds, "templateId").delete().run();
60
+ }
61
+ }
@@ -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";
@@ -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
+ }
@@ -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
  }
@@ -2,6 +2,7 @@ import { HTTPResult } from "@antelopejs/interface-api";
2
2
  import { GetModel } from "@antelopejs/interface-database-decorators";
3
3
  import {
4
4
  DEFAULT_FALLBACK_LOCALE,
5
+ HTTP_CONFLICT,
5
6
  HTTP_NOT_FOUND,
6
7
  LOCALE_LIST_SEPARATOR,
7
8
  } from "../constants";
@@ -9,7 +10,9 @@ import {
9
10
  type MailingTemplate,
10
11
  type MailingTemplateVersion,
11
12
  TemplateModel,
13
+ TemplateSlugModel,
12
14
  TemplateVersionModel,
15
+ templateSlugIdOf,
13
16
  } from "../db";
14
17
  import { diffContents, diffVariables, type TemplateChange } from "../engine";
15
18
  import {
@@ -39,6 +42,7 @@ const NEVER_PUBLISHED = 0;
39
42
  const STALE_DRAFT: TemplateAttentionReason = "stale_draft";
40
43
  const VERSION_NOT_FOUND = "$dms_mailing.errors.version_not_found";
41
44
  const TEMPLATE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
45
+ const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
42
46
 
43
47
  /** The template `id` names; 404 when there is none. */
44
48
  export async function requireTemplate(
@@ -50,6 +54,51 @@ export async function requireTemplate(
50
54
  return template;
51
55
  }
52
56
 
57
+ /**
58
+ * Gives `slug` to the template just inserted; false when another template
59
+ * holds it. A claim whose template is gone (deleted before its claim was
60
+ * freed) is taken over, through the same insert that settles a race.
61
+ */
62
+ async function claimTemplateSlug(
63
+ tenantId: string,
64
+ slug: string,
65
+ templateId: string,
66
+ ): Promise<boolean> {
67
+ const claims = GetModel(TemplateSlugModel, tenantId);
68
+ const id = templateSlugIdOf(tenantId, slug);
69
+ if (await claims.insertClaim(id, slug, templateId)) return true;
70
+ const holder = await claims.get(id);
71
+ if (holder) {
72
+ const templates = GetModel(TemplateModel, tenantId);
73
+ if (await templates.get(holder.templateId)) return false;
74
+ await claims.dropClaimOf(id, holder.templateId);
75
+ }
76
+ return claims.insertClaim(id, slug, templateId);
77
+ }
78
+
79
+ /**
80
+ * Keeps the template just inserted if it got its slug, or removes it again
81
+ * and refuses it like any taken slug: two creations racing past the free-slug
82
+ * check both insert, and only the claim's primary key tells them apart.
83
+ */
84
+ export async function assertSlugClaimed(
85
+ tenantId: string,
86
+ slug: string,
87
+ templateId: string,
88
+ ): Promise<void> {
89
+ if (await claimTemplateSlug(tenantId, slug, templateId)) return;
90
+ await GetModel(TemplateModel, tenantId).delete(templateId);
91
+ throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
92
+ }
93
+
94
+ /** Frees the slugs of deleted templates for new ones. */
95
+ export async function releaseTemplateSlugs(
96
+ tenantId: string,
97
+ templateIds: string[],
98
+ ): Promise<void> {
99
+ await GetModel(TemplateSlugModel, tenantId).releaseTemplates(templateIds);
100
+ }
101
+
53
102
  /** The content customers receive: the last published version. */
54
103
  export function parseContent(template: MailingTemplate): TemplateContent {
55
104
  return JSON.parse(template.json_content || EMPTY_CONTENT) as TemplateContent;