vintasend-managed-templates 1.0.0-alpha2

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 (38) hide show
  1. package/README.md +529 -0
  2. package/dist/base-template-manager-backend.d.ts +202 -0
  3. package/dist/base-template-manager-backend.d.ts.map +1 -0
  4. package/dist/base-template-manager-backend.js +1 -0
  5. package/dist/composition.d.ts +238 -0
  6. package/dist/composition.d.ts.map +1 -0
  7. package/dist/composition.js +0 -0
  8. package/dist/constants.d.ts +32 -0
  9. package/dist/constants.d.ts.map +1 -0
  10. package/dist/constants.js +31 -0
  11. package/dist/errors.d.ts +80 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +86 -0
  14. package/dist/filter-evaluation.d.ts +69 -0
  15. package/dist/filter-evaluation.d.ts.map +1 -0
  16. package/dist/filter-evaluation.js +252 -0
  17. package/dist/filters.d.ts +192 -0
  18. package/dist/filters.d.ts.map +1 -0
  19. package/dist/filters.js +252 -0
  20. package/dist/in-memory-template-manager-backend.d.ts +85 -0
  21. package/dist/in-memory-template-manager-backend.d.ts.map +1 -0
  22. package/dist/in-memory-template-manager-backend.js +323 -0
  23. package/dist/index.d.ts +19 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +18 -0
  26. package/dist/managed-template-renderer.d.ts +153 -0
  27. package/dist/managed-template-renderer.d.ts.map +1 -0
  28. package/dist/managed-template-renderer.js +152 -0
  29. package/dist/managed-template-service.d.ts +415 -0
  30. package/dist/managed-template-service.d.ts.map +1 -0
  31. package/dist/managed-template-service.js +712 -0
  32. package/dist/tags.d.ts +54 -0
  33. package/dist/tags.d.ts.map +1 -0
  34. package/dist/tags.js +108 -0
  35. package/dist/types.d.ts +100 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +1 -0
  38. package/package.json +41 -0
@@ -0,0 +1,712 @@
1
+ /**
2
+ * Backend-agnostic service for managing template versions and their statuses.
3
+ *
4
+ * `ManagedTemplateService` sits between a host application and the two seams this package
5
+ * defines: a `BaseTemplateManagerBackend` (where templates live) and a `ManagedTemplateRenderer`
6
+ * (how a template turns into something an adapter can send). Every storage call goes through the
7
+ * backend, so the service works unchanged against any implementation of that interface.
8
+ *
9
+ * What the service adds on top of the raw backend:
10
+ *
11
+ * * **Version resolution.** An absent `version` consistently means "the latest version of this
12
+ * key" across reads, status changes, and rendering, so callers never juggle version numbers
13
+ * unless they want a specific one.
14
+ * * **Status transitions.** Status changes are validated against `allowedStatusTransitions` and
15
+ * then written through the backend's audit trail, with named helpers (`activate` /
16
+ * `deactivate` / `archive`) for the common moves.
17
+ * * **Filter validation.** Filters are checked for shape and field names before they reach the
18
+ * backend, so a typo throws `ManagedTemplateInvalidFilterError` here instead of silently
19
+ * matching nothing (or blowing up) deep inside a backend's query translation.
20
+ * * **Tag hygiene.** Tag text is normalized and slugified here before it reaches the backend, so
21
+ * every implementation of the storage seam is handed the same slug for the same text, and text
22
+ * with nothing sluggable in it is rejected with `ManagedTemplateInvalidTagError` instead of
23
+ * becoming a tag no filter can ever name.
24
+ * * **Version-pinned rendering.** Rendering honours a notification's own
25
+ * `requestedTemplateVersion`, so a notification recorded against v3 renders v3 however many
26
+ * versions follow. The service adds an explicit `version` argument on top, overriding even
27
+ * that — which is what makes previewing an unpublished draft possible — and reports which
28
+ * version actually rendered, for the caller to record.
29
+ * * **Composition.** Templates are flattened before they render: a template that extends a base
30
+ * or includes a fragment reaches the engine as one string with no `managed_*` tag left in it
31
+ * (see `composition`). Reads are unaffected — `getTemplate` still hands back exactly what is
32
+ * stored, and `getComposedTemplate` is the explicit way to ask for the assembled form.
33
+ *
34
+ * Two deliberate non-policies, both chosen so the service stays a thin orchestration layer:
35
+ *
36
+ * * A key may have **any number of active versions at once**. Activating a version does not touch
37
+ * the ones already active; deciding which active version wins at render time is the host's call.
38
+ * * `changedBy` is **passed through untouched**, `null` included. The service never requires
39
+ * attribution on a status change.
40
+ */
41
+ import { TemplateComposer } from './composition.js';
42
+ import { ManagedTemplateInvalidFilterError, ManagedTemplateInvalidTagError, ManagedTemplateStatusTransitionError, ManagedTemplateUnsupportedOrderingError, } from './errors.js';
43
+ import { DEFAULT_TEMPLATE_BACKEND_FILTER_CAPABILITIES, FLAG_FILTER_FIELDS, isFieldFilter, isTagsFilter, KNOWN_FILTER_FIELDS, MANAGED_TEMPLATE_ORDER_BY_FIELDS, orderByCapabilityKey, pruneUnsupportedFilters, TAG_FILTER_FIELDS, } from './filters.js';
44
+ import { normalizeTagText, slugifyTag } from './tags.js';
45
+ /**
46
+ * Which status a version may move to, keyed by the status it is in now.
47
+ *
48
+ * `archived` is terminal: an archived version is a historical record, and bringing one back would
49
+ * make its audit trail read as though it had never been retired. Publish a new version instead.
50
+ */
51
+ export const DEFAULT_STATUS_TRANSITIONS = {
52
+ draft: ['active', 'archived'],
53
+ active: ['inactive', 'archived'],
54
+ inactive: ['active', 'archived'],
55
+ archived: [],
56
+ };
57
+ /**
58
+ * The filter a listing applies unless it was asked for every version: one row per key.
59
+ *
60
+ * Built per call rather than shared, so a backend that keeps the filter it was handed — or a
61
+ * caller that reads it off a fake and edits it — cannot change what the next listing means.
62
+ */
63
+ function currentVersionsOnly() {
64
+ return { mostRecentActiveVersion: true };
65
+ }
66
+ function isRecord(value) {
67
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
68
+ }
69
+ function describeType(value) {
70
+ if (value === null) {
71
+ return 'null';
72
+ }
73
+ if (Array.isArray(value)) {
74
+ return 'array';
75
+ }
76
+ return typeof value;
77
+ }
78
+ export class ManagedTemplateService {
79
+ /**
80
+ * @param templateManagerBackend where templates are stored and versioned.
81
+ * @param templateRenderer turns a `ManagedTemplate` into something an adapter can send. The
82
+ * service drives its `createTemplateContent` / `renderFromTemplateContent` pair rather than
83
+ * its own backend read, so the renderer only needs to agree with this service on the shape of
84
+ * a template, not on where templates live.
85
+ */
86
+ constructor(templateManagerBackend, templateRenderer, options = {}) {
87
+ this.templateManagerBackend = templateManagerBackend;
88
+ this.templateRenderer = templateRenderer;
89
+ this.capabilitiesCache = null;
90
+ this.validateStatusTransitions = options.validateStatusTransitions ?? true;
91
+ this.composeTemplates = options.composeTemplates ?? true;
92
+ this.composer = options.composer ?? TemplateComposer.fromBackend(templateManagerBackend);
93
+ this.allowedStatusTransitions = options.allowedStatusTransitions ?? DEFAULT_STATUS_TRANSITIONS;
94
+ }
95
+ // -------------------------------------------------------------------------------------------
96
+ // Capabilities
97
+ // -------------------------------------------------------------------------------------------
98
+ /**
99
+ * The backend's capability report, merged over the library default.
100
+ *
101
+ * Cached for the life of the service: a backend's capabilities are a static property of its
102
+ * implementation, so re-asking on every request would buy nothing.
103
+ */
104
+ getBackendSupportedFilterCapabilities() {
105
+ if (this.capabilitiesCache === null) {
106
+ const reported = this.templateManagerBackend.getFilterCapabilities?.() ?? {};
107
+ this.capabilitiesCache = {
108
+ ...DEFAULT_TEMPLATE_BACKEND_FILTER_CAPABILITIES,
109
+ ...Object.fromEntries(Object.entries(reported).map(([key, value]) => [key, Boolean(value)])),
110
+ };
111
+ }
112
+ return this.capabilitiesCache;
113
+ }
114
+ // -------------------------------------------------------------------------------------------
115
+ // Versions
116
+ // -------------------------------------------------------------------------------------------
117
+ /**
118
+ * Create the first version of a new template.
119
+ *
120
+ * Any tag text on the input that has no tag behind it yet becomes one, so a caller never has to
121
+ * create tags before using them.
122
+ *
123
+ * @throws ManagedTemplateInvalidTagError if a tag text has nothing that can be slugified.
124
+ */
125
+ async createTemplate(input) {
126
+ return this.templateManagerBackend.createTemplate(this.withCleanTags(input));
127
+ }
128
+ /**
129
+ * One version of a template. An absent `version` returns the latest version.
130
+ *
131
+ * @throws ManagedTemplateNotFoundError if the key (or that version of it) does not exist.
132
+ */
133
+ async getTemplate(templateKey, version = null) {
134
+ return this.templateManagerBackend.getTemplate(templateKey, version);
135
+ }
136
+ /**
137
+ * Create a new version of an existing template from the latest one.
138
+ *
139
+ * Templates are versioned rather than edited in place, so this never mutates a version that has
140
+ * already been published — the backend copies the latest version forward, applies the set
141
+ * fields of `input`, and returns the new version.
142
+ *
143
+ * Tags follow the same rule as every other field on the input: absent carries the previous
144
+ * version's tags forward, and an empty array means a version with none.
145
+ */
146
+ async updateTemplate(templateKey, input) {
147
+ return this.templateManagerBackend.updateTemplate(templateKey, this.withCleanTags(input));
148
+ }
149
+ /**
150
+ * Delete one version of a template, or its latest version when `version` is absent.
151
+ */
152
+ async deleteTemplate(templateKey, version = null) {
153
+ await this.templateManagerBackend.deleteTemplate(templateKey, version);
154
+ }
155
+ /** Every version of a template, newest version first. */
156
+ async getTemplateVersions(templateKey) {
157
+ const versions = await this.getFilteredTemplates({ key: templateKey });
158
+ return [...versions].sort((left, right) => right.version - left.version);
159
+ }
160
+ withCleanTags(input) {
161
+ if (input.tags === undefined || input.tags === null) {
162
+ return input;
163
+ }
164
+ return { ...input, tags: this.cleanTagTexts(input.tags) };
165
+ }
166
+ // -------------------------------------------------------------------------------------------
167
+ // Statuses
168
+ // -------------------------------------------------------------------------------------------
169
+ /**
170
+ * Move one version of a template to `status` and record it in the audit trail.
171
+ *
172
+ * Setting a version to the status it already holds is a no-op: the template is returned
173
+ * unchanged and no history entry is written, so repeating a call does not fill the audit trail
174
+ * with entries that record nothing.
175
+ *
176
+ * @throws ManagedTemplateNotFoundError if the key (or that version of it) does not exist.
177
+ * @throws ManagedTemplateStatusTransitionError if the move is not allowed from the version's
178
+ * current status and `validateStatusTransitions` is on.
179
+ */
180
+ async setStatus(templateKey, status, version = null, changedBy = null) {
181
+ const template = await this.getTemplate(templateKey, version);
182
+ if (template.status === status) {
183
+ return template;
184
+ }
185
+ this.checkStatusTransition(template, status);
186
+ await this.templateManagerBackend.createTemplateStatusUpdate({
187
+ templateKey,
188
+ version: template.version,
189
+ status,
190
+ changedBy,
191
+ });
192
+ // The backend seam returns nothing from a status update, so re-read to hand back a template
193
+ // whose status reflects the write rather than one captured before it.
194
+ return this.getTemplate(templateKey, template.version);
195
+ }
196
+ /**
197
+ * Publish one version of a template.
198
+ *
199
+ * Other versions of the same key that are already active are left alone — a key may hold
200
+ * several active versions at once, and choosing between them is the host's call.
201
+ */
202
+ async activate(templateKey, version = null, changedBy = null) {
203
+ return this.setStatus(templateKey, 'active', version, changedBy);
204
+ }
205
+ /** Retire one version without archiving it, so it can be activated again. */
206
+ async deactivate(templateKey, version = null, changedBy = null) {
207
+ return this.setStatus(templateKey, 'inactive', version, changedBy);
208
+ }
209
+ /** Archive one version. Terminal under the default transition table. */
210
+ async archive(templateKey, version = null, changedBy = null) {
211
+ return this.setStatus(templateKey, 'archived', version, changedBy);
212
+ }
213
+ /**
214
+ * The status audit trail for a template, most recent change first.
215
+ *
216
+ * @param version every version's history when absent, if the backend supports it.
217
+ */
218
+ async getStatusHistory(templateKey, version = null) {
219
+ const history = await this.templateManagerBackend.getTemplateStatusHistory(templateKey, version);
220
+ // Ties are broken by reversing the backend's own order rather than left to a stable sort.
221
+ // Two changes to one version land in the same millisecond often enough to matter — a client
222
+ // retrying, a migration replaying a trail — and a stable sort would leave the *older* of the
223
+ // two first, which is the one thing "most recent first" must never do. Backends return a
224
+ // trail oldest-first, so the later entry is the one further along the array.
225
+ return history
226
+ .map((record, index) => ({ record, index }))
227
+ .sort((left, right) => right.record.createdAt.getTime() - left.record.createdAt.getTime() ||
228
+ right.index - left.index)
229
+ .map(({ record }) => record);
230
+ }
231
+ /** Every template version in any of the given statuses. */
232
+ async getTemplatesByStatus(status) {
233
+ return this.templateManagerBackend.getTemplatesByStatus(status);
234
+ }
235
+ /**
236
+ * Whether `template` may move to `status`, without attempting the move.
237
+ *
238
+ * Lets a caller (a UI deciding which buttons to enable, say) ask the same question `setStatus`
239
+ * asks, instead of catching the exception to find out.
240
+ */
241
+ canTransitionTo(template, status) {
242
+ if (!this.validateStatusTransitions || template.status === status) {
243
+ return true;
244
+ }
245
+ return (this.allowedStatusTransitions[template.status] ?? []).includes(status);
246
+ }
247
+ /**
248
+ * Which statuses `template` may move to right now, in a stable order.
249
+ *
250
+ * The version's current status is excluded even though `canTransitionTo` returns true for it:
251
+ * setting a version to the status it already holds is a documented no-op, not a transition, and
252
+ * offering it as an action would be offering to do nothing.
253
+ */
254
+ allowedTransitionsFor(template) {
255
+ return ['draft', 'active', 'inactive', 'archived'].filter((status) => status !== template.status && this.canTransitionTo(template, status));
256
+ }
257
+ checkStatusTransition(template, status) {
258
+ if (this.canTransitionTo(template, status)) {
259
+ return;
260
+ }
261
+ const allowed = [...(this.allowedStatusTransitions[template.status] ?? [])].sort();
262
+ const allowedNames = allowed.length > 0 ? allowed.join(', ') : 'nothing';
263
+ throw new ManagedTemplateStatusTransitionError(`Template '${template.key}' v${template.version} cannot move from ` +
264
+ `'${template.status}' to '${status}'. Allowed: ${allowedNames}.`);
265
+ }
266
+ // -------------------------------------------------------------------------------------------
267
+ // Tags
268
+ // -------------------------------------------------------------------------------------------
269
+ /**
270
+ * Create a tag, failing if its text already slugs onto an existing one.
271
+ *
272
+ * Tagging a template creates missing tags on its own, so this is for the case where a tag is
273
+ * being defined ahead of any template using it — and where a collision with an existing tag is
274
+ * worth hearing about rather than silently resolving.
275
+ */
276
+ async createTag(text, tenant = null) {
277
+ return this.templateManagerBackend.createTag(this.cleanTagText(text), tenant);
278
+ }
279
+ /** One tag by slug, or by the text it was created from. */
280
+ async getTag(slug) {
281
+ return this.templateManagerBackend.getTag(slug);
282
+ }
283
+ /** Tags, optionally narrowed by status, by a text search, or by tenant. */
284
+ async getTags(status = null, search = null, tenant = null) {
285
+ return this.templateManagerBackend.getTags(status, search, tenant);
286
+ }
287
+ /** The tags still on offer — what a tag picker should show. */
288
+ async getActiveTags(tenant = null) {
289
+ return this.getTags(['active'], null, tenant);
290
+ }
291
+ /**
292
+ * Rename a tag, regenerating its slug from the new text.
293
+ *
294
+ * The templates carrying the tag keep it. The slug changes, though, so a saved filter naming
295
+ * the old slug stops matching — a rename is a change of identity, not a display change.
296
+ */
297
+ async updateTag(slug, text) {
298
+ return this.templateManagerBackend.updateTag(slug, this.cleanTagText(text));
299
+ }
300
+ /** Retire a tag from the pickers without touching the templates carrying it. */
301
+ async archiveTag(slug) {
302
+ return this.setTagStatus(slug, 'archived');
303
+ }
304
+ /**
305
+ * Put an archived tag back on offer.
306
+ *
307
+ * Unlike an archived template version — terminal, because reviving one would rewrite what its
308
+ * audit trail says happened — a tag carries no history to contradict, so archiving one is
309
+ * reversible.
310
+ */
311
+ async restoreTag(slug) {
312
+ return this.setTagStatus(slug, 'active');
313
+ }
314
+ async setTagStatus(slug, status) {
315
+ return this.templateManagerBackend.setTagStatus(slug, status);
316
+ }
317
+ /** Delete a tag, removing it from every template carrying it. */
318
+ async deleteTag(slug) {
319
+ await this.templateManagerBackend.deleteTag(slug);
320
+ }
321
+ /** Resolve tag texts to tags, creating the ones that do not exist yet. */
322
+ async getOrCreateTags(texts, tenant = null) {
323
+ return this.templateManagerBackend.getOrCreateTags(this.cleanTagTexts(texts), tenant);
324
+ }
325
+ /** The tags on one version of a template, or on its latest version. */
326
+ async getTemplateTags(templateKey, version = null) {
327
+ return this.templateManagerBackend.getTemplateTags(templateKey, version);
328
+ }
329
+ /**
330
+ * Replace the tags on one version of a template, creating any that do not exist.
331
+ *
332
+ * Retagging edits the version in place instead of creating a new one: tags are how a template
333
+ * is found, not part of what it renders, so relabelling should not spawn a version and drop it
334
+ * back to `draft`.
335
+ */
336
+ async setTemplateTags(templateKey, tags, version = null) {
337
+ return this.templateManagerBackend.setTemplateTags(templateKey, this.cleanTagTexts(tags), version);
338
+ }
339
+ /** Add tags to a version, leaving the ones already on it in place. */
340
+ async addTemplateTags(templateKey, tags, version = null) {
341
+ const template = await this.getTemplate(templateKey, version);
342
+ const existing = template.tags.map((tag) => tag.slug);
343
+ const added = ManagedTemplateService.slugsFor(tags).filter((slug) => !existing.includes(slug));
344
+ if (added.length === 0) {
345
+ return template;
346
+ }
347
+ return this.setTemplateTags(templateKey, [...existing, ...added], template.version);
348
+ }
349
+ /**
350
+ * Remove tags from a version. Tags it does not carry are ignored.
351
+ *
352
+ * The tags themselves survive — this unlinks them from one version, it does not delete them.
353
+ */
354
+ async removeTemplateTags(templateKey, tags, version = null) {
355
+ const template = await this.getTemplate(templateKey, version);
356
+ const unwanted = new Set(ManagedTemplateService.slugsFor(tags));
357
+ const remaining = template.tags.map((tag) => tag.slug).filter((slug) => !unwanted.has(slug));
358
+ if (remaining.length === template.tags.length) {
359
+ return template;
360
+ }
361
+ return this.setTemplateTags(templateKey, remaining, template.version);
362
+ }
363
+ /**
364
+ * The templates carrying these tags — all of them, or any of them.
365
+ *
366
+ * A shorthand for the `includesAllTags` / `includesAnyOfTags` filters, which is what it builds.
367
+ * Follows the same empty-collection rule they do: matching *all* of no tags returns everything,
368
+ * matching *any* of no tags returns nothing.
369
+ */
370
+ async getTemplatesByTags(tags, matchAll = true) {
371
+ const slugs = ManagedTemplateService.slugsFor(tags);
372
+ return this.getFilteredTemplates(matchAll ? { includesAllTags: slugs } : { includesAnyOfTags: slugs });
373
+ }
374
+ /**
375
+ * Trim a tag's text, rejecting it when nothing sluggable is left.
376
+ *
377
+ * Checked here rather than left to the backend so every backend is handed text it can slugify,
378
+ * and so a caller hears about `' '` or `'!!!'` at the call site instead of ending up with a
379
+ * tag whose slug is empty and which no filter can name.
380
+ */
381
+ cleanTagText(text) {
382
+ const cleaned = normalizeTagText(text);
383
+ if (!cleaned || !slugifyTag(cleaned)) {
384
+ throw new ManagedTemplateInvalidTagError(`Tag text ${JSON.stringify(text)} has no characters that can be turned into a slug.`);
385
+ }
386
+ return cleaned;
387
+ }
388
+ /** Clean each text, dropping repeats that slug onto a tag already in the list. */
389
+ cleanTagTexts(texts) {
390
+ const cleaned = [];
391
+ const seen = new Set();
392
+ for (const text of texts) {
393
+ const candidate = this.cleanTagText(text);
394
+ const slug = slugifyTag(candidate);
395
+ if (seen.has(slug)) {
396
+ continue;
397
+ }
398
+ seen.add(slug);
399
+ cleaned.push(candidate);
400
+ }
401
+ return cleaned;
402
+ }
403
+ /**
404
+ * Slugify a caller's tag texts, dropping the ones with nothing sluggable.
405
+ *
406
+ * Unlike `cleanTagTexts` this never throws: these slugs are used to *match*, and a tag no store
407
+ * could hold simply matches nothing — which is a correct answer, not an error worth
408
+ * interrupting a search for.
409
+ */
410
+ static slugsFor(tags) {
411
+ const slugs = [];
412
+ for (const tag of tags) {
413
+ const slug = slugifyTag(tag);
414
+ if (slug && !slugs.includes(slug)) {
415
+ slugs.push(slug);
416
+ }
417
+ }
418
+ return slugs;
419
+ }
420
+ // -------------------------------------------------------------------------------------------
421
+ // Queries
422
+ // -------------------------------------------------------------------------------------------
423
+ /**
424
+ * The current version of every template — one row per key.
425
+ *
426
+ * "Current" is the `mostRecentActiveVersion` filter: the highest-numbered active or draft
427
+ * version of each key. It is the default because a listing is nearly always a list of
428
+ * *templates*, and the store holds a row per *version*, so the unfiltered read shows the same
429
+ * template once per version it has ever had and hides the current one among its own history.
430
+ *
431
+ * Pass `includeAllVersions: true` for the raw read.
432
+ */
433
+ async getAllTemplates(includeAllVersions = false) {
434
+ if (includeAllVersions) {
435
+ return this.templateManagerBackend.getAllTemplates();
436
+ }
437
+ return this.getFilteredTemplates(currentVersionsOnly());
438
+ }
439
+ /**
440
+ * The templates matching `filters`.
441
+ *
442
+ * @throws ManagedTemplateInvalidFilterError if the filter is malformed or names an unknown
443
+ * field.
444
+ */
445
+ async getFilteredTemplates(filters) {
446
+ this.validateFilter(filters);
447
+ return this.templateManagerBackend.getFilteredTemplates(this.negotiate(filters));
448
+ }
449
+ /**
450
+ * The filter with everything the backend cannot answer removed.
451
+ *
452
+ * A capability report nothing acts on is decoration, and every caller left to walk the map
453
+ * itself would reach a slightly different conclusion. Dropping only ever widens the result, so
454
+ * the failure mode is extra rows a caller can see — `getAllTemplates()` against a backend that
455
+ * cannot answer `mostRecentActiveVersion` returns every version rather than throwing.
456
+ *
457
+ * Ordering is *not* negotiated here. It is refused instead, because dropping it leaves no
458
+ * trace in the rows for a caller to notice.
459
+ */
460
+ negotiate(filters) {
461
+ return pruneUnsupportedFilters(filters, this.getBackendSupportedFilterCapabilities());
462
+ }
463
+ /**
464
+ * One page of templates, one row per key by default.
465
+ *
466
+ * @param page 1-indexed.
467
+ */
468
+ async getPaginatedTemplates(page, pageSize, includeAllVersions = false, orderBy) {
469
+ ManagedTemplateService.validatePagination(page, pageSize);
470
+ this.validateOrderBy(orderBy);
471
+ if (includeAllVersions) {
472
+ return this.templateManagerBackend.getPaginatedTemplates(page, pageSize, orderBy);
473
+ }
474
+ return this.getPaginatedFilteredTemplates(currentVersionsOnly(), page, pageSize, orderBy);
475
+ }
476
+ /**
477
+ * One page of the templates matching `filters`.
478
+ *
479
+ * @param page 1-indexed.
480
+ */
481
+ async getPaginatedFilteredTemplates(filters, page, pageSize, orderBy) {
482
+ this.validateFilter(filters);
483
+ ManagedTemplateService.validatePagination(page, pageSize);
484
+ this.validateOrderBy(orderBy);
485
+ return this.templateManagerBackend.getPaginatedFilteredTemplates(this.negotiate(filters), page, pageSize, orderBy);
486
+ }
487
+ /**
488
+ * Refuse an order the backend cannot apply, rather than passing it on to be ignored.
489
+ *
490
+ * Unlike an unsupported filter — which a caller drops, getting more rows than it asked for and
491
+ * being able to tell — an ignored order returns exactly the rows requested in an arbitrary
492
+ * sequence. Nothing downstream can detect that, so the only honest options are to apply it or
493
+ * to refuse, and the backend has already said which one this is.
494
+ */
495
+ validateOrderBy(orderBy) {
496
+ if (orderBy === undefined) {
497
+ return;
498
+ }
499
+ if (!MANAGED_TEMPLATE_ORDER_BY_FIELDS.includes(orderBy.field)) {
500
+ throw new ManagedTemplateUnsupportedOrderingError(`'${orderBy.field}' is not an orderable field. Order by one of: ` +
501
+ `${MANAGED_TEMPLATE_ORDER_BY_FIELDS.join(', ')}.`);
502
+ }
503
+ if (orderBy.direction !== 'asc' && orderBy.direction !== 'desc') {
504
+ throw new ManagedTemplateUnsupportedOrderingError(`'${orderBy.direction}' is not a sort direction. Use 'asc' or 'desc'.`);
505
+ }
506
+ const key = orderByCapabilityKey(orderBy.field);
507
+ if (!this.getBackendSupportedFilterCapabilities()[key]) {
508
+ throw new ManagedTemplateUnsupportedOrderingError(`The configured template backend cannot order by '${orderBy.field}' ` +
509
+ `(${key} is false). Read getBackendSupportedFilterCapabilities() and offer only the ` +
510
+ 'fields it reports.');
511
+ }
512
+ }
513
+ /** Every field this backend can order a listing by, in vocabulary order. */
514
+ getSupportedOrderByFields() {
515
+ const capabilities = this.getBackendSupportedFilterCapabilities();
516
+ return MANAGED_TEMPLATE_ORDER_BY_FIELDS.filter((field) => capabilities[orderByCapabilityKey(field)] === true);
517
+ }
518
+ /**
519
+ * Check a filter's shape and field names, throwing rather than passing a broken filter on.
520
+ *
521
+ * A backend translating an unknown field usually either matches nothing or throws something
522
+ * backend-specific, both of which are hard to debug from the call site. This catches the common
523
+ * mistakes — a typo'd field name, an `and`/`or` that is not an array, a logical group carrying
524
+ * sibling keys — while the caller's own frame is still on the stack. It does not validate
525
+ * lookup values; the backend remains the authority there.
526
+ *
527
+ * @throws ManagedTemplateInvalidFilterError if the filter is malformed or names an unknown
528
+ * field.
529
+ */
530
+ validateFilter(filters, path = 'filters') {
531
+ if (!isRecord(filters)) {
532
+ throw new ManagedTemplateInvalidFilterError(`${path} must be an object, got ${describeType(filters)}.`);
533
+ }
534
+ if (isFieldFilter(filters)) {
535
+ const known = new Set(KNOWN_FILTER_FIELDS);
536
+ const unknown = Object.keys(filters)
537
+ .filter((key) => !known.has(key))
538
+ .sort();
539
+ if (unknown.length > 0) {
540
+ throw new ManagedTemplateInvalidFilterError(`${path} names unknown field(s): ${unknown.join(', ')}. ` +
541
+ `Known fields: ${[...KNOWN_FILTER_FIELDS].sort().join(', ')}.`);
542
+ }
543
+ this.validateTagFields(filters, path);
544
+ this.validateFlagFields(filters, path);
545
+ return;
546
+ }
547
+ // A logical group is exactly one of and/or/not, and nothing else. Allowing siblings would
548
+ // make the intended combination ambiguous.
549
+ const keys = Object.keys(filters);
550
+ if (keys.length > 1) {
551
+ throw new ManagedTemplateInvalidFilterError(`${path} mixes a logical operator with other keys (${[...keys].sort().join(', ')}). ` +
552
+ 'Wrap the field filter in its own group instead.');
553
+ }
554
+ const key = keys[0];
555
+ const value = filters[key];
556
+ if (key === 'not') {
557
+ this.validateFilter(value, `${path}.not`);
558
+ return;
559
+ }
560
+ if (!Array.isArray(value)) {
561
+ throw new ManagedTemplateInvalidFilterError(`${path}.${key} must be an array of filters, got ${describeType(value)}.`);
562
+ }
563
+ if (value.length === 0) {
564
+ throw new ManagedTemplateInvalidFilterError(`${path}.${key} must not be empty.`);
565
+ }
566
+ value.forEach((subFilter, index) => {
567
+ this.validateFilter(subFilter, `${path}.${key}[${index}]`);
568
+ });
569
+ }
570
+ /**
571
+ * Reject a tag filter that is not an array of strings.
572
+ *
573
+ * One of the two value checks `validateFilter` does make, because the failure it prevents is
574
+ * silent rather than loud: a bare `'welcome'` would have a backend ask for the tags `w`, `e`,
575
+ * `l`, `c` and return nothing, with no error anywhere.
576
+ */
577
+ validateTagFields(filters, path) {
578
+ for (const field of TAG_FILTER_FIELDS) {
579
+ const value = filters[field];
580
+ if (value === undefined) {
581
+ continue;
582
+ }
583
+ if (!isTagsFilter(value)) {
584
+ throw new ManagedTemplateInvalidFilterError(`${path}.${field} must be an array of tag slugs, got ${describeType(value)}. ` +
585
+ 'Wrap a single tag in an array.');
586
+ }
587
+ }
588
+ }
589
+ /**
590
+ * Reject a flag filter whose value is not a boolean.
591
+ *
592
+ * Checked for the same reason the tag fields are: the failure is silent otherwise. A backend
593
+ * reads a flag for its truthiness, so the string `'false'` — what a query parameter that
594
+ * skipped parsing arrives as — would ask for exactly what the caller meant to switch off.
595
+ */
596
+ validateFlagFields(filters, path) {
597
+ for (const field of FLAG_FILTER_FIELDS) {
598
+ const value = filters[field];
599
+ if (value === undefined) {
600
+ continue;
601
+ }
602
+ if (typeof value !== 'boolean') {
603
+ throw new ManagedTemplateInvalidFilterError(`${path}.${field} must be a boolean, got ${describeType(value)}.`);
604
+ }
605
+ }
606
+ }
607
+ static validatePagination(page, pageSize) {
608
+ if (!Number.isInteger(page) || page < 1) {
609
+ throw new RangeError(`page must be an integer of 1 or greater, got ${page}.`);
610
+ }
611
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
612
+ throw new RangeError(`pageSize must be an integer of 1 or greater, got ${pageSize}.`);
613
+ }
614
+ }
615
+ // -------------------------------------------------------------------------------------------
616
+ // Composition
617
+ // -------------------------------------------------------------------------------------------
618
+ /**
619
+ * Flatten a template's inheritance and inclusion into a single self-contained one.
620
+ *
621
+ * Composition is what a store-backed template has instead of an engine loader: the engine is
622
+ * handed source rather than a name, so a `{% managed_extends %}` here is resolved against the
623
+ * backend before the engine ever sees the template. The result has no `managed_*` tag left in
624
+ * it, and the engine's own syntax is untouched.
625
+ *
626
+ * A no-op — returning the very template it was given — when the template composes to itself,
627
+ * and when the service was built with `composeTemplates: false`.
628
+ */
629
+ async composeTemplate(template) {
630
+ if (!this.composeTemplates) {
631
+ return template;
632
+ }
633
+ return this.composer.compose(template);
634
+ }
635
+ /**
636
+ * One version of a template, assembled as the engine will receive it.
637
+ *
638
+ * The counterpart to `getTemplate`, which is deliberately literal about what is stored.
639
+ */
640
+ async getComposedTemplate(templateKey, version = null) {
641
+ return this.composeTemplate(await this.getTemplate(templateKey, version));
642
+ }
643
+ /**
644
+ * Assemble a template and throw the result away, to surface any problem now.
645
+ *
646
+ * What a form or a deploy check calls: it throws exactly what rendering would have thrown — a
647
+ * malformed tag, a base that does not exist, a loop — at a point where someone can still fix it
648
+ * rather than at send time.
649
+ */
650
+ async validateComposition(template) {
651
+ await this.composer.validate(template);
652
+ }
653
+ /**
654
+ * The templates one template directly extends or includes.
655
+ *
656
+ * Direct references only, and nothing is resolved, so this answers "what does this template
657
+ * name" without needing any of them to exist.
658
+ */
659
+ getTemplateReferences(template) {
660
+ return this.composer.references(template);
661
+ }
662
+ /**
663
+ * Whether a template is a base to build on rather than one to send.
664
+ *
665
+ * Read off the source every time it is asked, which makes it the authority:
666
+ * `template.isAbstract` is a backend's stored copy of this answer, kept for the `isAbstract`
667
+ * filter to query, and this is what that copy is supposed to say.
668
+ *
669
+ * To *find* the bases rather than test one, filter on the stored flag — it is indexed and this
670
+ * is not:
671
+ *
672
+ * ```ts
673
+ * await service.getFilteredTemplates({ isAbstract: false }); // everything sendable
674
+ * ```
675
+ *
676
+ * Neither one refuses anything: the service renders an abstract template quite happily, and
677
+ * gives you the layout with an empty hole. Keeping bases out of a picker is the host's call.
678
+ */
679
+ isAbstract(template) {
680
+ return this.composer.isAbstract(template);
681
+ }
682
+ // -------------------------------------------------------------------------------------------
683
+ // Rendering
684
+ // -------------------------------------------------------------------------------------------
685
+ /**
686
+ * Render a notification against a specific version of its template.
687
+ *
688
+ * The notification's `bodyTemplate` is the template key. Which version renders is decided in
689
+ * this order: the `version` argument, then the notification's own `requestedTemplateVersion`,
690
+ * then whatever the backend considers current.
691
+ *
692
+ * The argument is there to render a version the notification is *not* pinned to — previewing an
693
+ * unpublished draft, or reproducing what an old notification looked like. Leave it off and this
694
+ * renders what a real send would.
695
+ */
696
+ async render(notification, context, version = null) {
697
+ return this.templateRenderer.renderManaged(notification, context, version);
698
+ }
699
+ /**
700
+ * Render a notification against a template already in hand, with no backend read.
701
+ *
702
+ * The template is composed by this service — through *its* composer and *its*
703
+ * `composeTemplates` setting, not the renderer's — so one already fetched and edited in memory
704
+ * renders the same way a stored one does.
705
+ */
706
+ async renderTemplate(notification, template, context) {
707
+ const composed = await this.composeTemplate(template);
708
+ const content = this.templateRenderer.createTemplateContent(composed);
709
+ const rendered = await this.templateRenderer.renderFromTemplateContent(notification, content, context);
710
+ return { key: template.key, version: template.version, rendered };
711
+ }
712
+ }