@blamejs/blamejs-shop 0.0.65 → 0.0.70
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/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.emailTemplates
|
|
4
|
+
* @title Email templates — operator-editable transactional bodies
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Where `email` composes a fixed catalog of transactional bodies
|
|
8
|
+
* the framework ships (order receipt, ship notification, refund,
|
|
9
|
+
* password reset), this primitive lets operators override those
|
|
10
|
+
* bodies without forking — per-slug subject + HTML + text +
|
|
11
|
+
* variable schema + locale variants + a published-version
|
|
12
|
+
* history. The renderer takes a per-call `variables` object and
|
|
13
|
+
* returns `{ subject, body_html, body_text }` ready for the
|
|
14
|
+
* mailer.
|
|
15
|
+
*
|
|
16
|
+
* Composition:
|
|
17
|
+
*
|
|
18
|
+
* var templates = bShop.emailTemplates.create({ query: q });
|
|
19
|
+
*
|
|
20
|
+
* await templates.defineTemplate({
|
|
21
|
+
* slug: "order-confirmation-default",
|
|
22
|
+
* title: "Default order confirmation",
|
|
23
|
+
* kind: "order_confirmation",
|
|
24
|
+
* subject: "Order {{order_id}} confirmed",
|
|
25
|
+
* body_html: "<h1>Thanks {{customer_name}}</h1>...",
|
|
26
|
+
* body_text: "Thanks {{customer_name}}...",
|
|
27
|
+
* variables_schema: {
|
|
28
|
+
* required: ["customer_name", "order_id"],
|
|
29
|
+
* optional: ["grand_total_formatted"],
|
|
30
|
+
* },
|
|
31
|
+
* locale: "en",
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* // A fresh defineTemplate creates a v1 row that is NOT yet
|
|
35
|
+
* // published — operators preview the render, then publish:
|
|
36
|
+
* await templates.publishVersion("order-confirmation-default", { locale: "en" });
|
|
37
|
+
*
|
|
38
|
+
* var rendered = await templates.renderTemplate({
|
|
39
|
+
* slug: "order-confirmation-default",
|
|
40
|
+
* locale: "en",
|
|
41
|
+
* variables: {
|
|
42
|
+
* customer_name: "Alex <tag>",
|
|
43
|
+
* order_id: "ord_123",
|
|
44
|
+
* grand_total_formatted: "$42.00",
|
|
45
|
+
* },
|
|
46
|
+
* });
|
|
47
|
+
* // rendered.body_html contains HTML-escaped customer_name
|
|
48
|
+
*
|
|
49
|
+
* Variable substitution discipline:
|
|
50
|
+
*
|
|
51
|
+
* - `{{var_name}}` — the value is rendered through
|
|
52
|
+
* `b.template.escapeHtml`. This is the safe default; every
|
|
53
|
+
* customer-bound field (name, address, free-text) uses it.
|
|
54
|
+
*
|
|
55
|
+
* - `{{var_name|raw}}` — opt-out of escaping. The operator
|
|
56
|
+
* authored the template and vouches for this slot being a
|
|
57
|
+
* pre-rendered HTML fragment. The framework refuses to inject
|
|
58
|
+
* a value into a `|raw` slot if the variable schema did not
|
|
59
|
+
* declare the name in its `raw` whitelist.
|
|
60
|
+
*
|
|
61
|
+
* Locale fallback:
|
|
62
|
+
*
|
|
63
|
+
* `getTemplate({ slug, locale })` and `renderTemplate(...)` look
|
|
64
|
+
* up the locale variant first; if there is no published version
|
|
65
|
+
* for that exact locale, they fall back to `en`. Missing both
|
|
66
|
+
* surfaces the operator-actionable error
|
|
67
|
+
* `EMAIL_TEMPLATE_NOT_FOUND`.
|
|
68
|
+
*
|
|
69
|
+
* Versions:
|
|
70
|
+
*
|
|
71
|
+
* Every `defineTemplate({ slug, locale, ... })` appends a new
|
|
72
|
+
* version row (`version_number` = max + 1) — operators iterate
|
|
73
|
+
* the body without losing the prior history. `publishVersion`
|
|
74
|
+
* flips the active version atomically so the renderer always
|
|
75
|
+
* reads exactly one published row per (slug, locale).
|
|
76
|
+
*
|
|
77
|
+
* Storage:
|
|
78
|
+
* - `email_templates` + `email_template_versions` (migration
|
|
79
|
+
* `0125_email_templates.sql`).
|
|
80
|
+
*
|
|
81
|
+
* @primitive emailTemplates
|
|
82
|
+
* @related shop.email, b.template.escapeHtml, b.uuid.v7
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
// ---- constants ----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
|
|
88
|
+
var VAR_NAME_RE = /^[a-z_][a-z0-9_]*$/;
|
|
89
|
+
var LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
90
|
+
var DEFAULT_LOCALE = "en";
|
|
91
|
+
|
|
92
|
+
var KINDS = [
|
|
93
|
+
"order_confirmation",
|
|
94
|
+
"order_shipped",
|
|
95
|
+
"order_delivered",
|
|
96
|
+
"order_refunded",
|
|
97
|
+
"password_reset",
|
|
98
|
+
"account_verification",
|
|
99
|
+
"abandoned_cart",
|
|
100
|
+
"wishlist_discount",
|
|
101
|
+
"review_request",
|
|
102
|
+
"welcome",
|
|
103
|
+
"generic",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
var MAX_TITLE_LEN = 200;
|
|
107
|
+
var MAX_SUBJECT_LEN = 200;
|
|
108
|
+
var MAX_BODY_LEN = 256 * 1024; // 256 KiB — wide enough for HTML
|
|
109
|
+
var MAX_LIST_LIMIT = 500;
|
|
110
|
+
var MAX_VARIABLE_KEYS = 100; // schema sanity bound
|
|
111
|
+
|
|
112
|
+
// Placeholder shape — `{{var_name}}` or `{{var_name|raw}}`. The
|
|
113
|
+
// regex is anchored to the curly-brace pair so an operator's HTML
|
|
114
|
+
// containing literal `{ }` won't accidentally match.
|
|
115
|
+
var PLACEHOLDER_RE = /\{\{\s*([a-z_][a-z0-9_]*)(?:\s*\|\s*(raw))?\s*\}\}/gi;
|
|
116
|
+
|
|
117
|
+
var bShop;
|
|
118
|
+
function _b() {
|
|
119
|
+
if (!bShop) bShop = require("./index");
|
|
120
|
+
return bShop.framework;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Monotonic clock seam — every primitive path that needs a
|
|
124
|
+
// `now` reads through this helper so tests can inject a fake
|
|
125
|
+
// clock via the per-call `now` opt without monkey-patching
|
|
126
|
+
// `Date.now`. The default falls through to `Date.now()`.
|
|
127
|
+
function _now() { return Date.now(); }
|
|
128
|
+
|
|
129
|
+
// ---- validators ---------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function _validateSlug(s, label) {
|
|
132
|
+
if (typeof s !== "string" || !s.length) {
|
|
133
|
+
throw new TypeError("emailTemplates: " + label + " must be a non-empty string");
|
|
134
|
+
}
|
|
135
|
+
if (!SLUG_RE.test(s)) {
|
|
136
|
+
throw new TypeError(
|
|
137
|
+
"emailTemplates: " + label + " must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _validateTitle(s) {
|
|
144
|
+
if (typeof s !== "string" || !s.length) {
|
|
145
|
+
throw new TypeError("emailTemplates: title must be a non-empty string");
|
|
146
|
+
}
|
|
147
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
148
|
+
throw new TypeError("emailTemplates: title must be <= " + MAX_TITLE_LEN + " characters");
|
|
149
|
+
}
|
|
150
|
+
if (/[\r\n\0]/.test(s)) {
|
|
151
|
+
throw new TypeError("emailTemplates: title must not contain CR / LF / NUL");
|
|
152
|
+
}
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _validateKind(s) {
|
|
157
|
+
if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
|
|
158
|
+
throw new TypeError(
|
|
159
|
+
"emailTemplates: kind must be one of " + KINDS.join(", ")
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _validateSubject(s) {
|
|
166
|
+
if (typeof s !== "string" || !s.length) {
|
|
167
|
+
throw new TypeError("emailTemplates: subject must be a non-empty string");
|
|
168
|
+
}
|
|
169
|
+
if (s.length > MAX_SUBJECT_LEN) {
|
|
170
|
+
throw new TypeError("emailTemplates: subject must be <= " + MAX_SUBJECT_LEN + " characters");
|
|
171
|
+
}
|
|
172
|
+
if (/[\r\n\0]/.test(s)) {
|
|
173
|
+
throw new TypeError("emailTemplates: subject must not contain CR / LF / NUL");
|
|
174
|
+
}
|
|
175
|
+
return s;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _validateBody(s, label) {
|
|
179
|
+
if (typeof s !== "string" || !s.length) {
|
|
180
|
+
throw new TypeError("emailTemplates: " + label + " must be a non-empty string");
|
|
181
|
+
}
|
|
182
|
+
if (s.length > MAX_BODY_LEN) {
|
|
183
|
+
throw new TypeError("emailTemplates: " + label + " must be <= " + MAX_BODY_LEN + " bytes");
|
|
184
|
+
}
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _validateLocale(s, label) {
|
|
189
|
+
if (typeof s !== "string" || !s.length) {
|
|
190
|
+
throw new TypeError("emailTemplates: " + (label || "locale") + " must be a non-empty string");
|
|
191
|
+
}
|
|
192
|
+
if (!LOCALE_RE.test(s)) {
|
|
193
|
+
throw new TypeError(
|
|
194
|
+
"emailTemplates: " + (label || "locale") + " must be BCP-47-ish (e.g. 'en' / 'en-US' / 'pt-BR')"
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
// Normalize the language subtag to lowercase so 'EN' / 'en' index
|
|
198
|
+
// the same column — keeps the locale-fallback predicate honest.
|
|
199
|
+
var parts = s.split("-");
|
|
200
|
+
parts[0] = parts[0].toLowerCase();
|
|
201
|
+
return parts.join("-");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Variables schema — operator declares the keys the template
|
|
205
|
+
// references. The shape is intentionally minimal:
|
|
206
|
+
//
|
|
207
|
+
// { required?: [string], optional?: [string], raw?: [string] }
|
|
208
|
+
//
|
|
209
|
+
// `required` keys are mandatory at render time. `optional` keys
|
|
210
|
+
// pass through when present. `raw` keys MAY appear in the
|
|
211
|
+
// template via `{{name|raw}}` and skip HTML-escape; any key not
|
|
212
|
+
// listed in `raw` that the template uses with `|raw` is refused
|
|
213
|
+
// at definition time (operator-vouched, not caller-vouched).
|
|
214
|
+
function _validateVariablesSchema(schema) {
|
|
215
|
+
if (schema == null) return { required: [], optional: [], raw: [] };
|
|
216
|
+
if (typeof schema !== "object" || Array.isArray(schema)) {
|
|
217
|
+
throw new TypeError("emailTemplates: variables_schema must be an object");
|
|
218
|
+
}
|
|
219
|
+
var out = { required: [], optional: [], raw: [] };
|
|
220
|
+
var totalKeys = 0;
|
|
221
|
+
var seen = {};
|
|
222
|
+
var fields = ["required", "optional", "raw"];
|
|
223
|
+
for (var fi = 0; fi < fields.length; fi += 1) {
|
|
224
|
+
var f = fields[fi];
|
|
225
|
+
if (schema[f] == null) continue;
|
|
226
|
+
if (!Array.isArray(schema[f])) {
|
|
227
|
+
throw new TypeError(
|
|
228
|
+
"emailTemplates: variables_schema." + f + " must be an array"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
for (var i = 0; i < schema[f].length; i += 1) {
|
|
232
|
+
var k = schema[f][i];
|
|
233
|
+
if (typeof k !== "string" || !VAR_NAME_RE.test(k)) {
|
|
234
|
+
throw new TypeError(
|
|
235
|
+
"emailTemplates: variables_schema." + f +
|
|
236
|
+
" entries must match /[a-z_][a-z0-9_]*/ — got " + JSON.stringify(k)
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (seen[k] && f !== "raw") {
|
|
240
|
+
// `raw` may overlap with required/optional — the raw set is a
|
|
241
|
+
// capability flag, not a separate variable name space. The
|
|
242
|
+
// required + optional sets are mutually exclusive though.
|
|
243
|
+
throw new TypeError(
|
|
244
|
+
"emailTemplates: variables_schema key '" + k +
|
|
245
|
+
"' appears in multiple of required/optional"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (f !== "raw") seen[k] = true;
|
|
249
|
+
out[f].push(k);
|
|
250
|
+
totalKeys += 1;
|
|
251
|
+
if (totalKeys > MAX_VARIABLE_KEYS) {
|
|
252
|
+
throw new TypeError(
|
|
253
|
+
"emailTemplates: variables_schema must declare <= " + MAX_VARIABLE_KEYS + " keys"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Walk the template body + subject + collect every `{{name}}` /
|
|
262
|
+
// `{{name|raw}}` reference. Refuse a template whose `|raw` slots
|
|
263
|
+
// reference a variable the schema didn't list in `raw` — the
|
|
264
|
+
// operator vouches for raw at definition time, never at render
|
|
265
|
+
// time.
|
|
266
|
+
function _scanPlaceholders(text) {
|
|
267
|
+
var refs = [];
|
|
268
|
+
var m;
|
|
269
|
+
PLACEHOLDER_RE.lastIndex = 0;
|
|
270
|
+
while ((m = PLACEHOLDER_RE.exec(text)) !== null) {
|
|
271
|
+
refs.push({ name: m[1], raw: m[2] === "raw" });
|
|
272
|
+
}
|
|
273
|
+
return refs;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function _validateTemplateBodyVsSchema(parts, schema) {
|
|
277
|
+
var refs = [];
|
|
278
|
+
for (var pi = 0; pi < parts.length; pi += 1) {
|
|
279
|
+
var list = _scanPlaceholders(parts[pi]);
|
|
280
|
+
for (var i = 0; i < list.length; i += 1) refs.push(list[i]);
|
|
281
|
+
}
|
|
282
|
+
var declared = {};
|
|
283
|
+
for (var r = 0; r < schema.required.length; r += 1) declared[schema.required[r]] = true;
|
|
284
|
+
for (var o = 0; o < schema.optional.length; o += 1) declared[schema.optional[o]] = true;
|
|
285
|
+
var rawDeclared = {};
|
|
286
|
+
for (var rd = 0; rd < schema.raw.length; rd += 1) rawDeclared[schema.raw[rd]] = true;
|
|
287
|
+
for (var k = 0; k < refs.length; k += 1) {
|
|
288
|
+
var name = refs[k].name;
|
|
289
|
+
if (!declared[name]) {
|
|
290
|
+
throw new TypeError(
|
|
291
|
+
"emailTemplates: template references variable '{{" + name +
|
|
292
|
+
"}}' not declared in variables_schema.required / .optional"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (refs[k].raw && !rawDeclared[name]) {
|
|
296
|
+
throw new TypeError(
|
|
297
|
+
"emailTemplates: template uses '{{" + name +
|
|
298
|
+
"|raw}}' but variables_schema.raw does not list it — " +
|
|
299
|
+
"operator must vouch for the raw slot at definition time"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Validate the per-render `variables` payload against the schema.
|
|
306
|
+
// Refuses unknown keys (typos would silently render as empty),
|
|
307
|
+
// refuses missing required keys (the renderer can't ship a half-
|
|
308
|
+
// rendered email), accepts every declared optional key.
|
|
309
|
+
function _validateRenderVariables(schema, variables) {
|
|
310
|
+
if (variables == null || typeof variables !== "object" || Array.isArray(variables)) {
|
|
311
|
+
throw new TypeError("emailTemplates: variables must be an object");
|
|
312
|
+
}
|
|
313
|
+
var allowed = {};
|
|
314
|
+
for (var r = 0; r < schema.required.length; r += 1) allowed[schema.required[r]] = "required";
|
|
315
|
+
for (var o = 0; o < schema.optional.length; o += 1) allowed[schema.optional[o]] = "optional";
|
|
316
|
+
var keys = Object.keys(variables);
|
|
317
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
318
|
+
if (!Object.prototype.hasOwnProperty.call(allowed, keys[i])) {
|
|
319
|
+
throw new TypeError(
|
|
320
|
+
"emailTemplates: unknown variable '" + keys[i] +
|
|
321
|
+
"' — schema declares " + (Object.keys(allowed).join(", ") || "(none)")
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (var k = 0; k < schema.required.length; k += 1) {
|
|
326
|
+
if (!Object.prototype.hasOwnProperty.call(variables, schema.required[k])) {
|
|
327
|
+
throw new TypeError(
|
|
328
|
+
"emailTemplates: required variable '" + schema.required[k] + "' missing"
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Substitute `{{name}}` / `{{name|raw}}` against `variables`. Every
|
|
336
|
+
// non-raw slot runs through `b.template.escapeHtml`; `|raw` slots
|
|
337
|
+
// pass through verbatim. Optional variables that are not supplied
|
|
338
|
+
// render as the empty string (the operator declared the slot
|
|
339
|
+
// optional + the schema check already accepted the missing key).
|
|
340
|
+
function _renderBody(template, schema, variables) {
|
|
341
|
+
var rawSet = {};
|
|
342
|
+
for (var i = 0; i < schema.raw.length; i += 1) rawSet[schema.raw[i]] = true;
|
|
343
|
+
var escapeHtml = _b().template.escapeHtml;
|
|
344
|
+
return template.replace(PLACEHOLDER_RE, function (_match, name, suffix) {
|
|
345
|
+
var raw = suffix === "raw";
|
|
346
|
+
var val = Object.prototype.hasOwnProperty.call(variables, name)
|
|
347
|
+
? variables[name]
|
|
348
|
+
: "";
|
|
349
|
+
if (val == null) val = "";
|
|
350
|
+
// Belt-and-braces: even when a slot is `{{name|raw}}`, the
|
|
351
|
+
// schema must have whitelisted `name` in `raw`. The definition-
|
|
352
|
+
// time check already refused mismatched templates; this guard
|
|
353
|
+
// covers a schema that drifted post-definition (operator edited
|
|
354
|
+
// the schema after a publish).
|
|
355
|
+
if (raw && rawSet[name]) return String(val);
|
|
356
|
+
return escapeHtml(val);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Compose subject + html + text in one pass so the renderer's
|
|
361
|
+
// single failure surface is the schema check at the top, not a
|
|
362
|
+
// per-part inconsistency.
|
|
363
|
+
function _renderAll(row, variables) {
|
|
364
|
+
return {
|
|
365
|
+
subject: _renderBody(row.subject, row._schema, variables),
|
|
366
|
+
body_html: _renderBody(row.body_html, row._schema, variables),
|
|
367
|
+
body_text: _renderBody(row.body_text, row._schema, variables),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- row → public shape -------------------------------------------------
|
|
372
|
+
|
|
373
|
+
function _rowToTemplate(row) {
|
|
374
|
+
if (!row) return null;
|
|
375
|
+
return {
|
|
376
|
+
slug: row.slug,
|
|
377
|
+
title: row.title,
|
|
378
|
+
kind: row.kind,
|
|
379
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
380
|
+
created_at: Number(row.created_at),
|
|
381
|
+
updated_at: Number(row.updated_at),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function _rowToVersion(row) {
|
|
386
|
+
if (!row) return null;
|
|
387
|
+
var schema;
|
|
388
|
+
try {
|
|
389
|
+
schema = JSON.parse(row.variables_schema_json);
|
|
390
|
+
} catch (_e) {
|
|
391
|
+
// drop-silent — a malformed JSON column would be a write-side
|
|
392
|
+
// corruption we surface as the operator-readable empty shape.
|
|
393
|
+
// The audit ledger is the source of truth for "did we write
|
|
394
|
+
// garbage?"; the renderer prefers an empty schema over a hard
|
|
395
|
+
// crash when reading historical rows.
|
|
396
|
+
schema = { required: [], optional: [], raw: [] };
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
id: row.id,
|
|
400
|
+
template_slug: row.template_slug,
|
|
401
|
+
locale: row.locale,
|
|
402
|
+
subject: row.subject,
|
|
403
|
+
body_html: row.body_html,
|
|
404
|
+
body_text: row.body_text,
|
|
405
|
+
variables_schema: schema,
|
|
406
|
+
version_number: Number(row.version_number),
|
|
407
|
+
published: Number(row.published) === 1,
|
|
408
|
+
created_at: Number(row.created_at),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---- factory ------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
function create(opts) {
|
|
415
|
+
opts = opts || {};
|
|
416
|
+
var query = opts.query;
|
|
417
|
+
if (!query) {
|
|
418
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function _getTemplateRow(slug) {
|
|
422
|
+
var r = await query(
|
|
423
|
+
"SELECT * FROM email_templates WHERE slug = ?1 LIMIT 1",
|
|
424
|
+
[slug],
|
|
425
|
+
);
|
|
426
|
+
return r.rows[0] || null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Read the published version row at `locale`; if none, fall back
|
|
430
|
+
// to `en`; if still none, return null. The renderer surfaces a
|
|
431
|
+
// structured `EMAIL_TEMPLATE_NOT_FOUND` so an operator missing a
|
|
432
|
+
// locale on the canonical English template can spot the gap.
|
|
433
|
+
async function _getPublishedVersionRow(slug, locale) {
|
|
434
|
+
var primary = await query(
|
|
435
|
+
"SELECT * FROM email_template_versions " +
|
|
436
|
+
"WHERE template_slug = ?1 AND locale = ?2 AND published = 1 LIMIT 1",
|
|
437
|
+
[slug, locale],
|
|
438
|
+
);
|
|
439
|
+
if (primary.rows[0]) return { row: primary.rows[0], locale_used: locale };
|
|
440
|
+
if (locale === DEFAULT_LOCALE) return null;
|
|
441
|
+
var fallback = await query(
|
|
442
|
+
"SELECT * FROM email_template_versions " +
|
|
443
|
+
"WHERE template_slug = ?1 AND locale = ?2 AND published = 1 LIMIT 1",
|
|
444
|
+
[slug, DEFAULT_LOCALE],
|
|
445
|
+
);
|
|
446
|
+
if (fallback.rows[0]) return { row: fallback.rows[0], locale_used: DEFAULT_LOCALE };
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function _maxVersionNumber(slug, locale) {
|
|
451
|
+
var r = await query(
|
|
452
|
+
"SELECT MAX(version_number) AS mx FROM email_template_versions " +
|
|
453
|
+
"WHERE template_slug = ?1 AND locale = ?2",
|
|
454
|
+
[slug, locale],
|
|
455
|
+
);
|
|
456
|
+
var mx = r.rows[0] && r.rows[0].mx;
|
|
457
|
+
return mx == null ? 0 : Number(mx);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
KINDS: KINDS,
|
|
462
|
+
DEFAULT_LOCALE: DEFAULT_LOCALE,
|
|
463
|
+
|
|
464
|
+
// Define a template + append a new version row at the
|
|
465
|
+
// requested locale. The first call for a slug creates the
|
|
466
|
+
// `email_templates` row in addition to v1; subsequent calls
|
|
467
|
+
// append v2 / v3 / etc. Versions are NOT auto-published —
|
|
468
|
+
// operators preview a render, then call `publishVersion`.
|
|
469
|
+
defineTemplate: async function (input) {
|
|
470
|
+
if (!input || typeof input !== "object") {
|
|
471
|
+
throw new TypeError("emailTemplates.defineTemplate: input object required");
|
|
472
|
+
}
|
|
473
|
+
var slug = _validateSlug(input.slug, "slug");
|
|
474
|
+
var title = _validateTitle(input.title);
|
|
475
|
+
var kind = _validateKind(input.kind);
|
|
476
|
+
var subject = _validateSubject(input.subject);
|
|
477
|
+
var bodyHtml = _validateBody(input.body_html, "body_html");
|
|
478
|
+
var bodyText = _validateBody(input.body_text, "body_text");
|
|
479
|
+
var schema = _validateVariablesSchema(input.variables_schema);
|
|
480
|
+
var locale = _validateLocale(input.locale != null ? input.locale : DEFAULT_LOCALE);
|
|
481
|
+
_validateTemplateBodyVsSchema([subject, bodyHtml, bodyText], schema);
|
|
482
|
+
|
|
483
|
+
var now = input.now == null ? _now() : input.now;
|
|
484
|
+
if (typeof now !== "number" || !Number.isInteger(now) || now < 0) {
|
|
485
|
+
throw new TypeError("emailTemplates.defineTemplate: now must be a non-negative integer epoch-ms");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
var existing = await _getTemplateRow(slug);
|
|
489
|
+
if (existing && existing.archived_at != null) {
|
|
490
|
+
throw new TypeError(
|
|
491
|
+
"emailTemplates.defineTemplate: template '" + slug +
|
|
492
|
+
"' is archived — pick a fresh slug or restore the row"
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (!existing) {
|
|
496
|
+
await query(
|
|
497
|
+
"INSERT INTO email_templates (slug, title, kind, archived_at, created_at, updated_at) " +
|
|
498
|
+
"VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
|
|
499
|
+
[slug, title, kind, now],
|
|
500
|
+
);
|
|
501
|
+
} else {
|
|
502
|
+
if (existing.kind !== kind) {
|
|
503
|
+
throw new TypeError(
|
|
504
|
+
"emailTemplates.defineTemplate: template '" + slug +
|
|
505
|
+
"' is kind '" + existing.kind + "' — cannot redefine as '" + kind + "'"
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
await query(
|
|
509
|
+
"UPDATE email_templates SET title = ?1, updated_at = ?2 WHERE slug = ?3",
|
|
510
|
+
[title, now, slug],
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
var versionNumber = (await _maxVersionNumber(slug, locale)) + 1;
|
|
515
|
+
var id = _b().uuid.v7();
|
|
516
|
+
await query(
|
|
517
|
+
"INSERT INTO email_template_versions " +
|
|
518
|
+
"(id, template_slug, locale, subject, body_html, body_text, " +
|
|
519
|
+
"variables_schema_json, version_number, published, created_at) " +
|
|
520
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9)",
|
|
521
|
+
[id, slug, locale, subject, bodyHtml, bodyText, JSON.stringify(schema), versionNumber, now],
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
template: _rowToTemplate(await _getTemplateRow(slug)),
|
|
526
|
+
version: _rowToVersion((await query(
|
|
527
|
+
"SELECT * FROM email_template_versions WHERE id = ?1 LIMIT 1",
|
|
528
|
+
[id],
|
|
529
|
+
)).rows[0]),
|
|
530
|
+
};
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
// Read the published version for (slug, locale), with `en`
|
|
534
|
+
// fallback. Returns `null` when the slug doesn't exist or has
|
|
535
|
+
// no published version at either locale.
|
|
536
|
+
getTemplate: async function (input) {
|
|
537
|
+
if (!input || typeof input !== "object") {
|
|
538
|
+
throw new TypeError("emailTemplates.getTemplate: input object required");
|
|
539
|
+
}
|
|
540
|
+
var slug = _validateSlug(input.slug, "slug");
|
|
541
|
+
var locale = _validateLocale(input.locale != null ? input.locale : DEFAULT_LOCALE);
|
|
542
|
+
var t = await _getTemplateRow(slug);
|
|
543
|
+
if (!t || t.archived_at != null) return null;
|
|
544
|
+
var hit = await _getPublishedVersionRow(slug, locale);
|
|
545
|
+
if (!hit) return null;
|
|
546
|
+
return {
|
|
547
|
+
template: _rowToTemplate(t),
|
|
548
|
+
version: _rowToVersion(hit.row),
|
|
549
|
+
locale_used: hit.locale_used,
|
|
550
|
+
};
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
// List templates with optional `kind` filter + active-only
|
|
554
|
+
// toggle. The default surfaces every non-archived template so
|
|
555
|
+
// the operator dashboard shows the catalog without a separate
|
|
556
|
+
// archive panel.
|
|
557
|
+
listTemplates: async function (listOpts) {
|
|
558
|
+
listOpts = listOpts || {};
|
|
559
|
+
var kind = null;
|
|
560
|
+
if (listOpts.kind != null) kind = _validateKind(listOpts.kind);
|
|
561
|
+
var activeOnly = listOpts.active_only !== false; // default true
|
|
562
|
+
var limit = listOpts.limit == null ? MAX_LIST_LIMIT : Number(listOpts.limit);
|
|
563
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
564
|
+
throw new TypeError("emailTemplates.listTemplates: limit must be 1..." + MAX_LIST_LIMIT);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
var sql = "SELECT * FROM email_templates";
|
|
568
|
+
var params = [];
|
|
569
|
+
var clauses = [];
|
|
570
|
+
if (kind) {
|
|
571
|
+
clauses.push("kind = ?" + (params.length + 1));
|
|
572
|
+
params.push(kind);
|
|
573
|
+
}
|
|
574
|
+
if (activeOnly) {
|
|
575
|
+
clauses.push("archived_at IS NULL");
|
|
576
|
+
}
|
|
577
|
+
if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
|
|
578
|
+
sql += " ORDER BY created_at DESC, slug ASC LIMIT ?" + (params.length + 1);
|
|
579
|
+
params.push(limit);
|
|
580
|
+
var rows = (await query(sql, params)).rows;
|
|
581
|
+
var out = [];
|
|
582
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_rowToTemplate(rows[i]));
|
|
583
|
+
return out;
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// Patch the template-level row (`title` only — the body lives
|
|
587
|
+
// on versions). Refuses unknown patch keys so a caller can't
|
|
588
|
+
// accidentally route a body update through here and bypass the
|
|
589
|
+
// version-history discipline.
|
|
590
|
+
updateTemplate: async function (input) {
|
|
591
|
+
if (!input || typeof input !== "object") {
|
|
592
|
+
throw new TypeError("emailTemplates.updateTemplate: input object required");
|
|
593
|
+
}
|
|
594
|
+
var slug = _validateSlug(input.slug, "slug");
|
|
595
|
+
var patch = input.patch;
|
|
596
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
597
|
+
throw new TypeError("emailTemplates.updateTemplate: patch must be an object");
|
|
598
|
+
}
|
|
599
|
+
var allowed = { title: true };
|
|
600
|
+
var keys = Object.keys(patch);
|
|
601
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
602
|
+
if (!allowed[keys[i]]) {
|
|
603
|
+
throw new TypeError(
|
|
604
|
+
"emailTemplates.updateTemplate: patch key '" + keys[i] +
|
|
605
|
+
"' is not patchable — body edits go through defineTemplate (new version)"
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
var row = await _getTemplateRow(slug);
|
|
610
|
+
if (!row) {
|
|
611
|
+
throw new TypeError("emailTemplates.updateTemplate: template '" + slug + "' not found");
|
|
612
|
+
}
|
|
613
|
+
if (row.archived_at != null) {
|
|
614
|
+
throw new TypeError("emailTemplates.updateTemplate: template '" + slug + "' is archived");
|
|
615
|
+
}
|
|
616
|
+
var now = input.now == null ? _now() : input.now;
|
|
617
|
+
if (typeof now !== "number" || !Number.isInteger(now) || now < 0) {
|
|
618
|
+
throw new TypeError("emailTemplates.updateTemplate: now must be a non-negative integer epoch-ms");
|
|
619
|
+
}
|
|
620
|
+
if (patch.title != null) {
|
|
621
|
+
var title = _validateTitle(patch.title);
|
|
622
|
+
await query(
|
|
623
|
+
"UPDATE email_templates SET title = ?1, updated_at = ?2 WHERE slug = ?3",
|
|
624
|
+
[title, now, slug],
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
return _rowToTemplate(await _getTemplateRow(slug));
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
// Flip a version to `published = 1` for (slug, locale). The
|
|
631
|
+
// prior published row drops to 0 in the same statement pair
|
|
632
|
+
// so the renderer never reads two active rows for one (slug,
|
|
633
|
+
// locale). When no `version_id` is supplied, publishes the
|
|
634
|
+
// most recently created version at that locale.
|
|
635
|
+
publishVersion: async function (slug, publishOpts) {
|
|
636
|
+
_validateSlug(slug, "slug");
|
|
637
|
+
publishOpts = publishOpts || {};
|
|
638
|
+
var locale = _validateLocale(publishOpts.locale != null ? publishOpts.locale : DEFAULT_LOCALE);
|
|
639
|
+
|
|
640
|
+
var t = await _getTemplateRow(slug);
|
|
641
|
+
if (!t) {
|
|
642
|
+
throw new TypeError("emailTemplates.publishVersion: template '" + slug + "' not found");
|
|
643
|
+
}
|
|
644
|
+
if (t.archived_at != null) {
|
|
645
|
+
throw new TypeError("emailTemplates.publishVersion: template '" + slug + "' is archived");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
var targetRow;
|
|
649
|
+
if (publishOpts.version_id != null) {
|
|
650
|
+
var got = await query(
|
|
651
|
+
"SELECT * FROM email_template_versions WHERE id = ?1 AND template_slug = ?2 AND locale = ?3 LIMIT 1",
|
|
652
|
+
[publishOpts.version_id, slug, locale],
|
|
653
|
+
);
|
|
654
|
+
targetRow = got.rows[0];
|
|
655
|
+
if (!targetRow) {
|
|
656
|
+
throw new TypeError(
|
|
657
|
+
"emailTemplates.publishVersion: version_id '" + publishOpts.version_id +
|
|
658
|
+
"' not found for (" + slug + ", " + locale + ")"
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
var latest = await query(
|
|
663
|
+
"SELECT * FROM email_template_versions " +
|
|
664
|
+
"WHERE template_slug = ?1 AND locale = ?2 " +
|
|
665
|
+
"ORDER BY version_number DESC LIMIT 1",
|
|
666
|
+
[slug, locale],
|
|
667
|
+
);
|
|
668
|
+
targetRow = latest.rows[0];
|
|
669
|
+
if (!targetRow) {
|
|
670
|
+
throw new TypeError(
|
|
671
|
+
"emailTemplates.publishVersion: no versions exist for (" + slug + ", " + locale + ")"
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Demote the currently-published row (if any) for this
|
|
677
|
+
// (slug, locale) — the renderer's invariant is one published
|
|
678
|
+
// row per (slug, locale).
|
|
679
|
+
await query(
|
|
680
|
+
"UPDATE email_template_versions SET published = 0 " +
|
|
681
|
+
"WHERE template_slug = ?1 AND locale = ?2 AND published = 1 AND id <> ?3",
|
|
682
|
+
[slug, locale, targetRow.id],
|
|
683
|
+
);
|
|
684
|
+
await query(
|
|
685
|
+
"UPDATE email_template_versions SET published = 1 WHERE id = ?1",
|
|
686
|
+
[targetRow.id],
|
|
687
|
+
);
|
|
688
|
+
var now = publishOpts.now == null ? _now() : publishOpts.now;
|
|
689
|
+
if (typeof now !== "number" || !Number.isInteger(now) || now < 0) {
|
|
690
|
+
throw new TypeError("emailTemplates.publishVersion: now must be a non-negative integer epoch-ms");
|
|
691
|
+
}
|
|
692
|
+
await query(
|
|
693
|
+
"UPDATE email_templates SET updated_at = ?1 WHERE slug = ?2",
|
|
694
|
+
[now, slug],
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
var post = await query(
|
|
698
|
+
"SELECT * FROM email_template_versions WHERE id = ?1 LIMIT 1",
|
|
699
|
+
[targetRow.id],
|
|
700
|
+
);
|
|
701
|
+
return _rowToVersion(post.rows[0]);
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
// Soft-delete. The row stays so the audit ledger that points
|
|
705
|
+
// at the slug still resolves; every published version goes
|
|
706
|
+
// inactive (`getTemplate` returns null for an archived
|
|
707
|
+
// template).
|
|
708
|
+
archiveTemplate: async function (slug) {
|
|
709
|
+
_validateSlug(slug, "slug");
|
|
710
|
+
var row = await _getTemplateRow(slug);
|
|
711
|
+
if (!row) {
|
|
712
|
+
throw new TypeError("emailTemplates.archiveTemplate: template '" + slug + "' not found");
|
|
713
|
+
}
|
|
714
|
+
if (row.archived_at != null) {
|
|
715
|
+
// Idempotent — re-archiving is a no-op so a retry path
|
|
716
|
+
// doesn't double-flip the row's timestamp.
|
|
717
|
+
return _rowToTemplate(row);
|
|
718
|
+
}
|
|
719
|
+
var now = _now();
|
|
720
|
+
await query(
|
|
721
|
+
"UPDATE email_templates SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
|
|
722
|
+
[now, slug],
|
|
723
|
+
);
|
|
724
|
+
// Also drop the published flag everywhere — an archived
|
|
725
|
+
// template should render via `getTemplate` as "not found".
|
|
726
|
+
await query(
|
|
727
|
+
"UPDATE email_template_versions SET published = 0 WHERE template_slug = ?1",
|
|
728
|
+
[slug],
|
|
729
|
+
);
|
|
730
|
+
return _rowToTemplate(await _getTemplateRow(slug));
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
// Render the active version's subject + html + text against
|
|
734
|
+
// the supplied variables. The locale fallback + schema check +
|
|
735
|
+
// HTML-escape + raw-slot vouch all run before the return.
|
|
736
|
+
renderTemplate: async function (input) {
|
|
737
|
+
if (!input || typeof input !== "object") {
|
|
738
|
+
throw new TypeError("emailTemplates.renderTemplate: input object required");
|
|
739
|
+
}
|
|
740
|
+
var slug = _validateSlug(input.slug, "slug");
|
|
741
|
+
var locale = _validateLocale(input.locale != null ? input.locale : DEFAULT_LOCALE);
|
|
742
|
+
var variables = input.variables == null ? {} : input.variables;
|
|
743
|
+
if (typeof variables !== "object" || Array.isArray(variables)) {
|
|
744
|
+
throw new TypeError("emailTemplates.renderTemplate: variables must be an object");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
var t = await _getTemplateRow(slug);
|
|
748
|
+
if (!t || t.archived_at != null) {
|
|
749
|
+
var notFound = new Error(
|
|
750
|
+
"emailTemplates.renderTemplate: template '" + slug + "' not found"
|
|
751
|
+
);
|
|
752
|
+
notFound.code = "EMAIL_TEMPLATE_NOT_FOUND";
|
|
753
|
+
throw notFound;
|
|
754
|
+
}
|
|
755
|
+
var hit = await _getPublishedVersionRow(slug, locale);
|
|
756
|
+
if (!hit) {
|
|
757
|
+
var noVer = new Error(
|
|
758
|
+
"emailTemplates.renderTemplate: no published version for ('" +
|
|
759
|
+
slug + "', '" + locale + "') or fallback '" + DEFAULT_LOCALE + "'"
|
|
760
|
+
);
|
|
761
|
+
noVer.code = "EMAIL_TEMPLATE_NOT_FOUND";
|
|
762
|
+
throw noVer;
|
|
763
|
+
}
|
|
764
|
+
var v = _rowToVersion(hit.row);
|
|
765
|
+
_validateRenderVariables(v.variables_schema, variables);
|
|
766
|
+
|
|
767
|
+
var rendered = _renderAll({
|
|
768
|
+
subject: v.subject,
|
|
769
|
+
body_html: v.body_html,
|
|
770
|
+
body_text: v.body_text,
|
|
771
|
+
_schema: v.variables_schema,
|
|
772
|
+
}, variables);
|
|
773
|
+
return {
|
|
774
|
+
subject: rendered.subject,
|
|
775
|
+
body_html: rendered.body_html,
|
|
776
|
+
body_text: rendered.body_text,
|
|
777
|
+
version_id: v.id,
|
|
778
|
+
version_number: v.version_number,
|
|
779
|
+
locale_used: hit.locale_used,
|
|
780
|
+
};
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
// Page through every version for (slug, locale), newest first.
|
|
784
|
+
// Operators audit the body history through this surface; the
|
|
785
|
+
// version_number is the stable handle for `publishVersion`.
|
|
786
|
+
versionsFor: async function (slug, locale) {
|
|
787
|
+
_validateSlug(slug, "slug");
|
|
788
|
+
var loc = _validateLocale(locale != null ? locale : DEFAULT_LOCALE);
|
|
789
|
+
var rows = (await query(
|
|
790
|
+
"SELECT * FROM email_template_versions " +
|
|
791
|
+
"WHERE template_slug = ?1 AND locale = ?2 " +
|
|
792
|
+
"ORDER BY version_number DESC",
|
|
793
|
+
[slug, loc],
|
|
794
|
+
)).rows;
|
|
795
|
+
var out = [];
|
|
796
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_rowToVersion(rows[i]));
|
|
797
|
+
return out;
|
|
798
|
+
},
|
|
799
|
+
|
|
800
|
+
// Standalone schema-vs-variables check. Useful as a preview
|
|
801
|
+
// gate in the operator dashboard before they commit a render.
|
|
802
|
+
validateVariables: function (input) {
|
|
803
|
+
if (!input || typeof input !== "object") {
|
|
804
|
+
throw new TypeError("emailTemplates.validateVariables: input object required");
|
|
805
|
+
}
|
|
806
|
+
var schema = _validateVariablesSchema(input.schema);
|
|
807
|
+
_validateRenderVariables(schema, input.variables == null ? {} : input.variables);
|
|
808
|
+
return { ok: true };
|
|
809
|
+
},
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
module.exports = {
|
|
814
|
+
create: create,
|
|
815
|
+
KINDS: KINDS,
|
|
816
|
+
DEFAULT_LOCALE: DEFAULT_LOCALE,
|
|
817
|
+
};
|