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.
- package/README.md +529 -0
- package/dist/base-template-manager-backend.d.ts +202 -0
- package/dist/base-template-manager-backend.d.ts.map +1 -0
- package/dist/base-template-manager-backend.js +1 -0
- package/dist/composition.d.ts +238 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +0 -0
- package/dist/constants.d.ts +32 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +31 -0
- package/dist/errors.d.ts +80 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +86 -0
- package/dist/filter-evaluation.d.ts +69 -0
- package/dist/filter-evaluation.d.ts.map +1 -0
- package/dist/filter-evaluation.js +252 -0
- package/dist/filters.d.ts +192 -0
- package/dist/filters.d.ts.map +1 -0
- package/dist/filters.js +252 -0
- package/dist/in-memory-template-manager-backend.d.ts +85 -0
- package/dist/in-memory-template-manager-backend.d.ts.map +1 -0
- package/dist/in-memory-template-manager-backend.js +323 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/managed-template-renderer.d.ts +153 -0
- package/dist/managed-template-renderer.d.ts.map +1 -0
- package/dist/managed-template-renderer.js +152 -0
- package/dist/managed-template-service.d.ts +415 -0
- package/dist/managed-template-service.d.ts.map +1 -0
- package/dist/managed-template-service.js +712 -0
- package/dist/tags.d.ts +54 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +108 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
# vintasend-managed-templates
|
|
2
|
+
|
|
3
|
+
Database-backed notification templates for
|
|
4
|
+
[VintaSend](https://github.com/vintasoftware/vintasend-ts): versioning, a
|
|
5
|
+
draft/active/inactive/archived lifecycle with an audit trail, tags, composition and filtering — all
|
|
6
|
+
on top of a storage seam you (or a ready-made package) implement.
|
|
7
|
+
|
|
8
|
+
A regular VintaSend template renderer reads templates from wherever its engine looks, which is
|
|
9
|
+
usually files on disk. That means every copy change is a deploy. This package moves the templates
|
|
10
|
+
into a data store so someone who is not a developer can edit them, keeps every edit as a new
|
|
11
|
+
version, and lets you publish a version deliberately instead of the moment it is saved.
|
|
12
|
+
|
|
13
|
+
It is storage-agnostic on its own — it defines the interface, not the database. Pair it with a
|
|
14
|
+
manager backend such as
|
|
15
|
+
[vintasend-medplum-template-manager](https://github.com/vintasoftware/vintasend-medplum-template-manager/),
|
|
16
|
+
or implement `BaseTemplateManagerBackend` yourself.
|
|
17
|
+
|
|
18
|
+
This is the TypeScript sibling of
|
|
19
|
+
[vintasend-managed-templates](https://github.com/vintasoftware/vintasend-managed-templates) for
|
|
20
|
+
Python. The two agree on the tag language, the slug rules, the lifecycle and the filter vocabulary,
|
|
21
|
+
so a store written by one is readable by the other.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install vintasend-managed-templates
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Node 20+. The only dependency is `vintasend` itself.
|
|
30
|
+
|
|
31
|
+
## The pieces
|
|
32
|
+
|
|
33
|
+
| Piece | What it is |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `BaseTemplateManagerBackend` | The storage seam. Template CRUD, versions, status history, tags, filtering and pagination. |
|
|
36
|
+
| `ManagedTemplateService` | The API you call. Wraps a backend and a renderer with version resolution, status-transition rules, filter validation and tag normalization. |
|
|
37
|
+
| `ManagedTemplateEmailRenderer` / `ManagedTemplateTextRenderer` | A VintaSend template renderer that wraps *another* renderer and feeds it a stored template instead of a template path. |
|
|
38
|
+
| `TemplateComposer` | Resolves template inheritance and inclusion against the store, before the engine runs. |
|
|
39
|
+
| `slugifyTag` / `nextAvailableSlug` | The shared slug rules, so every backend derives the same slug from the same text. |
|
|
40
|
+
| `InMemoryTemplateManagerBackend` | A complete backend that keeps everything in memory — for tests, and as the executable statement of what the seam means. |
|
|
41
|
+
|
|
42
|
+
Everything is asynchronous, unlike the Python sibling: a TypeScript store is a network call more
|
|
43
|
+
often than not.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import {
|
|
49
|
+
InMemoryTemplateManagerBackend,
|
|
50
|
+
ManagedTemplateEmailRenderer,
|
|
51
|
+
ManagedTemplateService,
|
|
52
|
+
} from 'vintasend-managed-templates';
|
|
53
|
+
|
|
54
|
+
const managerBackend = new InMemoryTemplateManagerBackend(); // any BaseTemplateManagerBackend
|
|
55
|
+
const renderer = new ManagedTemplateEmailRenderer<Config>(
|
|
56
|
+
managerBackend,
|
|
57
|
+
innerRenderer, // any VintaSend email renderer
|
|
58
|
+
);
|
|
59
|
+
const service = new ManagedTemplateService<Config>(managerBackend, renderer);
|
|
60
|
+
|
|
61
|
+
await service.createTemplate({
|
|
62
|
+
key: 'welcome', // what notifications reference
|
|
63
|
+
name: 'Welcome email',
|
|
64
|
+
description: 'Sent right after signup',
|
|
65
|
+
templateManagedBackend: 'medplum', // which manager backend stores it
|
|
66
|
+
bodyTemplate: '<p>Hi #{name}, welcome!</p>',
|
|
67
|
+
subjectTemplate: 'Welcome aboard',
|
|
68
|
+
preheaderTemplate: null,
|
|
69
|
+
tenant: null,
|
|
70
|
+
tags: ['onboarding', 'Black Friday'],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await service.activate('welcome', null, 'hugo@example.com');
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
To send through it, hand the wrapping renderer to your adapter and set the notification's
|
|
77
|
+
`bodyTemplate` to the template **key** instead of a path:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const notificationService = new VintaSendFactory<Config>().create(
|
|
81
|
+
[new MyEmailAdapterFactory().create(renderer, false, adapterConfig)],
|
|
82
|
+
notificationBackend,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await notificationService.createNotification({
|
|
86
|
+
userId: user.id,
|
|
87
|
+
notificationType: 'EMAIL',
|
|
88
|
+
title: 'Welcome',
|
|
89
|
+
bodyTemplate: 'welcome', // a managed template key, not a file path
|
|
90
|
+
contextName: 'welcomeContext',
|
|
91
|
+
contextParameters: { userId: user.id },
|
|
92
|
+
sendAfter: null,
|
|
93
|
+
subjectTemplate: null,
|
|
94
|
+
extraParams: null,
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Nothing else about creating or sending notifications changes.
|
|
99
|
+
|
|
100
|
+
### What the inner renderer has to do
|
|
101
|
+
|
|
102
|
+
`ManagedTemplateRenderer` looks the template up, builds the content out of the stored strings, and
|
|
103
|
+
calls the inner renderer's `renderFromTemplateContent`. So the inner renderer receives **template
|
|
104
|
+
source in the `body` field**, where a file-based renderer would expect a name a loader resolves.
|
|
105
|
+
|
|
106
|
+
Every renderer in the VintaSend ecosystem implements `renderFromTemplateContent` — it is the seam
|
|
107
|
+
VintaSend already uses to render content it holds rather than loads — so `vintasend-pug`,
|
|
108
|
+
`vintasend-react-email` and the rest work unchanged. A renderer of your own only needs to compile
|
|
109
|
+
the string it is handed rather than open a file.
|
|
110
|
+
|
|
111
|
+
The email content is VintaSend's `EmailTemplateContent` plus one field:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
type ManagedEmailTemplateContent = {
|
|
115
|
+
subject: string | null;
|
|
116
|
+
body: string;
|
|
117
|
+
preheader: string | null; // added, so a renderer that knows about preheaders can use it
|
|
118
|
+
};
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A renderer that does not know about preheaders ignores the extra field, which is why it is added
|
|
122
|
+
rather than replacing the shape.
|
|
123
|
+
|
|
124
|
+
## Composition: bases, blocks and includes
|
|
125
|
+
|
|
126
|
+
A file-based renderer gets composition for free. Pug's `extends` and Nunjucks' `include` hand a
|
|
127
|
+
*name* to a loader, and a loader reads files — so the header, the footer and the wrapper every
|
|
128
|
+
email shares live in one file that every other file points at.
|
|
129
|
+
|
|
130
|
+
Managed templates are not files. They reach the engine as source, so a loader has nothing to
|
|
131
|
+
resolve and those tags have nothing to load. Without composition the shared chrome would have to be
|
|
132
|
+
pasted into every row in the store, and changing the footer would mean editing all of them.
|
|
133
|
+
|
|
134
|
+
This package resolves its own set of tags **before** the engine sees anything. What the engine
|
|
135
|
+
receives is one flat string with no `managed_*` tag left in it; its own syntax is untouched.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
await service.createTemplate({
|
|
139
|
+
key: 'base-email',
|
|
140
|
+
name: 'Base email',
|
|
141
|
+
description: 'The wrapper every email uses',
|
|
142
|
+
templateManagedBackend: 'medplum',
|
|
143
|
+
bodyTemplate: [
|
|
144
|
+
'<html>',
|
|
145
|
+
' <body>',
|
|
146
|
+
' {% managed_block header %}<h1>Acme</h1>{% managed_endblock %}',
|
|
147
|
+
' {% managed_children %}',
|
|
148
|
+
' {% managed_include "footer" %}',
|
|
149
|
+
' </body>',
|
|
150
|
+
'</html>',
|
|
151
|
+
].join('\n'),
|
|
152
|
+
subjectTemplate: '[Acme] {% managed_children %}',
|
|
153
|
+
preheaderTemplate: null,
|
|
154
|
+
tenant: null,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await service.createTemplate({
|
|
158
|
+
key: 'welcome',
|
|
159
|
+
name: 'Welcome email',
|
|
160
|
+
description: 'Sent right after signup',
|
|
161
|
+
templateManagedBackend: 'medplum',
|
|
162
|
+
bodyTemplate: [
|
|
163
|
+
'{% managed_extends "base-email" %}',
|
|
164
|
+
'{% managed_block header %}<h1>Welcome!</h1>{% managed_endblock %}',
|
|
165
|
+
'<p>Hi #{name}, welcome aboard.</p>',
|
|
166
|
+
].join('\n'),
|
|
167
|
+
subjectTemplate: '{% managed_extends "base-email" %}Welcome aboard',
|
|
168
|
+
preheaderTemplate: null,
|
|
169
|
+
tenant: null,
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`welcome` now renders inside the base, with its own header and the shared footer, and its subject
|
|
174
|
+
comes out as `[Acme] Welcome aboard`. `#{name}` is never looked at — the context is the engine's
|
|
175
|
+
business.
|
|
176
|
+
|
|
177
|
+
### The tags
|
|
178
|
+
|
|
179
|
+
| Tag | What it does |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `{% managed_extends "key" %}` | This template is a child of `key`. At most one per template, never inside a block. Pin the parent with `version=2`. |
|
|
182
|
+
| `{% managed_children %}` | In a base: where the child's content goes. Rendered with no child, the hole is simply empty. |
|
|
183
|
+
| `{% managed_block name %}…{% managed_endblock %}` | A named region a child may replace. Unreplaced, it renders what it was declared with. Blocks may nest. |
|
|
184
|
+
| `{% managed_super %}` | Inside a child's block: the content it is overriding. Chains through as many levels of inheritance as there are. |
|
|
185
|
+
| `{% managed_include "key" %}` | Splice another template in here. It is composed in full first, so an include may itself extend and include. Pins the same way: `version=7`. |
|
|
186
|
+
|
|
187
|
+
Everything a child writes **outside** a block is its children content, and it lands in the base's
|
|
188
|
+
`{% managed_children %}`. So a child can both fill the hole and override named regions.
|
|
189
|
+
|
|
190
|
+
The `managed_` prefix is reserved: an unknown `{% managed_something %}` is an error rather than
|
|
191
|
+
text passed through, so a typo surfaces at edit time instead of shipping. Change the prefix by
|
|
192
|
+
handing the renderer or the service its own composer:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { TemplateComposer } from 'vintasend-managed-templates';
|
|
196
|
+
|
|
197
|
+
const composer = TemplateComposer.fromBackend(managerBackend, { tagPrefix: 'tpl_' });
|
|
198
|
+
const renderer = new ManagedTemplateEmailRenderer<Config>(managerBackend, innerRenderer, {
|
|
199
|
+
composer,
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### One field at a time
|
|
204
|
+
|
|
205
|
+
A template carries three sources — body, subject and preheader — and each composes against the
|
|
206
|
+
**same field** of the template it references. A child's body extends the base's body; its subject
|
|
207
|
+
extends the base's subject. So a base can define a subject prefix and a body wrapper at once, and
|
|
208
|
+
neither leaks into the other. A field the base leaves empty composes to nothing rather than to an
|
|
209
|
+
error.
|
|
210
|
+
|
|
211
|
+
### Whitespace
|
|
212
|
+
|
|
213
|
+
A structural tag (`extends`, `block`, `endblock`) alone on its line is taken out *with* the line,
|
|
214
|
+
so a layout written across several lines does not compose into one padded with blank ones. The
|
|
215
|
+
placeholder tags (`children`, `include`, `super`) are never line-trimmed: what replaces them lands
|
|
216
|
+
exactly where the tag stood, indentation and all.
|
|
217
|
+
|
|
218
|
+
### Abstract templates
|
|
219
|
+
|
|
220
|
+
A template is *abstract* when it declares a `{% managed_children %}` hole, or declares blocks
|
|
221
|
+
without extending anything — a layout meant to be built on rather than sent. That is a fact about
|
|
222
|
+
the source, so it follows the template as it is edited.
|
|
223
|
+
|
|
224
|
+
**The check** recomputes from the source every time, which makes it the authority:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
service.isAbstract(base); // true
|
|
228
|
+
service.isAbstract(welcome); // false
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**The flag** is that same answer, denormalized onto the template so it can be queried:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
base.isAbstract; // true — stored, not recomputed
|
|
235
|
+
await service.getFilteredTemplates({ isAbstract: false }); // every sendable template
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Filtering is the reason the flag exists. Without it, a picker that has to leave the bases out would
|
|
239
|
+
read and parse every row in the store to draw one page. Nobody writes the flag — there is no field
|
|
240
|
+
for it on either write input — because a stored copy that disagreed with the source would be a lie
|
|
241
|
+
a filter goes on repeating. It is a **backend's job to derive it on every write** with
|
|
242
|
+
`isAbstract()`; see [Implementing a manager backend](#implementing-a-manager-backend).
|
|
243
|
+
|
|
244
|
+
Composing an abstract template directly is allowed and gives you the layout with an empty hole.
|
|
245
|
+
Neither the check nor the flag refuses anything — keeping bases out of a picker is the host's call.
|
|
246
|
+
|
|
247
|
+
### Versions
|
|
248
|
+
|
|
249
|
+
A reference with no version resolves the same way any other read does: to whatever version that key
|
|
250
|
+
currently is. Pin it when a template must keep composing against an exact parent.
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
{% managed_extends "base-email" version=2 %}
|
|
254
|
+
{% managed_include "footer" version=7 %}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Nothing inside the quoted key is interpreted, so a key is only ever a key.
|
|
258
|
+
|
|
259
|
+
### Checking a template before it ships
|
|
260
|
+
|
|
261
|
+
Composition failures are this package's, not the engine's, so nothing downstream can report them.
|
|
262
|
+
Catch them where someone can still fix them:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
await service.validateComposition(template); // throws exactly what rendering would have
|
|
266
|
+
await service.getComposedTemplate('welcome'); // what the engine will actually receive
|
|
267
|
+
service.getTemplateReferences(template); // the bases and fragments it names, unresolved
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
| Error | Thrown when |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `ManagedTemplateCompositionSyntaxError` | A tag is malformed, unknown, or unbalanced |
|
|
273
|
+
| `ManagedTemplateCompositionReferenceError` | A base or fragment does not exist |
|
|
274
|
+
| `ManagedTemplateCompositionCycleError` | The references loop |
|
|
275
|
+
| `ManagedTemplateCompositionDepthError` | The chain runs past the composer's `maxDepth` (25 by default) |
|
|
276
|
+
|
|
277
|
+
All four extend `ManagedTemplateCompositionError`. A missing *reference* is also a missing
|
|
278
|
+
*template*: Python expresses that with multiple inheritance, which TypeScript has no equivalent
|
|
279
|
+
for, so use `isNotFoundError(error)` when you want to treat the two as one condition — and test for
|
|
280
|
+
`ManagedTemplateCompositionError` **first** where the distinction matters, since a base that does
|
|
281
|
+
not exist is a broken composition of a template that does.
|
|
282
|
+
|
|
283
|
+
### Turning it off
|
|
284
|
+
|
|
285
|
+
Composition is on by default. A store that predates it and holds `managed_`-prefixed text meant to
|
|
286
|
+
reach the engine verbatim can opt out:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
new ManagedTemplateEmailRenderer(managerBackend, innerRenderer, { composeTemplates: false });
|
|
290
|
+
new ManagedTemplateService(managerBackend, renderer, { composeTemplates: false });
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Reads are never composed either way: `getTemplate` hands back exactly what is stored, which is what
|
|
294
|
+
an editing UI needs. `getComposedTemplate` is the explicit way to ask for the assembled form.
|
|
295
|
+
|
|
296
|
+
## Templates and versions
|
|
297
|
+
|
|
298
|
+
Templates are **versioned, never edited in place**. `updateTemplate` copies the latest version
|
|
299
|
+
forward, applies the fields the input sets, and returns the new version — so a published version's
|
|
300
|
+
body can never change under a notification that already referenced it. The copy starts in `draft`
|
|
301
|
+
whatever its predecessor was in.
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
await service.updateTemplate('welcome', {
|
|
305
|
+
bodyTemplate: '<p>Hi #{name}, welcome aboard!</p>',
|
|
306
|
+
// every other field is carried forward from the latest version
|
|
307
|
+
// tags: undefined carries them forward; [] clears them
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await service.getTemplate('welcome'); // latest version
|
|
311
|
+
await service.getTemplate('welcome', 1); // a specific one
|
|
312
|
+
await service.getTemplateVersions('welcome'); // every version, newest first
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
An absent `version` means "the latest version of this key" everywhere in the service — reads,
|
|
316
|
+
status changes, tagging, and rendering — so callers only deal with version numbers when they
|
|
317
|
+
actually want a specific one.
|
|
318
|
+
|
|
319
|
+
### Version-pinned rendering
|
|
320
|
+
|
|
321
|
+
`renderManaged` reports which version rendered, which is the only way to find out afterwards what
|
|
322
|
+
an unpinned notification went out with:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
const { version, rendered } = await renderer.renderManaged(notification, context);
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Which version renders is decided in this order: an explicit `version` argument, then the
|
|
329
|
+
notification's own `requestedTemplateVersion`, then whatever the backend considers current.
|
|
330
|
+
|
|
331
|
+
`requestedTemplateVersion` is a first-class VintaSend field: pass it to `createNotification`, or
|
|
332
|
+
let the service resolve it for you with `pinTemplateVersions`. This package is what makes it mean
|
|
333
|
+
anything — `getLatestTemplateVersion` is how the service resolves "whatever is current right now",
|
|
334
|
+
and it is overridden here to read the store.
|
|
335
|
+
|
|
336
|
+
`render` also stamps the version it used onto the payload it returns, as `templateVersion`. An
|
|
337
|
+
adapter returns that payload from `send()`, and the service records it on the notification as
|
|
338
|
+
`usedTemplateVersion` — which on an unpinned notification is the only record of which version went
|
|
339
|
+
out. See
|
|
340
|
+
[Template Version Pinning](https://github.com/vintasoftware/vintasend-ts#template-version-pinning).
|
|
341
|
+
|
|
342
|
+
## Statuses
|
|
343
|
+
|
|
344
|
+
A version moves through `draft → active → inactive → archived`, and every move is written to the
|
|
345
|
+
backend's audit trail:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
await service.activate('welcome', null, 'hugo@example.com');
|
|
349
|
+
await service.deactivate('welcome');
|
|
350
|
+
await service.archive('welcome', 1);
|
|
351
|
+
await service.getStatusHistory('welcome'); // newest change first
|
|
352
|
+
service.canTransitionTo(template, 'active');
|
|
353
|
+
service.allowedTransitionsFor(template); // what a UI should offer
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The default transition table:
|
|
357
|
+
|
|
358
|
+
| From | May move to |
|
|
359
|
+
|---|---|
|
|
360
|
+
| `draft` | `active`, `archived` |
|
|
361
|
+
| `active` | `inactive`, `archived` |
|
|
362
|
+
| `inactive` | `active`, `archived` |
|
|
363
|
+
| `archived` | — terminal |
|
|
364
|
+
|
|
365
|
+
Anything else throws `ManagedTemplateStatusTransitionError`. Setting a version to the status it
|
|
366
|
+
already holds is a no-op: no history entry, no error. Pass your own `allowedStatusTransitions` for
|
|
367
|
+
a different lifecycle, or `validateStatusTransitions: false` to leave the ordering entirely to your
|
|
368
|
+
application.
|
|
369
|
+
|
|
370
|
+
Two things the service deliberately does *not* decide for you:
|
|
371
|
+
|
|
372
|
+
* **A key may have several `active` versions at once.** Activating one does not deactivate the
|
|
373
|
+
others; choosing which active version wins at render time is the host's call.
|
|
374
|
+
* **`changedBy` is passed through untouched, `null` included.** Attribution is never required.
|
|
375
|
+
|
|
376
|
+
## Tags
|
|
377
|
+
|
|
378
|
+
Tags are many-to-many with template *versions* and are identified by a slug derived from the text
|
|
379
|
+
someone typed. Slugging lives in `tags.ts` rather than in a backend, so every store agrees on what
|
|
380
|
+
`Promoção` slugs to — and agrees with the Python package. Every call that takes a slug also accepts
|
|
381
|
+
the original text.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
await service.addTemplateTags('welcome', ['Black Friday']); // creates the tag if it is new
|
|
385
|
+
await service.removeTemplateTags('welcome', ['black-friday']);
|
|
386
|
+
await service.setTemplateTags('welcome', ['onboarding']); // replaces; [] clears
|
|
387
|
+
await service.getTemplatesByTags(['onboarding', 'email'], false); // any of them
|
|
388
|
+
await service.getActiveTags(); // what a tag picker should show
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Retagging **edits the version in place** instead of creating one. Tags are how a template is found,
|
|
392
|
+
not part of what it renders, so relabelling for findability does not spawn a version and drop it
|
|
393
|
+
back to `draft`.
|
|
394
|
+
|
|
395
|
+
Archiving a tag (`archiveTag` / `restoreTag`) takes it out of the pickers but keeps every link:
|
|
396
|
+
filtering by an archived tag still returns the templates carrying it. `deleteTag` is the
|
|
397
|
+
irreversible one — it removes the label from the templates too.
|
|
398
|
+
|
|
399
|
+
Text with nothing sluggable in it (`' '`, `'!!!'`) throws `ManagedTemplateInvalidTagError` at the
|
|
400
|
+
call site, rather than becoming a tag no filter can ever name.
|
|
401
|
+
|
|
402
|
+
## Filtering and pagination
|
|
403
|
+
|
|
404
|
+
Filters are plain objects spelled exactly the way `vintasend` spells its notification filters, and
|
|
405
|
+
compose with `and` / `or` / `not`:
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
await service.getFilteredTemplates({
|
|
409
|
+
and: [
|
|
410
|
+
{ status: { lookup: 'in', value: ['active'] } },
|
|
411
|
+
{ name: { lookup: 'includes', value: 'welcome', caseSensitive: false } },
|
|
412
|
+
{ includesAnyOfTags: ['onboarding', 'transactional'] },
|
|
413
|
+
{ createdAtRange: { from: new Date('2026-01-01') } },
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await service.getPaginatedFilteredTemplates(filters, 1, 20); // page is 1-indexed
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Fields: `name`, `description`, `key`, `version`, `templateManagedBackend`, `status`,
|
|
421
|
+
`createdAtRange`, `updatedAtRange`, `includesAllTags`, `includesAnyOfTags`, `isAbstract`,
|
|
422
|
+
`mostRecentActiveVersion`. String lookups are `exact` / `startsWith` / `endsWith` / `includes`;
|
|
423
|
+
numeric ones are `gt` / `gte` / `lt` / `lte`.
|
|
424
|
+
|
|
425
|
+
A filter is checked for shape and field names before it reaches the backend, so a typo throws
|
|
426
|
+
`ManagedTemplateInvalidFilterError` at the call site instead of silently matching nothing deep
|
|
427
|
+
inside a backend's query translation.
|
|
428
|
+
|
|
429
|
+
### One row per key: `mostRecentActiveVersion`
|
|
430
|
+
|
|
431
|
+
The store holds a row per *version*, so an unfiltered read shows a template once for every version
|
|
432
|
+
it has ever had. `mostRecentActiveVersion` collapses that to one row per key — the highest-numbered
|
|
433
|
+
`active` or `draft` version, which is what is live plus the draft on its way to replacing it. A key
|
|
434
|
+
whose versions are all `inactive` or `archived` has no current version and drops out.
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
await service.getAllTemplates(); // one row per key — the current version
|
|
438
|
+
await service.getAllTemplates(true); // every version of every key
|
|
439
|
+
await service.getPaginatedTemplates(1, 20); // same default
|
|
440
|
+
await service.getFilteredTemplates({ mostRecentActiveVersion: true }); // the filter itself
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
**The two listing methods apply it by default**; pass `true` for the raw read.
|
|
444
|
+
`getFilteredTemplates` and `getPaginatedFilteredTemplates` do *not* add it — a filter means what it
|
|
445
|
+
says — so name the field yourself when a filtered listing should be one row per key.
|
|
446
|
+
|
|
447
|
+
### Capabilities
|
|
448
|
+
|
|
449
|
+
A backend declares only what it *cannot* do, and its report is merged over the library default:
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
service.getBackendSupportedFilterCapabilities();
|
|
453
|
+
// { 'logical.or': false, 'stringLookups.includes': false, 'orderBy.name': false, ... }
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Read it to drop a filter a backend cannot honour rather than sending one it will ignore or throw
|
|
457
|
+
on.
|
|
458
|
+
|
|
459
|
+
Most keys default to `true`, so a filter field added in a later release does not force every
|
|
460
|
+
backend to re-declare support for it. **New vocabulary is the exception and defaults to `false`** —
|
|
461
|
+
otherwise every backend that shipped before the field existed would claim a filter it silently
|
|
462
|
+
ignores. Every `orderBy.*` key is in that category today.
|
|
463
|
+
|
|
464
|
+
### Ordering
|
|
465
|
+
|
|
466
|
+
`getPaginatedTemplates` and `getPaginatedFilteredTemplates` take an optional `orderBy`:
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
await service.getPaginatedTemplates(1, 20, false, { field: 'updatedAt', direction: 'desc' });
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Orderable fields are `key`, `name`, `version`, `status`, `createdAt` and `updatedAt` — each a
|
|
473
|
+
scalar the backend already stores per row, so a store can answer it from an index. Tags are absent
|
|
474
|
+
because a many-to-many has no single value to compare, and `mostRecentActiveVersion` because it is
|
|
475
|
+
a filter, not a field.
|
|
476
|
+
|
|
477
|
+
**An order the backend cannot apply is refused, not dropped.** This is the one place the library
|
|
478
|
+
does not follow the "drop what the backend cannot do" rule, and the asymmetry is deliberate:
|
|
479
|
+
|
|
480
|
+
| | Unsupported filter | Unsupported order |
|
|
481
|
+
|---|---|---|
|
|
482
|
+
| If ignored | more rows come back than asked for | the same rows come back in an arbitrary sequence |
|
|
483
|
+
| Can the caller tell? | yes — the rows are visibly wrong | no |
|
|
484
|
+
|
|
485
|
+
So `ManagedTemplateService` throws `ManagedTemplateUnsupportedOrderingError`, naming the capability
|
|
486
|
+
key. Ask first and offer only what the backend reports:
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
service.getSupportedOrderByFields(); // ['createdAt', 'updatedAt']
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
The order has to reach the store. Sorting a page after it has been chosen orders rows *within* the
|
|
493
|
+
page while the rows selected *for* it came back in the backend's own order — correct-looking on
|
|
494
|
+
page 1 and wrong on every page after it. A backend that holds the whole result set anyway can sort
|
|
495
|
+
with `sortTemplates` from this package, which gives a **total, stable** order: every field breaks
|
|
496
|
+
ties on `(key, version)`, and neither the tiebreak nor the placement of absent values flips with
|
|
497
|
+
the direction, so a page boundary cannot move between two requests and drop or repeat a row.
|
|
498
|
+
|
|
499
|
+
## Implementing a manager backend
|
|
500
|
+
|
|
501
|
+
Implement `BaseTemplateManagerBackend`. `InMemoryTemplateManagerBackend` is a complete
|
|
502
|
+
implementation to read against, and the seam's own test suite
|
|
503
|
+
(`src/__tests__/in-memory-backend.test.ts`) doubles as a conformance checklist.
|
|
504
|
+
|
|
505
|
+
The three rules that are easy to miss:
|
|
506
|
+
|
|
507
|
+
1. **Derive `isAbstract` on every write** that touches a source field, with `isAbstract()` from
|
|
508
|
+
this package, and store the answer. A source whose composition tags are malformed has no answer:
|
|
509
|
+
store `false` rather than letting the syntax error out of the write.
|
|
510
|
+
2. **`updateTemplate` inserts, never updates.** Copy the latest version forward, bump the version,
|
|
511
|
+
start the copy in `draft`, and leave the version it was copied from untouched.
|
|
512
|
+
3. **`mostRecentActiveVersion` is answered against the key, not the row.** "This row is `active` or
|
|
513
|
+
`draft`, and no `active`-or-`draft` row of the same key is numbered higher."
|
|
514
|
+
|
|
515
|
+
Slug every tag with `slugifyTag` and keep slugs unique with `nextAvailableSlug`, so your store and
|
|
516
|
+
every other one derive the same identity from the same text.
|
|
517
|
+
|
|
518
|
+
## Development
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
npm install
|
|
522
|
+
npm test
|
|
523
|
+
npm run typecheck
|
|
524
|
+
npm run lint
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
## License
|
|
528
|
+
|
|
529
|
+
MIT
|