@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/cap-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
- package/packages/bundled-features/src/config/web/i18n.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
- package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +20 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
- package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +3 -1
- package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +35 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +77 -36
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +105 -96
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
- package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -111
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
- package/packages/bundled-features/src/template-resolver/index.ts +8 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
- package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +8 -2
- package/packages/bundled-features/src/template-resolver/template-validation.ts +31 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +42 -4
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +2 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
- package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +6 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
- package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
- package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
- package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/embedded/README.md +8 -0
- package/samples/recipes/embedded/src/feature.ts +8 -0
- package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
- package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import { defineWriteHandler
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
-
import {
|
|
5
|
-
collectChannels,
|
|
6
|
-
createEmailAttachmentReader,
|
|
7
|
-
EMAIL_CHANNEL_NAME,
|
|
8
|
-
} from "../../delivery/index.ts";
|
|
9
|
-
import { fileContextForTenant } from "../../file-foundation/index.ts";
|
|
10
4
|
import { TEST_MAIL_SUBJECT_PREFIX } from "../constants.ts";
|
|
11
5
|
import { TemplateResolverErrors } from "../qualified-names.ts";
|
|
6
|
+
import {
|
|
7
|
+
refuseTemplateEdit,
|
|
8
|
+
type TemplateEditPolicy,
|
|
9
|
+
templateEditAccess,
|
|
10
|
+
} from "../template-edit-policy.ts";
|
|
12
11
|
import { renderTemplatePreview } from "../template-preview.ts";
|
|
12
|
+
import {
|
|
13
|
+
emailChannelOf,
|
|
14
|
+
resolveTestMailAddress,
|
|
15
|
+
testMailChannelContext,
|
|
16
|
+
} from "./send-test-shared.ts";
|
|
13
17
|
import { localeSchema, templateSlugSchema } from "./shared.ts";
|
|
14
18
|
|
|
15
19
|
const TEST_MAIL_RATE_LIMIT = { limit: 10, windowSeconds: 3600 } as const;
|
|
16
20
|
|
|
17
|
-
function emailChannelOf(registry: Registry) {
|
|
18
|
-
return collectChannels(registry).find((channel) => channel.name === EMAIL_CHANNEL_NAME);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
21
|
// Transport errors can carry provider responses or the address, so the client only gets a fixed code.
|
|
22
22
|
function testMailFailed(code: "channel_error" | "not_sent") {
|
|
23
23
|
return writeFailure(
|
|
@@ -34,105 +34,99 @@ function errorCodeOf(error: unknown): string | undefined {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// The only recipient is the caller's own address, resolved server-side, so the handler can never mail a third party.
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (!outcome.ok) return writeFailure(outcome.error);
|
|
60
|
-
const { preview, sender } = outcome;
|
|
61
|
-
const mailAttachments = includeAttachments
|
|
62
|
-
? outcome.mailAttachments
|
|
63
|
-
: outcome.mailAttachments.filter((ref) => ref.kind === "inline");
|
|
64
|
-
|
|
65
|
-
const channel = emailChannelOf(ctx.registry);
|
|
66
|
-
if (channel === undefined) {
|
|
67
|
-
return writeFailure(
|
|
68
|
-
new UnprocessableError(TemplateResolverErrors.testMailNoChannel, {
|
|
69
|
-
i18nKey: "templateResolver.errors.testMail.noChannel",
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
const channelContext = {
|
|
74
|
-
db: ctx.db,
|
|
75
|
-
registry: ctx.registry,
|
|
76
|
-
sseBroker: undefined,
|
|
77
|
-
tenantId: event.user.tenantId,
|
|
78
|
-
secrets: ctx.secrets,
|
|
79
|
-
config: ctx.config,
|
|
80
|
-
readAttachment: createEmailAttachmentReader(
|
|
81
|
-
ctx.db,
|
|
82
|
-
ctx.registry,
|
|
83
|
-
event.user.tenantId,
|
|
84
|
-
ctx.config,
|
|
85
|
-
(tenantId) => fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
|
|
86
|
-
),
|
|
87
|
-
};
|
|
88
|
-
const address = (await channel.resolve?.(event.user.id, channelContext)) ?? null;
|
|
89
|
-
if (address === null) {
|
|
90
|
-
return writeFailure(
|
|
91
|
-
new UnprocessableError(TemplateResolverErrors.testMailNoAddress, {
|
|
92
|
-
i18nKey: "templateResolver.errors.testMail.noAddress",
|
|
93
|
-
}),
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const subject = `${prefixSubject ? TEST_MAIL_SUBJECT_PREFIX : ""}${preview.subject ?? ""}`;
|
|
98
|
-
let sentStatus: string;
|
|
99
|
-
try {
|
|
100
|
-
const result = await channel.send(
|
|
101
|
-
address,
|
|
102
|
-
{
|
|
103
|
-
// The template slug is the notification name, so the test mail takes the transport the real mail takes.
|
|
104
|
-
notificationType: event.payload.slug,
|
|
105
|
-
title: subject,
|
|
106
|
-
body: undefined,
|
|
107
|
-
data: undefined,
|
|
108
|
-
locale: event.payload.locale,
|
|
109
|
-
},
|
|
110
|
-
channelContext,
|
|
111
|
-
{
|
|
112
|
-
subject,
|
|
113
|
-
html: preview.html,
|
|
114
|
-
text: preview.text ?? "",
|
|
115
|
-
...sender,
|
|
116
|
-
attachments: mailAttachments,
|
|
117
|
-
},
|
|
118
|
-
);
|
|
119
|
-
sentStatus = result.status;
|
|
120
|
-
} catch (error) {
|
|
121
|
-
// Class and code only: the message can carry the address or a provider response body.
|
|
122
|
-
ctx.log?.warn("template test mail: channel threw", {
|
|
123
|
-
tenantId: event.user.tenantId,
|
|
124
|
-
errorClass: error instanceof Error ? error.name : typeof error,
|
|
125
|
-
errorCode: errorCodeOf(error),
|
|
37
|
+
export function makeSendTestWrite(policy: TemplateEditPolicy) {
|
|
38
|
+
return defineWriteHandler({
|
|
39
|
+
name: "send-test",
|
|
40
|
+
schema: z.object({
|
|
41
|
+
slug: templateSlugSchema,
|
|
42
|
+
locale: localeSchema,
|
|
43
|
+
content: z.string().max(200_000).optional(),
|
|
44
|
+
meta: z.unknown().optional(),
|
|
45
|
+
variableSchema: z.record(z.string(), z.unknown()).default({}),
|
|
46
|
+
variables: z.record(z.string(), z.unknown()).optional(),
|
|
47
|
+
includeAttachments: z.boolean().default(true),
|
|
48
|
+
prefixSubject: z.boolean().default(true),
|
|
49
|
+
}),
|
|
50
|
+
access: templateEditAccess(policy.tenantEditable),
|
|
51
|
+
rateLimit: { per: "user+handler", ...TEST_MAIL_RATE_LIMIT },
|
|
52
|
+
description:
|
|
53
|
+
"Sends the rendered preview of a mail template draft, or of the stored template when no content is given, to the calling user's own address with the subject prefixed by [TEST] unless prefixSubject is false, carrying the inline images and, unless includeAttachments is false, the files attached to the template; there is no recipient input, the sample values come from the declared variable examples unless variables replaces them (checked against the variable schema), and a draft that fails the write rules is rejected without sending.",
|
|
54
|
+
handler: async (event, ctx) => {
|
|
55
|
+
// Sending a test is part of editing, so it follows the same edit policy as the editor writes.
|
|
56
|
+
const refused = refuseTemplateEdit(policy, event.user, {
|
|
57
|
+
slug: event.payload.slug,
|
|
58
|
+
kind: "mail",
|
|
126
59
|
});
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
status: sentStatus,
|
|
60
|
+
if (refused !== null) return refused;
|
|
61
|
+
const { includeAttachments, prefixSubject, ...previewPayload } = event.payload;
|
|
62
|
+
const outcome = await renderTemplatePreview(ctx, event.user, {
|
|
63
|
+
...previewPayload,
|
|
64
|
+
kind: "mail",
|
|
133
65
|
});
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
66
|
+
if (!outcome.ok) return writeFailure(outcome.error);
|
|
67
|
+
const { preview, sender } = outcome;
|
|
68
|
+
const mailAttachments = includeAttachments
|
|
69
|
+
? outcome.mailAttachments
|
|
70
|
+
: outcome.mailAttachments.filter((ref) => ref.kind === "inline");
|
|
71
|
+
|
|
72
|
+
const channel = emailChannelOf(ctx.registry);
|
|
73
|
+
if (channel === undefined) {
|
|
74
|
+
return writeFailure(
|
|
75
|
+
new UnprocessableError(TemplateResolverErrors.testMailNoChannel, {
|
|
76
|
+
i18nKey: "templateResolver.errors.testMail.noChannel",
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const channelContext = testMailChannelContext(ctx, event.user);
|
|
81
|
+
const address = await resolveTestMailAddress(channel, channelContext, event.user);
|
|
82
|
+
if (address === null) {
|
|
83
|
+
return writeFailure(
|
|
84
|
+
new UnprocessableError(TemplateResolverErrors.testMailNoAddress, {
|
|
85
|
+
i18nKey: "templateResolver.errors.testMail.noAddress",
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const subject = `${prefixSubject ? TEST_MAIL_SUBJECT_PREFIX : ""}${preview.subject ?? ""}`;
|
|
91
|
+
let sentStatus: string;
|
|
92
|
+
try {
|
|
93
|
+
const result = await channel.send(
|
|
94
|
+
address,
|
|
95
|
+
{
|
|
96
|
+
// The template slug is the notification name, so the test mail takes the transport the real mail takes.
|
|
97
|
+
notificationType: event.payload.slug,
|
|
98
|
+
title: subject,
|
|
99
|
+
body: undefined,
|
|
100
|
+
data: undefined,
|
|
101
|
+
locale: event.payload.locale,
|
|
102
|
+
},
|
|
103
|
+
channelContext,
|
|
104
|
+
{
|
|
105
|
+
subject,
|
|
106
|
+
html: preview.html,
|
|
107
|
+
text: preview.text ?? "",
|
|
108
|
+
...sender,
|
|
109
|
+
attachments: mailAttachments,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
sentStatus = result.status;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// Class and code only: the message can carry the address or a provider response body.
|
|
115
|
+
ctx.log?.warn("template test mail: channel threw", {
|
|
116
|
+
tenantId: event.user.tenantId,
|
|
117
|
+
errorClass: error instanceof Error ? error.name : typeof error,
|
|
118
|
+
errorCode: errorCodeOf(error),
|
|
119
|
+
});
|
|
120
|
+
return testMailFailed("channel_error");
|
|
121
|
+
}
|
|
122
|
+
if (sentStatus !== "sent") {
|
|
123
|
+
ctx.log?.warn("template test mail: channel did not send", {
|
|
124
|
+
tenantId: event.user.tenantId,
|
|
125
|
+
status: sentStatus,
|
|
126
|
+
});
|
|
127
|
+
return testMailFailed("not_sent");
|
|
128
|
+
}
|
|
129
|
+
return { isSuccess: true as const, data: { subject, checks: preview.checks } };
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -9,6 +9,11 @@ import * as z from "zod";
|
|
|
9
9
|
import { requireForTenant } from "../../shared/index.ts";
|
|
10
10
|
import { TEMPLATE_KINDS, TEXT_BLOCK_KIND, UPSERT_KINDS } from "../constants.ts";
|
|
11
11
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
|
|
12
|
+
import {
|
|
13
|
+
refuseTemplateEdit,
|
|
14
|
+
type TemplateEditPolicy,
|
|
15
|
+
templateEditAccess,
|
|
16
|
+
} from "../template-edit-policy.ts";
|
|
12
17
|
import { resolveTemplateVariables } from "../template-validation.ts";
|
|
13
18
|
import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.ts";
|
|
14
19
|
import { parseStoredJson, storedRecord } from "./template-gates.ts";
|
|
@@ -33,115 +38,117 @@ import { gateTemplateWrite } from "./template-write.ts";
|
|
|
33
38
|
//
|
|
34
39
|
// Kinds that upsertSystem/upsertTenant also write run the same validation
|
|
35
40
|
// pipeline here, and because saving publishes, publish issues reject too.
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
41
|
+
export function makeSetWrite(policy: TemplateEditPolicy) {
|
|
42
|
+
return defineWriteHandler({
|
|
43
|
+
name: "set",
|
|
44
|
+
schema: z.object({
|
|
45
|
+
slug: slugSchema,
|
|
46
|
+
locale: localeSchema,
|
|
47
|
+
/** Which kind to author. Defaults to text-block. */
|
|
48
|
+
kind: z.enum(TEMPLATE_KINDS).optional(),
|
|
49
|
+
title: z.string().min(1).max(200),
|
|
50
|
+
content: z.string().max(200_000).nullable(),
|
|
51
|
+
contentFormat: contentFormatSchema.default("markdown"),
|
|
52
|
+
/** Folder path for the content tree. Optional + null = root node. */
|
|
53
|
+
folder: folderSchema.nullable().optional(),
|
|
54
|
+
/** Optional cross-tenant write — SystemAdmin only. Typical use: the
|
|
55
|
+
* editor lets a SystemAdmin write onto SYSTEM_TENANT_ID, otherwise the
|
|
56
|
+
* text lands on their own platform tenant and the legal routes miss it. */
|
|
57
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
58
|
+
}),
|
|
59
|
+
access: templateEditAccess(policy.tenantEditable),
|
|
60
|
+
description:
|
|
61
|
+
"Creates or overwrites one content-tree resource addressed by slug, kind and locale, carrying a title and folder and going live on save with no draft stage; a SystemAdmin may target the system tenant, where shared legal and marketing copy lives. A TenantAdmin may write only the slug and kind pairs the app lists in tenantEditable, otherwise SystemAdmin only.",
|
|
62
|
+
handler: async (event, ctx) => {
|
|
63
|
+
const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
|
|
64
|
+
const refused = refuseTemplateEdit(policy, event.user, { slug: event.payload.slug, kind });
|
|
65
|
+
if (refused !== null) return refused;
|
|
66
|
+
const override = event.payload.tenantIdOverride;
|
|
67
|
+
// @cast-boundary engine-payload — override is a zod-validated string, the
|
|
68
|
+
// user's own tenantId is already TenantId-branded.
|
|
69
|
+
const tenantId = (override ?? event.user.tenantId) as TenantId;
|
|
70
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
});
|
|
72
|
+
const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
73
|
+
tenantId,
|
|
74
|
+
slug: event.payload.slug,
|
|
75
|
+
kind,
|
|
76
|
+
locale: event.payload.locale,
|
|
77
|
+
});
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
79
|
+
const scope = tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const);
|
|
80
|
+
let content = event.payload.content;
|
|
81
|
+
if (isOneOf(UPSERT_KINDS, kind)) {
|
|
82
|
+
const gated = gateTemplateWrite(
|
|
83
|
+
{
|
|
84
|
+
kind,
|
|
85
|
+
scope,
|
|
86
|
+
contentFormat: event.payload.contentFormat,
|
|
87
|
+
content,
|
|
88
|
+
rawMeta: parseStoredJson(existing?.meta ?? null),
|
|
89
|
+
variables: resolveTemplateVariables(
|
|
90
|
+
ctx.registry,
|
|
91
|
+
event.payload.slug,
|
|
92
|
+
storedRecord(existing?.variableSchema ?? null),
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
"active",
|
|
96
|
+
);
|
|
97
|
+
if (!gated.ok) return gated.failure;
|
|
98
|
+
content = gated.evaluated.content;
|
|
99
|
+
}
|
|
94
100
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
101
|
+
const fields = {
|
|
102
|
+
slug: event.payload.slug,
|
|
103
|
+
kind,
|
|
104
|
+
locale: event.payload.locale,
|
|
105
|
+
title: event.payload.title,
|
|
106
|
+
content,
|
|
107
|
+
contentFormat: event.payload.contentFormat,
|
|
108
|
+
folder: event.payload.folder ?? null,
|
|
109
|
+
variableSchema: "{}",
|
|
110
|
+
linkedResources: "{}",
|
|
111
|
+
scope,
|
|
112
|
+
parentTemplateId: null,
|
|
113
|
+
// Text blocks have no draft stage — saving publishes, so resolveTemplate
|
|
114
|
+
// (which only returns active rows) finds them.
|
|
115
|
+
status: "active" as const,
|
|
116
|
+
};
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
if (existing) {
|
|
119
|
+
const result = await executor.update(
|
|
120
|
+
{
|
|
121
|
+
id: existing.id,
|
|
122
|
+
version: existing.version,
|
|
123
|
+
// Only the editable columns — slug/kind/locale are the unique key,
|
|
124
|
+
// and rewriting scope/status/variableSchema here would reset whatever
|
|
125
|
+
// the template upserts put on the row.
|
|
126
|
+
changes: {
|
|
127
|
+
title: fields.title,
|
|
128
|
+
content: fields.content,
|
|
129
|
+
contentFormat: fields.contentFormat,
|
|
130
|
+
folder: fields.folder,
|
|
131
|
+
},
|
|
125
132
|
},
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
event.user,
|
|
134
|
+
db,
|
|
135
|
+
{ streamTenantId },
|
|
136
|
+
);
|
|
137
|
+
if (!result.isSuccess) return result;
|
|
138
|
+
return {
|
|
139
|
+
isSuccess: true as const,
|
|
140
|
+
data: { slug: event.payload.slug, locale: event.payload.locale, isNew: false },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = await executor.create({ ...fields, tenantId }, event.user, db, {
|
|
145
|
+
streamTenantId,
|
|
146
|
+
});
|
|
131
147
|
if (!result.isSuccess) return result;
|
|
132
148
|
return {
|
|
133
149
|
isSuccess: true as const,
|
|
134
|
-
data: { slug: event.payload.slug, locale: event.payload.locale, isNew:
|
|
150
|
+
data: { slug: event.payload.slug, locale: event.payload.locale, isNew: true },
|
|
135
151
|
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
streamTenantId,
|
|
140
|
-
});
|
|
141
|
-
if (!result.isSuccess) return result;
|
|
142
|
-
return {
|
|
143
|
-
isSuccess: true as const,
|
|
144
|
-
data: { slug: event.payload.slug, locale: event.payload.locale, isNew: true },
|
|
145
|
-
};
|
|
146
|
-
},
|
|
147
|
-
});
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -15,6 +15,12 @@ import { requireForTenant } from "../../shared/index.ts";
|
|
|
15
15
|
import type { TemplateResourceRow } from "../table.ts";
|
|
16
16
|
import { templateResourcesTable } from "../table.ts";
|
|
17
17
|
import { activationChanges, parseTemplateDraft, toWorkingRow } from "../template-draft.ts";
|
|
18
|
+
import {
|
|
19
|
+
refuseMissingTemplate,
|
|
20
|
+
refuseTemplateEdit,
|
|
21
|
+
type TemplateEditPolicy,
|
|
22
|
+
templateEditAccess,
|
|
23
|
+
} from "../template-edit-policy.ts";
|
|
18
24
|
import { executor } from "./shared.ts";
|
|
19
25
|
import { publishGateIssues } from "./template-gates.ts";
|
|
20
26
|
|
|
@@ -27,8 +33,10 @@ function isRowOfTarget(row: TemplateResourceRow, target: StatusTarget, callerTen
|
|
|
27
33
|
: row.tenantId === callerTenantId && row.scope === "tenant";
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
export function writeAccessFor(target: StatusTarget) {
|
|
31
|
-
return
|
|
36
|
+
export function writeAccessFor(target: StatusTarget, policy: TemplateEditPolicy) {
|
|
37
|
+
return target === "system"
|
|
38
|
+
? { roles: ["SystemAdmin"] }
|
|
39
|
+
: templateEditAccess(policy.tenantEditable);
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
// The tenant db also reads system rows; the row's own tenant must match the
|
|
@@ -65,6 +73,7 @@ function planStatusChange(
|
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
function createStatusUpdateHandler(
|
|
76
|
+
policy: TemplateEditPolicy,
|
|
68
77
|
name: string,
|
|
69
78
|
target: StatusTarget,
|
|
70
79
|
status: TemplateStatus,
|
|
@@ -73,13 +82,20 @@ function createStatusUpdateHandler(
|
|
|
73
82
|
return defineWriteHandler({
|
|
74
83
|
name,
|
|
75
84
|
schema: z.object({ id: z.string().min(1) }),
|
|
76
|
-
access: writeAccessFor(target),
|
|
85
|
+
access: writeAccessFor(target, policy),
|
|
77
86
|
// A system template change reaches every tenant without an override.
|
|
78
87
|
...(target === "system" ? { agent: { risk: "high" as const } } : {}),
|
|
79
88
|
description,
|
|
80
89
|
handler: async (event, ctx) => {
|
|
81
90
|
const { db, scoped, row } = await loadTargetRow(ctx, target, event.user, event.payload.id);
|
|
82
|
-
if (!row)
|
|
91
|
+
if (!row) {
|
|
92
|
+
const refused = target === "own" ? refuseMissingTemplate(policy, event.user) : null;
|
|
93
|
+
return refused ?? writeFailure(new NotFoundError("template-resource", event.payload.id));
|
|
94
|
+
}
|
|
95
|
+
if (target === "own") {
|
|
96
|
+
const refused = refuseTemplateEdit(policy, event.user, row);
|
|
97
|
+
if (refused !== null) return refused;
|
|
98
|
+
}
|
|
83
99
|
const plan = planStatusChange(ctx, row, status);
|
|
84
100
|
if (!plan.ok) return plan.failure;
|
|
85
101
|
const result = await executor.update(
|
|
@@ -94,27 +110,39 @@ function createStatusUpdateHandler(
|
|
|
94
110
|
});
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
export
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
export
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
113
|
+
export function makeArchiveWrite(policy: TemplateEditPolicy) {
|
|
114
|
+
return createStatusUpdateHandler(
|
|
115
|
+
policy,
|
|
116
|
+
"archive",
|
|
117
|
+
"own",
|
|
118
|
+
"archived",
|
|
119
|
+
"Sets one template resource of the caller's tenant to archived so the resolver stops serving it while the row and its content survive; use it to retire a template reversibly instead of deleting it. An open draft stays. Outside the templates the app lists in tenantEditable only a SystemAdmin may use it.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
export function makePublishWrite(policy: TemplateEditPolicy) {
|
|
123
|
+
return createStatusUpdateHandler(
|
|
124
|
+
policy,
|
|
125
|
+
"publish",
|
|
126
|
+
"own",
|
|
127
|
+
"active",
|
|
128
|
+
"Sets one template resource of the caller's tenant to active so the resolver starts serving it; use it to release a draft written by upsert-tenant (a draft saved beside an active version replaces it), or to bring an archived template back; a version with unknown placeholders or unused required variables is refused and the served version stays. Outside the templates the app lists in tenantEditable only a SystemAdmin may use it.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
export function makeArchiveSystemWrite(policy: TemplateEditPolicy) {
|
|
132
|
+
return createStatusUpdateHandler(
|
|
133
|
+
policy,
|
|
134
|
+
"archive-system",
|
|
135
|
+
"system",
|
|
136
|
+
"archived",
|
|
137
|
+
"Sets one system-default template to archived so the resolver stops serving it to every tenant that has no override; SystemAdmin only, reversible with publish-system.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
export function makePublishSystemWrite(policy: TemplateEditPolicy) {
|
|
141
|
+
return createStatusUpdateHandler(
|
|
142
|
+
policy,
|
|
143
|
+
"publish-system",
|
|
144
|
+
"system",
|
|
145
|
+
"active",
|
|
146
|
+
"Sets one system-default template to active so the resolver serves it again to every tenant that has no override; an open draft beside the active version replaces it; SystemAdmin only, a version with unknown placeholders or unused required variables is refused.",
|
|
147
|
+
);
|
|
148
|
+
}
|