@cosmicdrift/kumiko-bundled-features 0.364.0 → 0.365.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/dist/audit/feature.js +1 -1
- package/dist/audit/run-escape-hatch-retention.d.ts +1 -0
- package/dist/audit/run-escape-hatch-retention.js +39 -12
- package/dist/auth-email-password/bootstrap.d.ts +1 -3
- package/dist/auth-email-password/feature.js +2 -0
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -3
- package/dist/auth-email-password/handlers/invite-create.write.d.ts +2 -3
- package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
- package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +1 -1
- package/dist/auth-email-password/magic-link-mail.js +6 -3
- package/dist/auth-email-password/mail-i18n.d.ts +3 -0
- package/dist/auth-email-password/mail-i18n.js +10 -0
- package/dist/billing-foundation/consumer-protection/termination-record.js +8 -1
- package/dist/billing-foundation/i18n.js +6 -0
- package/dist/billing-foundation/subscription-tier-sync.js +8 -2
- package/dist/cap-counter/book-cap-usage.js +8 -0
- package/dist/cap-counter/cap-reservation.js +7 -3
- package/dist/cap-counter/handlers/increment-rolling.write.d.ts +1 -0
- package/dist/cap-counter/handlers/increment-rolling.write.js +2 -0
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -1
- package/dist/cap-overview/handlers/tenant-options.query.js +3 -1
- package/dist/channel-discord/discord-channel.js +3 -2
- package/dist/channel-email/email-channel.js +24 -19
- package/dist/channel-email/smtp-transport.js +6 -1
- package/dist/channel-slack/slack-channel.js +3 -2
- package/dist/channel-teams/teams-channel.js +3 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.js +2 -2
- package/dist/custom-fields/db/queries/retention.d.ts +1 -1
- package/dist/custom-fields/db/queries/retention.js +2 -1
- package/dist/data-retention/run-retention-cleanup.js +4 -5
- package/dist/delivery/attachment-ref.js +16 -1
- package/dist/delivery/delivery-service.js +71 -82
- package/dist/feature-toggles/toggle-runtime.d.ts +5 -3
- package/dist/feature-toggles/toggle-runtime.js +25 -12
- package/dist/files-tenant-data/index.d.ts +1 -0
- package/dist/files-tenant-data/index.js +2 -1
- package/dist/form-draft/db/queries/owned-file-refs.d.ts +1 -1
- package/dist/form-draft/db/queries/owned-file-refs.js +9 -2
- package/dist/form-draft/handlers/discard.write.js +1 -1
- package/dist/jobs/job-run-logger.js +5 -2
- package/dist/metrics/filters.d.ts +16 -1
- package/dist/metrics/filters.js +27 -11
- package/dist/personal-access-tokens/revoke-for-user.js +3 -7
- package/dist/secrets/handlers/delete.write.js +3 -1
- package/dist/secrets/write-gate.d.ts +3 -1
- package/dist/secrets/write-gate.js +6 -3
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/dist/shared/resource-image-formats.d.ts +23 -0
- package/dist/shared/resource-image-formats.js +21 -0
- package/dist/shared/sharp-input-limits.js +7 -8
- package/dist/step-dispatcher/webhook-runner.js +2 -2
- package/dist/template-resolver/compile-document-body.d.ts +3 -0
- package/dist/template-resolver/compile-document-body.js +63 -12
- package/dist/template-resolver/compile-steps-block.d.ts +10 -0
- package/dist/template-resolver/compile-steps-block.js +45 -0
- package/dist/template-resolver/constants.d.ts +4 -1
- package/dist/template-resolver/constants.js +10 -1
- package/dist/template-resolver/document-sanitizer.js +3 -0
- package/dist/template-resolver/document-schema.d.ts +2 -1
- package/dist/template-resolver/document-schema.js +6 -1
- package/dist/template-resolver/document-steps.d.ts +17 -0
- package/dist/template-resolver/document-steps.js +93 -0
- package/dist/template-resolver/feature.js +9 -0
- package/dist/template-resolver/handlers/bundle-import.d.ts +7 -1
- package/dist/template-resolver/handlers/bundle-import.js +63 -38
- package/dist/template-resolver/handlers/collection-set.write.js +4 -3
- package/dist/template-resolver/handlers/copy-locale.write.d.ts +1 -1
- package/dist/template-resolver/handlers/copy-locale.write.js +83 -55
- package/dist/template-resolver/handlers/diff-to-system.query.js +1 -0
- package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +1 -1
- package/dist/template-resolver/handlers/import-bundle-system.write.js +16 -5
- package/dist/template-resolver/handlers/import-bundle.write.d.ts +1 -1
- package/dist/template-resolver/handlers/import-bundle.write.js +11 -1
- package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
- package/dist/template-resolver/handlers/overview.query.js +9 -1
- package/dist/template-resolver/handlers/resource-upload.write.d.ts +2 -2
- package/dist/template-resolver/handlers/resource-upload.write.js +35 -5
- package/dist/template-resolver/handlers/validate.query.js +4 -1
- package/dist/template-resolver/index.d.ts +3 -3
- package/dist/template-resolver/index.js +3 -3
- package/dist/template-resolver/layout-appearance.d.ts +1 -1
- package/dist/template-resolver/layout-appearance.js +19 -9
- package/dist/template-resolver/mail-constants.d.ts +2 -0
- package/dist/template-resolver/mail-constants.js +3 -0
- package/dist/template-resolver/resource-image.d.ts +2 -9
- package/dist/template-resolver/resource-image.js +2 -11
- package/dist/template-resolver/seeding.js +2 -0
- package/dist/template-resolver/store-template-resource.d.ts +53 -9
- package/dist/template-resolver/store-template-resource.js +250 -97
- package/dist/template-resolver/template-bundle.js +4 -0
- package/dist/template-resolver/template-draft.d.ts +3 -0
- package/dist/template-resolver/template-draft.js +1 -1
- package/dist/template-resolver/template-meta.d.ts +5 -1
- package/dist/template-resolver/template-meta.js +7 -0
- package/dist/template-resolver/template-preview.js +1 -2
- package/dist/template-resolver/template-resources.d.ts +0 -5
- package/dist/template-resolver/template-resources.js +2 -34
- package/dist/template-resolver/template-validation.js +12 -9
- package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -0
- package/dist/template-resolver/web/document-editor-extensions.js +26 -1
- package/dist/template-resolver/web/document-editor.js +5 -1
- package/dist/template-resolver/web/document-tiptap-editor.js +30 -7
- package/dist/template-resolver/web/document-toolbar-blocks.d.ts +5 -5
- package/dist/template-resolver/web/document-toolbar-blocks.js +2 -0
- package/dist/template-resolver/web/i18n.js +5 -0
- package/dist/template-resolver/web/index.d.ts +1 -0
- package/dist/template-resolver/web/index.js +1 -0
- package/dist/template-resolver/web/placeholder-line-editor.d.ts +4 -2
- package/dist/template-resolver/web/placeholder-line-editor.js +8 -6
- package/dist/tenant/handlers/update-member-roles.write.js +3 -6
- package/dist/tenant/seeding.js +23 -24
- package/dist/tenant-handover/move-entity-graph.js +14 -4
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +1 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +8 -1
- package/dist/tenant-lifecycle/stages.js +3 -2
- package/dist/tier-engine/constants.d.ts +2 -0
- package/dist/tier-engine/constants.js +4 -0
- package/dist/tier-engine/entity.d.ts +15 -14
- package/dist/tier-engine/entity.js +4 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +5 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +7 -4
- package/dist/tier-engine/index.d.ts +1 -1
- package/dist/tier-engine/index.js +1 -1
- package/dist/tier-engine/tier-resolver.d.ts +13 -13
- package/dist/user/constants.d.ts +1 -0
- package/dist/user/invited-global-roles.d.ts +2 -4
- package/dist/user-data-rights/email-templates.js +0 -1
- package/dist/user-data-rights/i18n.js +5 -0
- package/dist/user-data-rights/lib/update-user-lifecycle.d.ts +2 -0
- package/dist/user-data-rights/lib/update-user-lifecycle.js +6 -2
- package/dist/user-data-rights/run-forget-cleanup.js +2 -2
- package/dist/waitlist/feature.js +1 -1
- package/dist/waitlist/handlers/invite.write.js +21 -3
- package/dist/waitlist/handlers/reject.write.js +3 -16
- package/dist/waitlist/handlers/submit.write.js +29 -7
- package/dist/waitlist/i18n.js +9 -0
- package/dist/waitlist/lib.d.ts +2 -1
- package/dist/waitlist/lib.js +15 -0
- package/dist/workflow-runner/feature.js +1 -1
- package/package.json +10 -10
- package/src/audit/changes.json +12 -0
- package/src/auth-email-password/changes.json +18 -0
- package/src/billing-foundation/changes.json +12 -0
- package/src/cap-counter/changes.json +13 -0
- package/src/cap-overview/changes.json +6 -0
- package/src/channel-email/changes.json +18 -0
- package/src/delivery/changes.json +12 -0
- package/src/document-ingest-foundation/changes.json +6 -0
- package/src/feature-toggles/changes.json +7 -0
- package/src/jobs/changes.json +12 -0
- package/src/metrics/changes.json +6 -0
- package/src/personal-access-tokens/changes.json +6 -0
- package/src/secrets/changes.json +6 -0
- package/src/template-resolver/changes.json +81 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +6 -0
- package/src/tier-engine/changes.json +12 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/waitlist/changes.json +18 -0
|
@@ -76,15 +76,13 @@ function listItemFields(variables, listName) {
|
|
|
76
76
|
function isKnownName(name, scope, itemFields) {
|
|
77
77
|
const { variables } = scope;
|
|
78
78
|
const root = rootName(name);
|
|
79
|
-
|
|
79
|
+
// Declared variables and item fields are scalars: a dotted path under one never renders a value.
|
|
80
|
+
if (Object.hasOwn(itemFields, name))
|
|
80
81
|
return { known: true, used: "" };
|
|
81
|
-
}
|
|
82
82
|
if (scope.systemPlaceholders.has(root))
|
|
83
83
|
return { known: true, used: "" };
|
|
84
84
|
if (Object.hasOwn(variables, name))
|
|
85
85
|
return { known: true, used: name };
|
|
86
|
-
if (Object.hasOwn(variables, root))
|
|
87
|
-
return { known: true, used: root };
|
|
88
86
|
return { known: false, used: "" };
|
|
89
87
|
}
|
|
90
88
|
function recordName(name, itemFields, scope) {
|
|
@@ -97,6 +95,10 @@ function recordName(name, itemFields, scope) {
|
|
|
97
95
|
function enclosingEachName(element) {
|
|
98
96
|
return element.closest("tr[data-each]")?.getAttribute("data-each") ?? null;
|
|
99
97
|
}
|
|
98
|
+
const NAME_ATTRIBUTE_BY_TAG = {
|
|
99
|
+
span: "data-variable",
|
|
100
|
+
div: "data-steps",
|
|
101
|
+
};
|
|
100
102
|
function scanBodyPlaceholders(body, scope) {
|
|
101
103
|
withParsedDocument(body, (root) => {
|
|
102
104
|
for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
|
|
@@ -107,11 +109,12 @@ function scanBodyPlaceholders(body, scope) {
|
|
|
107
109
|
else
|
|
108
110
|
scope.unknown.add(listName);
|
|
109
111
|
}
|
|
110
|
-
for (const element of Array.from(root.querySelectorAll("span[data-variable], a[href], img[data-resource]"))) {
|
|
112
|
+
for (const element of Array.from(root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"))) {
|
|
111
113
|
const itemFields = listItemFields(scope.variables, enclosingEachName(element));
|
|
112
114
|
const tag = element.tagName.toLowerCase();
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
|
|
116
|
+
if (nameAttribute !== undefined) {
|
|
117
|
+
recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
|
|
115
118
|
}
|
|
116
119
|
else if (tag === "a") {
|
|
117
120
|
const href = element.getAttribute("href") ?? "";
|
|
@@ -127,7 +130,7 @@ function scanBodyPlaceholders(body, scope) {
|
|
|
127
130
|
});
|
|
128
131
|
}
|
|
129
132
|
const HANDLEBARS_EXPRESSION = /\{\{([^{}]*)\}\}/g;
|
|
130
|
-
// Meta text supports only plain `{{name}}`
|
|
133
|
+
// Meta text supports only plain `{{name}}` references; every other
|
|
131
134
|
// Handlebars construct is reported as unknown so it can never reach the compiler.
|
|
132
135
|
function scanMetaPlaceholders(text, scope) {
|
|
133
136
|
const leftover = text.replace(HANDLEBARS_EXPRESSION, (_match, inner) => {
|
|
@@ -144,7 +147,7 @@ function scanMetaPlaceholders(text, scope) {
|
|
|
144
147
|
function metaTexts(meta) {
|
|
145
148
|
if (meta === null || !("subject" in meta))
|
|
146
149
|
return [];
|
|
147
|
-
return [meta.subject,
|
|
150
|
+
return [meta.subject, meta.previewText, meta.headerChip, meta.tone, meta.footerNote].filter((text) => text !== undefined);
|
|
148
151
|
}
|
|
149
152
|
export function validateTemplatePlaceholders(input) {
|
|
150
153
|
const scope = {
|
|
@@ -6,6 +6,7 @@ export type DocumentEditorLabels = {
|
|
|
6
6
|
readonly image: string;
|
|
7
7
|
readonly pageBreak: string;
|
|
8
8
|
readonly button?: string;
|
|
9
|
+
readonly steps?: string;
|
|
9
10
|
/** Canvas block label of an image, e.g. "Image · inline". */
|
|
10
11
|
readonly imageBlock?: string;
|
|
11
12
|
readonly variableLabels?: Readonly<Record<string, string>>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ListKeymap } from "@tiptap/extension-list";
|
|
2
2
|
import { Dropcursor, Gapcursor, Placeholder, UndoRedo } from "@tiptap/extensions";
|
|
3
|
-
import { DocumentButton, DocumentPageBreak, DocumentResource, DocumentVariable, documentExtensions, } from "../document-schema.js";
|
|
3
|
+
import { DocumentButton, DocumentPageBreak, DocumentResource, DocumentSteps, DocumentVariable, documentExtensions, } from "../document-schema.js";
|
|
4
|
+
import { readDocumentStepsAttribute } from "../document-steps.js";
|
|
4
5
|
const DOCUMENT_RESOURCE_SIZE_TOKEN = "{size}";
|
|
5
6
|
export const DOCUMENT_BLOCK_ATTRIBUTE = "data-document-block";
|
|
6
7
|
function createElementWithClass(tag, className, text) {
|
|
@@ -110,6 +111,28 @@ function createPageBreakView(pageBreakLabel) {
|
|
|
110
111
|
return { dom };
|
|
111
112
|
};
|
|
112
113
|
}
|
|
114
|
+
// A static sketch of the row: which stage is reached is only known at render time.
|
|
115
|
+
function createStepsView(labels) {
|
|
116
|
+
return ({ node }) => {
|
|
117
|
+
const dom = createElementWithClass("div", "relative my-3 rounded border border-dashed border-input px-3 pb-2 pt-3");
|
|
118
|
+
dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
|
|
119
|
+
dom.contentEditable = "false";
|
|
120
|
+
const variable = String(node.attrs["variable"]);
|
|
121
|
+
const variableLabels = labels.variableLabels ?? {};
|
|
122
|
+
const variableLabel = Object.hasOwn(variableLabels, variable)
|
|
123
|
+
? variableLabels[variable]
|
|
124
|
+
: undefined;
|
|
125
|
+
dom.append(createBlockLabel(`${labels.steps ?? "Steps"} · ${variableLabel ?? variable}`));
|
|
126
|
+
const row = createElementWithClass("div", "flex gap-1.5");
|
|
127
|
+
for (const step of readDocumentStepsAttribute(node.attrs["steps"])) {
|
|
128
|
+
const cell = createElementWithClass("div", "min-w-0 flex-1");
|
|
129
|
+
cell.append(createElementWithClass("div", "h-1 rounded-full bg-primary/40"), createElementWithClass("div", "truncate pt-1.5 text-xs text-muted-foreground", step.label));
|
|
130
|
+
row.append(cell);
|
|
131
|
+
}
|
|
132
|
+
dom.append(row);
|
|
133
|
+
return { dom };
|
|
134
|
+
};
|
|
135
|
+
}
|
|
113
136
|
// Replaced by name, not appended: the schema must keep exactly one node per
|
|
114
137
|
// name, and only the editor view differs from the shared document schema.
|
|
115
138
|
export function createDocumentEditorExtensions(labels) {
|
|
@@ -125,6 +148,8 @@ export function createDocumentEditorExtensions(labels) {
|
|
|
125
148
|
return labels.blockLabels
|
|
126
149
|
? DocumentButton.extend({ addNodeView: () => createButtonView(labels.button ?? "") })
|
|
127
150
|
: extension;
|
|
151
|
+
case DocumentSteps.name:
|
|
152
|
+
return DocumentSteps.extend({ addNodeView: () => createStepsView(labels) });
|
|
128
153
|
case DocumentPageBreak.name:
|
|
129
154
|
return DocumentPageBreak.extend({
|
|
130
155
|
addNodeView: () => createPageBreakView(labels.pageBreak),
|
|
@@ -13,5 +13,9 @@ export const DocumentBlocksContext = createContext(undefined);
|
|
|
13
13
|
export function DocumentContentEditor(props) {
|
|
14
14
|
const contextBlocks = useContext(DocumentBlocksContext);
|
|
15
15
|
const editorProps = props.blocks === undefined ? { ...props, blocks: contextBlocks } : props;
|
|
16
|
-
return (_jsx(Suspense
|
|
16
|
+
return (_jsx(Suspense
|
|
17
|
+
// Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
|
|
18
|
+
, {
|
|
19
|
+
// Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
|
|
20
|
+
fallback: _jsx(TextareaContentEditor, { id: props.id, value: props.value, onChange: props.onChange, variables: [], readOnly: true }), children: _jsx(LazyDocumentTiptapEditor, { ...editorProps }) }));
|
|
17
21
|
}
|
|
@@ -10,10 +10,11 @@ import { cn } from "@cosmicdrift/kumiko-renderer-web";
|
|
|
10
10
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
11
11
|
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
|
|
12
12
|
import { BubbleMenu } from "@tiptap/react/menus";
|
|
13
|
-
import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold as BoldIcon, Braces, ChevronDown, Image as ImageIcon, Italic as ItalicIcon, Link as LinkIcon, List, ListOrdered, Minus, MousePointerClick, Repeat, Scissors, Table as TableIcon, Underline as UnderlineIcon, } from "lucide-react";
|
|
13
|
+
import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold as BoldIcon, Braces, ChevronDown, Image as ImageIcon, Italic as ItalicIcon, Link as LinkIcon, List, ListChecks, ListOrdered, Minus, MousePointerClick, Repeat, Scissors, Table as TableIcon, Underline as UnderlineIcon, } from "lucide-react";
|
|
14
14
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
15
15
|
import { createPortal } from "react-dom";
|
|
16
16
|
import { DOCUMENT_TEXT_ALIGNMENTS, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "../document-schema.js";
|
|
17
|
+
import { parseDocumentStepsInput } from "../document-steps.js";
|
|
17
18
|
import { createDocumentEditorExtensions, DOCUMENT_BLOCK_ATTRIBUTE, } from "./document-editor-extensions.js";
|
|
18
19
|
import { MAIL_DOCUMENT_BLOCKS, } from "./document-toolbar-blocks.js";
|
|
19
20
|
import { createEditorHandle } from "./editor-handle.js";
|
|
@@ -33,6 +34,7 @@ const BLOCK_PANEL = {
|
|
|
33
34
|
button: "button",
|
|
34
35
|
repeat: "repeat",
|
|
35
36
|
align: "align",
|
|
37
|
+
steps: "steps",
|
|
36
38
|
};
|
|
37
39
|
const ALIGNMENT_ICONS = {
|
|
38
40
|
left: _jsx(AlignLeft, { size: 16 }),
|
|
@@ -185,6 +187,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }) {
|
|
|
185
187
|
const [resource, setResource] = useState("");
|
|
186
188
|
const [alt, setAlt] = useState("");
|
|
187
189
|
const [buttonText, setButtonText] = useState(() => t("template-resolver.documentEditor.buttonDefaultText"));
|
|
190
|
+
const [stepsText, setStepsText] = useState(() => t("template-resolver.documentEditor.stepsDefault"));
|
|
188
191
|
const [chosenName, setChosenName] = useState("");
|
|
189
192
|
const [error, setError] = useState(null);
|
|
190
193
|
const referenceNames = variables.filter(isDocumentReferenceName);
|
|
@@ -231,7 +234,15 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }) {
|
|
|
231
234
|
}
|
|
232
235
|
insertAndClose({ type: "resource", attrs: { resource, alt: alt || null } });
|
|
233
236
|
};
|
|
234
|
-
const
|
|
237
|
+
const submitSteps = () => {
|
|
238
|
+
const steps = parseDocumentStepsInput(stepsText);
|
|
239
|
+
if (steps === null) {
|
|
240
|
+
setError(t("template-resolver.documentEditor.invalidSteps"));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
insertAndClose({ type: "steps", attrs: { variable: selectedName, steps } });
|
|
244
|
+
};
|
|
245
|
+
const needsVariables = kind === "button" || kind === "repeat" || kind === "placeholder" || kind === "steps";
|
|
235
246
|
const noVariables = needsVariables && selectable.length === 0;
|
|
236
247
|
const idPrefix = `${editorId}-panel`;
|
|
237
248
|
return (_jsxs("div", { className: "flex flex-col gap-2 border-b border-input bg-muted/40 p-2", children: [noVariables && (_jsx("p", { className: "text-sm text-muted-foreground", children: t("template-resolver.documentEditor.noVariables") })), kind === "link" && (_jsx(Field, { id: `${idPrefix}-href`, label: t("template-resolver.documentEditor.linkHref"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-href`, name: "href", value: href, onChange: setHref, placeholder: t("template-resolver.documentEditor.linkHrefPlaceholder") }) })), kind === "placeholder" && !noVariables && (_jsx("div", { className: "flex flex-wrap gap-1", children: _jsx(VariableChips, { variables: selectable, onInsert: (name) => insertAndClose({ type: "variable", attrs: { name } }) }) })), kind === "align" && (_jsx("div", { className: "flex flex-wrap gap-1", children: DOCUMENT_TEXT_ALIGNMENTS.map((alignment) => (_jsxs(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => {
|
|
@@ -242,7 +253,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }) {
|
|
|
242
253
|
.updateAttributes("heading", { textAlign: alignment })
|
|
243
254
|
.run();
|
|
244
255
|
onClose();
|
|
245
|
-
}, children: [ALIGNMENT_ICONS[alignment], t(ALIGNMENT_LABEL_KEYS[alignment])] }, alignment))) })), kind === "image" && (_jsxs(_Fragment, { children: [_jsx(Field, { id: `${idPrefix}-resource`, label: t("template-resolver.documentEditor.resourceKey"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-resource`, name: "resource", value: resource, onChange: setResource }) }), _jsx(Field, { id: `${idPrefix}-alt`, label: t("template-resolver.documentEditor.imageAlt"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-alt`, name: "alt", value: alt, onChange: setAlt }) })] })), kind === "button" && !noVariables && (_jsxs(_Fragment, { children: [_jsx(NameSelect, { id: `${idPrefix}-target`, label: t("template-resolver.documentEditor.buttonTarget"), names: selectable, value: selectedName, onChange: setChosenName }), _jsx(Field, { id: `${idPrefix}-text`, label: t("template-resolver.documentEditor.buttonText"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-text`, name: "text", value: buttonText, onChange: setButtonText }) })] })), kind === "repeat" && !noVariables && (_jsx(NameSelect, { id: `${idPrefix}-list`, label: t("template-resolver.documentEditor.repeatList"), names: selectable, value: selectedName, onChange: setChosenName })), error !== null && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })), _jsx("div", { className: "flex flex-wrap gap-2", children: _jsx(PanelActions, { onCancel: onClose, ...(kind === "link" && { onInsert: submitLink }), ...(kind === "link" &&
|
|
256
|
+
}, children: [ALIGNMENT_ICONS[alignment], t(ALIGNMENT_LABEL_KEYS[alignment])] }, alignment))) })), kind === "image" && (_jsxs(_Fragment, { children: [_jsx(Field, { id: `${idPrefix}-resource`, label: t("template-resolver.documentEditor.resourceKey"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-resource`, name: "resource", value: resource, onChange: setResource }) }), _jsx(Field, { id: `${idPrefix}-alt`, label: t("template-resolver.documentEditor.imageAlt"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-alt`, name: "alt", value: alt, onChange: setAlt }) })] })), kind === "button" && !noVariables && (_jsxs(_Fragment, { children: [_jsx(NameSelect, { id: `${idPrefix}-target`, label: t("template-resolver.documentEditor.buttonTarget"), names: selectable, value: selectedName, onChange: setChosenName }), _jsx(Field, { id: `${idPrefix}-text`, label: t("template-resolver.documentEditor.buttonText"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-text`, name: "text", value: buttonText, onChange: setButtonText }) })] })), kind === "repeat" && !noVariables && (_jsx(NameSelect, { id: `${idPrefix}-list`, label: t("template-resolver.documentEditor.repeatList"), names: selectable, value: selectedName, onChange: setChosenName })), kind === "steps" && !noVariables && (_jsxs(_Fragment, { children: [_jsx(NameSelect, { id: `${idPrefix}-steps-variable`, label: t("template-resolver.documentEditor.stepsVariable"), names: selectable, value: selectedName, onChange: setChosenName }), _jsx(Field, { id: `${idPrefix}-steps-list`, label: t("template-resolver.documentEditor.stepsList"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-steps-list`, name: "steps", value: stepsText, onChange: setStepsText }) })] })), error !== null && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })), _jsx("div", { className: "flex flex-wrap gap-2", children: _jsx(PanelActions, { onCancel: onClose, ...(kind === "link" && { onInsert: submitLink }), ...(kind === "link" &&
|
|
246
257
|
editor.isActive("link") && {
|
|
247
258
|
onRemoveLink: () => {
|
|
248
259
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
@@ -256,7 +267,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }) {
|
|
|
256
267
|
content: buttonText ? [{ type: "text", text: buttonText }] : [],
|
|
257
268
|
}),
|
|
258
269
|
}), ...(kind === "repeat" &&
|
|
259
|
-
!noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) }) }) })] }));
|
|
270
|
+
!noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) }), ...(kind === "steps" && !noVariables && { onInsert: submitSteps }) }) })] }));
|
|
260
271
|
}
|
|
261
272
|
export default function DocumentTiptapEditor({ id, value, onChange, variables, readOnly, blocks = MAIL_DOCUMENT_BLOCKS, variableLabels = NO_LABELS, systemVariables = NO_NAMES, resourcePreviews = NO_PREVIEWS, chrome = "default", toolbar = "full", renderPaper, toolbarHost, onEditorReady, }) {
|
|
262
273
|
const t = useTranslation();
|
|
@@ -272,6 +283,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
|
|
|
272
283
|
const imageBlockLabel = t("template-resolver.documentEditor.imageBlock");
|
|
273
284
|
const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
|
|
274
285
|
const buttonLabel = t("template-resolver.documentEditor.button");
|
|
286
|
+
const stepsLabel = t("template-resolver.documentEditor.steps");
|
|
275
287
|
const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
|
|
276
288
|
const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
|
|
277
289
|
const stablePreviews = useStableByKey(resourcePreviews, previewsKey(resourcePreviews));
|
|
@@ -280,6 +292,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
|
|
|
280
292
|
image: imageLabel,
|
|
281
293
|
pageBreak: pageBreakLabel,
|
|
282
294
|
button: buttonLabel,
|
|
295
|
+
steps: stepsLabel,
|
|
283
296
|
imageBlock: imageBlockLabel,
|
|
284
297
|
variableLabels: stableLabels,
|
|
285
298
|
systemVariables: stableSystem,
|
|
@@ -291,6 +304,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
|
|
|
291
304
|
imageBlockLabel,
|
|
292
305
|
pageBreakLabel,
|
|
293
306
|
buttonLabel,
|
|
307
|
+
stepsLabel,
|
|
294
308
|
stableLabels,
|
|
295
309
|
stableSystem,
|
|
296
310
|
stablePreviews,
|
|
@@ -308,6 +322,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
|
|
|
308
322
|
immediatelyRender: false,
|
|
309
323
|
editorProps: { attributes: { id } },
|
|
310
324
|
onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
|
|
325
|
+
onDestroy: () => onEditorReady?.(null),
|
|
311
326
|
onUpdate: ({ editor: e }) => onChange(e.getHTML()),
|
|
312
327
|
}, [extensions]);
|
|
313
328
|
// @tiptap/react v3 does not re-render on selection-only transactions, so
|
|
@@ -344,18 +359,25 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
|
|
|
344
359
|
});
|
|
345
360
|
// The entry loads asynchronously, so `value` arrives after useEditor read
|
|
346
361
|
// its initial content; the guard keeps in-flight typing from being clobbered.
|
|
362
|
+
// When `extensions` change, useEditor's own effect destroys this render's
|
|
363
|
+
// editor earlier in the same commit (a late validate result in the layout
|
|
364
|
+
// editor does that); reading it then throws and unmounts the whole app. The
|
|
365
|
+
// replacement editor re-renders and syncs `value` itself.
|
|
347
366
|
// kumiko-lint-ignore no-raw-hooks DOM integration: syncs the controlled value into the imperative tiptap editor
|
|
348
367
|
useEffect(() => {
|
|
349
|
-
if (editor && value !== editor.getHTML()) {
|
|
368
|
+
if (editor && !editor.isDestroyed && value !== editor.getHTML()) {
|
|
350
369
|
editor.commands.setContent(value, { emitUpdate: false });
|
|
351
370
|
}
|
|
352
371
|
}, [value, editor]);
|
|
353
372
|
// kumiko-lint-ignore no-raw-hooks DOM integration: tiptap exposes editability only imperatively via setEditable
|
|
354
373
|
useEffect(() => {
|
|
355
|
-
|
|
374
|
+
// setEditable emits "update" by default, which would report the mount as an edit.
|
|
375
|
+
if (editor && !editor.isDestroyed)
|
|
376
|
+
editor.setEditable(!readOnly, false);
|
|
356
377
|
}, [readOnly, editor]);
|
|
378
|
+
// Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
|
|
357
379
|
if (!editor)
|
|
358
|
-
return (_jsx(TextareaContentEditor, { id: id, value: value, onChange: onChange, variables:
|
|
380
|
+
return (_jsx(TextareaContentEditor, { id: id, value: value, onChange: onChange, variables: [], readOnly: true }));
|
|
359
381
|
const togglePanel = (kind) => setOpenPanel((current) => (current === kind ? null : kind));
|
|
360
382
|
const setBlockStyle = (style) => {
|
|
361
383
|
if (!isParagraphStyle(style))
|
|
@@ -391,6 +413,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
|
|
|
391
413
|
repeat: { label: t("template-resolver.documentEditor.repeat"), icon: _jsx(Repeat, { size: 16 }) },
|
|
392
414
|
pageBreak: { label: pageBreakLabel, icon: _jsx(Scissors, { size: 16 }) },
|
|
393
415
|
align: { label: t("template-resolver.documentEditor.align"), icon: _jsx(AlignLeft, { size: 16 }) },
|
|
416
|
+
steps: { label: stepsLabel, icon: _jsx(ListChecks, { size: 16 }) },
|
|
394
417
|
};
|
|
395
418
|
const repeatTable = activeState?.repeatTable ?? null;
|
|
396
419
|
const repeatListLabel = repeatTable !== null && repeatTable.lists.length === 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ContentEditorProps } from "@cosmicdrift/kumiko-renderer";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
export type DocumentToolbarBlock = "image" | "table" | "button" | "divider" | "repeat" | "pageBreak" | "align";
|
|
3
|
+
export type DocumentToolbarBlock = "image" | "table" | "button" | "divider" | "repeat" | "pageBreak" | "align" | "steps";
|
|
4
4
|
/** Imperative access for an outside panel (placeholder list, resource list). */
|
|
5
5
|
export type DocumentEditorHandle = {
|
|
6
6
|
readonly insertPlaceholder: (name: string) => void;
|
|
@@ -32,10 +32,10 @@ export type DocumentEditorProps = ContentEditorProps & {
|
|
|
32
32
|
/** Canvas only: renders the toolbar (and its insert panel) into this element instead of above the editor;
|
|
33
33
|
* null renders none. Lets several region editors share one toolbar that follows the focused one. */
|
|
34
34
|
readonly toolbarHost?: HTMLElement | null;
|
|
35
|
-
/** Called with the handle once the editor exists and with null when it is destroyed. */
|
|
36
|
-
readonly onEditorReady?: (handle: DocumentEditorHandle) => void;
|
|
35
|
+
/** Called with the handle once the editor exists and with null when it is destroyed (unmount or rebuild), so a held handle never outlives its editor. */
|
|
36
|
+
readonly onEditorReady?: (handle: DocumentEditorHandle | null) => void;
|
|
37
37
|
};
|
|
38
|
-
export declare const MAIL_DOCUMENT_BLOCKS: readonly ["image", "table", "button", "divider", "repeat"];
|
|
39
|
-
export declare const PDF_DOCUMENT_BLOCKS: readonly ["align", "image", "table", "divider", "repeat", "pageBreak"];
|
|
38
|
+
export declare const MAIL_DOCUMENT_BLOCKS: readonly ["image", "table", "button", "divider", "repeat", "steps"];
|
|
39
|
+
export declare const PDF_DOCUMENT_BLOCKS: readonly ["align", "image", "table", "divider", "repeat", "steps", "pageBreak"];
|
|
40
40
|
/** Block set for a template-resource kind; every kind except "document-pdf" gets the mail set. */
|
|
41
41
|
export declare function documentBlocksForKind(kind: string | undefined): readonly DocumentToolbarBlock[];
|
|
@@ -4,6 +4,7 @@ export const MAIL_DOCUMENT_BLOCKS = [
|
|
|
4
4
|
"button",
|
|
5
5
|
"divider",
|
|
6
6
|
"repeat",
|
|
7
|
+
"steps",
|
|
7
8
|
];
|
|
8
9
|
export const PDF_DOCUMENT_BLOCKS = [
|
|
9
10
|
"align",
|
|
@@ -11,6 +12,7 @@ export const PDF_DOCUMENT_BLOCKS = [
|
|
|
11
12
|
"table",
|
|
12
13
|
"divider",
|
|
13
14
|
"repeat",
|
|
15
|
+
"steps",
|
|
14
16
|
"pageBreak",
|
|
15
17
|
];
|
|
16
18
|
/** Block set for a template-resource kind; every kind except "document-pdf" gets the mail set. */
|
|
@@ -39,6 +39,11 @@ export const defaultTranslations = {
|
|
|
39
39
|
"template-resolver.documentEditor.removeLink": "Remove link",
|
|
40
40
|
"template-resolver.documentEditor.repeat": "Repeat",
|
|
41
41
|
"template-resolver.documentEditor.repeatList": "List to repeat",
|
|
42
|
+
"template-resolver.documentEditor.steps": "Steps",
|
|
43
|
+
"template-resolver.documentEditor.stepsVariable": "Current step from",
|
|
44
|
+
"template-resolver.documentEditor.stepsList": "Steps, separated by commas (value=label)",
|
|
45
|
+
"template-resolver.documentEditor.stepsDefault": "reported=Reported, investigating=Investigating, resolved=Resolved",
|
|
46
|
+
"template-resolver.documentEditor.invalidSteps": "Enter 1 to 8 different steps; values use letters, digits, - and _.",
|
|
42
47
|
"template-resolver.documentEditor.resourceKey": "Resource key",
|
|
43
48
|
"template-resolver.documentEditor.table": "Table",
|
|
44
49
|
"template-resolver.editor.titleLabel": "Title",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { MAIL_TONES, type MailTone } from "../mail-constants.js";
|
|
1
2
|
export { PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, } from "../pdf-constants.js";
|
|
2
3
|
export { TemplateResolverErrors, TemplateResolverHandlers, TemplateResolverQueries, } from "../qualified-names.js";
|
|
3
4
|
export { textBlocksClient } from "./client-plugin.js";
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// — the server side (defineFeature) lives in
|
|
5
5
|
// `@cosmicdrift/kumiko-bundled-features/template-resolver` and has no React or
|
|
6
6
|
// DOM deps.
|
|
7
|
+
export { MAIL_TONES } from "../mail-constants.js";
|
|
7
8
|
export { PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, } from "../pdf-constants.js";
|
|
8
9
|
export { TemplateResolverErrors, TemplateResolverHandlers, TemplateResolverQueries, } from "../qualified-names.js";
|
|
9
10
|
export { textBlocksClient } from "./client-plugin.js";
|
|
@@ -11,7 +11,9 @@ export type PlaceholderLineEditorProps = {
|
|
|
11
11
|
readonly readOnly?: boolean;
|
|
12
12
|
/** Drops the input frame so the line sits flat inside a label/value row. */
|
|
13
13
|
readonly flat?: boolean;
|
|
14
|
-
|
|
14
|
+
/** Hint shown while the line is empty. */
|
|
15
|
+
readonly placeholder?: string;
|
|
16
|
+
readonly onEditorReady?: (handle: DocumentEditorHandle | null) => void;
|
|
15
17
|
readonly "aria-label": string;
|
|
16
18
|
};
|
|
17
|
-
export default function PlaceholderLineEditor({ id, value, onChange, variableLabels, systemVariables, readOnly, flat, onEditorReady, "aria-label": ariaLabel, }: PlaceholderLineEditorProps): ReactNode;
|
|
19
|
+
export default function PlaceholderLineEditor({ id, value, onChange, variableLabels, systemVariables, readOnly, flat, placeholder, onEditorReady, "aria-label": ariaLabel, }: PlaceholderLineEditorProps): ReactNode;
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
6
6
|
// as chips while editing. Lives in the same lazy chunk as the document editor.
|
|
7
7
|
import { cn } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
8
|
import { Extension, Node } from "@tiptap/core";
|
|
9
|
-
import { UndoRedo } from "@tiptap/extensions";
|
|
9
|
+
import { Placeholder, UndoRedo } from "@tiptap/extensions";
|
|
10
10
|
import { EditorContent, useEditor } from "@tiptap/react";
|
|
11
11
|
import StarterKit from "@tiptap/starter-kit";
|
|
12
12
|
import { useMemo, useState } from "react";
|
|
@@ -25,7 +25,7 @@ const BlockLineBreaks = Extension.create({
|
|
|
25
25
|
});
|
|
26
26
|
const NO_LABELS = {};
|
|
27
27
|
const NO_NAMES = [];
|
|
28
|
-
function LineEditorBody({ id, initialValue, onChange, variableLabels, systemVariables, readOnly, flat, onEditorReady, ariaLabel, }) {
|
|
28
|
+
function LineEditorBody({ id, initialValue, onChange, variableLabels, systemVariables, readOnly, flat, placeholder, onEditorReady, ariaLabel, }) {
|
|
29
29
|
const extensions = useMemo(() => [
|
|
30
30
|
LineDocument,
|
|
31
31
|
StarterKit.configure({
|
|
@@ -55,7 +55,8 @@ function LineEditorBody({ id, initialValue, onChange, variableLabels, systemVari
|
|
|
55
55
|
}),
|
|
56
56
|
UndoRedo,
|
|
57
57
|
BlockLineBreaks,
|
|
58
|
-
|
|
58
|
+
...(placeholder === undefined ? [] : [Placeholder.configure({ placeholder })]),
|
|
59
|
+
], [variableLabels, systemVariables, flat, placeholder]);
|
|
59
60
|
const editor = useEditor({
|
|
60
61
|
extensions,
|
|
61
62
|
content: {
|
|
@@ -71,11 +72,12 @@ function LineEditorBody({ id, initialValue, onChange, variableLabels, systemVari
|
|
|
71
72
|
transformPastedHTML: (html) => singleLine(html.replace(/<[^>]*>/g, " ")),
|
|
72
73
|
},
|
|
73
74
|
onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
|
|
75
|
+
onDestroy: () => onEditorReady?.(null),
|
|
74
76
|
onUpdate: ({ editor: e }) => onChange(contentToLine(e.getJSON().content?.[0]?.content)),
|
|
75
77
|
});
|
|
76
|
-
return (_jsx(EditorContent, { editor: editor, className: cn("flex items-center bg-transparent text-base md:text-sm", flat ? "min-h-10" : "min-h-9 rounded-md border border-input px-3", "[&_.ProseMirror]:w-full [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror]:outline-none", "[&_p]:m-0", readOnly && "opacity-60") }));
|
|
78
|
+
return (_jsx(EditorContent, { editor: editor, className: cn("flex items-center bg-transparent text-base md:text-sm", flat ? "min-h-10" : "min-h-9 rounded-md border border-input px-3", "[&_.ProseMirror]:w-full [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror]:outline-none", "[&_p]:m-0", "[&_.is-editor-empty]:before:pointer-events-none [&_.is-editor-empty]:before:float-left [&_.is-editor-empty]:before:h-0 [&_.is-editor-empty]:before:text-muted-foreground [&_.is-editor-empty]:before:content-[attr(data-placeholder)]", readOnly && "opacity-60") }));
|
|
77
79
|
}
|
|
78
|
-
export default function PlaceholderLineEditor({ id, value, onChange, variableLabels = NO_LABELS, systemVariables = NO_NAMES, readOnly = false, flat = false, onEditorReady, "aria-label": ariaLabel, }) {
|
|
80
|
+
export default function PlaceholderLineEditor({ id, value, onChange, variableLabels = NO_LABELS, systemVariables = NO_NAMES, readOnly = false, flat = false, placeholder, onEditorReady, "aria-label": ariaLabel, }) {
|
|
79
81
|
const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
|
|
80
82
|
const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
|
|
81
83
|
// The text is the source of truth. Typing reports it through onChange and
|
|
@@ -90,5 +92,5 @@ export default function PlaceholderLineEditor({ id, value, onChange, variableLab
|
|
|
90
92
|
return (_jsx(LineEditorBody, { id: id, initialValue: value, onChange: (next) => {
|
|
91
93
|
setEmitted(next);
|
|
92
94
|
onChange(next);
|
|
93
|
-
}, variableLabels: stableLabels, systemVariables: stableSystem, readOnly: readOnly, flat: flat, onEditorReady: onEditorReady, ariaLabel: ariaLabel }, `${epoch}-${readOnly}-${JSON.stringify(stableLabels)}`));
|
|
95
|
+
}, variableLabels: stableLabels, systemVariables: stableSystem, readOnly: readOnly, flat: flat, placeholder: placeholder, onEditorReady: onEditorReady, ariaLabel: ariaLabel }, `${epoch}-${readOnly}-${JSON.stringify(stableLabels)}-${stableSystem.join("\n")}`));
|
|
94
96
|
}
|
|
@@ -21,9 +21,6 @@ function resolveTargetTenantId(isSystem, payloadTenantId, sessionTenantId) {
|
|
|
21
21
|
return sessionTenantId;
|
|
22
22
|
return parseTenantId(payloadTenantId) ?? undefined;
|
|
23
23
|
}
|
|
24
|
-
function foreignStreamTenant(user, targetTenantId) {
|
|
25
|
-
return targetTenantId === user.tenantId ? undefined : targetTenantId;
|
|
26
|
-
}
|
|
27
24
|
// Editing your OWN roles keeps the current session: roles are re-derived per
|
|
28
25
|
// request, so the new roles apply at once without a re-login. Other sessions
|
|
29
26
|
// of that user and any foreign edit still revoke all.
|
|
@@ -123,13 +120,13 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
123
120
|
await ctx.writeAs(createSystemUser(targetTenantId), REVOKE_ALL_SESSIONS_QN, revokeSessionsPayload(event.payload.userId, targetTenantId, event.user));
|
|
124
121
|
}
|
|
125
122
|
// The membership stream lives on the membership's tenant (where seedTenantMembership/
|
|
126
|
-
// invite-accept wrote it);
|
|
127
|
-
//
|
|
123
|
+
// invite-accept wrote it); streamTenantId addresses it with the operator as actor, also
|
|
124
|
+
// without membership there.
|
|
128
125
|
const result = await executor.update({
|
|
129
126
|
id: row["id"], // @cast-boundary db-row
|
|
130
127
|
version: row["version"], // @cast-boundary db-row
|
|
131
128
|
changes: { roles: JSON.stringify(nextRoles) },
|
|
132
|
-
}, event.user, db, { streamTenantId:
|
|
129
|
+
}, event.user, db, { streamTenantId: targetTenantId });
|
|
133
130
|
return withResponseData(result, {
|
|
134
131
|
...event.payload,
|
|
135
132
|
roles: nextRoles,
|
package/dist/tenant/seeding.js
CHANGED
|
@@ -42,6 +42,26 @@ const tenantExecutor = createEventStoreExecutor(tenantTable, tenantEntity, {
|
|
|
42
42
|
const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
43
43
|
entityName: "tenant-membership",
|
|
44
44
|
});
|
|
45
|
+
function buildSeedHookNotify(context, scopedUser, targetTenantId, notifyTiming) {
|
|
46
|
+
const notifyFactory = context._notifyFactory;
|
|
47
|
+
if (!notifyFactory)
|
|
48
|
+
return context.notify;
|
|
49
|
+
const notifyAfterCommit = (context._notifyAfterCommitFactory ?? notifyFactory)(scopedUser, targetTenantId);
|
|
50
|
+
if (notifyTiming === "inline")
|
|
51
|
+
return notifyAfterCommit;
|
|
52
|
+
// Still inside the caller's write tx: delivery DB work must not wait for an app-pool slot.
|
|
53
|
+
const notifyInsideTransaction = (context._notifyInTransactionFactory ?? notifyFactory)(scopedUser, targetTenantId);
|
|
54
|
+
const scheduleAfterCommit = context.scheduleAfterCommit;
|
|
55
|
+
if (!scheduleAfterCommit)
|
|
56
|
+
return notifyInsideTransaction;
|
|
57
|
+
return createDeferringNotify({
|
|
58
|
+
registry: context.registry,
|
|
59
|
+
tenantId: targetTenantId,
|
|
60
|
+
notifyAfterCommit,
|
|
61
|
+
notifyNow: notifyInsideTransaction,
|
|
62
|
+
scheduleAfterCommit,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
45
65
|
// Shared by seedTenant/seedTenantMembership/auth-email-password's
|
|
46
66
|
// seedUserWithPassword: fires the same entity-scoped postSave hooks the
|
|
47
67
|
// real dispatcher would (registry.getEntityPostSaveHooks by entityName),
|
|
@@ -61,26 +81,6 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
|
|
|
61
81
|
// (HandlerContext from a live write), afterCommit hooks are queued onto the
|
|
62
82
|
// dispatcher's sink and flush post-commit. Without a sink (fixture / plain
|
|
63
83
|
// Connection) they still fire immediately — there is no outer TX to wait for.
|
|
64
|
-
//
|
|
65
|
-
function buildSeedHookNotify(context, scopedUser, targetTenantId, notifyTiming) {
|
|
66
|
-
const notifyFactory = context._notifyFactory;
|
|
67
|
-
if (!notifyFactory)
|
|
68
|
-
return context.notify;
|
|
69
|
-
if (notifyTiming === "inline")
|
|
70
|
-
return notifyFactory(scopedUser, targetTenantId);
|
|
71
|
-
// Still inside the caller's write tx: delivery DB work must not wait for an app-pool slot.
|
|
72
|
-
const notifyInsideTransaction = (context._notifyInTransactionFactory ?? notifyFactory)(scopedUser, targetTenantId);
|
|
73
|
-
const scheduleAfterCommit = context.scheduleAfterCommit;
|
|
74
|
-
if (!scheduleAfterCommit)
|
|
75
|
-
return notifyInsideTransaction;
|
|
76
|
-
return createDeferringNotify({
|
|
77
|
-
registry: context.registry,
|
|
78
|
-
tenantId: targetTenantId,
|
|
79
|
-
notifyAfterCommit: notifyFactory(scopedUser, targetTenantId),
|
|
80
|
-
notifyNow: notifyInsideTransaction,
|
|
81
|
-
scheduleAfterCommit,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
84
|
// `runner` comes from the caller, which already holds a declared DbRunner —
|
|
85
85
|
// a TenantDb exposes no runner back out to feature code.
|
|
86
86
|
function scopeSeedHookContext(context, targetTenantId, runner,
|
|
@@ -132,11 +132,10 @@ target) {
|
|
|
132
132
|
const scopedContext = target === undefined
|
|
133
133
|
? hooks.context
|
|
134
134
|
: scopeSeedHookContext(hooks.context, target.tenantId, target.db, "deferUntilCommit");
|
|
135
|
-
const afterCommitContext = target === undefined
|
|
136
|
-
? hooks.context
|
|
137
|
-
: scopeSeedHookContext(hooks.context, target.tenantId, target.db, "inline");
|
|
138
135
|
await lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.inTransaction);
|
|
139
|
-
const runAfterCommit = () => lifecycle.runPostSave(pseudoType, entityData,
|
|
136
|
+
const runAfterCommit = () => lifecycle.runPostSave(pseudoType, entityData, target === undefined
|
|
137
|
+
? hooks.context
|
|
138
|
+
: scopeSeedHookContext(hooks.context, target.tenantId, target.db, "inline"), HookPhases.afterCommit);
|
|
140
139
|
if (scopedContext.scheduleAfterCommit) {
|
|
141
140
|
scopedContext.scheduleAfterCommit(runAfterCommit);
|
|
142
141
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// child entity, kumiko-framework#3035's named-error requirement), the WHOLE
|
|
22
22
|
// transaction — root row included — rolls back, so a caller can safely
|
|
23
23
|
// retry the same token after fixing the app's `transferable` declarations.
|
|
24
|
-
import { entityTableFromRegistry, executeRawQuery, extractTableName, physicalColumnName, } from "@cosmicdrift/kumiko-framework/db";
|
|
24
|
+
import { entityTableFromRegistry, executeRawQuery, extractTableName, physicalColumnName, toPostgresArrayLiteral, } from "@cosmicdrift/kumiko-framework/db";
|
|
25
25
|
import { MAX_TRANSFER_DEPTH } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
26
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
27
27
|
import { transferAggregateStreams } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -47,7 +47,12 @@ async function moveFileRefs(args) {
|
|
|
47
47
|
if (args.entityIds.length === 0)
|
|
48
48
|
return 0;
|
|
49
49
|
const rows = await executeRawQuery(args.db, `UPDATE ${FILE_REFS_TABLE} SET tenant_id = $1 ` +
|
|
50
|
-
`WHERE tenant_id = $2 AND entity_type = $3 AND entity_id = ANY($4) RETURNING id`, [
|
|
50
|
+
`WHERE tenant_id = $2 AND entity_type = $3 AND entity_id = ANY($4) RETURNING id`, [
|
|
51
|
+
args.destinationTenantId,
|
|
52
|
+
args.sourceTenantId,
|
|
53
|
+
args.entityType,
|
|
54
|
+
toPostgresArrayLiteral(args.entityIds),
|
|
55
|
+
]);
|
|
51
56
|
const movedIds = rows.map((row) => row.id);
|
|
52
57
|
if (movedIds.length === 0)
|
|
53
58
|
return 0;
|
|
@@ -78,7 +83,12 @@ async function moveFileRefs(args) {
|
|
|
78
83
|
async function moveChildRows(args) {
|
|
79
84
|
const rows = await executeRawQuery(args.db, `UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
|
|
80
85
|
`WHERE "${args.typeCol}" = $2 AND "${args.idCol}" = ANY($3) AND "${args.tenantCol}" = $4 ` +
|
|
81
|
-
`RETURNING "${args.pkCol}" AS id`, [
|
|
86
|
+
`RETURNING "${args.pkCol}" AS id`, [
|
|
87
|
+
args.destinationTenantId,
|
|
88
|
+
args.parentEntityType,
|
|
89
|
+
toPostgresArrayLiteral(args.parentRowIds),
|
|
90
|
+
args.sourceTenantId,
|
|
91
|
+
]);
|
|
82
92
|
return rows.map((row) => row.id);
|
|
83
93
|
}
|
|
84
94
|
// A `reference` field names its target entity in the schema, so unlike a
|
|
@@ -87,7 +97,7 @@ async function moveChildRows(args) {
|
|
|
87
97
|
async function moveReferencedChildRows(args) {
|
|
88
98
|
const rows = await executeRawQuery(args.db, `UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
|
|
89
99
|
`WHERE "${args.refCol}" = ANY($2) AND "${args.tenantCol}" = $3 ` +
|
|
90
|
-
`RETURNING "${args.pkCol}" AS id`, [args.destinationTenantId, args.parentRowIds, args.sourceTenantId]);
|
|
100
|
+
`RETURNING "${args.pkCol}" AS id`, [args.destinationTenantId, toPostgresArrayLiteral(args.parentRowIds), args.sourceTenantId]);
|
|
91
101
|
return rows.map((row) => row.id);
|
|
92
102
|
}
|
|
93
103
|
// Resolves one edge against the ids its parent type just moved, and reports
|
|
@@ -20,6 +20,7 @@ export declare function runNextDestructionStage(args: {
|
|
|
20
20
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
21
21
|
readonly escapeHatchAuditLog?: Logger;
|
|
22
22
|
readonly actor?: string;
|
|
23
|
+
readonly deadlineAt?: number;
|
|
23
24
|
}): Promise<StageTickResult>;
|
|
24
25
|
export declare function hasTenantDestructionWork(reads: Pick<TenantDb, "count">, now: Temporal.Instant): Promise<boolean>;
|
|
25
26
|
/** Cron entry: start destruction for tenants past grace, then advance stages. */
|
|
@@ -164,7 +164,7 @@ export async function runNextDestructionStage(args) {
|
|
|
164
164
|
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
165
165
|
escapeHatchAuditLog: args.escapeHatchAuditLog,
|
|
166
166
|
actor: args.actor,
|
|
167
|
-
deadlineAt: Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
|
|
167
|
+
deadlineAt: args.deadlineAt ?? Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
|
|
168
168
|
};
|
|
169
169
|
let version = lastEventVersion(events);
|
|
170
170
|
if (!isStageContinuation(events, next.name)) {
|
|
@@ -249,9 +249,16 @@ export async function runTenantDestructionSweep(args) {
|
|
|
249
249
|
}
|
|
250
250
|
let advanced = 0;
|
|
251
251
|
const destroying = await selectMany(args.db, tenantTable, destroyingWhere);
|
|
252
|
+
// One budget per sweep tick, split evenly so a large tenant cannot eat the
|
|
253
|
+
// window and starve the ones behind it. Each slice starts when its tenant
|
|
254
|
+
// starts: anchoring to a shared tick deadline would hand tenants after an
|
|
255
|
+
// overrun a zero slice, and a no-progress stage counts as a failed attempt.
|
|
256
|
+
const sliceMs = DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length);
|
|
252
257
|
for (const row of destroying) {
|
|
258
|
+
const tenantDeadlineAt = Date.now() + sliceMs;
|
|
253
259
|
try {
|
|
254
260
|
const result = await runNextDestructionStage({
|
|
261
|
+
deadlineAt: tenantDeadlineAt,
|
|
255
262
|
db: args.db,
|
|
256
263
|
registry: args.registry,
|
|
257
264
|
tenantId: row.id,
|
|
@@ -13,8 +13,9 @@ const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityN
|
|
|
13
13
|
const tenantMembershipCrud = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
14
14
|
entityName: "tenant-membership",
|
|
15
15
|
});
|
|
16
|
-
// Every hook
|
|
17
|
-
//
|
|
16
|
+
// Every hook runs each tick (they are idempotent) but all share one ctx.deadlineAt: an
|
|
17
|
+
// earlier hook that keeps reporting done:false can use up the budget, so later chunking
|
|
18
|
+
// hooks time out with nothing processed until it finishes.
|
|
18
19
|
function newOutcomeAccumulator() {
|
|
19
20
|
let allDone = true;
|
|
20
21
|
let processed = 0;
|
|
@@ -9,6 +9,8 @@ export declare const TierAssignmentSources: {
|
|
|
9
9
|
readonly billing: "billing";
|
|
10
10
|
readonly default: "default";
|
|
11
11
|
};
|
|
12
|
+
export type TierAssignmentSource = (typeof TierAssignmentSources)[keyof typeof TierAssignmentSources];
|
|
13
|
+
export declare function isTierAssignmentSource(value: unknown): value is TierAssignmentSource;
|
|
12
14
|
export declare const TierEngineQueries: {
|
|
13
15
|
readonly list: "tier-engine:query:tier-assignment:list";
|
|
14
16
|
readonly getActiveTier: "tier-engine:query:get-active-tier";
|
|
@@ -19,6 +19,10 @@ export const TierAssignmentSources = {
|
|
|
19
19
|
billing: "billing",
|
|
20
20
|
default: "default",
|
|
21
21
|
};
|
|
22
|
+
const TIER_ASSIGNMENT_SOURCE_VALUES = Object.values(TierAssignmentSources);
|
|
23
|
+
export function isTierAssignmentSource(value) {
|
|
24
|
+
return typeof value === "string" && TIER_ASSIGNMENT_SOURCE_VALUES.includes(value);
|
|
25
|
+
}
|
|
22
26
|
// Qualified query handler names.
|
|
23
27
|
export const TierEngineQueries = {
|
|
24
28
|
list: "tier-engine:query:tier-assignment:list",
|