@antelopejs/dms-mailing 0.6.1 → 0.7.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 +8 -7
- package/dist/constants.d.ts +1 -2
- package/dist/constants.js +2 -3
- package/dist/constants.js.map +1 -1
- package/dist/data/templates-table.d.ts +4 -1
- package/dist/data/templates-table.js +55 -20
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +5 -5
- package/dist/db/models/index.d.ts +0 -1
- package/dist/db/models/index.js +0 -1
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.js +2 -3
- package/dist/db/models/send-events.model.js.map +1 -1
- package/dist/db/models/sends.model.js +2 -3
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +10 -1
- package/dist/db/models/templates.model.js +29 -10
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/tables/index.d.ts +0 -1
- package/dist/db/tables/index.js +0 -1
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/settings.table.d.ts +0 -1
- package/dist/db/tables/settings.table.js +0 -4
- package/dist/db/tables/settings.table.js.map +1 -1
- package/dist/db/tables/templates.table.d.ts +7 -4
- package/dist/db/tables/templates.table.js +4 -14
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/formats.d.ts +13 -0
- package/dist/engine/formats.js +85 -0
- package/dist/engine/formats.js.map +1 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +14 -7
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js +3 -5
- package/dist/engine/tokens.js.map +1 -1
- package/dist/frontend-module.d.ts +2 -0
- package/dist/frontend-module.js +24 -0
- package/dist/frontend-module.js.map +1 -0
- package/dist/hooks/index.d.ts +0 -1
- package/dist/hooks/index.js +0 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/tenant-export.js +4 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +3 -3
- package/dist/implementations/dms-mailing/index.js +8 -7
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +2 -20
- package/dist/index.js.map +1 -1
- package/dist/pages/overview.d.ts +1 -1
- package/dist/pages/overview.js +11 -13
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/sends.d.ts +1 -1
- package/dist/pages/sends.js +16 -8
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/settings/form.js +8 -0
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/templates.js +3 -8
- package/dist/pages/templates.js.map +1 -1
- package/dist/pages/toolbar.d.ts +12 -0
- package/dist/pages/toolbar.js +41 -0
- package/dist/pages/toolbar.js.map +1 -0
- package/dist/realtime.d.ts +2 -2
- package/dist/realtime.js +8 -8
- package/dist/realtime.js.map +1 -1
- package/dist/routes/metrics.d.ts +9 -2
- package/dist/routes/metrics.js +90 -35
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.d.ts +1 -0
- package/dist/routes/period.js +19 -0
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +6 -3
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -7
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.d.ts +8 -4
- package/dist/routes/templates.js +41 -20
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/categories.d.ts +2 -1
- package/dist/services/categories.js +9 -0
- package/dist/services/categories.js.map +1 -1
- package/dist/services/declarations.js +42 -184
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +1 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +7 -0
- package/dist/services/localized-text.js +28 -0
- package/dist/services/localized-text.js.map +1 -0
- package/dist/services/send-import.js +0 -2
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send-stat-items.js +34 -12
- package/dist/services/send-stat-items.js.map +1 -1
- package/dist/services/send-stats.d.ts +7 -0
- package/dist/services/send-stats.js +17 -5
- package/dist/services/send-stats.js.map +1 -1
- package/dist/services/send.d.ts +2 -0
- package/dist/services/send.js +11 -12
- package/dist/services/send.js.map +1 -1
- package/dist/services/sender-declaration.d.ts +2 -0
- package/dist/services/sender-declaration.js +18 -0
- package/dist/services/sender-declaration.js.map +1 -0
- package/dist/services/settings.d.ts +1 -1
- package/dist/services/settings.js +1 -5
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +2 -2
- package/dist/services/starters.js +1 -10
- package/dist/services/starters.js.map +1 -1
- package/dist/services/templates.d.ts +13 -7
- package/dist/services/templates.js +72 -33
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -1
- package/dist/services/webhook-status.js +6 -5
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/settings.d.ts +8 -0
- package/dist/types/settings.js +33 -6
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +25 -7
- package/dist/validation/declarations.schema.js +16 -3
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/settings.schema.d.ts +41 -41
- package/dist/validation/settings.schema.js +30 -6
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +13 -0
- package/dist/validation/templates.schema.js +6 -1
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +15 -13
- package/frontend-vue/app/components/FunnelCard.vue +46 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
- package/frontend-vue/app/components/OverviewGate.vue +0 -8
- package/frontend-vue/app/components/ProviderPill.vue +50 -0
- package/frontend-vue/app/components/SendDrawer.vue +206 -183
- package/frontend-vue/app/components/StarterGallery.vue +1 -1
- package/frontend-vue/app/components/TemplateCard.vue +4 -1
- package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
- package/frontend-vue/app/components/TemplateGallery.vue +57 -17
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
- package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
- package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
- package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
- package/frontend-vue/app/composables/useMailingApi.ts +3 -3
- package/frontend-vue/app/composables/useMailingReads.ts +2 -2
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
- package/frontend-vue/app/types/mailing.ts +18 -0
- package/frontend-vue/app/utils/categories.ts +58 -49
- package/frontend-vue/app/utils/funnel.ts +25 -0
- package/frontend-vue/app/utils/period.ts +0 -7
- package/frontend-vue/app/utils/starters.ts +3 -1
- package/frontend-vue/app/utils/tokens.ts +6 -1
- package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/categories-input.test.ts +4 -10
- package/frontend-vue/tests/categories.test.ts +55 -17
- package/frontend-vue/tests/conditions.test.ts +1 -4
- package/frontend-vue/tests/funnel-card.test.ts +27 -0
- package/frontend-vue/tests/funnel.test.ts +25 -0
- package/frontend-vue/tests/localized-defaults.test.ts +0 -29
- package/frontend-vue/tests/new-template-modal.test.ts +1 -1
- package/frontend-vue/tests/overview-gate.test.ts +1 -24
- package/frontend-vue/tests/page-reads.test.ts +9 -25
- package/frontend-vue/tests/period.test.ts +0 -12
- package/frontend-vue/tests/replay-toast.test.ts +2 -1
- package/frontend-vue/tests/send-drawer.test.ts +123 -0
- package/frontend-vue/tests/template-drawer.test.ts +1 -1
- package/frontend-vue/tests/template-gallery.test.ts +90 -3
- package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
- package/package.json +8 -7
- package/skills/dms-mailing/SKILL.md +25 -18
- package/src/constants.ts +2 -2
- package/src/data/sends-table.ts +1 -1
- package/src/data/templates-table.ts +125 -33
- package/src/db/models/index.ts +0 -1
- package/src/db/models/send-events.model.ts +2 -2
- package/src/db/models/sends.model.ts +7 -8
- package/src/db/models/templates.model.ts +64 -16
- package/src/db/tables/index.ts +0 -1
- package/src/db/tables/settings.table.ts +0 -8
- package/src/db/tables/templates.table.ts +12 -17
- package/src/engine/formats.ts +124 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/resolve.ts +18 -7
- package/src/engine/tokens.ts +7 -5
- package/src/frontend-module.ts +36 -0
- package/src/hooks/index.ts +0 -1
- package/src/hooks/tenant-export.ts +8 -1
- package/src/implementations/dms-mailing/index.ts +5 -16
- package/src/index.ts +3 -29
- package/src/pages/overview.ts +20 -16
- package/src/pages/sends.ts +27 -11
- package/src/pages/settings/form.ts +16 -0
- package/src/pages/templates.ts +4 -8
- package/src/pages/toolbar.ts +77 -0
- package/src/realtime.ts +17 -8
- package/src/routes/metrics.ts +158 -45
- package/src/routes/period.ts +32 -0
- package/src/routes/sends.ts +13 -4
- package/src/routes/settings.ts +11 -8
- package/src/routes/templates.ts +81 -37
- package/src/services/categories.ts +14 -1
- package/src/services/declarations.ts +64 -317
- package/src/services/events.ts +1 -1
- package/src/services/localized-text.ts +53 -0
- package/src/services/send-import.ts +2 -2
- package/src/services/send-stat-items.ts +60 -13
- package/src/services/send-stats.ts +37 -5
- package/src/services/send.ts +19 -20
- package/src/services/sender-declaration.ts +27 -0
- package/src/services/settings.ts +3 -10
- package/src/services/starters.ts +3 -15
- package/src/services/templates.ts +109 -63
- package/src/services/webhook-status.ts +3 -3
- package/src/types/settings.ts +55 -5
- package/src/validation/declarations.schema.ts +29 -3
- package/src/validation/settings.schema.ts +56 -14
- package/src/validation/templates.schema.ts +10 -0
- package/dist/db/models/template-slugs.model.d.ts +0 -27
- package/dist/db/models/template-slugs.model.js +0 -43
- package/dist/db/models/template-slugs.model.js.map +0 -1
- package/dist/db/tables/template-slugs.table.d.ts +0 -7
- package/dist/db/tables/template-slugs.table.js +0 -35
- package/dist/db/tables/template-slugs.table.js.map +0 -1
- package/dist/hooks/database-ready.d.ts +0 -7
- package/dist/hooks/database-ready.js +0 -26
- package/dist/hooks/database-ready.js.map +0 -1
- package/dist/services/fingerprint.d.ts +0 -1
- package/dist/services/fingerprint.js +0 -20
- package/dist/services/fingerprint.js.map +0 -1
- package/frontend-vue/app/components/PageHeader.vue +0 -94
- package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
- package/src/db/models/template-slugs.model.ts +0 -61
- package/src/db/tables/template-slugs.table.ts +0 -29
- package/src/hooks/database-ready.ts +0 -47
- package/src/services/fingerprint.ts +0 -22
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Controller,
|
|
3
3
|
HTTPResult,
|
|
4
|
+
Parameter,
|
|
4
5
|
type RequestContext,
|
|
5
6
|
} from "@antelopejs/interface-api";
|
|
6
7
|
import { assertValidation } from "@antelopejs/interface-api-util";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
type DataControllerCallback,
|
|
10
11
|
RegisterDataController,
|
|
11
12
|
} from "@antelopejs/interface-data-api";
|
|
13
|
+
import type { Validation } from "@antelopejs/interface-data-api/components";
|
|
12
14
|
import {
|
|
13
15
|
Access,
|
|
14
16
|
AccessMode,
|
|
@@ -17,9 +19,17 @@ import {
|
|
|
17
19
|
Listable,
|
|
18
20
|
Mandatory,
|
|
19
21
|
ModelReference,
|
|
22
|
+
ModifierKey,
|
|
20
23
|
Sortable,
|
|
21
24
|
} from "@antelopejs/interface-data-api/metadata";
|
|
22
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
GetModel,
|
|
27
|
+
LocalizationModifier,
|
|
28
|
+
} from "@antelopejs/interface-database-decorators";
|
|
29
|
+
import {
|
|
30
|
+
LOCALIZED_TEXT_FALLBACK_LOCALE,
|
|
31
|
+
type LocalizedText,
|
|
32
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
23
33
|
import { authenticateTenantRequest } from "@antelopejs/interface-dms/guards";
|
|
24
34
|
import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
|
|
25
35
|
import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
|
|
@@ -48,28 +58,35 @@ import { footerForCategory } from "../engine";
|
|
|
48
58
|
import {
|
|
49
59
|
MailingTemplate,
|
|
50
60
|
SendModel,
|
|
61
|
+
type TemplateFields,
|
|
51
62
|
TemplateModel,
|
|
52
63
|
TemplateVersionModel,
|
|
53
64
|
} from "../db";
|
|
65
|
+
import {
|
|
66
|
+
CONTENT_LANGUAGE_HEADER,
|
|
67
|
+
localeOfRequest,
|
|
68
|
+
withTextIn,
|
|
69
|
+
} from "../services/localized-text";
|
|
54
70
|
import { findCategory, getSettings } from "../services/settings";
|
|
55
71
|
import { findStarter, starterFields } from "../services/starters";
|
|
56
72
|
import { refreshTemplateAttention } from "../services/template-attention";
|
|
57
73
|
import {
|
|
58
|
-
assertSlugClaimed,
|
|
59
74
|
blankContent,
|
|
60
75
|
blankFields,
|
|
61
76
|
contentFieldsOf,
|
|
62
77
|
displayName,
|
|
63
78
|
initializeTemplate,
|
|
64
|
-
|
|
79
|
+
refuseTakenSlug,
|
|
65
80
|
} from "../services/templates";
|
|
66
81
|
import type { TemplateCategory } from "../types";
|
|
67
82
|
|
|
68
|
-
import {
|
|
83
|
+
import {
|
|
84
|
+
createTemplateSchema,
|
|
85
|
+
templateDetailsSchema,
|
|
86
|
+
} from "../validation/templates.schema";
|
|
69
87
|
import { parseBody } from "./body";
|
|
70
88
|
import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
|
|
71
89
|
|
|
72
|
-
const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
|
|
73
90
|
const SLUG_IMMUTABLE = "$dms_mailing.errors.slug_immutable";
|
|
74
91
|
const SOURCE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
|
|
75
92
|
const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
|
|
@@ -116,7 +133,7 @@ async function seedFields(
|
|
|
116
133
|
tenantId: string,
|
|
117
134
|
seed: TemplateSeed,
|
|
118
135
|
fallbackLocale: string,
|
|
119
|
-
): Promise<
|
|
136
|
+
): Promise<TemplateFields> {
|
|
120
137
|
const source = await resolveSource(tenantId, seed.sourceTemplateId);
|
|
121
138
|
if (source) return contentFieldsOf(source);
|
|
122
139
|
if (!seed.starterId) {
|
|
@@ -144,23 +161,43 @@ function assertKnownCategory(
|
|
|
144
161
|
throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
/** The
|
|
148
|
-
|
|
149
|
-
|
|
164
|
+
/** The row `new` answers since interface-data-api 0.3: its key and fields. */
|
|
165
|
+
interface CreatedRow {
|
|
166
|
+
_id: string;
|
|
167
|
+
[field: string]: unknown;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The creation's seed, and the body to store: slug trimmed, and the name
|
|
172
|
+
* typed in the reader's language as every language of the localized field,
|
|
173
|
+
* the fallback one included until someone translates it. A body a guard
|
|
174
|
+
* already parsed arrives as an object.
|
|
175
|
+
*/
|
|
176
|
+
function parseCreation(body: Validation.WrittenBody, locale: string) {
|
|
177
|
+
const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
|
|
150
178
|
const seed = assertValidation(
|
|
151
179
|
raw,
|
|
152
180
|
(value) => createTemplateSchema.parse(value),
|
|
153
181
|
() => INVALID_TEMPLATE,
|
|
154
182
|
HTTP_BAD_REQUEST,
|
|
155
183
|
);
|
|
156
|
-
|
|
184
|
+
// Handed as an object: interface-data-api 0.3 refuses a JSON string.
|
|
185
|
+
const normalized = {
|
|
157
186
|
...raw,
|
|
158
187
|
slug: seed.slug,
|
|
159
|
-
name: seed.name,
|
|
160
|
-
}
|
|
188
|
+
name: withTextIn(undefined, locale, seed.name),
|
|
189
|
+
};
|
|
161
190
|
return { seed, normalized };
|
|
162
191
|
}
|
|
163
192
|
|
|
193
|
+
/** What the data API locks a localized field with: the controller's key. */
|
|
194
|
+
interface LocalizedController {
|
|
195
|
+
language: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The key that writes every language of a localized field at once. */
|
|
199
|
+
const ALL_LANGUAGES = "*";
|
|
200
|
+
|
|
164
201
|
function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
165
202
|
return {
|
|
166
203
|
...base,
|
|
@@ -168,30 +205,24 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
168
205
|
this: unknown,
|
|
169
206
|
ctx: RequestContext,
|
|
170
207
|
params: unknown,
|
|
171
|
-
body:
|
|
208
|
+
body: Validation.WrittenBody,
|
|
172
209
|
...rest: unknown[]
|
|
173
210
|
) {
|
|
174
|
-
const { seed, normalized } = parseCreation(body);
|
|
211
|
+
const { seed, normalized } = parseCreation(body, localeOfRequest(ctx));
|
|
175
212
|
const tenantId = getRequestTenantId(ctx);
|
|
176
|
-
const model = GetModel(TemplateModel, tenantId);
|
|
177
|
-
if (await model.getBySlug(seed.slug)) {
|
|
178
|
-
throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
|
|
179
|
-
}
|
|
180
213
|
const settings = await getSettings(tenantId);
|
|
181
214
|
assertKnownCategory(settings.categories, seed.category);
|
|
182
215
|
const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
normalized,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const templateId = ids[0] as string;
|
|
191
|
-
await assertSlugClaimed(tenantId, seed.slug, templateId);
|
|
216
|
+
// The module's creation dialogs send one typed name; the stored name
|
|
217
|
+
// holds every language, written at once.
|
|
218
|
+
(this as LocalizedController).language = ALL_LANGUAGES;
|
|
219
|
+
const created = (await refuseTakenSlug(
|
|
220
|
+
base.func.call(this, ctx, params, normalized, ...rest),
|
|
221
|
+
)) as CreatedRow;
|
|
222
|
+
const templateId = created._id;
|
|
192
223
|
const user = await authenticateTenantRequest(ctx);
|
|
193
224
|
await initializeTemplate(tenantId, templateId, displayName(user), fields);
|
|
194
|
-
return
|
|
225
|
+
return created;
|
|
195
226
|
},
|
|
196
227
|
};
|
|
197
228
|
}
|
|
@@ -249,7 +280,6 @@ export function afterDelete(tenantId: string, ids: string[]): AfterWrite {
|
|
|
249
280
|
await Promise.all(
|
|
250
281
|
ids.map((id) => versions.getAll(id, "templateId").delete().run()),
|
|
251
282
|
);
|
|
252
|
-
await releaseTemplateSlugs(tenantId, ids);
|
|
253
283
|
await refreshTemplateAttention(tenantId);
|
|
254
284
|
};
|
|
255
285
|
}
|
|
@@ -266,21 +296,78 @@ export async function assertEditable(
|
|
|
266
296
|
): Promise<void> {
|
|
267
297
|
if (body.slug !== undefined && body.slug !== current.slug)
|
|
268
298
|
throw new HTTPResult(HTTP_CONFLICT, SLUG_IMMUTABLE);
|
|
269
|
-
if (
|
|
299
|
+
if (body.name !== undefined && isBlankName(body.name))
|
|
270
300
|
throw new HTTPResult(HTTP_BAD_REQUEST, BLANK_NAME);
|
|
271
301
|
const { categories } = await getSettings(tenantId);
|
|
272
302
|
assertKnownCategory(categories, body.category);
|
|
273
303
|
}
|
|
274
304
|
|
|
275
|
-
/**
|
|
305
|
+
/**
|
|
306
|
+
* Saves what the module's drawer and editor change: the name, typed in the
|
|
307
|
+
* reader's language (its other languages kept), and the category.
|
|
308
|
+
*/
|
|
309
|
+
export async function saveDetails(
|
|
310
|
+
ctx: RequestContext,
|
|
311
|
+
template: MailingTemplate,
|
|
312
|
+
body: unknown,
|
|
313
|
+
author: string,
|
|
314
|
+
): Promise<void> {
|
|
315
|
+
const details = assertValidation(
|
|
316
|
+
body,
|
|
317
|
+
(value) => templateDetailsSchema.parse(value),
|
|
318
|
+
() => INVALID_TEMPLATE,
|
|
319
|
+
);
|
|
320
|
+
const tenantId = getRequestTenantId(ctx);
|
|
321
|
+
await assertEditable(tenantId, details, template);
|
|
322
|
+
const names = TemplateModel.namesOf(template);
|
|
323
|
+
await GetModel(TemplateModel, tenantId).updateFields(template._id, {
|
|
324
|
+
name: withTextIn(names, localeOfRequest(ctx), details.name),
|
|
325
|
+
...(details.category !== undefined && {
|
|
326
|
+
category: details.category ?? "",
|
|
327
|
+
}),
|
|
328
|
+
updatedBy: author,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Whether an edited name holds no text: the text of the reader's language,
|
|
334
|
+
* or, from a form editing every language, all of them.
|
|
335
|
+
*/
|
|
336
|
+
function isBlankName(name: unknown): boolean {
|
|
337
|
+
const texts = typeof name === "object" && name ? Object.values(name) : [name];
|
|
338
|
+
return !texts.some((text) => typeof text === "string" && text.trim());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The names trimmed, blank ones left out, the fallback language filled. */
|
|
342
|
+
function tidyNames(names: LocalizedText, locale: string): LocalizedText {
|
|
343
|
+
const tidy = Object.fromEntries(
|
|
344
|
+
Object.entries(names)
|
|
345
|
+
.map(([code, text]) => [code, text.trim()] as const)
|
|
346
|
+
.filter(([, text]) => text),
|
|
347
|
+
);
|
|
348
|
+
if (tidy[LOCALIZED_TEXT_FALLBACK_LOCALE]) return tidy;
|
|
349
|
+
const [first = ""] = Object.values(tidy);
|
|
350
|
+
return { ...tidy, [LOCALIZED_TEXT_FALLBACK_LOCALE]: tidy[locale] ?? first };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Once an edit of the name is written, stores it trimmed, and gives the
|
|
355
|
+
* fallback language a name when it has none, so every reader sees one.
|
|
356
|
+
*/
|
|
276
357
|
export function afterEdit(
|
|
277
358
|
model: TemplateModel,
|
|
278
359
|
id: string,
|
|
279
360
|
name: unknown,
|
|
361
|
+
locale: string,
|
|
280
362
|
): AfterWrite | undefined {
|
|
281
|
-
if (
|
|
363
|
+
if (name === undefined) return undefined;
|
|
282
364
|
return async () => {
|
|
283
|
-
await model.
|
|
365
|
+
const template = await model.get(id);
|
|
366
|
+
if (!template) return;
|
|
367
|
+
const names = TemplateModel.namesOf(template);
|
|
368
|
+
const tidy = tidyNames(names, locale);
|
|
369
|
+
if (JSON.stringify(tidy) !== JSON.stringify(names))
|
|
370
|
+
await model.updateFields(id, { name: tidy });
|
|
284
371
|
};
|
|
285
372
|
}
|
|
286
373
|
|
|
@@ -306,6 +393,11 @@ export class TemplatesTableAPI extends DataController(
|
|
|
306
393
|
@TenantScopedModel(SendModel)
|
|
307
394
|
declare sends: SendModel;
|
|
308
395
|
|
|
396
|
+
/** The language names are read, searched, sorted and written in. */
|
|
397
|
+
@Parameter(CONTENT_LANGUAGE_HEADER, "header")
|
|
398
|
+
@ModifierKey(LocalizationModifier)
|
|
399
|
+
declare language: string;
|
|
400
|
+
|
|
309
401
|
@Select()
|
|
310
402
|
@Listable()
|
|
311
403
|
@Exported()
|
package/src/db/models/index.ts
CHANGED
|
@@ -2,7 +2,6 @@ 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";
|
|
6
5
|
export * from "./template-versions.model";
|
|
7
6
|
export * from "./templates.model";
|
|
8
7
|
export * from "./webhook-status.model";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IsDuplicateKeyError } from "@antelopejs/interface-database";
|
|
1
2
|
import { BasicDataModel } from "@antelopejs/interface-database-decorators";
|
|
2
3
|
import { MAILING_SEND_EVENTS_TABLE_NAME, MailingSendEvent } from "../tables";
|
|
3
4
|
import { SendModel } from "./sends.model";
|
|
@@ -28,11 +29,10 @@ export class SendEventModel extends BasicDataModel(
|
|
|
28
29
|
* the same import already did.
|
|
29
30
|
*/
|
|
30
31
|
async insertImported(event: MailingSendEvent): Promise<void> {
|
|
31
|
-
if (await this.get(event._id)) return;
|
|
32
32
|
try {
|
|
33
33
|
await this.insert(event);
|
|
34
34
|
} catch (error) {
|
|
35
|
-
if (!(
|
|
35
|
+
if (!IsDuplicateKeyError(error)) throw error;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { BasicDataModel } from "@antelopejs/interface-database-decorators";
|
|
3
|
-
import
|
|
4
|
-
AtomicMutationOutcome,
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
type AtomicMutationOutcome,
|
|
5
|
+
IsDuplicateKeyError,
|
|
6
|
+
type ValueProxy,
|
|
6
7
|
} from "@antelopejs/interface-database";
|
|
7
8
|
import { isResolvedStage, stageOf } from "../../types";
|
|
8
9
|
import { MAILING_SENDS_TABLE_NAME, MailingSend } from "../tables";
|
|
@@ -89,17 +90,15 @@ export class SendModel extends BasicDataModel(
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
|
-
* Writes an imported send under its own id; false when
|
|
93
|
-
*
|
|
94
|
-
* slug claims, since the database exposes no typed conflict error.
|
|
93
|
+
* Writes an imported send under its own id; false when an earlier run of
|
|
94
|
+
* the same import already wrote it (its id is derived from the import's).
|
|
95
95
|
*/
|
|
96
96
|
async insertImported(send: ImportedSendRow): Promise<boolean> {
|
|
97
|
-
if (await this.get(send._id)) return false;
|
|
98
97
|
try {
|
|
99
98
|
await this.table.insert({ ...send, revision: randomUUID() }).run();
|
|
100
99
|
return true;
|
|
101
100
|
} catch (error) {
|
|
102
|
-
if (
|
|
101
|
+
if (IsDuplicateKeyError(error)) return false;
|
|
103
102
|
throw error;
|
|
104
103
|
}
|
|
105
104
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BasicDataModel,
|
|
4
|
+
triggerEvent,
|
|
5
|
+
} from "@antelopejs/interface-database-decorators";
|
|
6
|
+
import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
|
|
3
7
|
import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
|
|
4
|
-
import { TemplateSlugModel } from "./template-slugs.model";
|
|
5
8
|
|
|
6
9
|
/** The attention fields of a template, as the refresh writes them. */
|
|
7
10
|
export type TemplateAttentionFields = Pick<
|
|
@@ -9,22 +12,76 @@ export type TemplateAttentionFields = Pick<
|
|
|
9
12
|
"attention" | "needsAttention"
|
|
10
13
|
>;
|
|
11
14
|
|
|
15
|
+
const isLocalizedText = (value: unknown): value is LocalizedText =>
|
|
16
|
+
typeof value === "object" &&
|
|
17
|
+
value !== null &&
|
|
18
|
+
Object.values(value).every((text) => typeof text === "string");
|
|
19
|
+
|
|
20
|
+
/** Where a row keeps its modifier-held fields, the localized name among them. */
|
|
21
|
+
const MODIFIER_CONTAINER = "_internal";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Template fields as code writes them: the name holds every language
|
|
25
|
+
* (`{ en: "Welcome", fr: "Bienvenue" }`).
|
|
26
|
+
*/
|
|
27
|
+
export type TemplateFields = Omit<Partial<MailingTemplate>, "name"> & {
|
|
28
|
+
name?: LocalizedText;
|
|
29
|
+
};
|
|
30
|
+
|
|
12
31
|
export class TemplateModel extends BasicDataModel(
|
|
13
32
|
MailingTemplate,
|
|
14
33
|
MAILING_TEMPLATES_TABLE_NAME,
|
|
15
34
|
) {
|
|
35
|
+
/** The row of `fields`, its name written in every language it holds. */
|
|
36
|
+
static fromFields(fields: TemplateFields): MailingTemplate {
|
|
37
|
+
return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The names of `template`, one per language. */
|
|
41
|
+
static namesOf(template: MailingTemplate): LocalizedText {
|
|
42
|
+
// Unlocked with "*", the localized field holds the whole per-language record.
|
|
43
|
+
const names: unknown = template.localize("*", ["name"]).name;
|
|
44
|
+
return isLocalizedText(names) ? names : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The name of `template` in `locale`, else in the fallback language. */
|
|
48
|
+
static nameIn(template: MailingTemplate, locale: string): string {
|
|
49
|
+
return template.localize(locale, ["name"]).name ?? "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Inserts a template whose name holds every language. */
|
|
53
|
+
insertFields(fields: TemplateFields) {
|
|
54
|
+
return this.insert(TemplateModel.fromFields(fields));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Updates a template, a name given holding every language. Every write of
|
|
59
|
+
* a template goes through here rather than `update`: an update event leaves
|
|
60
|
+
* the row's modifier container (`_internal`, where the localized name
|
|
61
|
+
* lives) empty when the patch has no name, and the database merges a row
|
|
62
|
+
* shallowly, so that empty container would wipe the stored name.
|
|
63
|
+
*/
|
|
64
|
+
updateFields(id: string, fields: TemplateFields): Promise<number> {
|
|
65
|
+
const row = TemplateModel.fromFields(fields);
|
|
66
|
+
triggerEvent(row, "update");
|
|
67
|
+
const stored = TemplateModel.toDatabase(row);
|
|
68
|
+
if (fields.name === undefined) delete stored[MODIFIER_CONTAINER];
|
|
69
|
+
return this.table.get(id).update(stored).run();
|
|
70
|
+
}
|
|
71
|
+
|
|
16
72
|
/** Publishes only over the exact semantic revision that was inspected. */
|
|
17
73
|
async publishIfCurrent(
|
|
18
74
|
template: MailingTemplate,
|
|
19
|
-
patch:
|
|
75
|
+
patch: TemplateFields,
|
|
20
76
|
): Promise<boolean> {
|
|
77
|
+
const stored = TemplateModel.toDatabase(TemplateModel.fromFields(patch));
|
|
21
78
|
const outcome = await this.table
|
|
22
79
|
.atomicMutation(template._id, {
|
|
23
80
|
type: "update",
|
|
24
81
|
revisionField: "revision",
|
|
25
82
|
expectedRevision: template.revision ?? { kind: "missing" },
|
|
26
83
|
nextRevision: randomUUID(),
|
|
27
|
-
patch: { ...
|
|
84
|
+
patch: { ...stored, updatedAt: new Date() },
|
|
28
85
|
})
|
|
29
86
|
.run();
|
|
30
87
|
if (outcome === "unknown")
|
|
@@ -32,19 +89,10 @@ export class TemplateModel extends BasicDataModel(
|
|
|
32
89
|
return outcome === "applied";
|
|
33
90
|
}
|
|
34
91
|
|
|
92
|
+
/** The template holding `slug`: the unique index keeps it to one. */
|
|
35
93
|
async getBySlug(slug: string): Promise<MailingTemplate | undefined> {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
// Senders must see the claimed template, not an earlier draft candidate.
|
|
39
|
-
if (rows.length > 1) {
|
|
40
|
-
const claims = await new TemplateSlugModel(this.database).getBy(
|
|
41
|
-
"templateId",
|
|
42
|
-
...rows.map((row) => row._id),
|
|
43
|
-
);
|
|
44
|
-
const claim = claims.find((candidate) => candidate.slug === slug);
|
|
45
|
-
if (claim) return rows.find((row) => row._id === claim.templateId);
|
|
46
|
-
}
|
|
47
|
-
return rows[0];
|
|
94
|
+
const [template] = await this.getBy("slug", slug);
|
|
95
|
+
return template;
|
|
48
96
|
}
|
|
49
97
|
|
|
50
98
|
/**
|
package/src/db/tables/index.ts
CHANGED
|
@@ -2,7 +2,6 @@ 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";
|
|
6
5
|
export * from "./template-versions.table";
|
|
7
6
|
export * from "./templates.table";
|
|
8
7
|
export * from "./webhook-status.table";
|
|
@@ -46,12 +46,4 @@ export class MailingSettings extends Table {
|
|
|
46
46
|
|
|
47
47
|
@Field("string")
|
|
48
48
|
declare webhookSecret: string;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* JSON-serialized fingerprint of each category `DeclareTemplateCategory`
|
|
52
|
-
* applied, by its id: what tells a category untouched since from one a
|
|
53
|
-
* user relabelled or removed.
|
|
54
|
-
*/
|
|
55
|
-
@Field("string")
|
|
56
|
-
declare json_declared_categories?: string;
|
|
57
49
|
}
|
|
@@ -4,18 +4,21 @@ import {
|
|
|
4
4
|
CreationTime,
|
|
5
5
|
Field,
|
|
6
6
|
Index,
|
|
7
|
+
LocalizationModifier,
|
|
8
|
+
Localized,
|
|
7
9
|
Modifier,
|
|
8
10
|
RegisterTable,
|
|
9
11
|
Table,
|
|
10
12
|
UpdateTime,
|
|
11
13
|
} from "@antelopejs/interface-database-decorators";
|
|
12
14
|
import { TENANT_SCHEMA_NAME } from "@antelopejs/interface-dms/constants";
|
|
15
|
+
import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
|
|
13
16
|
import type { TemplateStatus } from "../../types";
|
|
14
17
|
|
|
15
18
|
export const MAILING_TEMPLATES_TABLE_NAME = "mailing_templates";
|
|
16
19
|
|
|
17
20
|
@RegisterTable(MAILING_TEMPLATES_TABLE_NAME, TENANT_SCHEMA_NAME)
|
|
18
|
-
export class MailingTemplate extends Table {
|
|
21
|
+
export class MailingTemplate extends Table.with(LocalizationModifier) {
|
|
19
22
|
@Field("string")
|
|
20
23
|
declare _id: string;
|
|
21
24
|
|
|
@@ -28,10 +31,17 @@ export class MailingTemplate extends Table {
|
|
|
28
31
|
@Field("date")
|
|
29
32
|
declare updatedAt: Date;
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
/** What sends name the template by: one template per slug in a tenant. */
|
|
35
|
+
@Index({ unique: true })
|
|
32
36
|
@Field("string")
|
|
33
37
|
declare slug: string;
|
|
34
38
|
|
|
39
|
+
/**
|
|
40
|
+
* One name per language (`{ en: "Welcome", fr: "Bienvenue" }`): read it
|
|
41
|
+
* after `template.localize(locale, ["name"])`, write every language at
|
|
42
|
+
* once through `localize("*")`.
|
|
43
|
+
*/
|
|
44
|
+
@Localized({ fallbackLocale: LOCALIZED_TEXT_FALLBACK_LOCALE })
|
|
35
45
|
@Field("string")
|
|
36
46
|
declare name: string;
|
|
37
47
|
|
|
@@ -132,25 +142,10 @@ export class MailingTemplate extends Table {
|
|
|
132
142
|
@Field("date")
|
|
133
143
|
declare unpublishedAt?: Date;
|
|
134
144
|
|
|
135
|
-
/**
|
|
136
|
-
* Fingerprint of the last declaration `DeclareTemplate` applied; blank for
|
|
137
|
-
* a template made in the dashboard. A template still matching it is
|
|
138
|
-
* untouched since, and the next declaration may refresh it.
|
|
139
|
-
*/
|
|
140
|
-
@Field("string")
|
|
141
|
-
declare declaredHash?: string;
|
|
142
|
-
|
|
143
145
|
/** Every semantic edit invalidates a publication's optimistic snapshot. */
|
|
144
146
|
@Field("string")
|
|
145
147
|
declare revision?: string;
|
|
146
148
|
|
|
147
|
-
/** An unmodified declaration seed can be completed after a crashed creator. */
|
|
148
|
-
@Field("string")
|
|
149
|
-
declare declarationSeedRevision?: string;
|
|
150
|
-
|
|
151
|
-
@Field("string")
|
|
152
|
-
declare pendingDeclaredHash?: string;
|
|
153
|
-
|
|
154
149
|
/**
|
|
155
150
|
* Complete latest version row, committed atomically with its content. Its
|
|
156
151
|
* history row can be recovered after an interrupted publication.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { VariableDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An amount in the minor unit of its currency (cents for `EUR`), like the
|
|
5
|
+
* DMS's composed-text `money` parameter: `{ value: 987420, currency: "EUR" }`
|
|
6
|
+
* reads "€9,874.20" in English, "9 874,20 €" in French.
|
|
7
|
+
*/
|
|
8
|
+
export interface MoneyValue {
|
|
9
|
+
value: number;
|
|
10
|
+
/** ISO 4217 code. An unknown code writes the amount as a plain number. */
|
|
11
|
+
currency: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** How a send writes its typed values: its locale, and the paths typed `date`. */
|
|
15
|
+
export interface ValueFormat {
|
|
16
|
+
locale: string;
|
|
17
|
+
datePaths: Set<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const FALLBACK_LOCALE = "en";
|
|
21
|
+
const DATE_STYLE: Intl.DateTimeFormatOptions = { dateStyle: "long" };
|
|
22
|
+
const UTC_DATE_STYLE: Intl.DateTimeFormatOptions = {
|
|
23
|
+
...DATE_STYLE,
|
|
24
|
+
timeZone: "UTC",
|
|
25
|
+
};
|
|
26
|
+
/** A calendar day: written as that day, whatever the server's time zone. */
|
|
27
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
|
+
/** A moment: written as its day in the server's time zone. */
|
|
29
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
30
|
+
const DECIMAL_BASE = 10;
|
|
31
|
+
const DATE_TYPE = "date";
|
|
32
|
+
|
|
33
|
+
/** `locale` when the runtime can format for it, English otherwise. */
|
|
34
|
+
export function formattingLocale(locale: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return Intl.NumberFormat.supportedLocalesOf(locale)[0] ?? FALLBACK_LOCALE;
|
|
37
|
+
} catch {
|
|
38
|
+
return FALLBACK_LOCALE;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The paths a template declares `date`: their ISO strings are written as dates. */
|
|
43
|
+
export function valueFormatOf(
|
|
44
|
+
locale: string,
|
|
45
|
+
variables: VariableDefinition[] = [],
|
|
46
|
+
): ValueFormat {
|
|
47
|
+
return {
|
|
48
|
+
locale: formattingLocale(locale),
|
|
49
|
+
datePaths: new Set(
|
|
50
|
+
variables
|
|
51
|
+
.filter((variable) => variable.type === DATE_TYPE)
|
|
52
|
+
.map((variable) => variable.path),
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isMoneyValue(value: unknown): value is MoneyValue {
|
|
58
|
+
if (!value || typeof value !== "object") return false;
|
|
59
|
+
const candidate = value as Partial<MoneyValue>;
|
|
60
|
+
return (
|
|
61
|
+
typeof candidate.value === "number" &&
|
|
62
|
+
Number.isFinite(candidate.value) &&
|
|
63
|
+
typeof candidate.currency === "string"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatMoney(money: MoneyValue, locale: string): string {
|
|
68
|
+
try {
|
|
69
|
+
const currency = new Intl.NumberFormat(locale, {
|
|
70
|
+
style: "currency",
|
|
71
|
+
currency: money.currency,
|
|
72
|
+
});
|
|
73
|
+
const digits = currency.resolvedOptions().maximumFractionDigits ?? 0;
|
|
74
|
+
return currency.format(money.value / DECIMAL_BASE ** digits);
|
|
75
|
+
} catch {
|
|
76
|
+
return new Intl.NumberFormat(locale).format(money.value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatDate(
|
|
81
|
+
date: Date,
|
|
82
|
+
options: Intl.DateTimeFormatOptions,
|
|
83
|
+
locale: string,
|
|
84
|
+
): string {
|
|
85
|
+
return new Intl.DateTimeFormat(locale, options).format(date);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isoDateOptions(value: string): Intl.DateTimeFormatOptions | null {
|
|
89
|
+
if (ISO_DAY.test(value)) return UTC_DATE_STYLE;
|
|
90
|
+
if (ISO_DATE_TIME.test(value)) return DATE_STYLE;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An ISO string at a `date` path as the date it names; `null` otherwise. */
|
|
95
|
+
function formatIsoDate(value: string, locale: string): string | null {
|
|
96
|
+
const options = isoDateOptions(value);
|
|
97
|
+
if (!options) return null;
|
|
98
|
+
const date = new Date(value);
|
|
99
|
+
return Number.isNaN(date.getTime())
|
|
100
|
+
? null
|
|
101
|
+
: formatDate(date, options, locale);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A value as a recipient reads it. A money value and a `Date` format
|
|
106
|
+
* themselves in the send's locale wherever they appear; an ISO string is
|
|
107
|
+
* written as a date only at a path the template declares `date`; anything
|
|
108
|
+
* else, a string already formatted by the caller among them, as given.
|
|
109
|
+
*/
|
|
110
|
+
export function formatValue(
|
|
111
|
+
value: unknown,
|
|
112
|
+
path = "",
|
|
113
|
+
format?: ValueFormat,
|
|
114
|
+
): string {
|
|
115
|
+
const locale = format?.locale ?? FALLBACK_LOCALE;
|
|
116
|
+
if (isMoneyValue(value)) return formatMoney(value, locale);
|
|
117
|
+
if (value instanceof Date)
|
|
118
|
+
return Number.isNaN(value.getTime())
|
|
119
|
+
? String(value)
|
|
120
|
+
: formatDate(value, DATE_STYLE, locale);
|
|
121
|
+
if (typeof value === "string" && format?.datePaths.has(path))
|
|
122
|
+
return formatIsoDate(value, locale) ?? value;
|
|
123
|
+
return String(value);
|
|
124
|
+
}
|
package/src/engine/index.ts
CHANGED