@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. 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
+ };