@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.
- package/README.md +61 -339
- package/dist/data/templates-table.js +2 -10
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +1 -0
- package/dist/db/models/sends.model.d.ts +1 -0
- package/dist/db/models/settings.model.d.ts +1 -0
- package/dist/db/models/suppressions.model.d.ts +1 -0
- package/dist/db/models/template-slugs.model.d.ts +27 -0
- package/dist/db/models/template-slugs.model.js +43 -0
- package/dist/db/models/template-slugs.model.js.map +1 -0
- package/dist/db/models/template-versions.model.d.ts +1 -0
- package/dist/db/models/templates.model.d.ts +1 -0
- package/dist/db/models/webhook-status.model.d.ts +1 -0
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/template-slugs.table.d.ts +7 -0
- package/dist/db/tables/template-slugs.table.js +35 -0
- package/dist/db/tables/template-slugs.table.js.map +1 -0
- package/dist/routes/templates.js +1 -0
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/templates.d.ts +2 -0
- package/dist/services/templates.js +26 -0
- package/dist/services/templates.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +18 -24
- package/frontend-vue/app/components/DuplicateModal.vue +9 -3
- package/frontend-vue/app/components/Editor.vue +3 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
- package/frontend-vue/app/components/OverviewGate.vue +20 -29
- package/frontend-vue/app/components/PageHeader.vue +8 -12
- package/frontend-vue/app/components/RealSendModal.vue +3 -1
- package/frontend-vue/app/components/RenderingModal.vue +5 -7
- package/frontend-vue/app/components/RetentionInput.vue +13 -23
- package/frontend-vue/app/components/SendDrawer.vue +5 -2
- package/frontend-vue/app/components/StarterGallery.vue +11 -9
- package/frontend-vue/app/components/TemplateCard.vue +19 -4
- package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
- package/frontend-vue/app/components/TemplateGallery.vue +25 -20
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
- package/frontend-vue/app/components/TestSendModal.vue +3 -1
- package/frontend-vue/app/components/VersionHistory.vue +3 -3
- package/frontend-vue/app/components/WebhookStatus.vue +20 -17
- package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
- package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
- package/frontend-vue/app/composables/useEditorActions.ts +3 -1
- package/frontend-vue/app/composables/useEditorSession.ts +6 -4
- package/frontend-vue/app/composables/useMailingApi.ts +5 -58
- package/frontend-vue/app/composables/useMailingReads.ts +229 -0
- package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
- package/frontend-vue/app/composables/useSortableList.ts +159 -0
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
- package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
- package/frontend-vue/app/utils/gallery.ts +19 -7
- package/frontend-vue/app/utils/period.ts +0 -5
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +6 -13
- package/frontend-vue/tests/categories-input.test.ts +43 -13
- package/frontend-vue/tests/editor-block-list.test.ts +46 -6
- package/frontend-vue/tests/editor-session.test.ts +4 -0
- package/frontend-vue/tests/gallery.test.ts +16 -0
- package/frontend-vue/tests/helpers/api-data.ts +173 -0
- package/frontend-vue/tests/new-template-modal.test.ts +17 -5
- package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
- package/frontend-vue/tests/overview-gate.test.ts +59 -21
- package/frontend-vue/tests/page-reads.test.ts +157 -0
- package/frontend-vue/tests/period.test.ts +0 -9
- package/frontend-vue/tests/retention-input.test.ts +30 -9
- package/frontend-vue/tests/send-modals.test.ts +8 -1
- package/frontend-vue/tests/sortable-list.test.ts +253 -0
- package/frontend-vue/tests/template-drawer.test.ts +30 -14
- package/frontend-vue/tests/template-gallery.test.ts +45 -24
- package/package.json +31 -14
- package/skills/dms-mailing/SKILL.md +63 -0
- package/src/data/templates-table.ts +7 -27
- package/src/db/models/index.ts +1 -0
- package/src/db/models/template-slugs.model.ts +61 -0
- package/src/db/tables/index.ts +1 -0
- package/src/db/tables/template-slugs.table.ts +29 -0
- package/src/routes/templates.ts +2 -0
- package/src/services/templates.ts +49 -0
package/src/db/models/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/db/tables/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/routes/templates.ts
CHANGED
|
@@ -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;
|