@cosmicdrift/kumiko-samples 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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +1 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +54 -2
- package/packages/bundled-features/src/delivery/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +10 -5
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/testing.ts +2 -0
- package/packages/bundled-features/src/delivery/types.ts +4 -0
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
- package/packages/bundled-features/src/mail-foundation/feature.ts +50 -9
- package/packages/bundled-features/src/mail-foundation/index.ts +3 -0
- package/packages/bundled-features/src/template-resolver/changes.json +25 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +9 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +8 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +8 -1
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +6 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +21 -1
- package/packages/bundled-features/src/template-resolver/index.ts +4 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +54 -7
- package/packages/bundled-features/src/template-resolver/template-preview.ts +2 -0
- package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
- package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
- package/samples/apps/use-all-bundled/package.json +1 -0
|
@@ -3,6 +3,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
5
5
|
import { draftViewerTenantId, isHiddenSystemDraft, toWorkingRow } from "../template-draft.js";
|
|
6
|
+
import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
|
|
6
7
|
|
|
7
8
|
// Admin-Lookup für UI-Edit-Flow. Returnt das raw Template inkl. draft/
|
|
8
9
|
// archived Status. Tenant-Isolation: User sieht nur Templates des
|
|
@@ -19,7 +20,13 @@ export const findByIdQuery = defineQueryHandler({
|
|
|
19
20
|
const stored = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
|
|
20
21
|
id: query.payload.id,
|
|
21
22
|
});
|
|
22
|
-
if (
|
|
23
|
+
if (
|
|
24
|
+
!stored ||
|
|
25
|
+
isHiddenSystemDraft(query.user, stored) ||
|
|
26
|
+
isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, stored)
|
|
27
|
+
) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
23
30
|
const row = toWorkingRow(stored, draftViewerTenantId(query.user, stored));
|
|
24
31
|
const isOwnTenant = row.tenantId === query.user.tenantId;
|
|
25
32
|
const isSystemTemplate = row.scope === "system";
|
|
@@ -4,6 +4,7 @@ import * as z from "zod";
|
|
|
4
4
|
import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../constants.js";
|
|
5
5
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
6
6
|
import { isHiddenSystemDraft } from "../template-draft.js";
|
|
7
|
+
import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
|
|
7
8
|
|
|
8
9
|
// ctx.db is tenant-scoped even for SystemAdmin: every caller sees its own
|
|
9
10
|
// tenant's rows plus the system defaults, never another tenant's rows.
|
|
@@ -45,7 +46,11 @@ export const listQuery = defineQueryHandler({
|
|
|
45
46
|
})) as TemplateResourceRow[];
|
|
46
47
|
|
|
47
48
|
return rows
|
|
48
|
-
.filter(
|
|
49
|
+
.filter(
|
|
50
|
+
(row) =>
|
|
51
|
+
!isHiddenSystemDraft(query.user, row) &&
|
|
52
|
+
!isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row),
|
|
53
|
+
)
|
|
49
54
|
.map((row) => ({
|
|
50
55
|
id: String(row.id),
|
|
51
56
|
tenantId: row.tenantId,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
parseTemplateDraft,
|
|
12
12
|
servedVersionOf,
|
|
13
13
|
} from "../template-draft.js";
|
|
14
|
+
import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
|
|
14
15
|
import { collectTemplateUsage, type EffectiveMailLayoutSlugs } from "../template-usage.js";
|
|
15
16
|
import { storedRecord } from "./template-gates.js";
|
|
16
17
|
|
|
@@ -284,7 +285,7 @@ export const overviewQuery = defineQueryHandler({
|
|
|
284
285
|
}),
|
|
285
286
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
286
287
|
description:
|
|
287
|
-
"Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; use it to browse and filter all templates at a glance.",
|
|
288
|
+
"Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; templates of notifications registered with `systemOnly` are listed for SystemAdmins only; use it to browse and filter all templates at a glance.",
|
|
288
289
|
handler: async (query, ctx) => {
|
|
289
290
|
const payload = query.payload;
|
|
290
291
|
const fetchedRows = await selectMany<TemplateResourceRow>(
|
|
@@ -301,7 +302,11 @@ export const overviewQuery = defineQueryHandler({
|
|
|
301
302
|
});
|
|
302
303
|
}
|
|
303
304
|
|
|
304
|
-
const visibleRows = rows.filter(
|
|
305
|
+
const visibleRows = rows.filter(
|
|
306
|
+
(row) =>
|
|
307
|
+
!isHiddenSystemDraft(query.user, row) &&
|
|
308
|
+
!isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row),
|
|
309
|
+
);
|
|
305
310
|
const { drafts, layoutSlugByDocument } = buildDrafts(
|
|
306
311
|
groupBySlugAndKind(visibleRows),
|
|
307
312
|
query.user,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import {
|
|
3
3
|
type HandlerContext,
|
|
4
|
+
type Registry,
|
|
4
5
|
type SessionUser,
|
|
5
6
|
SYSTEM_TENANT_ID,
|
|
6
7
|
type TemplateVariables,
|
|
@@ -16,8 +17,10 @@ import {
|
|
|
16
17
|
import { requireForTenant } from "../../shared/index.js";
|
|
17
18
|
import type { ContentFormat, TemplateKind } from "../constants.js";
|
|
18
19
|
import type { TemplateWriteScope } from "../layout-regions.js";
|
|
20
|
+
import { TemplateResolverErrors } from "../qualified-names.js";
|
|
19
21
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
20
22
|
import { activationChanges, serializeTemplateDraft } from "../template-draft.js";
|
|
23
|
+
import { isSystemOnlyTemplate } from "../template-system-only.js";
|
|
21
24
|
import type { TemplateValidation } from "../template-validation.js";
|
|
22
25
|
import { executor } from "./shared.js";
|
|
23
26
|
import {
|
|
@@ -71,6 +74,21 @@ export function refuseTenantScopeUnderSystemTenant(
|
|
|
71
74
|
);
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
export function refuseSystemOnlyTenantOverride(
|
|
78
|
+
registry: Registry,
|
|
79
|
+
scope: TemplateWriteScope,
|
|
80
|
+
slug: string,
|
|
81
|
+
kind: string,
|
|
82
|
+
): WriteFailure | null {
|
|
83
|
+
if (scope !== "tenant" || !isSystemOnlyTemplate(registry, slug, kind)) return null;
|
|
84
|
+
return writeFailure(
|
|
85
|
+
new AccessDeniedError({
|
|
86
|
+
i18nKey: "templateResolver.errors.systemOnlyTemplate",
|
|
87
|
+
details: { reason: TemplateResolverErrors.systemOnlyTemplate },
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
export type GatedTemplateWrite =
|
|
75
93
|
| { readonly ok: true; readonly evaluated: Extract<TemplateWriteResult, { ok: true }> }
|
|
76
94
|
| { readonly ok: false; readonly failure: WriteFailure };
|
|
@@ -141,7 +159,9 @@ export async function writeTemplateRow(
|
|
|
141
159
|
args: TemplateRowWrite,
|
|
142
160
|
): Promise<WriteResult<WrittenTemplateRow>> {
|
|
143
161
|
const { ctx, user, tenantId, scope, status, payload } = args;
|
|
144
|
-
const refused =
|
|
162
|
+
const refused =
|
|
163
|
+
refuseTenantScopeUnderSystemTenant(scope, tenantId) ??
|
|
164
|
+
refuseSystemOnlyTenantOverride(ctx.registry, scope, payload.slug, payload.kind);
|
|
145
165
|
if (refused !== null) return refused;
|
|
146
166
|
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
147
167
|
|
|
@@ -46,15 +46,18 @@ export {
|
|
|
46
46
|
type DocumentViolation,
|
|
47
47
|
type DocumentViolationCode,
|
|
48
48
|
documentViolationsToFieldIssues,
|
|
49
|
+
flattenParagraphsToLineBreaks,
|
|
49
50
|
type SanitizeBodyResult,
|
|
50
51
|
type SanitizeHtmlResult,
|
|
51
52
|
sanitizeDocumentHtml,
|
|
52
53
|
sanitizeTemplateBody,
|
|
53
54
|
} from "./document-sanitizer.js";
|
|
54
55
|
export {
|
|
56
|
+
DOCUMENT_CONDITION_ATTRIBUTE,
|
|
55
57
|
DOCUMENT_NODE_NAMES,
|
|
56
58
|
DOCUMENT_TEXT_ALIGNMENTS,
|
|
57
59
|
DocumentButton,
|
|
60
|
+
DocumentCondition,
|
|
58
61
|
DocumentDivider,
|
|
59
62
|
DocumentLink,
|
|
60
63
|
DocumentPageBreak,
|
|
@@ -144,6 +147,7 @@ export {
|
|
|
144
147
|
} from "./template-meta.js";
|
|
145
148
|
export { type DocumentDraftOutcome, evaluateDocumentDraft } from "./template-preview.js";
|
|
146
149
|
export { readTemplateResource, type TemplateResourceFile } from "./template-resources.js";
|
|
150
|
+
export { isSystemOnlyTemplate } from "./template-system-only.js";
|
|
147
151
|
export { collectTemplateUsage, type TemplateUsageEntry } from "./template-usage.js";
|
|
148
152
|
export {
|
|
149
153
|
hasPlaceholderProblems,
|
|
@@ -60,6 +60,7 @@ export const TemplateResolverErrors = {
|
|
|
60
60
|
alreadyArchived: "template_already_archived",
|
|
61
61
|
targetExists: "target_exists",
|
|
62
62
|
systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
|
|
63
|
+
systemOnlyTemplate: "system_only_template",
|
|
63
64
|
bundleLocalesDiffer: "bundle_locales_differ",
|
|
64
65
|
resourceTooLarge: "template_resource_too_large",
|
|
65
66
|
resourceUnsupportedImage: "template_resource_unsupported_image",
|
|
@@ -28,11 +28,13 @@ import {
|
|
|
28
28
|
getStreamVersion,
|
|
29
29
|
getUnscopedStreamMaxVersionForSeed,
|
|
30
30
|
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
31
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
31
32
|
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
33
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
32
34
|
import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants.js";
|
|
33
35
|
import { executor } from "./handlers/shared.js";
|
|
34
36
|
import { evaluateTemplateWrite } from "./handlers/template-gates.js";
|
|
35
|
-
import { TEMPLATE_RESOLVER_FEATURE } from "./qualified-names.js";
|
|
37
|
+
import { TEMPLATE_RESOLVER_FEATURE, TemplateResolverHandlers } from "./qualified-names.js";
|
|
36
38
|
import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
|
|
37
39
|
import type { TemplateBundle } from "./template-bundle.js";
|
|
38
40
|
import { activeStampChanges, servedStampChanges } from "./template-draft.js";
|
|
@@ -233,20 +235,21 @@ export async function seedSystemTemplate(
|
|
|
233
235
|
export type SeedSystemTemplateBundleOptions = {
|
|
234
236
|
readonly by?: SessionUser;
|
|
235
237
|
readonly ifExists?: SeedIfExists;
|
|
238
|
+
/** Required for bundles with resources (e.g. a `logo`): the files go through the
|
|
239
|
+
* import-bundle-system write, which needs the file provider of the system tenant. */
|
|
240
|
+
readonly dispatcher?: Dispatcher;
|
|
236
241
|
};
|
|
237
242
|
|
|
238
|
-
// Seeds one system row per bundle locale. Bundled resources need a file
|
|
239
|
-
//
|
|
240
|
-
//
|
|
243
|
+
// Seeds one system row per bundle locale. Bundled resources need a file provider and
|
|
244
|
+
// the system tenant's file space, so those bundles are written through the
|
|
245
|
+
// import-bundle-system handler on the seed dispatcher instead of directly.
|
|
241
246
|
export async function seedSystemTemplateBundle(
|
|
242
247
|
db: DbConnection,
|
|
243
248
|
bundle: TemplateBundle,
|
|
244
249
|
opts: SeedSystemTemplateBundleOptions = {},
|
|
245
250
|
): Promise<{ ids: Record<string, string> }> {
|
|
246
251
|
if (Object.keys(bundle.resources).length > 0) {
|
|
247
|
-
|
|
248
|
-
`seedSystemTemplateBundle ${bundle.slug}/${bundle.kind}: bundles with resources cannot be seeded; use the import-bundle-system write`,
|
|
249
|
-
);
|
|
252
|
+
return seedSystemTemplateBundleWithResources(db, bundle, opts);
|
|
250
253
|
}
|
|
251
254
|
const ids: Record<string, string> = {};
|
|
252
255
|
for (const [locale, entry] of Object.entries(bundle.locales)) {
|
|
@@ -266,6 +269,50 @@ export async function seedSystemTemplateBundle(
|
|
|
266
269
|
return { ids };
|
|
267
270
|
}
|
|
268
271
|
|
|
272
|
+
async function seedSystemTemplateBundleWithResources(
|
|
273
|
+
db: DbConnection,
|
|
274
|
+
bundle: TemplateBundle,
|
|
275
|
+
opts: SeedSystemTemplateBundleOptions,
|
|
276
|
+
): Promise<{ ids: Record<string, string> }> {
|
|
277
|
+
const label = `seedSystemTemplateBundle ${bundle.slug}/${bundle.kind}`;
|
|
278
|
+
const { dispatcher } = opts;
|
|
279
|
+
if (!dispatcher) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`${label}: bundles with resources need a file provider; pass opts.dispatcher (the dispatcher of the seed deps) so the files are stored through the import-bundle-system write`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if ((opts.ifExists ?? "skip") === "skip") {
|
|
285
|
+
const existingIds: Record<string, string> = {};
|
|
286
|
+
for (const locale of Object.keys(bundle.locales)) {
|
|
287
|
+
const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
288
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
289
|
+
slug: bundle.slug,
|
|
290
|
+
kind: bundle.kind,
|
|
291
|
+
locale,
|
|
292
|
+
});
|
|
293
|
+
if (row) existingIds[locale] = String(row.id);
|
|
294
|
+
}
|
|
295
|
+
if (Object.keys(existingIds).length === Object.keys(bundle.locales).length) {
|
|
296
|
+
return { ids: existingIds };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const result = await dispatcher.write(
|
|
300
|
+
TemplateResolverHandlers.importBundleSystem,
|
|
301
|
+
{ bundle },
|
|
302
|
+
opts.by ?? createSystemUser(SYSTEM_TENANT_ID, ["SystemAdmin"]),
|
|
303
|
+
);
|
|
304
|
+
if (!result.isSuccess) {
|
|
305
|
+
throw new Error(`${label} failed: ${result.error.code}: ${result.error.message}`);
|
|
306
|
+
}
|
|
307
|
+
return { ids: importedIds(result.data) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function importedIds(data: unknown): Record<string, string> {
|
|
311
|
+
const ids = isPlainObject(data) ? data["ids"] : undefined;
|
|
312
|
+
if (!isPlainObject(ids)) throw new Error("seedSystemTemplateBundle: import returned no ids");
|
|
313
|
+
return Object.fromEntries(Object.entries(ids).map(([locale, id]) => [locale, String(id)]));
|
|
314
|
+
}
|
|
315
|
+
|
|
269
316
|
export function hasNotificationTemplates(registry: Registry): boolean {
|
|
270
317
|
return Array.from(registry.getAllNotifications().values()).some(
|
|
271
318
|
(notification) => notification.template !== undefined,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { isSystemAdmin } from "./template-draft.js";
|
|
3
|
+
|
|
4
|
+
const NOTIFICATION_TEMPLATE_KIND = "mail";
|
|
5
|
+
|
|
6
|
+
// A notification registered with systemOnly keeps its mail template as the system default:
|
|
7
|
+
// tenants neither list nor override it, and overrides stored before the flag are ignored.
|
|
8
|
+
export function isSystemOnlyTemplate(registry: Registry, slug: string, kind: string): boolean {
|
|
9
|
+
return (
|
|
10
|
+
kind === NOTIFICATION_TEMPLATE_KIND &&
|
|
11
|
+
registry.getAllNotifications().get(slug)?.systemOnly === true
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSystemOnlyTemplateHiddenFrom(
|
|
16
|
+
user: { readonly roles: readonly string[] },
|
|
17
|
+
registry: Registry,
|
|
18
|
+
row: { readonly slug: string; readonly kind: string },
|
|
19
|
+
): boolean {
|
|
20
|
+
return !isSystemAdmin(user) && isSystemOnlyTemplate(registry, row.slug, row.kind);
|
|
21
|
+
}
|
|
@@ -6,8 +6,12 @@ import type {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import type { Element as ScanElement } from "happy-dom";
|
|
8
8
|
import * as z from "zod";
|
|
9
|
-
import { withParsedDocument } from "./document-sanitizer.js";
|
|
10
|
-
import {
|
|
9
|
+
import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.js";
|
|
10
|
+
import {
|
|
11
|
+
DOCUMENT_CONDITION_ATTRIBUTE,
|
|
12
|
+
isDocumentPlaceholderHref,
|
|
13
|
+
isDocumentReferenceName,
|
|
14
|
+
} from "./document-schema.js";
|
|
11
15
|
import type { TemplateMeta } from "./template-meta.js";
|
|
12
16
|
|
|
13
17
|
export const SYSTEM_PLACEHOLDERS: ReadonlySet<string> = new Set([
|
|
@@ -56,6 +60,7 @@ const scalarVariableSchema = z.discriminatedUnion("type", [
|
|
|
56
60
|
.optional(),
|
|
57
61
|
}),
|
|
58
62
|
z.object({ ...variableCommon, type: z.literal("date"), example: textLikeExample }),
|
|
63
|
+
z.object({ ...variableCommon, type: z.literal("percent"), example: z.number().optional() }),
|
|
59
64
|
]);
|
|
60
65
|
|
|
61
66
|
const variableDefSchema = z.union([
|
|
@@ -154,30 +159,49 @@ const NAME_ATTRIBUTE_BY_TAG: Readonly<Record<string, string>> = {
|
|
|
154
159
|
div: "data-steps",
|
|
155
160
|
};
|
|
156
161
|
|
|
162
|
+
function scanRepeatRows(root: ScanElement, scope: PlaceholderScope): void {
|
|
163
|
+
for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
|
|
164
|
+
const listName = element.getAttribute("data-each") ?? "";
|
|
165
|
+
const def = Object.hasOwn(scope.variables, listName) ? scope.variables[listName] : undefined;
|
|
166
|
+
if (def?.type === "list") scope.used.add(listName);
|
|
167
|
+
else scope.unknown.add(listName);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function scanConditions(root: ScanElement, scope: PlaceholderScope): void {
|
|
172
|
+
for (const element of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
|
|
173
|
+
const itemFields = listItemFields(
|
|
174
|
+
scope.variables,
|
|
175
|
+
enclosingEachName(conditionScopeElement(element)),
|
|
176
|
+
);
|
|
177
|
+
recordName(element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "", itemFields, scope);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function scanReferences(root: ScanElement, scope: PlaceholderScope): void {
|
|
182
|
+
for (const element of Array.from(
|
|
183
|
+
root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"),
|
|
184
|
+
)) {
|
|
185
|
+
const itemFields = listItemFields(scope.variables, enclosingEachName(element));
|
|
186
|
+
const tag = element.tagName.toLowerCase();
|
|
187
|
+
const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
|
|
188
|
+
if (nameAttribute !== undefined) {
|
|
189
|
+
recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
|
|
190
|
+
} else if (tag === "a") {
|
|
191
|
+
const href = element.getAttribute("href") ?? "";
|
|
192
|
+
if (isDocumentPlaceholderHref(href)) recordName(href.slice(2, -2), itemFields, scope);
|
|
193
|
+
} else {
|
|
194
|
+
const key = element.getAttribute("data-resource") ?? "";
|
|
195
|
+
if (!scope.resourceKeys.has(key)) scope.unknownResources.add(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
157
200
|
function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
|
|
158
201
|
withParsedDocument(body, (root) => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (def?.type === "list") scope.used.add(listName);
|
|
163
|
-
else scope.unknown.add(listName);
|
|
164
|
-
}
|
|
165
|
-
for (const element of Array.from(
|
|
166
|
-
root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"),
|
|
167
|
-
)) {
|
|
168
|
-
const itemFields = listItemFields(scope.variables, enclosingEachName(element));
|
|
169
|
-
const tag = element.tagName.toLowerCase();
|
|
170
|
-
const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
|
|
171
|
-
if (nameAttribute !== undefined) {
|
|
172
|
-
recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
|
|
173
|
-
} else if (tag === "a") {
|
|
174
|
-
const href = element.getAttribute("href") ?? "";
|
|
175
|
-
if (isDocumentPlaceholderHref(href)) recordName(href.slice(2, -2), itemFields, scope);
|
|
176
|
-
} else {
|
|
177
|
-
const key = element.getAttribute("data-resource") ?? "";
|
|
178
|
-
if (!scope.resourceKeys.has(key)) scope.unknownResources.add(key);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
202
|
+
scanRepeatRows(root, scope);
|
|
203
|
+
scanConditions(root, scope);
|
|
204
|
+
scanReferences(root, scope);
|
|
181
205
|
});
|
|
182
206
|
}
|
|
183
207
|
|
|
@@ -96,6 +96,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
96
96
|
"“{detail}” templates are written through their own editor, not through a collection.",
|
|
97
97
|
"templateResolver.errors.noSystemTemplate": "There is no active system template to reset to.",
|
|
98
98
|
"templateResolver.errors.noDraft": "There is no draft to discard.",
|
|
99
|
+
"templateResolver.errors.systemOnlyTemplate":
|
|
100
|
+
"Only the platform maintains this template; it cannot be overridden per tenant.",
|
|
99
101
|
"templateResolver.errors.template.invalidMeta": "The template settings are invalid.",
|
|
100
102
|
"templateResolver.errors.template.meta.subject.required": "The subject is missing.",
|
|
101
103
|
"templateResolver.errors.template.meta.subject.invalid":
|
|
@@ -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",
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
SYSTEM_INVOICE_ISSUED,
|
|
33
33
|
SYSTEM_PDF_LAYOUT,
|
|
34
34
|
SYSTEM_TENANCY_CERTIFICATE,
|
|
35
|
+
SYSTEM_TERMS_CHANGED,
|
|
35
36
|
TENANT_INVOICE_OVERRIDES,
|
|
36
37
|
TENANT_MAIL_LAYOUT_VERSIONS,
|
|
37
38
|
TENANT_UTILITY_STATEMENT,
|
|
@@ -165,6 +166,7 @@ export const seedTemplates: SeedFn = async (stack) => {
|
|
|
165
166
|
await upsertSystem(SYSTEM_PDF_LAYOUT);
|
|
166
167
|
for (const mail of SYSTEM_AUTH_MAILS) await upsertSystem(mail);
|
|
167
168
|
await upsertSystem(SYSTEM_INVOICE_ISSUED);
|
|
169
|
+
await upsertSystem(SYSTEM_TERMS_CHANGED);
|
|
168
170
|
for (const document of SYSTEM_INVOICE_DOCUMENTS) await upsertSystem(document);
|
|
169
171
|
const certificate = await upsertSystem(SYSTEM_TENANCY_CERTIFICATE);
|
|
170
172
|
await stack.http.writeOk(
|
|
@@ -276,6 +276,18 @@ export const INVOICE_CORRECTED_DRAFT: SeedTemplate = {
|
|
|
276
276
|
meta: invoiceIssuedMeta("Ihre Rechnung {{invoiceNumber}} von {{tenantName}}"),
|
|
277
277
|
};
|
|
278
278
|
|
|
279
|
+
export const SYSTEM_TERMS_CHANGED: SeedTemplate = {
|
|
280
|
+
slug: "billing:notify:terms-changed",
|
|
281
|
+
kind: "mail",
|
|
282
|
+
locale: "de",
|
|
283
|
+
title: "AGB-Änderung",
|
|
284
|
+
content: [
|
|
285
|
+
`<p>Guten Tag ${v("customerName")},</p>`,
|
|
286
|
+
`<p>zum ${v("effectiveDate")} ändern sich die Allgemeinen Geschäftsbedingungen des Mieterportals.</p>`,
|
|
287
|
+
].join(""),
|
|
288
|
+
meta: { subject: "Änderung der Geschäftsbedingungen" },
|
|
289
|
+
};
|
|
290
|
+
|
|
279
291
|
export const PAYMENT_REMINDER_TEMPLATES: readonly SeedTemplate[] = [
|
|
280
292
|
{
|
|
281
293
|
slug: "billing:notify:payment-reminder",
|
|
@@ -63,10 +63,17 @@ const PAYMENT_REMINDER_VARIABLES = {
|
|
|
63
63
|
invoiceUrl: templateVariable.link({ required: true }),
|
|
64
64
|
} as const;
|
|
65
65
|
|
|
66
|
+
const TERMS_CHANGED_VARIABLES = {
|
|
67
|
+
customerName: templateVariable.text({ required: true, example: "Familie Yilmaz" }),
|
|
68
|
+
effectiveDate: templateVariable.date({ required: true, example: "2026-12-01" }),
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
66
71
|
export const billingFeature = defineFeature("billing", (r) => {
|
|
67
72
|
r.translations({ keys: toKeyFirst(billingTranslations) });
|
|
68
73
|
|
|
69
74
|
r.notification("invoice-issued", { variables: INVOICE_ISSUED_VARIABLES });
|
|
70
75
|
r.notification("invoice-corrected", { variables: INVOICE_ISSUED_VARIABLES });
|
|
71
76
|
r.notification("payment-reminder", { variables: PAYMENT_REMINDER_VARIABLES });
|
|
77
|
+
// Legal wording the platform owns: tenant admins neither see nor override it.
|
|
78
|
+
r.notification("terms-changed", { variables: TERMS_CHANGED_VARIABLES, systemOnly: true });
|
|
72
79
|
});
|
|
@@ -8,11 +8,13 @@ export const billingTranslations: TranslationsByLocale = {
|
|
|
8
8
|
"billing:notify.invoice-issued.label": "Rechnung gestellt",
|
|
9
9
|
"billing:notify.invoice-corrected.label": "Rechnung korrigiert",
|
|
10
10
|
"billing:notify.payment-reminder.label": "Mahnlauf",
|
|
11
|
+
"billing:notify.terms-changed.label": "AGB-Änderung",
|
|
11
12
|
},
|
|
12
13
|
en: {
|
|
13
14
|
"billing:feature.label": "Invoices",
|
|
14
15
|
"billing:notify.invoice-issued.label": "Invoice issued",
|
|
15
16
|
"billing:notify.invoice-corrected.label": "Invoice corrected",
|
|
16
17
|
"billing:notify.payment-reminder.label": "Dunning run",
|
|
18
|
+
"billing:notify.terms-changed.label": "Terms update",
|
|
17
19
|
},
|
|
18
20
|
};
|
|
@@ -407,7 +407,7 @@
|
|
|
407
407
|
},
|
|
408
408
|
{
|
|
409
409
|
"name": "channel-email",
|
|
410
|
-
"description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails.",
|
|
410
|
+
"description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
|
|
411
411
|
"toggleableDefault": null,
|
|
412
412
|
"requires": [
|
|
413
413
|
"delivery"
|
|
@@ -725,7 +725,7 @@
|
|
|
725
725
|
},
|
|
726
726
|
{
|
|
727
727
|
"name": "delivery",
|
|
728
|
-
"description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route.",
|
|
728
|
+
"description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
|
|
729
729
|
"toggleableDefault": null,
|
|
730
730
|
"requires": [
|
|
731
731
|
"tenant"
|
|
@@ -1507,7 +1507,7 @@
|
|
|
1507
1507
|
},
|
|
1508
1508
|
{
|
|
1509
1509
|
"name": "mail-foundation",
|
|
1510
|
-
"description": "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences.",
|
|
1510
|
+
"description": "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.",
|
|
1511
1511
|
"toggleableDefault": null,
|
|
1512
1512
|
"requires": [
|
|
1513
1513
|
"config"
|
|
@@ -2122,7 +2122,7 @@
|
|
|
2122
2122
|
},
|
|
2123
2123
|
{
|
|
2124
2124
|
"name": "renderer-mail-html",
|
|
2125
|
-
"description": "A `renderer-foundation` plugin that handles `RenderRequest { kind: \"mail\" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: \"mail\"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: \"mail-layout\"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout,
|
|
2125
|
+
"description": "A `renderer-foundation` plugin that handles `RenderRequest { kind: \"mail\" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: \"mail\"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: \"mail-layout\"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout, and an optional `siteUrl` resolver supplies the site link of the card when the tenant branding has no site URL (without either, the link is left out). The brand row and `data-resource=\"logo\"` images use the branding logo upload, or the branding logo https address when no file is uploaded. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.",
|
|
2126
2126
|
"toggleableDefault": null,
|
|
2127
2127
|
"requires": [
|
|
2128
2128
|
"renderer-foundation",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@cosmicdrift/kumiko-document-designer": "workspace:*",
|
|
22
22
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
23
23
|
"@cosmicdrift/kumiko-headless": "workspace:*",
|
|
24
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "workspace:*",
|
|
24
25
|
"@cosmicdrift/kumiko-renderer-puppeteer-client": "workspace:*",
|
|
25
26
|
"@cosmicdrift/kumiko-renderer-web": "workspace:*",
|
|
26
27
|
"@cosmicdrift/kumiko-server-runtime": "workspace:*",
|