@cosmicdrift/kumiko-bundled-features 0.366.0 → 0.367.0

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 (51) hide show
  1. package/dist/channel-email/email-channel.d.ts +5 -1
  2. package/dist/channel-email/email-channel.js +8 -2
  3. package/dist/channel-email/feature.js +1 -1
  4. package/dist/delivery/channel-context.js +1 -0
  5. package/dist/delivery/delivery-service.d.ts +2 -0
  6. package/dist/delivery/delivery-service.js +30 -4
  7. package/dist/delivery/feature.js +1 -1
  8. package/dist/delivery/handlers/unsubscribe-address.write.d.ts +6 -0
  9. package/dist/delivery/handlers/unsubscribe-address.write.js +8 -5
  10. package/dist/delivery/index.d.ts +3 -0
  11. package/dist/delivery/index.js +2 -0
  12. package/dist/delivery/testing.d.ts +2 -0
  13. package/dist/delivery/types.d.ts +2 -1
  14. package/dist/delivery/unsubscribe-link.d.ts +25 -0
  15. package/dist/delivery/unsubscribe-link.js +40 -0
  16. package/dist/delivery/unsubscribe-page-strings.d.ts +11 -0
  17. package/dist/delivery/unsubscribe-page-strings.js +15 -0
  18. package/dist/delivery/unsubscribe.d.ts +3 -0
  19. package/dist/delivery/unsubscribe.js +65 -29
  20. package/dist/mail-foundation/feature.d.ts +16 -1
  21. package/dist/mail-foundation/feature.js +29 -8
  22. package/dist/mail-foundation/index.d.ts +1 -1
  23. package/dist/mail-foundation/index.js +1 -1
  24. package/dist/template-resolver/compile-document-body.js +47 -11
  25. package/dist/template-resolver/document-sanitizer.d.ts +2 -0
  26. package/dist/template-resolver/document-sanitizer.js +16 -1
  27. package/dist/template-resolver/document-schema.d.ts +2 -0
  28. package/dist/template-resolver/document-schema.js +31 -0
  29. package/dist/template-resolver/handlers/bundle-import.js +3 -2
  30. package/dist/template-resolver/handlers/copy-locale.write.js +3 -2
  31. package/dist/template-resolver/handlers/find-by-id.query.js +5 -1
  32. package/dist/template-resolver/handlers/list.query.js +3 -1
  33. package/dist/template-resolver/handlers/overview.query.js +4 -2
  34. package/dist/template-resolver/handlers/template-write.d.ts +2 -1
  35. package/dist/template-resolver/handlers/template-write.js +12 -1
  36. package/dist/template-resolver/index.d.ts +3 -2
  37. package/dist/template-resolver/index.js +3 -2
  38. package/dist/template-resolver/qualified-names.d.ts +1 -0
  39. package/dist/template-resolver/qualified-names.js +1 -0
  40. package/dist/template-resolver/seeding.d.ts +4 -0
  41. package/dist/template-resolver/seeding.js +40 -5
  42. package/dist/template-resolver/template-preview.js +2 -0
  43. package/dist/template-resolver/template-system-only.d.ts +8 -0
  44. package/dist/template-resolver/template-system-only.js +11 -0
  45. package/dist/template-resolver/template-validation.js +42 -28
  46. package/dist/template-resolver/web/i18n.js +1 -0
  47. package/package.json +10 -10
  48. package/src/channel-email/changes.json +6 -0
  49. package/src/delivery/changes.json +12 -0
  50. package/src/template-resolver/changes.json +25 -0
  51. package/src/user-data-rights/changes.json +6 -0
@@ -0,0 +1,11 @@
1
+ import { isSystemAdmin } from "./template-draft.js";
2
+ const NOTIFICATION_TEMPLATE_KIND = "mail";
3
+ // A notification registered with systemOnly keeps its mail template as the system default:
4
+ // tenants neither list nor override it, and overrides stored before the flag are ignored.
5
+ export function isSystemOnlyTemplate(registry, slug, kind) {
6
+ return (kind === NOTIFICATION_TEMPLATE_KIND &&
7
+ registry.getAllNotifications().get(slug)?.systemOnly === true);
8
+ }
9
+ export function isSystemOnlyTemplateHiddenFrom(user, registry, row) {
10
+ return !isSystemAdmin(user) && isSystemOnlyTemplate(registry, row.slug, row.kind);
11
+ }
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod";
2
- import { withParsedDocument } from "./document-sanitizer.js";
3
- import { isDocumentPlaceholderHref, isDocumentReferenceName } from "./document-schema.js";
2
+ import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.js";
3
+ import { DOCUMENT_CONDITION_ATTRIBUTE, isDocumentPlaceholderHref, isDocumentReferenceName, } from "./document-schema.js";
4
4
  export const SYSTEM_PLACEHOLDERS = new Set([
5
5
  "tenantName",
6
6
  "signature",
@@ -34,6 +34,7 @@ const scalarVariableSchema = z.discriminatedUnion("type", [
34
34
  .optional(),
35
35
  }),
36
36
  z.object({ ...variableCommon, type: z.literal("date"), example: textLikeExample }),
37
+ z.object({ ...variableCommon, type: z.literal("percent"), example: z.number().optional() }),
37
38
  ]);
38
39
  const variableDefSchema = z.union([
39
40
  scalarVariableSchema,
@@ -99,34 +100,47 @@ const NAME_ATTRIBUTE_BY_TAG = {
99
100
  span: "data-variable",
100
101
  div: "data-steps",
101
102
  };
102
- function scanBodyPlaceholders(body, scope) {
103
- withParsedDocument(body, (root) => {
104
- for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
105
- const listName = element.getAttribute("data-each") ?? "";
106
- const def = Object.hasOwn(scope.variables, listName) ? scope.variables[listName] : undefined;
107
- if (def?.type === "list")
108
- scope.used.add(listName);
109
- else
110
- scope.unknown.add(listName);
103
+ function scanRepeatRows(root, scope) {
104
+ for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
105
+ const listName = element.getAttribute("data-each") ?? "";
106
+ const def = Object.hasOwn(scope.variables, listName) ? scope.variables[listName] : undefined;
107
+ if (def?.type === "list")
108
+ scope.used.add(listName);
109
+ else
110
+ scope.unknown.add(listName);
111
+ }
112
+ }
113
+ function scanConditions(root, scope) {
114
+ for (const element of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
115
+ const itemFields = listItemFields(scope.variables, enclosingEachName(conditionScopeElement(element)));
116
+ recordName(element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "", itemFields, scope);
117
+ }
118
+ }
119
+ function scanReferences(root, scope) {
120
+ for (const element of Array.from(root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"))) {
121
+ const itemFields = listItemFields(scope.variables, enclosingEachName(element));
122
+ const tag = element.tagName.toLowerCase();
123
+ const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
124
+ if (nameAttribute !== undefined) {
125
+ recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
126
+ }
127
+ else if (tag === "a") {
128
+ const href = element.getAttribute("href") ?? "";
129
+ if (isDocumentPlaceholderHref(href))
130
+ recordName(href.slice(2, -2), itemFields, scope);
111
131
  }
112
- for (const element of Array.from(root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"))) {
113
- const itemFields = listItemFields(scope.variables, enclosingEachName(element));
114
- const tag = element.tagName.toLowerCase();
115
- const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
116
- if (nameAttribute !== undefined) {
117
- recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
118
- }
119
- else if (tag === "a") {
120
- const href = element.getAttribute("href") ?? "";
121
- if (isDocumentPlaceholderHref(href))
122
- recordName(href.slice(2, -2), itemFields, scope);
123
- }
124
- else {
125
- const key = element.getAttribute("data-resource") ?? "";
126
- if (!scope.resourceKeys.has(key))
127
- scope.unknownResources.add(key);
128
- }
132
+ else {
133
+ const key = element.getAttribute("data-resource") ?? "";
134
+ if (!scope.resourceKeys.has(key))
135
+ scope.unknownResources.add(key);
129
136
  }
137
+ }
138
+ }
139
+ function scanBodyPlaceholders(body, scope) {
140
+ withParsedDocument(body, (root) => {
141
+ scanRepeatRows(root, scope);
142
+ scanConditions(root, scope);
143
+ scanReferences(root, scope);
130
144
  });
131
145
  }
132
146
  const HANDLEBARS_EXPRESSION = /\{\{([^{}]*)\}\}/g;
@@ -76,6 +76,7 @@ export const defaultTranslations = {
76
76
  "templateResolver.errors.collection.kindNotAllowed": "“{detail}” templates are written through their own editor, not through a collection.",
77
77
  "templateResolver.errors.noSystemTemplate": "There is no active system template to reset to.",
78
78
  "templateResolver.errors.noDraft": "There is no draft to discard.",
79
+ "templateResolver.errors.systemOnlyTemplate": "Only the platform maintains this template; it cannot be overridden per tenant.",
79
80
  "templateResolver.errors.template.invalidMeta": "The template settings are invalid.",
80
81
  "templateResolver.errors.template.meta.subject.required": "The subject is missing.",
81
82
  "templateResolver.errors.template.meta.subject.invalid": "The subject is invalid (at most 200 characters, no line breaks).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.366.0",
3
+ "version": "0.367.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -524,12 +524,12 @@
524
524
  }
525
525
  },
526
526
  "dependencies": {
527
- "@cosmicdrift/kumiko-dispatcher-live": "0.366.0",
528
- "@cosmicdrift/kumiko-framework": "0.366.0",
529
- "@cosmicdrift/kumiko-headless": "0.366.0",
530
- "@cosmicdrift/kumiko-renderer": "0.366.0",
531
- "@cosmicdrift/kumiko-renderer-web": "0.366.0",
532
- "@cosmicdrift/kumiko-types": "0.366.0",
527
+ "@cosmicdrift/kumiko-dispatcher-live": "0.367.0",
528
+ "@cosmicdrift/kumiko-framework": "0.367.0",
529
+ "@cosmicdrift/kumiko-headless": "0.367.0",
530
+ "@cosmicdrift/kumiko-renderer": "0.367.0",
531
+ "@cosmicdrift/kumiko-renderer-web": "0.367.0",
532
+ "@cosmicdrift/kumiko-types": "0.367.0",
533
533
  "@mollie/api-client": "^4.5.0",
534
534
  "@node-rs/argon2": "^2.0.2",
535
535
  "@tiptap/core": "3.31.3",
@@ -1078,9 +1078,9 @@
1078
1078
  ],
1079
1079
  "devDependencies": {
1080
1080
  "@testing-library/user-event": "^14.6.1",
1081
- "@cosmicdrift/kumiko-locale-de": "0.366.0",
1082
- "@cosmicdrift/kumiko-locale-es": "0.366.0",
1083
- "@cosmicdrift/kumiko-renderer-mail-html": "0.366.0",
1081
+ "@cosmicdrift/kumiko-locale-de": "0.367.0",
1082
+ "@cosmicdrift/kumiko-locale-es": "0.367.0",
1083
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.367.0",
1084
1084
  "jsqr": "^1.4.0",
1085
1085
  "unpdf": "^1.8.1"
1086
1086
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "improvement",
5
+ "title": "Email channel can send through the tenant's own mail provider",
6
+ "detail": "EmailChannelOptions gets tenantTransport, a resolver called per send with the delivery channel context. mail-foundation exports tenantMailTransport for it: a tenant with a provider configured sends through its own transport, a broken provider fails the send, a tenant without a provider uses the app transport. The transport is built per send and never cached, and the tenant comes from the server-side delivery context only. ChannelContext now carries the tenant's config accessor as config. Without tenantTransport nothing changes."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "fix",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "improvement",
5
+ "title": "Delivery signs unsubscribe links itself",
6
+ "detail": "r.notification(name, { unsubscribe: true }) makes delivery put a signed data.unsubscribeUrl into every email of that notification when the service has unsubscribeLinks. User recipients get a user token, route recipients an address token (only with a blind-index key, otherwise no link). The token is scoped to tenant, notification and channel; a link the caller passes in data.unsubscribeUrl wins. runProdApp option unsubscribe: { secret } mounts createUnsubscribeRoutes and wires the links; the base URL comes from auth.publicOrigin, auth.mail.baseUrl or APP_BASE_URL, and without one boot warns and no link is added."
7
+ },
8
+ {
9
+ "version": "0.367.0",
10
+ "type": "improvement",
11
+ "title": "Unsubscribe links use the tenant's host and the mail's language, apps learn about address opt-outs",
12
+ "detail": "unsubscribeLinks and the runProdApp option unsubscribe take tenantOrigin, an app callback that returns the sending tenant's own origin (subdomain or custom domain) per send; null, a throw or a non-https value fall back to the app origin and never fail the send. The signed token now carries the mail's locale (notify option locale), so the confirmation, result and undo pages render in that language; tokens without it keep working and show English. A form post to the resubscribe route now answers with an HTML page, JSON callers are unchanged. After a committed address opt-out an app job declared with trigger on DeliveryHandlers.unsubscribeAddress receives addressHash, notificationType and channel (unsubscribeAddressPayloadSchema) with the tenant in ctx.triggeredBy.tenantId; hashUnsubscribeAddress(email) matches it to the app's own subscriber. UNSUBSCRIBE_PAGE_EN exports the English page texts, German and Spanish come with the locale packages."
13
+ },
2
14
  {
3
15
  "version": "0.366.0",
4
16
  "type": "improvement",
@@ -1,4 +1,29 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "improvement",
5
+ "title": "Notifications can keep their mail template system-only",
6
+ "detail": "r.notification(name, { systemOnly: true }) marks the stored mail template as a platform text. Tenant admins no longer see it in the template overview, list or detail, and creating a tenant version (upsert-tenant, copy-locale, import-bundle, also a SystemAdmin writing for a tenant) is refused with access_denied and templateResolver.errors.systemOnlyTemplate. SystemAdmins edit the system template as before, and the mail renderer always uses it, even if a tenant row exists from before."
7
+ },
8
+ {
9
+ "version": "0.367.0",
10
+ "type": "fix",
11
+ "title": "Rich text placeholders no longer nest paragraphs",
12
+ "detail": "A richText placeholder alone in a paragraph compiles into a div that keeps the paragraph attributes, and one inside other text renders inline with line breaks instead of its own paragraphs. Mails and PDFs no longer contain <p><p>. richText and stepIs are now reserved template variable names, like the other renderer helpers.",
13
+ "migration": "A notification that declares a template variable called richText, stepIs or percent fails at boot; rename the variable."
14
+ },
15
+ {
16
+ "version": "0.367.0",
17
+ "type": "improvement",
18
+ "title": "Template bundles with a logo can be seeded",
19
+ "detail": "seedSystemTemplateBundle(db, bundle, { dispatcher }) now seeds bundles that carry resources such as a logo by running the import-bundle-system write on the given seed dispatcher. With ifExists \"skip\" (default) nothing is written when every locale row already exists; if only some exist, the whole bundle is imported again. Without a dispatcher such a bundle still fails with a message that names the missing option."
20
+ },
21
+ {
22
+ "version": "0.367.0",
23
+ "type": "improvement",
24
+ "title": "Template conditions with data-if and a percent variable type",
25
+ "detail": "A paragraph, heading or table row with data-if=\"name\" renders only when the variable has a value (0 counts as a value); inside a repeat row the name refers to the row field, on the repeat row itself to the root variable. The sanitizer keeps the attribute, and template validation reports a condition on an undeclared variable. New variable type percent (templateVariable.percent()) takes percentage points and renders locale-aware, 12 as \"12 %\" in German and \"12%\" in English; the preview uses its example value. The document designer has no toolbar control for conditions yet; the attribute survives editing."
26
+ },
2
27
  {
3
28
  "version": "0.365.0",
4
29
  "type": "breaking",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "fix",
5
+ "title": "Tests seed tenant memberships and events through the framework",
6
+ "detail": "Test-only change. Integration tests create read_tenant_memberships from tenantMembershipEntity and seed rows with seedRow instead of hand-written DDL and raw INSERTs. Fake events go through the event-store append, and backdated audit events are written with runSeedWritesAt instead of an UPDATE on created_at. No runtime behavior changes."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "improvement",