@antelopejs/dms-mailing 0.8.0 → 0.9.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 +41 -43
- package/dist/automation/email-event-trigger.d.ts +2 -2
- package/dist/automation/send-template-action.d.ts +11 -3
- package/dist/automation/send-template-action.js +1 -1
- package/dist/automation/send-template-action.js.map +1 -1
- package/dist/data/body.d.ts +2 -1
- package/dist/data/body.js +2 -1
- package/dist/data/body.js.map +1 -1
- package/dist/data/read-only-routes.d.ts +4 -4
- package/dist/data/sends-table.d.ts +4 -4
- package/dist/data/suppressions-table.d.ts +4 -4
- package/dist/data/templates-table.d.ts +35 -12
- package/dist/data/templates-table.js +29 -24
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +1 -2
- package/dist/data-types/categories-type.js +3 -5
- package/dist/data-types/categories-type.js.map +1 -1
- package/dist/data-types/category-type.d.ts +1 -2
- package/dist/data-types/category-type.js +3 -5
- package/dist/data-types/category-type.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +7 -5
- package/dist/db/models/sends.model.d.ts +7 -5
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +7 -5
- package/dist/db/models/suppressions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.js +4 -3
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +7 -5
- package/dist/db/models/webhook-status.model.d.ts +7 -5
- package/dist/db/tables/settings.table.d.ts +1 -0
- package/dist/db/tables/settings.table.js +4 -0
- package/dist/db/tables/settings.table.js.map +1 -1
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/changes.js +8 -19
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.d.ts +2 -1
- package/dist/engine/conditions.js +3 -2
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/coverage.d.ts +2 -1
- package/dist/engine/coverage.js.map +1 -1
- package/dist/engine/formats.d.ts +4 -6
- package/dist/engine/formats.js +17 -3
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.d.ts +4 -2
- package/dist/engine/paths.js +15 -3
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/plain-text.js +2 -4
- package/dist/engine/plain-text.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +1 -1
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/system-variables.d.ts +2 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/hooks/tenant-export.d.ts +1 -1
- package/dist/hooks/tenant-export.js +10 -9
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +1 -0
- package/dist/implementations/dms-mailing/index.js +3 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -1
- package/dist/pages/templates.js.map +1 -1
- package/dist/routes/events.js +4 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/metrics.js +27 -28
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.js +5 -5
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +3 -4
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +4 -3
- package/dist/routes/settings.js +20 -14
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/suppressions.js +9 -6
- package/dist/routes/suppressions.js.map +1 -1
- package/dist/routes/template-content.d.ts +27 -0
- package/dist/routes/template-content.js +3 -0
- package/dist/routes/template-content.js.map +1 -0
- package/dist/routes/templates.d.ts +7 -30
- package/dist/routes/templates.js +3 -2
- package/dist/routes/templates.js.map +1 -1
- package/dist/routes/zod-json.d.ts +3 -0
- package/dist/routes/zod-json.js +21 -0
- package/dist/routes/zod-json.js.map +1 -0
- package/dist/services/attention.js +14 -7
- package/dist/services/attention.js.map +1 -1
- package/dist/services/catalog.js +4 -2
- package/dist/services/catalog.js.map +1 -1
- package/dist/services/declaration-error.d.ts +1 -1
- package/dist/services/declaration-error.js.map +1 -1
- package/dist/services/declarations.js +4 -1
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +3 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/metrics.js +2 -5
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/send-import.js +4 -4
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +3 -4
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +6 -3
- package/dist/services/settings.js +24 -5
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +3 -2
- package/dist/services/starters.js +1 -1
- package/dist/services/starters.js.map +1 -1
- package/dist/services/templates.d.ts +2 -1
- package/dist/services/templates.js +5 -5
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-secret-declaration.d.ts +4 -0
- package/dist/services/webhook-secret-declaration.js +25 -0
- package/dist/services/webhook-secret-declaration.js.map +1 -0
- package/dist/services/webhook-status.d.ts +2 -2
- package/dist/services/webhook-status.js +13 -6
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/json.d.ts +1 -0
- package/dist/types/json.js +3 -0
- package/dist/types/json.js.map +1 -0
- package/dist/types/settings.d.ts +5 -2
- package/dist/types/settings.js +6 -1
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +41 -28
- package/dist/validation/declarations.schema.js +10 -4
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +0 -1
- package/dist/validation/guards.js +0 -4
- package/dist/validation/guards.js.map +1 -1
- package/dist/validation/json.d.ts +5 -0
- package/dist/validation/json.js +54 -0
- package/dist/validation/json.js.map +1 -0
- package/dist/validation/json.schema.d.ts +2 -0
- package/dist/validation/json.schema.js +9 -0
- package/dist/validation/json.schema.js.map +1 -0
- package/dist/validation/templates.schema.d.ts +25 -24
- package/dist/validation/templates.schema.js +4 -3
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/components/editor/EditorCanvas.vue +8 -4
- package/frontend-vue/app/composables/useSortableList.ts +2 -2
- package/frontend-vue/package.json +37 -36
- package/frontend-vue/pnpm-lock.yaml +65 -5
- package/frontend-vue/tests/editor-canvas.test.ts +142 -0
- package/frontend-vue/tests/sortable-list.test-d.ts +19 -0
- package/frontend-vue/tsconfig.typecheck.json +12 -0
- package/frontend-vue/vitest.config.ts +16 -0
- package/package.json +16 -15
- package/skills/dms-mailing/SKILL.md +5 -3
- package/src/automation/email-event-trigger.ts +2 -2
- package/src/automation/send-template-action.ts +33 -14
- package/src/data/body.ts +5 -2
- package/src/data/templates-table.ts +46 -52
- package/src/data-types/categories-type.ts +2 -2
- package/src/data-types/category-type.ts +2 -2
- package/src/db/models/sends.model.ts +2 -2
- package/src/db/models/template-versions.model.ts +5 -3
- package/src/db/tables/settings.table.ts +4 -0
- package/src/db/tables/templates.table.ts +1 -1
- package/src/engine/changes.ts +11 -29
- package/src/engine/conditions.ts +12 -4
- package/src/engine/coverage.ts +2 -1
- package/src/engine/formats.ts +31 -17
- package/src/engine/paths.ts +39 -6
- package/src/engine/plain-text.ts +2 -4
- package/src/engine/resolve.ts +12 -4
- package/src/engine/system-variables.ts +2 -1
- package/src/engine/tokens.ts +2 -1
- package/src/hooks/tenant-export.ts +15 -14
- package/src/implementations/dms-mailing/index.ts +1 -0
- package/src/pages/sends.ts +3 -2
- package/src/pages/templates.ts +3 -1
- package/src/routes/events.ts +7 -4
- package/src/routes/index.ts +1 -0
- package/src/routes/metrics.ts +30 -30
- package/src/routes/period.ts +5 -5
- package/src/routes/sends.ts +13 -8
- package/src/routes/settings.ts +34 -17
- package/src/routes/suppressions.ts +12 -8
- package/src/routes/template-content.ts +45 -0
- package/src/routes/templates.ts +11 -44
- package/src/routes/zod-json.ts +28 -0
- package/src/services/attention.ts +16 -15
- package/src/services/catalog.ts +4 -8
- package/src/services/declaration-error.ts +1 -1
- package/src/services/declarations.ts +4 -1
- package/src/services/events.ts +4 -2
- package/src/services/metrics.ts +3 -5
- package/src/services/send-import.ts +10 -8
- package/src/services/send.ts +3 -6
- package/src/services/settings.ts +55 -16
- package/src/services/starters.ts +8 -4
- package/src/services/templates.ts +16 -9
- package/src/services/webhook-secret-declaration.ts +47 -0
- package/src/services/webhook-status.ts +18 -9
- package/src/types/json.ts +1 -0
- package/src/types/settings.ts +16 -2
- package/src/validation/declarations.schema.ts +11 -3
- package/src/validation/guards.ts +0 -8
- package/src/validation/json.schema.ts +7 -0
- package/src/validation/json.ts +66 -0
- package/src/validation/templates.schema.ts +7 -3
|
@@ -3,7 +3,10 @@ import type {
|
|
|
3
3
|
JsonSchema,
|
|
4
4
|
} from "@antelopejs/interface-dms-automation";
|
|
5
5
|
import type { EmailAddress } from "@antelopejs/interface-email";
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
SendTemplateResult,
|
|
8
|
+
TemplateVariables,
|
|
9
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
7
10
|
import { sendUnlessSuppressed } from "../services/checked-send";
|
|
8
11
|
import { SEND_STATUSES } from "../types";
|
|
9
12
|
|
|
@@ -12,7 +15,7 @@ export interface SendTemplateActionInput {
|
|
|
12
15
|
slug: string;
|
|
13
16
|
to: EmailAddress | EmailAddress[];
|
|
14
17
|
locale?: string;
|
|
15
|
-
variables?:
|
|
18
|
+
variables?: TemplateVariables;
|
|
16
19
|
source?: string;
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -42,12 +45,12 @@ const INPUT_SCHEMA: JsonSchema = {
|
|
|
42
45
|
required: ["tenantId", "slug", "to"],
|
|
43
46
|
};
|
|
44
47
|
|
|
45
|
-
const RESULT_PROPERTIES
|
|
48
|
+
const RESULT_PROPERTIES = {
|
|
46
49
|
sendId: { type: "string" },
|
|
47
50
|
status: { type: "string", enum: SEND_STATUSES },
|
|
48
51
|
messageId: { type: "string" },
|
|
49
52
|
error: { type: "string" },
|
|
50
|
-
};
|
|
53
|
+
} satisfies { [name: string]: JsonSchema };
|
|
51
54
|
|
|
52
55
|
const OUTPUT_SCHEMA: JsonSchema = {
|
|
53
56
|
type: "object",
|
|
@@ -61,12 +64,21 @@ const OUTPUT_SCHEMA: JsonSchema = {
|
|
|
61
64
|
},
|
|
62
65
|
};
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
/** What the action hands the graph: the send's result, as JSON. */
|
|
68
|
+
type SendTemplateOutput = {
|
|
69
|
+
sendId: string;
|
|
70
|
+
status: SendTemplateResult["status"];
|
|
71
|
+
messageId?: string;
|
|
72
|
+
error?: string;
|
|
73
|
+
results?: SendTemplateOutput[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const failedRecipients = (result: SendTemplateOutput): SendTemplateOutput[] =>
|
|
65
77
|
(result.results ?? [result]).filter((entry) => entry.status === "failed");
|
|
66
78
|
|
|
67
79
|
export const sendTemplateAction: ActionType<
|
|
68
80
|
SendTemplateActionInput,
|
|
69
|
-
|
|
81
|
+
SendTemplateOutput
|
|
70
82
|
> = {
|
|
71
83
|
id: SEND_TEMPLATE_ACTION_ID,
|
|
72
84
|
name: "Send e-mail template",
|
|
@@ -78,13 +90,16 @@ export const sendTemplateAction: ActionType<
|
|
|
78
90
|
const source =
|
|
79
91
|
input.source ?? `${AUTOMATION_SOURCE_PREFIX}${ctx.procedureId}`;
|
|
80
92
|
try {
|
|
81
|
-
const result = await sendUnlessSuppressed(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
const result: SendTemplateOutput = await sendUnlessSuppressed(
|
|
94
|
+
input.slug,
|
|
95
|
+
{
|
|
96
|
+
tenantId: input.tenantId,
|
|
97
|
+
to: input.to,
|
|
98
|
+
locale: input.locale,
|
|
99
|
+
variables: input.variables,
|
|
100
|
+
source,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
88
103
|
const failures = failedRecipients(result);
|
|
89
104
|
if (failures.length > 0) {
|
|
90
105
|
ctx.log(
|
|
@@ -95,7 +110,11 @@ export const sendTemplateAction: ActionType<
|
|
|
95
110
|
}
|
|
96
111
|
return result;
|
|
97
112
|
} catch (error) {
|
|
98
|
-
ctx.log(
|
|
113
|
+
ctx.log(
|
|
114
|
+
"error",
|
|
115
|
+
`Template "${input.slug}" could not be sent`,
|
|
116
|
+
error instanceof Error ? error.message : String(error),
|
|
117
|
+
);
|
|
99
118
|
throw error;
|
|
100
119
|
}
|
|
101
120
|
},
|
package/src/data/body.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { HTTPResult } from "@antelopejs/interface-api";
|
|
2
2
|
import { HTTP_BAD_REQUEST } from "../constants";
|
|
3
|
+
import type { JsonValue } from "../types/json";
|
|
3
4
|
import { isText } from "../validation/guards";
|
|
5
|
+
import { parseJson } from "../validation/json";
|
|
4
6
|
|
|
5
7
|
const INVALID_BODY = "$dms_mailing.errors.invalid_body";
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
/** The JSON a request body carries, `{}` for an empty one; 400 when it is not JSON. */
|
|
10
|
+
export function parseBody(body: Buffer | string): JsonValue {
|
|
8
11
|
const raw = isText(body) ? body : body.toString("utf8");
|
|
9
12
|
if (!raw) return {};
|
|
10
13
|
try {
|
|
11
|
-
return
|
|
14
|
+
return parseJson(raw);
|
|
12
15
|
} catch {
|
|
13
16
|
throw new HTTPResult(HTTP_BAD_REQUEST, INVALID_BODY);
|
|
14
17
|
}
|
|
@@ -4,13 +4,17 @@ import {
|
|
|
4
4
|
Parameter,
|
|
5
5
|
type RequestContext,
|
|
6
6
|
} from "@antelopejs/interface-api";
|
|
7
|
-
import { assertValidation } from "@antelopejs/interface-api-util";
|
|
7
|
+
import { assert, assertValidation } from "@antelopejs/interface-api-util";
|
|
8
|
+
import { z } from "zod";
|
|
8
9
|
import {
|
|
9
10
|
DataController,
|
|
10
11
|
type DataControllerCallback,
|
|
11
12
|
RegisterDataController,
|
|
12
13
|
} from "@antelopejs/interface-data-api";
|
|
13
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
Parameters,
|
|
16
|
+
Validation,
|
|
17
|
+
} from "@antelopejs/interface-data-api/components";
|
|
14
18
|
import {
|
|
15
19
|
Access,
|
|
16
20
|
AccessMode,
|
|
@@ -79,19 +83,23 @@ import {
|
|
|
79
83
|
refuseTakenSlug,
|
|
80
84
|
} from "../services/templates";
|
|
81
85
|
import type { TemplateCategory } from "../types";
|
|
86
|
+
import type { JsonObject } from "../types/json";
|
|
82
87
|
|
|
83
88
|
import {
|
|
84
89
|
createTemplateSchema,
|
|
85
|
-
|
|
90
|
+
type TemplateDetails,
|
|
86
91
|
} from "../validation/templates.schema";
|
|
87
92
|
import { isObject, isText } from "../validation/guards";
|
|
93
|
+
import { isJsonObject } from "../validation/json";
|
|
88
94
|
import { parseBody } from "./body";
|
|
89
95
|
import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
|
|
90
96
|
|
|
91
97
|
const SLUG_IMMUTABLE = "$dms_mailing.errors.slug_immutable";
|
|
92
98
|
const SOURCE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
|
|
93
99
|
const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
|
|
94
|
-
|
|
100
|
+
export type { TemplateDetails };
|
|
101
|
+
|
|
102
|
+
export const INVALID_TEMPLATE = "$dms_mailing.errors.invalid_template";
|
|
95
103
|
const UNKNOWN_CATEGORY = "$dms_mailing.errors.unknown_category";
|
|
96
104
|
const TEMPLATE_IN_USE = "$dms_mailing.errors.template_in_use";
|
|
97
105
|
const TEMPLATE_WAS_SENT = "$dms_mailing.errors.template_was_sent";
|
|
@@ -147,25 +155,23 @@ async function seedFields(
|
|
|
147
155
|
return starterFields(starter, fallbackLocale);
|
|
148
156
|
}
|
|
149
157
|
|
|
150
|
-
const isBlank = (value: unknown) =>
|
|
151
|
-
value === undefined || value === null || value === "";
|
|
152
|
-
|
|
153
158
|
/** A category is left empty or picked among the ones the settings declare. */
|
|
154
159
|
function assertKnownCategory(
|
|
155
160
|
categories: TemplateCategory[],
|
|
156
|
-
category:
|
|
161
|
+
category: string | undefined,
|
|
157
162
|
): void {
|
|
158
|
-
if (
|
|
159
|
-
if (isText(category) && findCategory(categories, category)) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
163
|
+
if (!category || findCategory(categories, category)) return;
|
|
162
164
|
throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
/** The row `new` answers since interface-data-api 0.3
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
/** The key of the row `new` answers since interface-data-api 0.3. */
|
|
168
|
+
const createdRowSchema = z.object({ _id: z.string() });
|
|
169
|
+
|
|
170
|
+
/** The JSON object a creation sends; 400 for any other JSON. */
|
|
171
|
+
function parseCreationBody(body: Buffer): JsonObject {
|
|
172
|
+
const parsed = parseBody(body);
|
|
173
|
+
assert(isJsonObject(parsed), HTTP_BAD_REQUEST, INVALID_TEMPLATE);
|
|
174
|
+
return parsed;
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
/**
|
|
@@ -176,7 +182,7 @@ interface CreatedRow {
|
|
|
176
182
|
* object: interface-data-api 0.3 refuses a JSON string.
|
|
177
183
|
*/
|
|
178
184
|
function parseCreation(body: Validation.WrittenBody, locale: string) {
|
|
179
|
-
const raw = Buffer.isBuffer(body) ?
|
|
185
|
+
const raw = Buffer.isBuffer(body) ? parseCreationBody(body) : body;
|
|
180
186
|
const seed = assertValidation(
|
|
181
187
|
raw,
|
|
182
188
|
(value) => createTemplateSchema.parse(value),
|
|
@@ -191,11 +197,6 @@ function parseCreation(body: Validation.WrittenBody, locale: string) {
|
|
|
191
197
|
return { seed, normalized };
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
/** What the data API locks a localized field with: the controller's key. */
|
|
195
|
-
interface LocalizedController {
|
|
196
|
-
language: string;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
200
|
/** The key that writes every language of a localized field at once. */
|
|
200
201
|
const ALL_LANGUAGES = "*";
|
|
201
202
|
|
|
@@ -209,9 +210,9 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
209
210
|
return {
|
|
210
211
|
...base,
|
|
211
212
|
func: async function (
|
|
212
|
-
this:
|
|
213
|
+
this: TemplatesTableAPI,
|
|
213
214
|
ctx: RequestContext,
|
|
214
|
-
params:
|
|
215
|
+
params: Parameters.NewParameters,
|
|
215
216
|
body: Validation.WrittenBody,
|
|
216
217
|
...rest: unknown[]
|
|
217
218
|
) {
|
|
@@ -220,11 +221,11 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
220
221
|
const settings = await getSettings(tenantId);
|
|
221
222
|
assertKnownCategory(settings.categories, seed.category);
|
|
222
223
|
const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
|
|
223
|
-
|
|
224
|
-
const created =
|
|
225
|
-
base.func.call(this, ctx, params, normalized, ...rest),
|
|
226
|
-
)
|
|
227
|
-
const templateId = created._id;
|
|
224
|
+
this.language = ALL_LANGUAGES;
|
|
225
|
+
const created: unknown = await refuseTakenSlug(
|
|
226
|
+
Promise.resolve(base.func.call(this, ctx, params, normalized, ...rest)),
|
|
227
|
+
);
|
|
228
|
+
const templateId = createdRowSchema.parse(created)._id;
|
|
228
229
|
const user = await authenticateTenantRequest(ctx);
|
|
229
230
|
await initializeTemplate(tenantId, templateId, displayName(user), fields);
|
|
230
231
|
return created;
|
|
@@ -301,10 +302,17 @@ export async function assertEditable(
|
|
|
301
302
|
): Promise<void> {
|
|
302
303
|
if (body.slug !== undefined && body.slug !== current.slug)
|
|
303
304
|
throw new HTTPResult(HTTP_CONFLICT, SLUG_IMMUTABLE);
|
|
304
|
-
if (body.name !== undefined
|
|
305
|
-
|
|
305
|
+
if (body.name !== undefined) {
|
|
306
|
+
const texts = isObject(body.name) ? Object.values(body.name) : [body.name];
|
|
307
|
+
if (!texts.some((text) => isText(text) && text.trim()))
|
|
308
|
+
throw new HTTPResult(HTTP_BAD_REQUEST, BLANK_NAME);
|
|
309
|
+
}
|
|
306
310
|
const { categories } = await getSettings(tenantId);
|
|
307
|
-
|
|
311
|
+
const { category } = body;
|
|
312
|
+
if (category === undefined || category === null || category === "") return;
|
|
313
|
+
if (!isText(category))
|
|
314
|
+
throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
|
|
315
|
+
assertKnownCategory(categories, category);
|
|
308
316
|
}
|
|
309
317
|
|
|
310
318
|
/**
|
|
@@ -314,14 +322,9 @@ export async function assertEditable(
|
|
|
314
322
|
export async function saveDetails(
|
|
315
323
|
ctx: RequestContext,
|
|
316
324
|
template: MailingTemplate,
|
|
317
|
-
|
|
325
|
+
details: TemplateDetails,
|
|
318
326
|
author: string,
|
|
319
327
|
): Promise<void> {
|
|
320
|
-
const details = assertValidation(
|
|
321
|
-
body,
|
|
322
|
-
(value) => templateDetailsSchema.parse(value),
|
|
323
|
-
() => INVALID_TEMPLATE,
|
|
324
|
-
);
|
|
325
328
|
const tenantId = getRequestTenantId(ctx);
|
|
326
329
|
await assertEditable(tenantId, details, template);
|
|
327
330
|
const names = TemplateModel.namesOf(template);
|
|
@@ -334,15 +337,6 @@ export async function saveDetails(
|
|
|
334
337
|
});
|
|
335
338
|
}
|
|
336
339
|
|
|
337
|
-
/**
|
|
338
|
-
* Whether an edited name holds no text: the text of the reader's language,
|
|
339
|
-
* or, from a form editing every language, all of them.
|
|
340
|
-
*/
|
|
341
|
-
function isBlankName(name: unknown): boolean {
|
|
342
|
-
const texts = isObject(name) ? Object.values(name) : [name];
|
|
343
|
-
return !texts.some((text) => isText(text) && text.trim());
|
|
344
|
-
}
|
|
345
|
-
|
|
346
340
|
/** The names trimmed, blank ones left out, the fallback language filled. */
|
|
347
341
|
function tidyNames(names: LocalizedText, locale: string): LocalizedText {
|
|
348
342
|
const tidy = Object.fromEntries(
|
|
@@ -362,10 +356,8 @@ function tidyNames(names: LocalizedText, locale: string): LocalizedText {
|
|
|
362
356
|
export function afterEdit(
|
|
363
357
|
model: TemplateModel,
|
|
364
358
|
id: string,
|
|
365
|
-
name: unknown,
|
|
366
359
|
locale: string,
|
|
367
|
-
): AfterWrite
|
|
368
|
-
if (name === undefined) return undefined;
|
|
360
|
+
): AfterWrite {
|
|
369
361
|
return async () => {
|
|
370
362
|
const template = await model.get(id);
|
|
371
363
|
if (!template) return;
|
|
@@ -377,8 +369,10 @@ export function afterEdit(
|
|
|
377
369
|
}
|
|
378
370
|
|
|
379
371
|
/** The templates the attention refresh flagged. */
|
|
380
|
-
const isFlagged: FilterFunction<
|
|
381
|
-
|
|
372
|
+
const isFlagged: FilterFunction<TemplatesTableAPI, TemplatesTableAPI> = (
|
|
373
|
+
_context,
|
|
374
|
+
proxy,
|
|
375
|
+
) => proxy.default(false).eq(true);
|
|
382
376
|
|
|
383
377
|
const templateRoutes = {
|
|
384
378
|
...TableViewRoutes.All,
|
|
@@ -12,8 +12,8 @@ import { categoriesSchema } from "../validation/settings.schema";
|
|
|
12
12
|
*/
|
|
13
13
|
@RegisterDataType(TEMPLATE_CATEGORIES_TYPE_ID)
|
|
14
14
|
export class TemplateCategoriesType extends DataType {
|
|
15
|
-
constructor(
|
|
16
|
-
super([], undefined,
|
|
15
|
+
constructor() {
|
|
16
|
+
super([], undefined, {});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
protected defaultInputComponent() {
|
|
@@ -19,7 +19,7 @@ import { TEMPLATE_CATEGORY_TYPE_ID } from "../constants";
|
|
|
19
19
|
*/
|
|
20
20
|
@RegisterDataType(TEMPLATE_CATEGORY_TYPE_ID)
|
|
21
21
|
export class TemplateCategoryType extends DataType {
|
|
22
|
-
constructor(
|
|
22
|
+
constructor() {
|
|
23
23
|
super(
|
|
24
24
|
[
|
|
25
25
|
DefaultDataCompareTypes.Is,
|
|
@@ -28,7 +28,7 @@ export class TemplateCategoryType extends DataType {
|
|
|
28
28
|
DefaultDataCompareTypes.IsNotEmpty,
|
|
29
29
|
],
|
|
30
30
|
DefaultDataCompareTypes.Is,
|
|
31
|
-
|
|
31
|
+
{},
|
|
32
32
|
);
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -123,7 +123,7 @@ export class SendModel extends BasicDataModel(
|
|
|
123
123
|
.filter((row) => row.key("createdAt").ge(from))
|
|
124
124
|
.filter((row) => row.key("createdAt").lt(to))
|
|
125
125
|
.without("json_variables")
|
|
126
|
-
.run()
|
|
126
|
+
.run();
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
listOlderThan(limit: Date, pageSize: number): Promise<MailingSend[]> {
|
|
@@ -260,7 +260,7 @@ export class SendModel extends BasicDataModel(
|
|
|
260
260
|
.getAll(templateSlug, "templateSlug")
|
|
261
261
|
.filter((row) => row.key("createdAt").ge(from))
|
|
262
262
|
.without("json_variables")
|
|
263
|
-
.run()
|
|
263
|
+
.run();
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
/** How many sends the retention would delete with this cutoff. */
|
|
@@ -11,6 +11,8 @@ export function committedVersionOf(
|
|
|
11
11
|
template: Pick<MailingTemplate, "json_published_version">,
|
|
12
12
|
): MailingTemplateVersion | undefined {
|
|
13
13
|
if (!template.json_published_version) return undefined;
|
|
14
|
+
// SAFETY: json_published_version is written only by publishSnapshot and publishedAtCreation as the
|
|
15
|
+
// JSON.stringify of the version row they insert; its publishedAt reads back as a string, and the next line restores the Date.
|
|
14
16
|
const row = JSON.parse(
|
|
15
17
|
template.json_published_version,
|
|
16
18
|
) as MailingTemplateVersion;
|
|
@@ -33,12 +35,12 @@ export class TemplateVersionModel extends BasicDataModel(
|
|
|
33
35
|
await this.insert(version);
|
|
34
36
|
} catch (error) {
|
|
35
37
|
const stored = await this.get(version._id);
|
|
38
|
+
if (!stored) throw error;
|
|
39
|
+
const storedFields = new Map(Object.entries(stored));
|
|
36
40
|
if (
|
|
37
|
-
!stored ||
|
|
38
41
|
Object.entries(version).some(
|
|
39
42
|
([key, value]) =>
|
|
40
|
-
JSON.stringify(
|
|
41
|
-
JSON.stringify(value),
|
|
43
|
+
JSON.stringify(storedFields.get(key)) !== JSON.stringify(value),
|
|
42
44
|
)
|
|
43
45
|
)
|
|
44
46
|
throw error;
|
|
@@ -156,7 +156,7 @@ export class MailingTemplate extends Table.with(LocalizationModifier) {
|
|
|
156
156
|
|
|
157
157
|
/** Covers model updates and generated data-api edits in the same database write. */
|
|
158
158
|
class TemplateRevisionModifier extends Modifier {
|
|
159
|
-
update(object:
|
|
159
|
+
update(object: Pick<MailingTemplate, "revision">, field: "revision"): void {
|
|
160
160
|
object[field] = randomUUID();
|
|
161
161
|
}
|
|
162
162
|
}
|
package/src/engine/changes.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
2
|
import type {
|
|
2
3
|
Block,
|
|
3
4
|
BlockType,
|
|
@@ -6,7 +7,6 @@ import type {
|
|
|
6
7
|
VariableDefinition,
|
|
7
8
|
} from "../types";
|
|
8
9
|
import { walkBlocks } from "./walk";
|
|
9
|
-
import { isObject } from "../validation/guards";
|
|
10
10
|
|
|
11
11
|
export type TemplateChangeKind =
|
|
12
12
|
| "locale_added"
|
|
@@ -41,36 +41,21 @@ export const CONTRACT_LOCALE = "";
|
|
|
41
41
|
|
|
42
42
|
interface BlockSnapshot {
|
|
43
43
|
type: BlockType;
|
|
44
|
-
|
|
44
|
+
fields: object;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const NESTED_KEYS = new Set(["children", "elseChildren"]);
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
* A value with its object keys sorted at every depth, so two blocks equal but
|
|
51
|
-
* for the order their fields were written in read the same.
|
|
52
|
-
*/
|
|
53
|
-
function canonical(value: unknown): unknown {
|
|
54
|
-
if (Array.isArray(value)) return value.map(canonical);
|
|
55
|
-
if (!isObject(value)) return value;
|
|
49
|
+
function ownFields(block: Block): object {
|
|
56
50
|
return Object.fromEntries(
|
|
57
|
-
Object.
|
|
58
|
-
.sort()
|
|
59
|
-
.map((key) => [key, canonical(value[key])]),
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function ownFields(block: Block): string {
|
|
64
|
-
const entries = Object.entries(block).filter(
|
|
65
|
-
([key]) => !NESTED_KEYS.has(key),
|
|
51
|
+
Object.entries(block).filter(([key]) => !NESTED_KEYS.has(key)),
|
|
66
52
|
);
|
|
67
|
-
return JSON.stringify(canonical(Object.fromEntries(entries)));
|
|
68
53
|
}
|
|
69
54
|
|
|
70
55
|
function snapshotBlocks(blocks: Block[]): Map<string, BlockSnapshot> {
|
|
71
56
|
const snapshots = new Map<string, BlockSnapshot>();
|
|
72
57
|
walkBlocks(blocks, (block) => {
|
|
73
|
-
snapshots.set(block.id, { type: block.type,
|
|
58
|
+
snapshots.set(block.id, { type: block.type, fields: ownFields(block) });
|
|
74
59
|
});
|
|
75
60
|
return snapshots;
|
|
76
61
|
}
|
|
@@ -94,7 +79,7 @@ function blockChanges(
|
|
|
94
79
|
const touched = [...after].flatMap(([id, snapshot]) => {
|
|
95
80
|
const previous = before.get(id);
|
|
96
81
|
if (!previous) return [blockChange(locale, "block_added", id, snapshot)];
|
|
97
|
-
if (previous.
|
|
82
|
+
if (isDeepStrictEqual(previous.fields, snapshot.fields)) return [];
|
|
98
83
|
return [blockChange(locale, "block_changed", id, snapshot)];
|
|
99
84
|
});
|
|
100
85
|
return [...touched, ...removed];
|
|
@@ -162,13 +147,8 @@ export function diffContents(
|
|
|
162
147
|
|
|
163
148
|
function declarationsByPath(
|
|
164
149
|
variables: VariableDefinition[],
|
|
165
|
-
): Map<string,
|
|
166
|
-
return new Map(
|
|
167
|
-
variables.map((variable) => [
|
|
168
|
-
variable.path,
|
|
169
|
-
JSON.stringify(canonical(variable)),
|
|
170
|
-
]),
|
|
171
|
-
);
|
|
150
|
+
): Map<string, VariableDefinition> {
|
|
151
|
+
return new Map(variables.map((variable) => [variable.path, variable]));
|
|
172
152
|
}
|
|
173
153
|
|
|
174
154
|
function variableChange(
|
|
@@ -194,6 +174,8 @@ export function diffVariables(
|
|
|
194
174
|
const next = after.get(path);
|
|
195
175
|
if (previous === undefined) return [variableChange("variable_added", path)];
|
|
196
176
|
if (next === undefined) return [variableChange("variable_removed", path)];
|
|
197
|
-
return previous
|
|
177
|
+
return isDeepStrictEqual(previous, next)
|
|
178
|
+
? []
|
|
179
|
+
: [variableChange("variable_changed", path)];
|
|
198
180
|
});
|
|
199
181
|
}
|
package/src/engine/conditions.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TemplateVariables,
|
|
3
|
+
TemplateVariableValue,
|
|
4
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
1
5
|
import type { Condition, ConditionOperator } from "../types";
|
|
6
|
+
import { plainText } from "./formats";
|
|
2
7
|
import { getPath } from "./paths";
|
|
3
8
|
|
|
4
|
-
type Evaluator = (
|
|
9
|
+
type Evaluator = (
|
|
10
|
+
actual: TemplateVariableValue | undefined,
|
|
11
|
+
expected: string,
|
|
12
|
+
) => boolean;
|
|
5
13
|
|
|
6
14
|
const EVALUATORS: Record<ConditionOperator, Evaluator> = {
|
|
7
15
|
truthy: (actual) => Boolean(actual),
|
|
8
16
|
falsy: (actual) => !actual,
|
|
9
|
-
eq: (actual, expected) =>
|
|
10
|
-
ne: (actual, expected) =>
|
|
17
|
+
eq: (actual, expected) => plainText(actual) === expected,
|
|
18
|
+
ne: (actual, expected) => plainText(actual) !== expected,
|
|
11
19
|
gt: (actual, expected) => Number(actual) > Number(expected),
|
|
12
20
|
lt: (actual, expected) => Number(actual) < Number(expected),
|
|
13
21
|
};
|
|
@@ -15,7 +23,7 @@ const EVALUATORS: Record<ConditionOperator, Evaluator> = {
|
|
|
15
23
|
/** Whether `data` passes the condition a block is shown under. */
|
|
16
24
|
export function evaluateCondition(
|
|
17
25
|
condition: Condition,
|
|
18
|
-
data:
|
|
26
|
+
data: TemplateVariables,
|
|
19
27
|
): boolean {
|
|
20
28
|
const evaluate = EVALUATORS[condition.operator];
|
|
21
29
|
return evaluate(getPath(data, condition.path), condition.value);
|
package/src/engine/coverage.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
|
|
1
2
|
import { SYSTEM_VARIABLE_PATHS } from "../constants";
|
|
2
3
|
import type {
|
|
3
4
|
Block,
|
|
@@ -63,7 +64,7 @@ export function collectContentVariablePaths(
|
|
|
63
64
|
|
|
64
65
|
export function missingRequiredVariables(
|
|
65
66
|
declared: VariableDefinition[],
|
|
66
|
-
data:
|
|
67
|
+
data: TemplateVariables,
|
|
67
68
|
): string[] {
|
|
68
69
|
return declared
|
|
69
70
|
.filter((variable) => variable.required)
|
package/src/engine/formats.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MoneyValue,
|
|
3
|
+
TemplateVariableValue,
|
|
4
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
1
5
|
import type { VariableDefinition } from "../types";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* An amount in the minor unit of its currency (cents for `EUR`), like the
|
|
6
|
-
* DMS's composed-text `money` parameter: `{ value: 987420, currency: "EUR" }`
|
|
7
|
-
* reads "€9,874.20" in English, "9 874,20 €" in French.
|
|
8
|
-
*/
|
|
9
|
-
export interface MoneyValue {
|
|
10
|
-
value: number;
|
|
11
|
-
/** ISO 4217 code. An unknown code writes the amount as a plain number. */
|
|
12
|
-
currency: string;
|
|
13
|
-
}
|
|
6
|
+
import { isText } from "../validation/guards";
|
|
7
|
+
import { isPresent, isRecord } from "./paths";
|
|
14
8
|
|
|
15
9
|
/** How a send writes its typed values: its locale, and the paths typed `date`. */
|
|
16
10
|
export interface ValueFormat {
|
|
@@ -55,8 +49,10 @@ export function valueFormatOf(
|
|
|
55
49
|
};
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
export function isMoneyValue(
|
|
59
|
-
|
|
52
|
+
export function isMoneyValue(
|
|
53
|
+
value: TemplateVariableValue | undefined,
|
|
54
|
+
): value is MoneyValue {
|
|
55
|
+
if (!isRecord(value)) return false;
|
|
60
56
|
return (
|
|
61
57
|
typeof value.value === "number" &&
|
|
62
58
|
Number.isFinite(value.value) &&
|
|
@@ -101,6 +97,24 @@ function formatIsoDate(value: string, locale: string): string | null {
|
|
|
101
97
|
: formatDate(date, options, locale);
|
|
102
98
|
}
|
|
103
99
|
|
|
100
|
+
/** What `String()` writes for a record, which has no text of its own. */
|
|
101
|
+
const RECORD_TEXT = "[object Object]";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `String(value)` spelled out for each kind a variable can be: a list joins
|
|
105
|
+
* its items with commas, a missing item writing nothing, and a record writes
|
|
106
|
+
* {@link RECORD_TEXT}.
|
|
107
|
+
*/
|
|
108
|
+
export function plainText(value: TemplateVariableValue | undefined): string {
|
|
109
|
+
if (Array.isArray(value))
|
|
110
|
+
return value
|
|
111
|
+
.map((item) => (isPresent(item) ? plainText(item) : ""))
|
|
112
|
+
.join(",");
|
|
113
|
+
if (value instanceof Date) return value.toString();
|
|
114
|
+
if (isRecord(value)) return RECORD_TEXT;
|
|
115
|
+
return String(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
104
118
|
/**
|
|
105
119
|
* A value as a recipient reads it. A money value and a `Date` format
|
|
106
120
|
* themselves in the send's locale wherever they appear; an ISO string is
|
|
@@ -108,7 +122,7 @@ function formatIsoDate(value: string, locale: string): string | null {
|
|
|
108
122
|
* else, a string already formatted by the caller among them, as given.
|
|
109
123
|
*/
|
|
110
124
|
export function formatValue(
|
|
111
|
-
value:
|
|
125
|
+
value: TemplateVariableValue | undefined,
|
|
112
126
|
path = "",
|
|
113
127
|
format?: ValueFormat,
|
|
114
128
|
): string {
|
|
@@ -116,9 +130,9 @@ export function formatValue(
|
|
|
116
130
|
if (isMoneyValue(value)) return formatMoney(value, locale);
|
|
117
131
|
if (value instanceof Date)
|
|
118
132
|
return Number.isNaN(value.getTime())
|
|
119
|
-
?
|
|
133
|
+
? value.toString()
|
|
120
134
|
: formatDate(value, DATE_STYLE, locale);
|
|
121
135
|
if (isText(value) && format?.datePaths.has(path))
|
|
122
136
|
return formatIsoDate(value, locale) ?? value;
|
|
123
|
-
return
|
|
137
|
+
return plainText(value);
|
|
124
138
|
}
|