@cosmicdrift/kumiko-samples 0.377.0 → 0.378.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 (66) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  4. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
  5. package/packages/bundled-features/src/config/web/i18n.ts +1 -0
  6. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  7. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
  8. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  9. package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
  10. package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
  11. package/packages/bundled-features/src/template-resolver/changes.json +20 -0
  12. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
  13. package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
  14. package/packages/bundled-features/src/template-resolver/document-blocks.ts +3 -1
  15. package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
  16. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +35 -0
  17. package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
  18. package/packages/bundled-features/src/template-resolver/feature.ts +77 -36
  19. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
  20. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
  21. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
  22. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
  23. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
  24. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
  25. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
  26. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
  27. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +105 -96
  28. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
  29. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
  30. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
  31. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
  32. package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
  33. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
  34. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -111
  35. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
  36. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
  37. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
  38. package/packages/bundled-features/src/template-resolver/index.ts +8 -1
  39. package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
  40. package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
  41. package/packages/bundled-features/src/template-resolver/template-preview.ts +8 -2
  42. package/packages/bundled-features/src/template-resolver/template-validation.ts +31 -10
  43. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
  44. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +42 -4
  45. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +2 -0
  46. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  47. package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
  48. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  49. package/samples/apps/ui-walkthrough/src/app/shell.tsx +6 -0
  50. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
  51. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
  52. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
  53. package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
  54. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
  55. package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
  56. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
  57. package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +2 -0
  58. package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
  59. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  60. package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
  61. package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
  62. package/samples/recipes/delivery-notifications/README.md +1 -1
  63. package/samples/recipes/embedded/README.md +8 -0
  64. package/samples/recipes/embedded/src/feature.ts +8 -0
  65. package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
  66. package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
@@ -11,6 +11,7 @@ import {
11
11
  parseTemplateDraft,
12
12
  servedVersionOf,
13
13
  } from "../template-draft.ts";
14
+ import { mayEditTemplate, type TemplateEditPolicy } from "../template-edit-policy.ts";
14
15
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
15
16
  import { collectTemplateUsage, type EffectiveMailLayoutSlugs } from "../template-usage.ts";
16
17
  import { storedRecord } from "./template-gates.ts";
@@ -271,68 +272,71 @@ const filterSchema = z.object({
271
272
  value: z.array(z.string()),
272
273
  });
273
274
 
274
- export const overviewQuery = defineQueryHandler({
275
- name: "overview",
276
- schema: z.object({
277
- search: z.string().optional(),
278
- filters: z.array(filterSchema).optional(),
279
- layoutSlug: z.string().optional(),
280
- sort: z.string().optional(),
281
- sortDirection: z.enum(["asc", "desc"]).optional(),
282
- limit: z.number().int().min(1).max(200).default(50),
283
- offset: z.number().int().min(0).optional(),
284
- totalCount: z.boolean().optional(),
285
- }),
286
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
287
- description:
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.",
289
- handler: async (query, ctx) => {
290
- const payload = query.payload;
291
- const fetchedRows = await selectMany<TemplateResourceRow>(
292
- ctx.db,
293
- templateResourcesTable,
294
- { kind: { in: [...OVERVIEW_KINDS] } },
295
- { orderBy: { col: "id", direction: "asc" }, limit: OVERVIEW_ROW_LIMIT + 1 },
296
- );
297
- const truncated = fetchedRows.length > OVERVIEW_ROW_LIMIT;
298
- const rows = truncated ? fetchedRows.slice(0, OVERVIEW_ROW_LIMIT) : fetchedRows;
299
- if (truncated) {
300
- ctx.log?.warn("template overview: row limit reached, result is incomplete", {
301
- limit: OVERVIEW_ROW_LIMIT,
302
- });
303
- }
275
+ export function makeOverviewQuery(policy: TemplateEditPolicy) {
276
+ return defineQueryHandler({
277
+ name: "overview",
278
+ schema: z.object({
279
+ search: z.string().optional(),
280
+ filters: z.array(filterSchema).optional(),
281
+ layoutSlug: z.string().optional(),
282
+ sort: z.string().optional(),
283
+ sortDirection: z.enum(["asc", "desc"]).optional(),
284
+ limit: z.number().int().min(1).max(200).default(50),
285
+ offset: z.number().int().min(0).optional(),
286
+ totalCount: z.boolean().optional(),
287
+ }),
288
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
289
+ description:
290
+ "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, and a TenantAdmin sees only the templates the app lists in tenantEditable; use it to browse and filter all templates at a glance.",
291
+ handler: async (query, ctx) => {
292
+ const payload = query.payload;
293
+ const fetchedRows = await selectMany<TemplateResourceRow>(
294
+ ctx.db,
295
+ templateResourcesTable,
296
+ { kind: { in: [...OVERVIEW_KINDS] } },
297
+ { orderBy: { col: "id", direction: "asc" }, limit: OVERVIEW_ROW_LIMIT + 1 },
298
+ );
299
+ const truncated = fetchedRows.length > OVERVIEW_ROW_LIMIT;
300
+ const rows = truncated ? fetchedRows.slice(0, OVERVIEW_ROW_LIMIT) : fetchedRows;
301
+ if (truncated) {
302
+ ctx.log?.warn("template overview: row limit reached, result is incomplete", {
303
+ limit: OVERVIEW_ROW_LIMIT,
304
+ });
305
+ }
304
306
 
305
- const visibleRows = rows.filter(
306
- (row) =>
307
- !isHiddenSystemDraft(query.user, row) &&
308
- !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row),
309
- );
310
- const { drafts, layoutSlugByDocument } = buildDrafts(
311
- groupBySlugAndKind(visibleRows),
312
- query.user,
313
- );
314
- const effectiveLayouts = effectiveMailLayoutSlugs(visibleRows);
315
- const overviewRows = drafts.map((draft) =>
316
- withUsage(draft, layoutSlugByDocument, ctx.registry, effectiveLayouts),
317
- );
318
- const filtered = filterRows(
319
- overviewRows,
320
- payload.search,
321
- payload.filters ?? [],
322
- payload.layoutSlug,
323
- );
307
+ const visibleRows = rows.filter(
308
+ (row) =>
309
+ !isHiddenSystemDraft(query.user, row) &&
310
+ !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row) &&
311
+ mayEditTemplate(policy, query.user, row),
312
+ );
313
+ const { drafts, layoutSlugByDocument } = buildDrafts(
314
+ groupBySlugAndKind(visibleRows),
315
+ query.user,
316
+ );
317
+ const effectiveLayouts = effectiveMailLayoutSlugs(visibleRows);
318
+ const overviewRows = drafts.map((draft) =>
319
+ withUsage(draft, layoutSlugByDocument, ctx.registry, effectiveLayouts),
320
+ );
321
+ const filtered = filterRows(
322
+ overviewRows,
323
+ payload.search,
324
+ payload.filters ?? [],
325
+ payload.layoutSlug,
326
+ );
324
327
 
325
- const sortField: SortField =
326
- payload.sort !== undefined && isSortField(payload.sort) ? payload.sort : "modifiedAt";
327
- const direction = (payload.sortDirection ?? "desc") === "desc" ? -1 : 1;
328
- filtered.sort((a, b) => direction * compareBy(sortField, a, b) || a.id.localeCompare(b.id));
328
+ const sortField: SortField =
329
+ payload.sort !== undefined && isSortField(payload.sort) ? payload.sort : "modifiedAt";
330
+ const direction = (payload.sortDirection ?? "desc") === "desc" ? -1 : 1;
331
+ filtered.sort((a, b) => direction * compareBy(sortField, a, b) || a.id.localeCompare(b.id));
329
332
 
330
- const offset = payload.offset ?? 0;
331
- return {
332
- rows: filtered.slice(offset, offset + payload.limit),
333
- nextCursor: null,
334
- total: filtered.length,
335
- truncated,
336
- } satisfies OverviewResponse;
337
- },
338
- });
333
+ const offset = payload.offset ?? 0;
334
+ return {
335
+ rows: filtered.slice(offset, offset + payload.limit),
336
+ nextCursor: null,
337
+ total: filtered.length,
338
+ truncated,
339
+ } satisfies OverviewResponse;
340
+ },
341
+ });
342
+ }
@@ -10,6 +10,11 @@ import * as z from "zod";
10
10
  import { DOCUMENT_HTML_KINDS } from "../constants.ts";
11
11
  import { applyDocumentOps, documentOpsSchema } from "../document-ops.ts";
12
12
  import { parseDocumentJson, serializeDocumentJson } from "../document-sanitizer.ts";
13
+ import {
14
+ refuseTemplateEdit,
15
+ type TemplateEditPolicy,
16
+ templateEditAccess,
17
+ } from "../template-edit-policy.ts";
13
18
  import { resolveTemplateVariables } from "../template-validation.ts";
14
19
  import { loadDocumentBase } from "./document-base.ts";
15
20
  import { localeSchema, templateSlugSchema } from "./shared.ts";
@@ -20,106 +25,110 @@ function validationFailure(issue: FieldIssue) {
20
25
  return writeFailure(new ValidationError({ fields: [issue] }));
21
26
  }
22
27
 
23
- export const patchDraftWrite = defineWriteHandler({
24
- name: "patch-draft",
25
- schema: z.object({
26
- slug: templateSlugSchema,
27
- kind: z.enum(DOCUMENT_HTML_KINDS),
28
- locale: localeSchema,
29
- base: z.object({
30
- scope: z.enum(["tenant", "system"]),
31
- version: z.number().int().nonnegative(),
28
+ export function makePatchDraftWrite(policy: TemplateEditPolicy) {
29
+ return defineWriteHandler({
30
+ name: "patch-draft",
31
+ schema: z.object({
32
+ slug: templateSlugSchema,
33
+ kind: z.enum(DOCUMENT_HTML_KINDS),
34
+ locale: localeSchema,
35
+ base: z.object({
36
+ scope: z.enum(["tenant", "system"]),
37
+ version: z.number().int().nonnegative(),
38
+ }),
39
+ ops: documentOpsSchema,
32
40
  }),
33
- ops: documentOpsSchema,
34
- }),
35
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
36
- description:
37
- "Changes a mail or PDF document template with structured operations instead of a full rewrite and saves the result as the caller's tenant draft (never published, an active version keeps being served until publish): insertBlock after a block id (null = at the start; paragraph, heading, image, button, divider, pageBreak), replaceText inside one block (the text must occur exactly once), removeBlock, setResource on an image block, setMeta (flat merge, null removes a key) and setVariable (adds, replaces or with null removes a variable definition; refused when a notification declares the variables). Block ids and base come from document-blocks and refer to the state that was read; if the template changed since, the call fails with version_conflict and nothing is written. A patch that removes a required placeholder is refused. Returns the written row.",
38
- handler: async (event, ctx) => {
39
- const { slug, kind, locale, base, ops } = event.payload;
40
- const loaded = await loadDocumentBase(ctx, event.user, { slug, kind, locale });
41
- if (loaded === null) return writeFailure(new NotFoundError("template-resource", slug));
42
- if (loaded.base.scope !== base.scope || loaded.base.version !== base.version) {
43
- return writeFailure(
44
- new VersionConflictError({
45
- entityId: loaded.rowId,
46
- expectedVersion: base.version,
47
- currentVersion: loaded.base.version,
48
- }),
49
- );
50
- }
41
+ access: templateEditAccess(policy.tenantEditable),
42
+ description:
43
+ "Changes a mail or PDF document template with structured operations instead of a full rewrite and saves the result as the caller's tenant draft (never published, an active version keeps being served until publish): insertBlock after a block id (null = at the start; paragraph, heading, image, button, divider, pageBreak), replaceText inside one block (the text must occur exactly once), removeBlock, setResource on an image block, setMeta (flat merge, null removes a key) and setVariable (adds, replaces or with null removes a variable definition; refused when a notification declares the variables). Block ids and base come from document-blocks and refer to the state that was read; if the template changed since, the call fails with version_conflict and nothing is written. A patch that removes a required placeholder is refused. A TenantAdmin may patch only templates the app lists in tenantEditable, otherwise SystemAdmin only. Returns the written row.",
44
+ handler: async (event, ctx) => {
45
+ const { slug, kind, locale, base, ops } = event.payload;
46
+ const refused = refuseTemplateEdit(policy, event.user, { slug, kind });
47
+ if (refused !== null) return refused;
48
+ const loaded = await loadDocumentBase(ctx, event.user, { slug, kind, locale });
49
+ if (loaded === null) return writeFailure(new NotFoundError("template-resource", slug));
50
+ if (loaded.base.scope !== base.scope || loaded.base.version !== base.version) {
51
+ return writeFailure(
52
+ new VersionConflictError({
53
+ entityId: loaded.rowId,
54
+ expectedVersion: base.version,
55
+ currentVersion: loaded.base.version,
56
+ }),
57
+ );
58
+ }
59
+
60
+ const variableOpIndex = ops.findIndex((op) => op.op === "setVariable");
61
+ if (
62
+ variableOpIndex !== -1 &&
63
+ ctx.registry.getAllNotifications().get(slug)?.variables !== undefined
64
+ ) {
65
+ return validationFailure({
66
+ path: `ops.${variableOpIndex}`,
67
+ code: "variables_declared_by_feature",
68
+ i18nKey: "templateResolver.errors.patch.variablesDeclaredByFeature",
69
+ params: { slug },
70
+ });
71
+ }
51
72
 
52
- const variableOpIndex = ops.findIndex((op) => op.op === "setVariable");
53
- if (
54
- variableOpIndex !== -1 &&
55
- ctx.registry.getAllNotifications().get(slug)?.variables !== undefined
56
- ) {
57
- return validationFailure({
58
- path: `ops.${variableOpIndex}`,
59
- code: "variables_declared_by_feature",
60
- i18nKey: "templateResolver.errors.patch.variablesDeclaredByFeature",
61
- params: { slug },
73
+ const applied = applyDocumentOps({
74
+ doc: parseDocumentJson(loaded.content),
75
+ meta: loaded.meta,
76
+ variableSchema: loaded.variableSchema,
77
+ kind,
78
+ ops,
62
79
  });
63
- }
80
+ if (!applied.ok) return writeFailure(new ValidationError({ fields: applied.issues }));
81
+ const content = serializeDocumentJson(applied.doc);
64
82
 
65
- const applied = applyDocumentOps({
66
- doc: parseDocumentJson(loaded.content),
67
- meta: loaded.meta,
68
- variableSchema: loaded.variableSchema,
69
- kind,
70
- ops,
71
- });
72
- if (!applied.ok) return writeFailure(new ValidationError({ fields: applied.issues }));
73
- const content = serializeDocumentJson(applied.doc);
83
+ const draftOf = (
84
+ draftContent: string,
85
+ meta: unknown,
86
+ variableSchema: Record<string, unknown>,
87
+ ) =>
88
+ validateTemplateDraft(ctx, event.user, {
89
+ slug,
90
+ kind,
91
+ contentFormat: "html",
92
+ content: draftContent,
93
+ meta,
94
+ variableSchema,
95
+ locale,
96
+ scope: "tenant",
97
+ });
98
+ const before = await draftOf(loaded.content, loaded.meta, loaded.variableSchema);
99
+ const after = await draftOf(content, applied.meta, applied.variableSchema);
100
+ const alreadyMissing = new Set(before.response.missingRequired);
101
+ const removed = after.response.missingRequired.filter((name) => !alreadyMissing.has(name));
102
+ if (removed.length > 0) {
103
+ return validationFailure({
104
+ path: "ops",
105
+ code: "required_placeholder_removed",
106
+ i18nKey: "templateResolver.errors.patch.requiredPlaceholderRemoved",
107
+ params: { names: removed },
108
+ });
109
+ }
74
110
 
75
- const draftOf = (
76
- draftContent: string,
77
- meta: unknown,
78
- variableSchema: Record<string, unknown>,
79
- ) =>
80
- validateTemplateDraft(ctx, event.user, {
81
- slug,
82
- kind,
83
- contentFormat: "html",
84
- content: draftContent,
85
- meta,
86
- variableSchema,
87
- locale,
111
+ return writeTemplateRow({
112
+ ctx,
113
+ user: event.user,
114
+ tenantId: event.user.tenantId,
88
115
  scope: "tenant",
116
+ status: "draft",
117
+ variables: resolveTemplateVariables(ctx.registry, slug, applied.variableSchema),
118
+ payload: {
119
+ slug,
120
+ kind,
121
+ locale,
122
+ content,
123
+ contentFormat: "html",
124
+ variableSchema: applied.variableSchema,
125
+ meta: applied.meta,
126
+ title: loaded.title,
127
+ folder: loaded.folder,
128
+ parentTemplateId: loaded.parentTemplateId,
129
+ },
130
+ expectedVersion: loaded.base.scope === "tenant" ? loaded.base.version : null,
89
131
  });
90
- const before = await draftOf(loaded.content, loaded.meta, loaded.variableSchema);
91
- const after = await draftOf(content, applied.meta, applied.variableSchema);
92
- const alreadyMissing = new Set(before.response.missingRequired);
93
- const removed = after.response.missingRequired.filter((name) => !alreadyMissing.has(name));
94
- if (removed.length > 0) {
95
- return validationFailure({
96
- path: "ops",
97
- code: "required_placeholder_removed",
98
- i18nKey: "templateResolver.errors.patch.requiredPlaceholderRemoved",
99
- params: { names: removed },
100
- });
101
- }
102
-
103
- return writeTemplateRow({
104
- ctx,
105
- user: event.user,
106
- tenantId: event.user.tenantId,
107
- scope: "tenant",
108
- status: "draft",
109
- variables: resolveTemplateVariables(ctx.registry, slug, applied.variableSchema),
110
- payload: {
111
- slug,
112
- kind,
113
- locale,
114
- content,
115
- contentFormat: "html",
116
- variableSchema: applied.variableSchema,
117
- meta: applied.meta,
118
- title: loaded.title,
119
- folder: loaded.folder,
120
- parentTemplateId: loaded.parentTemplateId,
121
- },
122
- expectedVersion: loaded.base.scope === "tenant" ? loaded.base.version : null,
123
- });
124
- },
125
- });
132
+ },
133
+ });
134
+ }
@@ -4,55 +4,68 @@ import { NotFoundError, ValidationError, writeFailure } from "@cosmicdrift/kumik
4
4
  import * as z from "zod";
5
5
  import type { TemplateResourceRow } from "../table.ts";
6
6
  import { templateResourcesTable } from "../table.ts";
7
+ import {
8
+ refuseMissingTemplate,
9
+ refuseTemplateEdit,
10
+ type TemplateEditPolicy,
11
+ templateEditAccess,
12
+ } from "../template-edit-policy.ts";
7
13
  import { executor } from "./shared.ts";
8
14
 
9
- export const resetToSystemWrite = defineWriteHandler({
10
- name: "reset-to-system",
11
- schema: z.object({ id: z.string().min(1) }),
12
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
13
- description:
14
- "Retires a tenant override by archiving it so the resolver serves the active system template again; the override and its content survive and can be published again. Refused when there is no active system template to fall back to.",
15
- handler: async (event, ctx) => {
16
- const existing = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
17
- id: event.payload.id,
18
- });
19
- if (!existing || existing.tenantId !== event.user.tenantId || existing.scope !== "tenant") {
20
- return writeFailure(new NotFoundError("template-resource", event.payload.id));
21
- }
22
- const systemRow = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
23
- tenantId: SYSTEM_TENANT_ID,
24
- slug: existing.slug,
25
- kind: existing.kind,
26
- locale: existing.locale,
27
- status: "active",
28
- });
29
- if (!systemRow) {
30
- return writeFailure(
31
- new ValidationError({
32
- fields: [
33
- {
34
- path: "id",
35
- code: "no_system_template",
36
- i18nKey: "templateResolver.errors.noSystemTemplate",
37
- },
38
- ],
39
- }),
15
+ export function makeResetToSystemWrite(policy: TemplateEditPolicy) {
16
+ return defineWriteHandler({
17
+ name: "reset-to-system",
18
+ schema: z.object({ id: z.string().min(1) }),
19
+ access: templateEditAccess(policy.tenantEditable),
20
+ description:
21
+ "Retires a tenant override by archiving it so the resolver serves the active system template again; the override and its content survive and can be published again. Refused when there is no active system template to fall back to. Outside the templates the app lists in tenantEditable only a SystemAdmin may use it.",
22
+ handler: async (event, ctx) => {
23
+ const existing = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
24
+ id: event.payload.id,
25
+ });
26
+ if (!existing || existing.tenantId !== event.user.tenantId || existing.scope !== "tenant") {
27
+ return (
28
+ refuseMissingTemplate(policy, event.user) ??
29
+ writeFailure(new NotFoundError("template-resource", event.payload.id))
30
+ );
31
+ }
32
+ const refused = refuseTemplateEdit(policy, event.user, existing);
33
+ if (refused !== null) return refused;
34
+ const systemRow = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
35
+ tenantId: SYSTEM_TENANT_ID,
36
+ slug: existing.slug,
37
+ kind: existing.kind,
38
+ locale: existing.locale,
39
+ status: "active",
40
+ });
41
+ if (!systemRow) {
42
+ return writeFailure(
43
+ new ValidationError({
44
+ fields: [
45
+ {
46
+ path: "id",
47
+ code: "no_system_template",
48
+ i18nKey: "templateResolver.errors.noSystemTemplate",
49
+ },
50
+ ],
51
+ }),
52
+ );
53
+ }
54
+ const response = {
55
+ isSuccess: true as const,
56
+ data: {
57
+ id: String(existing.id),
58
+ status: "archived" as const,
59
+ systemTemplateId: String(systemRow.id),
60
+ },
61
+ };
62
+ if (existing.status === "archived") return response;
63
+ const result = await executor.update(
64
+ { id: existing.id, version: existing.version, changes: { status: "archived" } },
65
+ event.user,
66
+ ctx.db,
40
67
  );
41
- }
42
- const response = {
43
- isSuccess: true as const,
44
- data: {
45
- id: String(existing.id),
46
- status: "archived" as const,
47
- systemTemplateId: String(systemRow.id),
48
- },
49
- };
50
- if (existing.status === "archived") return response;
51
- const result = await executor.update(
52
- { id: existing.id, version: existing.version, changes: { status: "archived" } },
53
- event.user,
54
- ctx.db,
55
- );
56
- return result.isSuccess ? response : result;
57
- },
58
- });
68
+ return result.isSuccess ? response : result;
69
+ },
70
+ });
71
+ }
@@ -1,34 +1,49 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  import { linkTemplatePdfAttachment } from "../store-template-resource.ts";
4
+ import {
5
+ refuseTemplateEditById,
6
+ type TemplateEditPolicy,
7
+ templateEditAccess,
8
+ } from "../template-edit-policy.ts";
4
9
  import { resourceKeySchema, resourceTargetTenantId } from "./resource-shared.ts";
5
10
 
6
- export const resourceLinkPdfWrite = defineWriteHandler({
7
- name: "resource-link-pdf",
8
- schema: z.object({
9
- templateId: z.string().min(1),
10
- key: resourceKeySchema,
11
- /** Slug of a document-pdf template of the tenant or the system. */
12
- templateSlug: z.string().min(1),
13
- /** Name pattern of the attached PDF; may hold {{placeholders}}, must end with .pdf. */
14
- fileName: z.string().min(1),
15
- /** Cross-tenant target — SystemAdmin only. */
16
- tenantIdOverride: z.string().min(1).optional(),
17
- }),
18
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
19
- description:
20
- "Links a PDF rendered from a document-pdf template into a mail template under the key. Every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale; nothing is uploaded or stored. fileName is a pattern that may use the mail's {{placeholders}} and must end with .pdf. The document template must exist for the tenant or the system. Counts toward the 10 attachments per mail template together with uploaded files.",
21
- handler: async (event, ctx) => {
22
- const { templateId, key, templateSlug, fileName } = event.payload;
23
- const tenantId = resourceTargetTenantId(event.payload.tenantIdOverride, event.user.tenantId);
24
- return linkTemplatePdfAttachment({
25
- ctx,
26
- user: event.user,
27
- tenantId,
28
- templateId,
29
- key,
30
- templateSlug,
31
- fileName,
32
- });
33
- },
34
- });
11
+ export function makeResourceLinkPdfWrite(policy: TemplateEditPolicy) {
12
+ return defineWriteHandler({
13
+ name: "resource-link-pdf",
14
+ schema: z.object({
15
+ templateId: z.string().min(1),
16
+ key: resourceKeySchema,
17
+ /** Slug of a document-pdf template of the tenant or the system. */
18
+ templateSlug: z.string().min(1),
19
+ /** Name pattern of the attached PDF; may hold {{placeholders}}, must end with .pdf. */
20
+ fileName: z.string().min(1),
21
+ /** Cross-tenant target — SystemAdmin only. */
22
+ tenantIdOverride: z.string().min(1).optional(),
23
+ }),
24
+ access: templateEditAccess(policy.tenantEditable),
25
+ description:
26
+ "Links a PDF rendered from a document-pdf template into a mail template under the key. Every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale; nothing is uploaded or stored. fileName is a pattern that may use the mail's {{placeholders}} and must end with .pdf. The document template must exist for the tenant or the system. Counts toward the 10 attachments per mail template together with uploaded files. Outside the templates the app lists in tenantEditable only a SystemAdmin may use it.",
27
+ handler: async (event, ctx) => {
28
+ const { templateId, key, templateSlug, fileName } = event.payload;
29
+ const tenantId = resourceTargetTenantId(event.payload.tenantIdOverride, event.user.tenantId);
30
+ const refused = await refuseTemplateEditById(
31
+ ctx.db,
32
+ policy,
33
+ event.user,
34
+ tenantId,
35
+ templateId,
36
+ );
37
+ if (refused !== null) return refused;
38
+ return linkTemplatePdfAttachment({
39
+ ctx,
40
+ user: event.user,
41
+ tenantId,
42
+ templateId,
43
+ key,
44
+ templateSlug,
45
+ fileName,
46
+ });
47
+ },
48
+ });
49
+ }