@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.
Files changed (205) hide show
  1. package/README.md +41 -43
  2. package/dist/automation/email-event-trigger.d.ts +2 -2
  3. package/dist/automation/send-template-action.d.ts +11 -3
  4. package/dist/automation/send-template-action.js +1 -1
  5. package/dist/automation/send-template-action.js.map +1 -1
  6. package/dist/data/body.d.ts +2 -1
  7. package/dist/data/body.js +2 -1
  8. package/dist/data/body.js.map +1 -1
  9. package/dist/data/read-only-routes.d.ts +4 -4
  10. package/dist/data/sends-table.d.ts +4 -4
  11. package/dist/data/suppressions-table.d.ts +4 -4
  12. package/dist/data/templates-table.d.ts +35 -12
  13. package/dist/data/templates-table.js +29 -24
  14. package/dist/data/templates-table.js.map +1 -1
  15. package/dist/data-types/categories-type.d.ts +1 -2
  16. package/dist/data-types/categories-type.js +3 -5
  17. package/dist/data-types/categories-type.js.map +1 -1
  18. package/dist/data-types/category-type.d.ts +1 -2
  19. package/dist/data-types/category-type.js +3 -5
  20. package/dist/data-types/category-type.js.map +1 -1
  21. package/dist/db/models/send-events.model.d.ts +7 -5
  22. package/dist/db/models/sends.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.js.map +1 -1
  24. package/dist/db/models/settings.model.d.ts +7 -5
  25. package/dist/db/models/suppressions.model.d.ts +7 -5
  26. package/dist/db/models/template-versions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.js +4 -3
  28. package/dist/db/models/template-versions.model.js.map +1 -1
  29. package/dist/db/models/templates.model.d.ts +7 -5
  30. package/dist/db/models/webhook-status.model.d.ts +7 -5
  31. package/dist/db/tables/settings.table.d.ts +1 -0
  32. package/dist/db/tables/settings.table.js +4 -0
  33. package/dist/db/tables/settings.table.js.map +1 -1
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -19
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +3 -2
  39. package/dist/engine/conditions.js.map +1 -1
  40. package/dist/engine/coverage.d.ts +2 -1
  41. package/dist/engine/coverage.js.map +1 -1
  42. package/dist/engine/formats.d.ts +4 -6
  43. package/dist/engine/formats.js +17 -3
  44. package/dist/engine/formats.js.map +1 -1
  45. package/dist/engine/paths.d.ts +4 -2
  46. package/dist/engine/paths.js +15 -3
  47. package/dist/engine/paths.js.map +1 -1
  48. package/dist/engine/plain-text.js +2 -4
  49. package/dist/engine/plain-text.js.map +1 -1
  50. package/dist/engine/resolve.d.ts +2 -1
  51. package/dist/engine/resolve.js +1 -1
  52. package/dist/engine/resolve.js.map +1 -1
  53. package/dist/engine/system-variables.d.ts +2 -1
  54. package/dist/engine/system-variables.js.map +1 -1
  55. package/dist/engine/tokens.d.ts +2 -1
  56. package/dist/engine/tokens.js.map +1 -1
  57. package/dist/hooks/tenant-export.d.ts +1 -1
  58. package/dist/hooks/tenant-export.js +10 -9
  59. package/dist/hooks/tenant-export.js.map +1 -1
  60. package/dist/implementations/dms-mailing/index.d.ts +1 -0
  61. package/dist/implementations/dms-mailing/index.js +3 -1
  62. package/dist/implementations/dms-mailing/index.js.map +1 -1
  63. package/dist/pages/sends.js.map +1 -1
  64. package/dist/pages/templates.js +3 -1
  65. package/dist/pages/templates.js.map +1 -1
  66. package/dist/routes/events.js +4 -2
  67. package/dist/routes/events.js.map +1 -1
  68. package/dist/routes/index.d.ts +1 -0
  69. package/dist/routes/index.js +1 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/metrics.js +27 -28
  72. package/dist/routes/metrics.js.map +1 -1
  73. package/dist/routes/period.js +5 -5
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +3 -4
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +4 -3
  78. package/dist/routes/settings.js +20 -14
  79. package/dist/routes/settings.js.map +1 -1
  80. package/dist/routes/suppressions.js +9 -6
  81. package/dist/routes/suppressions.js.map +1 -1
  82. package/dist/routes/template-content.d.ts +27 -0
  83. package/dist/routes/template-content.js +3 -0
  84. package/dist/routes/template-content.js.map +1 -0
  85. package/dist/routes/templates.d.ts +7 -30
  86. package/dist/routes/templates.js +3 -2
  87. package/dist/routes/templates.js.map +1 -1
  88. package/dist/routes/zod-json.d.ts +3 -0
  89. package/dist/routes/zod-json.js +21 -0
  90. package/dist/routes/zod-json.js.map +1 -0
  91. package/dist/services/attention.js +14 -7
  92. package/dist/services/attention.js.map +1 -1
  93. package/dist/services/catalog.js +4 -2
  94. package/dist/services/catalog.js.map +1 -1
  95. package/dist/services/declaration-error.d.ts +1 -1
  96. package/dist/services/declaration-error.js.map +1 -1
  97. package/dist/services/declarations.js +4 -1
  98. package/dist/services/declarations.js.map +1 -1
  99. package/dist/services/events.js +3 -1
  100. package/dist/services/events.js.map +1 -1
  101. package/dist/services/metrics.js +2 -5
  102. package/dist/services/metrics.js.map +1 -1
  103. package/dist/services/send-import.js +4 -4
  104. package/dist/services/send-import.js.map +1 -1
  105. package/dist/services/send.js +3 -4
  106. package/dist/services/send.js.map +1 -1
  107. package/dist/services/settings.d.ts +6 -3
  108. package/dist/services/settings.js +24 -5
  109. package/dist/services/settings.js.map +1 -1
  110. package/dist/services/starters.d.ts +3 -2
  111. package/dist/services/starters.js +1 -1
  112. package/dist/services/starters.js.map +1 -1
  113. package/dist/services/templates.d.ts +2 -1
  114. package/dist/services/templates.js +5 -5
  115. package/dist/services/templates.js.map +1 -1
  116. package/dist/services/webhook-secret-declaration.d.ts +4 -0
  117. package/dist/services/webhook-secret-declaration.js +25 -0
  118. package/dist/services/webhook-secret-declaration.js.map +1 -0
  119. package/dist/services/webhook-status.d.ts +2 -2
  120. package/dist/services/webhook-status.js +13 -6
  121. package/dist/services/webhook-status.js.map +1 -1
  122. package/dist/types/json.d.ts +1 -0
  123. package/dist/types/json.js +3 -0
  124. package/dist/types/json.js.map +1 -0
  125. package/dist/types/settings.d.ts +5 -2
  126. package/dist/types/settings.js +6 -1
  127. package/dist/types/settings.js.map +1 -1
  128. package/dist/validation/declarations.schema.d.ts +41 -28
  129. package/dist/validation/declarations.schema.js +10 -4
  130. package/dist/validation/declarations.schema.js.map +1 -1
  131. package/dist/validation/guards.d.ts +0 -1
  132. package/dist/validation/guards.js +0 -4
  133. package/dist/validation/guards.js.map +1 -1
  134. package/dist/validation/json.d.ts +5 -0
  135. package/dist/validation/json.js +54 -0
  136. package/dist/validation/json.js.map +1 -0
  137. package/dist/validation/json.schema.d.ts +2 -0
  138. package/dist/validation/json.schema.js +9 -0
  139. package/dist/validation/json.schema.js.map +1 -0
  140. package/dist/validation/templates.schema.d.ts +25 -24
  141. package/dist/validation/templates.schema.js +4 -3
  142. package/dist/validation/templates.schema.js.map +1 -1
  143. package/frontend-vue/app/components/editor/EditorCanvas.vue +8 -4
  144. package/frontend-vue/app/composables/useSortableList.ts +2 -2
  145. package/frontend-vue/package.json +37 -36
  146. package/frontend-vue/pnpm-lock.yaml +65 -5
  147. package/frontend-vue/tests/editor-canvas.test.ts +142 -0
  148. package/frontend-vue/tests/sortable-list.test-d.ts +19 -0
  149. package/frontend-vue/tsconfig.typecheck.json +12 -0
  150. package/frontend-vue/vitest.config.ts +16 -0
  151. package/package.json +16 -15
  152. package/skills/dms-mailing/SKILL.md +5 -3
  153. package/src/automation/email-event-trigger.ts +2 -2
  154. package/src/automation/send-template-action.ts +33 -14
  155. package/src/data/body.ts +5 -2
  156. package/src/data/templates-table.ts +46 -52
  157. package/src/data-types/categories-type.ts +2 -2
  158. package/src/data-types/category-type.ts +2 -2
  159. package/src/db/models/sends.model.ts +2 -2
  160. package/src/db/models/template-versions.model.ts +5 -3
  161. package/src/db/tables/settings.table.ts +4 -0
  162. package/src/db/tables/templates.table.ts +1 -1
  163. package/src/engine/changes.ts +11 -29
  164. package/src/engine/conditions.ts +12 -4
  165. package/src/engine/coverage.ts +2 -1
  166. package/src/engine/formats.ts +31 -17
  167. package/src/engine/paths.ts +39 -6
  168. package/src/engine/plain-text.ts +2 -4
  169. package/src/engine/resolve.ts +12 -4
  170. package/src/engine/system-variables.ts +2 -1
  171. package/src/engine/tokens.ts +2 -1
  172. package/src/hooks/tenant-export.ts +15 -14
  173. package/src/implementations/dms-mailing/index.ts +1 -0
  174. package/src/pages/sends.ts +3 -2
  175. package/src/pages/templates.ts +3 -1
  176. package/src/routes/events.ts +7 -4
  177. package/src/routes/index.ts +1 -0
  178. package/src/routes/metrics.ts +30 -30
  179. package/src/routes/period.ts +5 -5
  180. package/src/routes/sends.ts +13 -8
  181. package/src/routes/settings.ts +34 -17
  182. package/src/routes/suppressions.ts +12 -8
  183. package/src/routes/template-content.ts +45 -0
  184. package/src/routes/templates.ts +11 -44
  185. package/src/routes/zod-json.ts +28 -0
  186. package/src/services/attention.ts +16 -15
  187. package/src/services/catalog.ts +4 -8
  188. package/src/services/declaration-error.ts +1 -1
  189. package/src/services/declarations.ts +4 -1
  190. package/src/services/events.ts +4 -2
  191. package/src/services/metrics.ts +3 -5
  192. package/src/services/send-import.ts +10 -8
  193. package/src/services/send.ts +3 -6
  194. package/src/services/settings.ts +55 -16
  195. package/src/services/starters.ts +8 -4
  196. package/src/services/templates.ts +16 -9
  197. package/src/services/webhook-secret-declaration.ts +47 -0
  198. package/src/services/webhook-status.ts +18 -9
  199. package/src/types/json.ts +1 -0
  200. package/src/types/settings.ts +16 -2
  201. package/src/validation/declarations.schema.ts +11 -3
  202. package/src/validation/guards.ts +0 -8
  203. package/src/validation/json.schema.ts +7 -0
  204. package/src/validation/json.ts +66 -0
  205. 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 { SendTemplateResult } from "@antelopejs/interface-dms-mailing";
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?: Record<string, unknown>;
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: JsonSchema = {
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
- const failedRecipients = (result: SendTemplateResult): SendTemplateResult[] =>
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
- SendTemplateResult
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(input.slug, {
82
- tenantId: input.tenantId,
83
- to: input.to,
84
- locale: input.locale,
85
- variables: input.variables,
86
- source,
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("error", `Template "${input.slug}" could not be sent`, error);
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
- export function parseBody(body: Buffer | string): Record<string, unknown> {
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 JSON.parse(raw) as Record<string, unknown>;
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 { Validation } from "@antelopejs/interface-data-api/components";
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
- templateDetailsSchema,
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
- const INVALID_TEMPLATE = "$dms_mailing.errors.invalid_template";
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: unknown,
161
+ category: string | undefined,
157
162
  ): void {
158
- if (isBlank(category)) return;
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: its key and fields. */
166
- interface CreatedRow {
167
- _id: string;
168
- [field: string]: unknown;
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) ? parseBody(body) : 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: unknown,
213
+ this: TemplatesTableAPI,
213
214
  ctx: RequestContext,
214
- params: unknown,
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
- (this as LocalizedController).language = ALL_LANGUAGES;
224
- const created = (await refuseTakenSlug(
225
- base.func.call(this, ctx, params, normalized, ...rest),
226
- )) as CreatedRow;
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 && isBlankName(body.name))
305
- throw new HTTPResult(HTTP_BAD_REQUEST, BLANK_NAME);
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
- assertKnownCategory(categories, body.category);
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
- body: unknown,
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 | undefined {
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<Record<string, unknown>> = (_context, proxy) =>
381
- proxy.default(false).eq(true);
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(public readonly options: Record<string, unknown> = {}) {
16
- super([], undefined, options);
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(public readonly options: Record<string, unknown> = {}) {
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
- options,
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() as Promise<SendSummary[]>;
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() as Promise<SendSummary[]>;
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(stored[key as keyof MailingTemplateVersion]) !==
41
- JSON.stringify(value),
43
+ JSON.stringify(storedFields.get(key)) !== JSON.stringify(value),
42
44
  )
43
45
  )
44
46
  throw error;
@@ -46,4 +46,8 @@ export class MailingSettings extends Table {
46
46
 
47
47
  @Field("string")
48
48
  declare webhookSecret: string;
49
+
50
+ /** Who set `webhookSecret`; absent on rows written before it was kept. */
51
+ @Field("string")
52
+ declare webhookSecretSource?: string;
49
53
  }
@@ -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: Record<string, unknown>, field: string): void {
159
+ update(object: Pick<MailingTemplate, "revision">, field: "revision"): void {
160
160
  object[field] = randomUUID();
161
161
  }
162
162
  }
@@ -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
- signature: string;
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.keys(value)
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, signature: ownFields(block) });
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.signature === snapshot.signature) return [];
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, 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 === next ? [] : [variableChange("variable_changed", path)];
177
+ return isDeepStrictEqual(previous, next)
178
+ ? []
179
+ : [variableChange("variable_changed", path)];
198
180
  });
199
181
  }
@@ -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 = (actual: unknown, expected: string) => boolean;
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) => String(actual) === expected,
10
- ne: (actual, expected) => String(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: Record<string, unknown>,
26
+ data: TemplateVariables,
19
27
  ): boolean {
20
28
  const evaluate = EVALUATORS[condition.operator];
21
29
  return evaluate(getPath(data, condition.path), condition.value);
@@ -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: Record<string, unknown>,
67
+ data: TemplateVariables,
67
68
  ): string[] {
68
69
  return declared
69
70
  .filter((variable) => variable.required)
@@ -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 { isObject, isText } from "../validation/guards";
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(value: unknown): value is MoneyValue {
59
- if (!isObject(value)) return false;
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: unknown,
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
- ? String(value)
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 String(value);
137
+ return plainText(value);
124
138
  }