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
package/dist/tags.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Turning free text into a tag slug, and keeping that slug unique.
3
+ *
4
+ * Tags are typed by humans ("Black Friday", "black friday ", "Black-Friday") and searched by
5
+ * machines, so every tag carries a normalized `slug` alongside the text it was written as. The
6
+ * slug is the identity: it is what a filter matches on, what a URL carries, and what a store
7
+ * enforces uniqueness over.
8
+ *
9
+ * Slugging lives here rather than in a backend so every implementation of the storage seam
10
+ * produces the same slug for the same text — and so the TypeScript and Python packages agree,
11
+ * which matters the moment two services read the same store.
12
+ *
13
+ * The rules, in order:
14
+ *
15
+ * 1. Unicode-normalize (NFKD) and drop everything outside ASCII, so `Promoção` and `Promocao`
16
+ * are the same tag.
17
+ * 2. Lowercase, then replace every run of non-alphanumeric characters with a single `-`.
18
+ * 3. Trim leading and trailing `-`.
19
+ *
20
+ * Text that is entirely non-Latin (`日本語`) folds to nothing under step 1, so it falls back to a
21
+ * Unicode-preserving pass that keeps alphanumeric characters as they are. Losing a whole tag to
22
+ * transliteration would be worse than a slug that is not URL-clean.
23
+ */
24
+ /**
25
+ * Longest slug this module will produce. Chosen to fit the 255-char columns backends use for
26
+ * it, leaving room for the `-2` / `-3` suffix uniqueness may need to append.
27
+ */
28
+ export declare const MAX_SLUG_LENGTH = 240;
29
+ /**
30
+ * Collapse a tag's whitespace and trim it, leaving the caller's casing intact.
31
+ *
32
+ * The text is what a UI displays, so this is deliberately gentle — it fixes the artifacts of
33
+ * typing (a trailing space, a double space) and nothing else. {@link slugifyTag} handles the rest.
34
+ */
35
+ export declare function normalizeTagText(text: string): string;
36
+ /**
37
+ * Normalize free text into a tag slug. Returns `''` for text with nothing sluggable.
38
+ *
39
+ * An empty result is returned rather than thrown on so callers can decide what it means: the
40
+ * service rejects it, while a filter treats it as a tag that matches nothing.
41
+ */
42
+ export declare function slugifyTag(text: string): string;
43
+ /**
44
+ * Return `slug`, or the first `slug-N` that `isTaken` says is free.
45
+ *
46
+ * Backends call this while holding whatever lock they use for tag writes: `isTaken` is a read,
47
+ * so two concurrent creates can both be told the same slug is free. The unique constraint on
48
+ * the store is what actually decides, and this only keeps the common case from hitting it.
49
+ *
50
+ * @param slug an already-slugified base.
51
+ * @param isTaken resolves to true when a tag with that slug already exists.
52
+ */
53
+ export declare function nextAvailableSlug(slug: string, isTaken: (candidate: string) => boolean | Promise<boolean>): Promise<string>;
54
+ //# sourceMappingURL=tags.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../src/tags.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;GAGG;AACH,eAAO,MAAM,eAAe,MAAM,CAAC;AA4BnC;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAS/C;AAeD;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACzD,OAAO,CAAC,MAAM,CAAC,CAcjB"}
package/dist/tags.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Turning free text into a tag slug, and keeping that slug unique.
3
+ *
4
+ * Tags are typed by humans ("Black Friday", "black friday ", "Black-Friday") and searched by
5
+ * machines, so every tag carries a normalized `slug` alongside the text it was written as. The
6
+ * slug is the identity: it is what a filter matches on, what a URL carries, and what a store
7
+ * enforces uniqueness over.
8
+ *
9
+ * Slugging lives here rather than in a backend so every implementation of the storage seam
10
+ * produces the same slug for the same text — and so the TypeScript and Python packages agree,
11
+ * which matters the moment two services read the same store.
12
+ *
13
+ * The rules, in order:
14
+ *
15
+ * 1. Unicode-normalize (NFKD) and drop everything outside ASCII, so `Promoção` and `Promocao`
16
+ * are the same tag.
17
+ * 2. Lowercase, then replace every run of non-alphanumeric characters with a single `-`.
18
+ * 3. Trim leading and trailing `-`.
19
+ *
20
+ * Text that is entirely non-Latin (`日本語`) folds to nothing under step 1, so it falls back to a
21
+ * Unicode-preserving pass that keeps alphanumeric characters as they are. Losing a whole tag to
22
+ * transliteration would be worse than a slug that is not URL-clean.
23
+ */
24
+ /**
25
+ * Longest slug this module will produce. Chosen to fit the 255-char columns backends use for
26
+ * it, leaving room for the `-2` / `-3` suffix uniqueness may need to append.
27
+ */
28
+ export const MAX_SLUG_LENGTH = 240;
29
+ const NON_ALPHANUMERIC = /[^a-z0-9]+/g;
30
+ const ALPHANUMERIC = /[\p{L}\p{N}]/u;
31
+ const DASH_RUN = /-+/g;
32
+ const ASCII_CEILING = 0x80;
33
+ function trimDashes(value) {
34
+ return value.replace(/^-+/, '').replace(/-+$/, '');
35
+ }
36
+ /**
37
+ * Drop everything outside ASCII, the way Python's `str.encode('ascii', 'ignore')` does.
38
+ *
39
+ * Dropping rather than substituting is what makes step 1 work: after NFKD, `ç` is `c` plus a
40
+ * combining cedilla, and the cedilla has to *vanish* for `Promoção` to slug as `promocao`.
41
+ * Turning it into a separator instead would give `promoc-ao`, a different tag.
42
+ */
43
+ function asciiOnly(value) {
44
+ let result = '';
45
+ for (const character of value) {
46
+ if ((character.codePointAt(0) ?? 0) < ASCII_CEILING) {
47
+ result += character;
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+ /**
53
+ * Collapse a tag's whitespace and trim it, leaving the caller's casing intact.
54
+ *
55
+ * The text is what a UI displays, so this is deliberately gentle — it fixes the artifacts of
56
+ * typing (a trailing space, a double space) and nothing else. {@link slugifyTag} handles the rest.
57
+ */
58
+ export function normalizeTagText(text) {
59
+ return text.split(/\s+/).filter(Boolean).join(' ');
60
+ }
61
+ /**
62
+ * Normalize free text into a tag slug. Returns `''` for text with nothing sluggable.
63
+ *
64
+ * An empty result is returned rather than thrown on so callers can decide what it means: the
65
+ * service rejects it, while a filter treats it as a tag that matches nothing.
66
+ */
67
+ export function slugifyTag(text) {
68
+ const folded = asciiOnly(text.normalize('NFKD'));
69
+ let slug = trimDashes(folded.toLowerCase().replace(NON_ALPHANUMERIC, '-'));
70
+ if (!slug) {
71
+ slug = slugifyUnicode(text);
72
+ }
73
+ return trimDashes(slug.slice(0, MAX_SLUG_LENGTH));
74
+ }
75
+ /**
76
+ * Slug for text ASCII folding empties out — `日本語`, `Привет` and the like.
77
+ *
78
+ * Alphanumeric characters survive as they are; everything else becomes a separator. Not
79
+ * URL-clean without percent-encoding, but a tag that exists beats a tag that vanished.
80
+ */
81
+ function slugifyUnicode(text) {
82
+ const characters = Array.from(text.toLowerCase(), (character) => ALPHANUMERIC.test(character) ? character : '-');
83
+ return trimDashes(characters.join('').replace(DASH_RUN, '-'));
84
+ }
85
+ /**
86
+ * Return `slug`, or the first `slug-N` that `isTaken` says is free.
87
+ *
88
+ * Backends call this while holding whatever lock they use for tag writes: `isTaken` is a read,
89
+ * so two concurrent creates can both be told the same slug is free. The unique constraint on
90
+ * the store is what actually decides, and this only keeps the common case from hitting it.
91
+ *
92
+ * @param slug an already-slugified base.
93
+ * @param isTaken resolves to true when a tag with that slug already exists.
94
+ */
95
+ export async function nextAvailableSlug(slug, isTaken) {
96
+ if (!(await isTaken(slug))) {
97
+ return slug;
98
+ }
99
+ // Truncate the base first so appending the suffix cannot push the result over the limit.
100
+ for (let suffixIndex = 2;; suffixIndex += 1) {
101
+ const suffix = `-${suffixIndex}`;
102
+ const base = trimDashes(slug.slice(0, MAX_SLUG_LENGTH - suffix.length));
103
+ const candidate = `${base}${suffix}`;
104
+ if (!(await isTaken(candidate))) {
105
+ return candidate;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,100 @@
1
+ import type { ManagedTemplateStatus, ManagedTemplateTagStatus } from './constants.js';
2
+ /**
3
+ * Whatever a store uses to key a row. Mirrors `vintasend`'s own `Identifier`: a backend on
4
+ * Postgres hands back a number, one on FHIR a string, and neither is this package's business.
5
+ */
6
+ export type ManagedTemplateId = string | number;
7
+ /**
8
+ * A label attached to any number of template versions, and versions carry any number of them.
9
+ *
10
+ * `slug` is the identity: it is normalized from `text` by {@link slugifyTag}, unique across the
11
+ * store, and what the tag filters match on. Editing `text` regenerates it.
12
+ */
13
+ export type ManagedTemplateTag = {
14
+ id: ManagedTemplateId;
15
+ text: string;
16
+ slug: string;
17
+ status: ManagedTemplateTagStatus;
18
+ createdAt: Date;
19
+ updatedAt: Date;
20
+ tenant: string | null;
21
+ };
22
+ /**
23
+ * One version of a managed template.
24
+ *
25
+ * Templates are versioned rather than edited in place, so a `ManagedTemplate` is always a
26
+ * specific version of `key` — never "the template" in the abstract.
27
+ */
28
+ export type ManagedTemplate = {
29
+ id: ManagedTemplateId;
30
+ key: string;
31
+ version: number;
32
+ name: string;
33
+ description: string;
34
+ templateManagedBackend: string;
35
+ bodyTemplate: string;
36
+ subjectTemplate: string | null;
37
+ preheaderTemplate: string | null;
38
+ status: ManagedTemplateStatus;
39
+ tenant: string | null;
40
+ createdAt: Date;
41
+ updatedAt: Date;
42
+ /** Every tag on this version, in the order the backend returns them. */
43
+ tags: ManagedTemplateTag[];
44
+ /**
45
+ * Whether this is a base to build on rather than a template to send: it declares a
46
+ * `{% managed_children %}` hole, or declares blocks without extending anything.
47
+ *
48
+ * Denormalized, not authored. Nobody sets it on a write — there is no field for it on either
49
+ * write input — because it is a fact about the source, and a stored copy that disagreed with
50
+ * the source would be a lie a filter repeats. A backend derives it on every write with
51
+ * {@link isAbstract} and stores the answer so a query can use it; `isAbstract` recomputed is
52
+ * always the authority.
53
+ */
54
+ isAbstract: boolean;
55
+ };
56
+ /** One entry in a template version's status audit trail. */
57
+ export type ManagedTemplateStatusHistory = {
58
+ templateKey: string;
59
+ version: number;
60
+ status: ManagedTemplateStatus;
61
+ createdAt: Date;
62
+ changedBy: string | null;
63
+ tenant: string | null;
64
+ };
65
+ /** What creating a template's first version needs. */
66
+ export type ManagedTemplateCreateInput = {
67
+ key: string;
68
+ name: string;
69
+ description: string;
70
+ templateManagedBackend: string;
71
+ bodyTemplate: string;
72
+ subjectTemplate: string | null;
73
+ preheaderTemplate: string | null;
74
+ tenant: string | null;
75
+ /**
76
+ * Tag *texts*, not slugs: a caller tags a template with what a person typed, and any text
77
+ * with no tag behind it yet becomes one. `null`, `undefined` and `[]` all mean "no tags".
78
+ */
79
+ tags?: string[] | null;
80
+ };
81
+ /**
82
+ * What creating the *next* version of an existing key needs.
83
+ *
84
+ * Every field is optional and an absent one means "carry this one forward": the backend copies
85
+ * the latest version and applies only the fields that are set. There is deliberately no
86
+ * `templateManagedBackend` or `tenant` here — neither can change across versions of one key.
87
+ */
88
+ export type ManagedTemplateUpdateInput = {
89
+ name?: string | null;
90
+ description?: string | null;
91
+ bodyTemplate?: string | null;
92
+ subjectTemplate?: string | null;
93
+ preheaderTemplate?: string | null;
94
+ /**
95
+ * Unlike the other fields, tags distinguish absent from empty: absent carries the previous
96
+ * version's tags forward, `[]` creates the version with none.
97
+ */
98
+ tags?: string[] | null;
99
+ };
100
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,gBAAgB,CAAC;AAEtF;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhD;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,EAAE,EAAE,iBAAiB,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,wBAAwB,CAAC;IACjC,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,EAAE,EAAE,iBAAiB,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,MAAM,EAAE,qBAAqB,CAAC;IAC9B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;IAChB,wEAAwE;IACxE,IAAI,EAAE,kBAAkB,EAAE,CAAC;IAC3B;;;;;;;;;OASG;IACH,UAAU,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,4DAA4D;AAC5D,MAAM,MAAM,4BAA4B,GAAG;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,qBAAqB,CAAC;IAC9B,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,sDAAsD;AACtD,MAAM,MAAM,0BAA0B,GAAG;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;CACxB,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,0BAA0B,GAAG;IACvC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;CACxB,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "vintasend-managed-templates",
3
+ "version": "1.0.0-alpha2",
4
+ "description": "Database-backed notification templates for VintaSend: versioning, a draft/active/inactive/archived lifecycle with an audit trail, tags, composition and filtering — on top of a storage seam you implement.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "prepare": "npm run build",
11
+ "prepublishOnly": "npm run build",
12
+ "lint": "biome check .",
13
+ "format": "biome check --write .",
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "test:coverage": "vitest run --coverage"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "author": "Vinta Software",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/vintasoftware/vintasend-ts-managed-templates.git"
27
+ },
28
+ "dependencies": {
29
+ "vintasend": "^1.0.0-alpha2"
30
+ },
31
+ "devDependencies": {
32
+ "@biomejs/biome": "^2.5.10",
33
+ "@types/node": "^25.0.8",
34
+ "@vitest/coverage-v8": "4.0.18",
35
+ "typescript": "^5.9.3",
36
+ "vitest": "4.0.18"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ }
41
+ }