@cosmicdrift/kumiko-samples 0.351.0 → 0.352.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 +2 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
- package/packages/bundled-features/src/cap-counter/changes.json +28 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
- package/packages/bundled-features/src/cap-counter/index.ts +9 -0
- package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
- package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
- package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
- package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
- package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/constants.ts +1 -0
- package/packages/bundled-features/src/delivery/events.ts +3 -0
- package/packages/bundled-features/src/delivery/feature.ts +50 -2
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
- package/packages/bundled-features/src/delivery/public-names.ts +5 -0
- package/packages/bundled-features/src/delivery/types.ts +12 -2
- package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/README.md +3 -3
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
- package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/index.ts +7 -0
- package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
- package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
- package/samples/apps/styleguide/src/app/shell.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
- package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/recipes/files-post-processing/package.json +1 -1
- package/samples/recipes/seed-migration/README.md +13 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
|
|
3
|
+
const MASK = "***";
|
|
4
|
+
const VISIBLE_TAIL_LENGTH = 4;
|
|
5
|
+
const MIN_LENGTH_FOR_VISIBLE_TAIL = 8;
|
|
6
|
+
|
|
7
|
+
function maskedUrl(address: string): string | undefined {
|
|
8
|
+
if (!URL.canParse(address)) return undefined;
|
|
9
|
+
const url = new URL(address);
|
|
10
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
11
|
+
return `${url.origin}/${MASK}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function maskedEmail(address: string): string | undefined {
|
|
15
|
+
const parts = address.split("@");
|
|
16
|
+
const [local, domain] = parts;
|
|
17
|
+
if (parts.length !== 2 || !local || domain === undefined) return undefined;
|
|
18
|
+
return `${local.charAt(0)}${MASK}@${domain}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The attempt log is an audit trail, not a contact list: it keeps enough of the address to
|
|
22
|
+
// recognise where a message went, never the full value. URLs go first because webhook URLs can
|
|
23
|
+
// contain an "@" and their path carries the secret.
|
|
24
|
+
export function maskRecipientAddress(address: string | null): string | null {
|
|
25
|
+
if (address === null) return null;
|
|
26
|
+
return maskPlainAddress(address);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function maskPlainAddress(address: string): string {
|
|
30
|
+
// isPiiCiphertext is a type predicate; its false branch would narrow `string` to `never`.
|
|
31
|
+
if (isPiiCiphertext(address)) return MASK;
|
|
32
|
+
return maskStructuredOrTail(address);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function maskStructuredOrTail(address: string): string {
|
|
36
|
+
const structured = maskedUrl(address) ?? maskedEmail(address);
|
|
37
|
+
if (structured !== undefined) return structured;
|
|
38
|
+
if (address.length < MIN_LENGTH_FOR_VISIBLE_TAIL) return MASK;
|
|
39
|
+
return `${MASK}${address.slice(-VISIBLE_TAIL_LENGTH)}`;
|
|
40
|
+
}
|
|
@@ -61,4 +61,9 @@ export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt" as const;
|
|
|
61
61
|
export const DeliveryJobNames = {
|
|
62
62
|
render: "render",
|
|
63
63
|
send: "send",
|
|
64
|
+
attemptLogRetention: "attempt-log-retention",
|
|
64
65
|
} as const;
|
|
66
|
+
|
|
67
|
+
// Clears recipientAddress on an attempt row after its recipient was forgotten.
|
|
68
|
+
export const DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT =
|
|
69
|
+
"delivery:event:attempt-address-erased" as const;
|
|
@@ -64,6 +64,9 @@ export type DeliveryChannel = {
|
|
|
64
64
|
// Absent for channels addressed only through `route` (tenant-owned chat
|
|
65
65
|
// targets): deliverToUser skips them without writing a no_address row.
|
|
66
66
|
resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
|
|
67
|
+
// "connection-name": the address is a tenant-chosen connection name (chat webhooks), not
|
|
68
|
+
// personal data, so the attempt log keeps it unmasked. Absent = personal, masked.
|
|
69
|
+
readonly addressKind?: "connection-name";
|
|
67
70
|
render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
|
|
68
71
|
send(
|
|
69
72
|
address: string,
|
|
@@ -118,14 +121,21 @@ export type DeliveryService = {
|
|
|
118
121
|
// payload — collectChannels derives it from the usage's entityName instead.
|
|
119
122
|
export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
|
|
120
123
|
|
|
124
|
+
function hasOptionalHooks(o: object): boolean {
|
|
125
|
+
return (
|
|
126
|
+
(!("render" in o) || o.render === undefined || typeof o.render === "function") &&
|
|
127
|
+
(!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
|
|
128
|
+
(!("addressKind" in o) || o.addressKind === undefined || o.addressKind === "connection-name")
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
121
132
|
export function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin {
|
|
122
133
|
return (
|
|
123
134
|
typeof o === "object" &&
|
|
124
135
|
o !== null &&
|
|
125
136
|
"mode" in o &&
|
|
126
137
|
DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
|
|
127
|
-
(
|
|
128
|
-
(!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
|
|
138
|
+
hasOptionalHooks(o) &&
|
|
129
139
|
"send" in o &&
|
|
130
140
|
typeof o.send === "function"
|
|
131
141
|
);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.352.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "The legal-pages README imprint example uses a fictional demo identity",
|
|
6
|
+
"detail": "The legal-pages README seeds its imprint example with a fictional demo identity and address instead of a real person's details."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.344.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -12,7 +12,7 @@ export type RenderRequest =
|
|
|
12
12
|
| { kind: "image-snapshot"; payload: DocumentPayload; options?: ImageOptions };
|
|
13
13
|
|
|
14
14
|
export type RenderResponse =
|
|
15
|
-
| { kind: "notification"; html: string }
|
|
15
|
+
| { kind: "notification"; html: string; text?: string }
|
|
16
16
|
| { kind: "mail-html"; html: string; text: string }
|
|
17
17
|
| { kind: "document-pdf"; pdfBytes: Uint8Array; pageCount: number; sizeBytes: number }
|
|
18
18
|
| {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.352.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Badge, stages track and chips for the simple mail renderer",
|
|
6
|
+
"detail": "Use badge, { stages } and { chips } in the template variables; the notification RenderResponse carries the matching plain text in text."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.344.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -31,12 +31,10 @@ export async function adaptToFoundation(
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
const variables = await resolveNotificationVariables(req, ctx);
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
});
|
|
39
|
-
return { kind: "notification", html };
|
|
34
|
+
const input = { template: req.payload.template ?? "", variables, locale: req.payload.locale };
|
|
35
|
+
const html = await renderer.render(input);
|
|
36
|
+
const text = renderer.renderText ? await renderer.renderText(input) : undefined;
|
|
37
|
+
return text === undefined ? { kind: "notification", html } : { kind: "notification", html, text };
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
export type RendererSimpleOptions = {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
|
|
3
|
+
export type MailTone = "neutral" | "info" | "success" | "warning" | "danger";
|
|
4
|
+
export type MailStageState = "done" | "current" | "upcoming";
|
|
5
|
+
|
|
6
|
+
export type MailBadge = { readonly label: string; readonly tone?: MailTone };
|
|
7
|
+
export type MailStage = { readonly label: string; readonly state: MailStageState };
|
|
8
|
+
export type MailChip = { readonly label: string; readonly tone?: MailTone };
|
|
9
|
+
|
|
10
|
+
// Literal types (as const) keep the colors compile-time known, so they can go into style
|
|
11
|
+
// attributes without escaping.
|
|
12
|
+
const TONE_COLORS = {
|
|
13
|
+
neutral: { bg: "#f3f4f6", fg: "#374151" },
|
|
14
|
+
info: { bg: "#dbeafe", fg: "#1e40af" },
|
|
15
|
+
success: { bg: "#dcfce7", fg: "#166534" },
|
|
16
|
+
warning: { bg: "#fef3c7", fg: "#92400e" },
|
|
17
|
+
danger: { bg: "#fee2e2", fg: "#991b1b" },
|
|
18
|
+
} as const satisfies Readonly<Record<MailTone, { readonly bg: string; readonly fg: string }>>;
|
|
19
|
+
|
|
20
|
+
type ToneColors = (typeof TONE_COLORS)[MailTone];
|
|
21
|
+
type PillMargin = "0" | "0 6px 6px 0";
|
|
22
|
+
|
|
23
|
+
const STAGE_STATES: readonly MailStageState[] = ["done", "current", "upcoming"];
|
|
24
|
+
const STAGE_DONE_COLOR = "#16a34a";
|
|
25
|
+
const STAGE_UPCOMING_BG = "#e5e7eb";
|
|
26
|
+
const STAGE_UPCOMING_FG = "#9ca3af";
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
29
|
+
return typeof value === "object" && value !== null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isMailTone(value: unknown): value is MailTone {
|
|
33
|
+
return typeof value === "string" && Object.hasOwn(TONE_COLORS, value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toneColors(tone: unknown): ToneColors {
|
|
37
|
+
return TONE_COLORS[isMailTone(tone) ? tone : "neutral"];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isStageState(value: unknown): value is MailStageState {
|
|
41
|
+
return STAGE_STATES.some((state) => state === value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Template data is runtime data (variables of a stored template): entries that do not match are
|
|
45
|
+
// skipped instead of failing the whole mail.
|
|
46
|
+
function isStage(value: unknown): value is MailStage {
|
|
47
|
+
return isRecord(value) && typeof value["label"] === "string" && isStageState(value["state"]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isChip(value: unknown): value is MailChip {
|
|
51
|
+
return isRecord(value) && typeof value["label"] === "string";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseBadge(value: unknown): MailBadge | undefined {
|
|
55
|
+
return isChip(value) && value.label !== "" ? value : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validStages(value: unknown): readonly MailStage[] {
|
|
59
|
+
return Array.isArray(value) ? value.filter(isStage) : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validChips(value: unknown): readonly MailChip[] {
|
|
63
|
+
return Array.isArray(value) ? value.filter(isChip) : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pill(chip: MailChip, margin: PillMargin): string {
|
|
67
|
+
const { bg, fg } = toneColors(chip.tone);
|
|
68
|
+
return `<span style="display:inline-block;margin:${margin};padding:2px 10px;border-radius:999px;background:${bg};color:${fg};font-size:12px;font-weight:600;line-height:18px">${escapeHtml(chip.label)}</span>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderBadge(badge: MailBadge): string {
|
|
72
|
+
return `<p style="margin:0 0 12px">${pill(badge, "0")}</p>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderChips(chips: unknown): string {
|
|
76
|
+
const valid = validChips(chips);
|
|
77
|
+
if (valid.length === 0) return "";
|
|
78
|
+
return `<p style="margin:0 0 16px">${valid.map((chip) => pill(chip, "0 6px 6px 0")).join("")}</p>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stageMarker(stage: MailStage, position: number, primaryColor: string): string {
|
|
82
|
+
const base =
|
|
83
|
+
"display:inline-block;width:24px;height:24px;line-height:24px;border-radius:12px;text-align:center;font-size:12px;font-weight:700";
|
|
84
|
+
const primary = escapeHtmlAttr(primaryColor);
|
|
85
|
+
if (stage.state === "done") {
|
|
86
|
+
return `<span style="${base};background:${STAGE_DONE_COLOR};color:#fff">✓</span>`;
|
|
87
|
+
}
|
|
88
|
+
if (stage.state === "current") {
|
|
89
|
+
return `<span style="${base};background:#fff;border:2px solid ${primary};line-height:20px;width:20px;height:20px;color:${primary}">${position}</span>`;
|
|
90
|
+
}
|
|
91
|
+
return `<span style="${base};background:${STAGE_UPCOMING_BG};color:${STAGE_UPCOMING_FG}">${position}</span>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stageCell(stage: MailStage, position: number, primaryColor: string): string {
|
|
95
|
+
const labelColor = stage.state === "upcoming" ? STAGE_UPCOMING_FG : "#111";
|
|
96
|
+
const weight = stage.state === "current" ? 700 : 400;
|
|
97
|
+
return `<td align="center" valign="top" style="padding:0 4px;font-family:sans-serif">${stageMarker(stage, position, primaryColor)}<div style="margin:4px 0 0;font-size:12px;font-weight:${weight};color:${labelColor}">${escapeHtml(stage.label)}</div></td>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stageConnector(previous: MailStage): string {
|
|
101
|
+
const color = previous.state === "done" ? STAGE_DONE_COLOR : STAGE_UPCOMING_BG;
|
|
102
|
+
return `<td valign="top" style="padding:11px 0 0"><div style="width:24px;height:2px;line-height:2px;font-size:2px;background:${color}"> </div></td>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderStages(stages: unknown, primaryColor: string): string {
|
|
106
|
+
const valid = validStages(stages);
|
|
107
|
+
if (valid.length === 0) return "";
|
|
108
|
+
const cellsHtml = valid
|
|
109
|
+
.map((stage, index) => {
|
|
110
|
+
const previous = valid[index - 1];
|
|
111
|
+
const connector = previous ? stageConnector(previous) : "";
|
|
112
|
+
return `${connector}${stageCell(stage, index + 1, primaryColor)}`;
|
|
113
|
+
})
|
|
114
|
+
.join("");
|
|
115
|
+
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 16px"><tr>${cellsHtml}</tr></table>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const STAGE_TEXT_MARKER: Readonly<Record<MailStageState, string>> = {
|
|
119
|
+
done: "✓",
|
|
120
|
+
current: "▶",
|
|
121
|
+
upcoming: "○",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function badgeText(badge: MailBadge): string {
|
|
125
|
+
return `[${badge.label}]`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function stagesText(stages: unknown): string {
|
|
129
|
+
return validStages(stages)
|
|
130
|
+
.map((stage) => `${STAGE_TEXT_MARKER[stage.state]} ${stage.label}`)
|
|
131
|
+
.join(" → ");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function chipsText(chips: unknown): string {
|
|
135
|
+
return validChips(chips)
|
|
136
|
+
.map((chip) => chip.label)
|
|
137
|
+
.join(" · ");
|
|
138
|
+
}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
import type { NotificationRenderer } from "../delivery/index.js";
|
|
3
3
|
import { renderSafeMarkdown } from "../page-render/index.js";
|
|
4
|
+
import {
|
|
5
|
+
badgeText,
|
|
6
|
+
chipsText,
|
|
7
|
+
type MailBadge,
|
|
8
|
+
type MailChip,
|
|
9
|
+
type MailStage,
|
|
10
|
+
parseBadge,
|
|
11
|
+
renderBadge,
|
|
12
|
+
renderChips,
|
|
13
|
+
renderStages,
|
|
14
|
+
stagesText,
|
|
15
|
+
} from "./mail-blocks.js";
|
|
4
16
|
|
|
5
17
|
export type MailBranding = {
|
|
6
18
|
/** Shown as text when there is no logo, and as the logo alt text. */
|
|
@@ -170,10 +182,13 @@ type Section =
|
|
|
170
182
|
| { readonly text: string }
|
|
171
183
|
| { readonly heading: string }
|
|
172
184
|
| { readonly markdown: string }
|
|
173
|
-
| { readonly button: { readonly label: string; readonly url: string } }
|
|
185
|
+
| { readonly button: { readonly label: string; readonly url: string } }
|
|
186
|
+
| { readonly stages: readonly MailStage[] }
|
|
187
|
+
| { readonly chips: readonly MailChip[] };
|
|
174
188
|
|
|
175
189
|
type EmailTemplateData = {
|
|
176
190
|
// Preferred: structured email data
|
|
191
|
+
readonly badge?: MailBadge;
|
|
177
192
|
readonly header?: string;
|
|
178
193
|
readonly sections?: readonly Section[];
|
|
179
194
|
readonly footer?: string;
|
|
@@ -183,6 +198,7 @@ type EmailTemplateData = {
|
|
|
183
198
|
};
|
|
184
199
|
|
|
185
200
|
function templateContent(variables: Readonly<Record<string, unknown>>): {
|
|
201
|
+
readonly badge: MailBadge | undefined;
|
|
186
202
|
readonly header: string | undefined;
|
|
187
203
|
readonly sections: readonly Section[] | undefined;
|
|
188
204
|
readonly footer: string | undefined;
|
|
@@ -190,6 +206,7 @@ function templateContent(variables: Readonly<Record<string, unknown>>): {
|
|
|
190
206
|
const data = variables as EmailTemplateData; // @cast-boundary render-helper
|
|
191
207
|
// Without structured fields, title + body become the header and a single text section.
|
|
192
208
|
return {
|
|
209
|
+
badge: parseBadge(data.badge),
|
|
193
210
|
header: data.header ?? data.title,
|
|
194
211
|
sections: data.sections ?? (data.body ? [{ text: data.body }] : undefined),
|
|
195
212
|
footer: data.footer,
|
|
@@ -202,6 +219,8 @@ function sectionText(section: Section): string {
|
|
|
202
219
|
if ("heading" in section) return section.heading;
|
|
203
220
|
if ("markdown" in section) return section.markdown;
|
|
204
221
|
if ("button" in section) return `${section.button.label}: ${section.button.url}`;
|
|
222
|
+
if ("stages" in section) return stagesText(section.stages);
|
|
223
|
+
if ("chips" in section) return chipsText(section.chips);
|
|
205
224
|
return "";
|
|
206
225
|
}
|
|
207
226
|
|
|
@@ -219,6 +238,8 @@ function renderSection(section: Section, primaryColor: string): string {
|
|
|
219
238
|
if ("button" in section) {
|
|
220
239
|
return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:${escapeHtmlAttr(primaryColor)};color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
|
|
221
240
|
}
|
|
241
|
+
if ("stages" in section) return renderStages(section.stages, primaryColor);
|
|
242
|
+
if ("chips" in section) return renderChips(section.chips);
|
|
222
243
|
return "";
|
|
223
244
|
}
|
|
224
245
|
|
|
@@ -232,13 +253,15 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
|
|
|
232
253
|
name: "simple",
|
|
233
254
|
|
|
234
255
|
async render(input) {
|
|
235
|
-
const { header, sections, footer } = templateContent(input.variables);
|
|
256
|
+
const { badge, header, sections, footer } = templateContent(input.variables);
|
|
236
257
|
|
|
237
258
|
const parts: string[] = [];
|
|
238
259
|
parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
|
|
239
260
|
parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
|
|
240
261
|
parts.push(renderBrandingHeader(branding, primaryColor, logoUrl));
|
|
241
262
|
|
|
263
|
+
if (badge) parts.push(renderBadge(badge));
|
|
264
|
+
|
|
242
265
|
if (header) {
|
|
243
266
|
parts.push(
|
|
244
267
|
`<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`,
|
|
@@ -263,9 +286,15 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
|
|
|
263
286
|
},
|
|
264
287
|
|
|
265
288
|
async renderText(input) {
|
|
266
|
-
const { header, sections, footer } = templateContent(input.variables);
|
|
289
|
+
const { badge, header, sections, footer } = templateContent(input.variables);
|
|
267
290
|
const brandingFooter = brandingFooterLines(branding, input.locale).join("\n");
|
|
268
|
-
return [
|
|
291
|
+
return [
|
|
292
|
+
badge ? badgeText(badge) : undefined,
|
|
293
|
+
header,
|
|
294
|
+
...(sections ?? []).map(sectionText),
|
|
295
|
+
footer,
|
|
296
|
+
brandingFooter,
|
|
297
|
+
]
|
|
269
298
|
.filter((block): block is string => block !== undefined && block !== "")
|
|
270
299
|
.join("\n\n");
|
|
271
300
|
},
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.352.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "createTierOptionGate builds the availability rows and gates select options by tier on write",
|
|
6
|
+
"detail": "withTierOptionGate rejects a disallowed option with the lowest tier that allows it and lets an unchanged stored value pass on update."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.278.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -29,6 +29,13 @@ export {
|
|
|
29
29
|
createTierEngineFeature,
|
|
30
30
|
tierEngineFeature,
|
|
31
31
|
} from "./feature.js";
|
|
32
|
+
export {
|
|
33
|
+
createTierOptionGate,
|
|
34
|
+
type TierOptionAvailability,
|
|
35
|
+
type TierOptionGate,
|
|
36
|
+
type TierOptionGateDeps,
|
|
37
|
+
type TierOptionSpec,
|
|
38
|
+
} from "./tier-option-gate.js";
|
|
32
39
|
export {
|
|
33
40
|
createTierResolver,
|
|
34
41
|
type TierResolver,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Tier-gated options of a select field: one spec drives the availability query the
|
|
2
|
+
// renderer shows (disabled option + "from <tier>" hint) and the write-path check that
|
|
3
|
+
// actually enforces it, so the two cannot drift apart.
|
|
4
|
+
|
|
5
|
+
import type { EntityTableMeta, SchemaTable, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
UnprocessableError,
|
|
9
|
+
type WriteFailure,
|
|
10
|
+
writeFailure,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
|
|
13
|
+
import {
|
|
14
|
+
type CapGuardContext,
|
|
15
|
+
type CapLimitContext,
|
|
16
|
+
readPayloadField,
|
|
17
|
+
} from "../cap-counter/index.js";
|
|
18
|
+
|
|
19
|
+
export type TierOptionSpec<TCaps> = {
|
|
20
|
+
readonly field: string;
|
|
21
|
+
readonly options: readonly string[];
|
|
22
|
+
readonly isOptionAllowed: (option: string, caps: TCaps) => boolean;
|
|
23
|
+
readonly code: string;
|
|
24
|
+
readonly i18nKey: string;
|
|
25
|
+
/** Lets an update that resends the unchanged stored value of a no longer allowed
|
|
26
|
+
* option pass (edit forms submit the whole row). Without it the stored value is not looked up. */
|
|
27
|
+
readonly table?: SchemaTable | EntityTableMeta;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TierOptionAvailability = {
|
|
31
|
+
readonly value: string;
|
|
32
|
+
readonly disabled: boolean;
|
|
33
|
+
readonly hint?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TierOptionGateDeps<TTier extends string, TCaps> = {
|
|
37
|
+
/** Ascending: the first tier that allows an option is the one named as required. */
|
|
38
|
+
readonly tierOrder: readonly TTier[];
|
|
39
|
+
readonly capsForTier: (tier: TTier, context: CapLimitContext) => TCaps | Promise<TCaps>;
|
|
40
|
+
readonly resolveTier: (db: TenantDb) => Promise<TTier>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type TierOptionGate<TTier extends string, TCaps> = {
|
|
44
|
+
readonly optionAvailability: (
|
|
45
|
+
db: TenantDb,
|
|
46
|
+
context: CapLimitContext,
|
|
47
|
+
spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">,
|
|
48
|
+
hintFor: (requiredTier: TTier) => string,
|
|
49
|
+
) => Promise<readonly TierOptionAvailability[]>;
|
|
50
|
+
/** `recordId` is the id of the edited row; with `spec.table` it enables the unchanged-value pass. */
|
|
51
|
+
readonly checkTierOption: (
|
|
52
|
+
ctx: CapGuardContext,
|
|
53
|
+
value: unknown,
|
|
54
|
+
spec: TierOptionSpec<TCaps>,
|
|
55
|
+
recordId?: string,
|
|
56
|
+
) => Promise<WriteFailure | null>;
|
|
57
|
+
readonly withTierOptionGate: (
|
|
58
|
+
handler: WriteHandlerDef,
|
|
59
|
+
spec: TierOptionSpec<TCaps>,
|
|
60
|
+
) => WriteHandlerDef;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function limitContextOf(ctx: CapGuardContext): CapLimitContext {
|
|
64
|
+
return ctx.config ? { config: ctx.config } : {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function recordIdOf(payload: unknown): string | undefined {
|
|
68
|
+
return isPlainObject(payload) && typeof payload["id"] === "string" ? payload["id"] : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createTierOptionGate<TTier extends string, TCaps>(
|
|
72
|
+
deps: TierOptionGateDeps<TTier, TCaps>,
|
|
73
|
+
): TierOptionGate<TTier, TCaps> {
|
|
74
|
+
async function capsPerTier(
|
|
75
|
+
context: CapLimitContext,
|
|
76
|
+
): Promise<readonly { readonly tier: TTier; readonly caps: TCaps }[]> {
|
|
77
|
+
return Promise.all(
|
|
78
|
+
deps.tierOrder.map(async (tier) => ({ tier, caps: await deps.capsForTier(tier, context) })),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function optionAvailability(
|
|
83
|
+
db: TenantDb,
|
|
84
|
+
context: CapLimitContext,
|
|
85
|
+
spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">,
|
|
86
|
+
hintFor: (requiredTier: TTier) => string,
|
|
87
|
+
): Promise<readonly TierOptionAvailability[]> {
|
|
88
|
+
const currentCaps = await deps.capsForTier(await deps.resolveTier(db), context);
|
|
89
|
+
const tiers = await capsPerTier(context);
|
|
90
|
+
return spec.options.map((value) => {
|
|
91
|
+
if (spec.isOptionAllowed(value, currentCaps)) return { value, disabled: false };
|
|
92
|
+
const required = tiers.find(({ caps }) => spec.isOptionAllowed(value, caps));
|
|
93
|
+
return required === undefined
|
|
94
|
+
? { value, disabled: true }
|
|
95
|
+
: { value, disabled: true, hint: hintFor(required.tier) };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function storedValueMatches(
|
|
100
|
+
ctx: CapGuardContext,
|
|
101
|
+
spec: TierOptionSpec<TCaps>,
|
|
102
|
+
value: string,
|
|
103
|
+
recordId: string | undefined,
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
if (spec.table === undefined || recordId === undefined) return false;
|
|
106
|
+
const stored = await ctx.db.fetchOne<Record<string, unknown>>(spec.table, { id: recordId });
|
|
107
|
+
return stored?.[spec.field] === value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function checkTierOption(
|
|
111
|
+
ctx: CapGuardContext,
|
|
112
|
+
value: unknown,
|
|
113
|
+
spec: TierOptionSpec<TCaps>,
|
|
114
|
+
recordId?: string,
|
|
115
|
+
): Promise<WriteFailure | null> {
|
|
116
|
+
// skip: an absent or non-string value is the schema's business, not a tier violation.
|
|
117
|
+
if (typeof value !== "string") return null;
|
|
118
|
+
const context = limitContextOf(ctx);
|
|
119
|
+
const currentCaps = await deps.capsForTier(await deps.resolveTier(ctx.db), context);
|
|
120
|
+
if (spec.isOptionAllowed(value, currentCaps)) return null;
|
|
121
|
+
if (await storedValueMatches(ctx, spec, value, recordId)) return null;
|
|
122
|
+
const required = (await capsPerTier(context)).find(({ caps }) =>
|
|
123
|
+
spec.isOptionAllowed(value, caps),
|
|
124
|
+
);
|
|
125
|
+
return writeFailure(
|
|
126
|
+
new UnprocessableError(spec.code, {
|
|
127
|
+
i18nKey: spec.i18nKey,
|
|
128
|
+
details: { field: spec.field, value, requiredTier: required?.tier },
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function withTierOptionGate(
|
|
134
|
+
handler: WriteHandlerDef,
|
|
135
|
+
spec: TierOptionSpec<TCaps>,
|
|
136
|
+
): WriteHandlerDef {
|
|
137
|
+
return {
|
|
138
|
+
...handler,
|
|
139
|
+
handler: async (event, ctx) => {
|
|
140
|
+
const value = readPayloadField(event.payload, spec.field);
|
|
141
|
+
const failure = await checkTierOption(ctx, value, spec, recordIdOf(event.payload));
|
|
142
|
+
return failure ?? handler.handler(event, ctx);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { optionAvailability, checkTierOption, withTierOptionGate };
|
|
148
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
appendEventInTenantDb,
|
|
4
|
+
getStreamVersionInTenantDb,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
6
|
+
import {
|
|
7
|
+
DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
|
|
8
|
+
deliveryAttemptsTable,
|
|
9
|
+
} from "../../delivery/index.js";
|
|
3
10
|
import { featureMounted } from "./feature-mounted.js";
|
|
4
11
|
|
|
5
12
|
// userData-Hooks for delivery's attempt log (deferred from #797, closed by
|
|
@@ -28,9 +35,32 @@ export const deliveryAttemptExportHook: UserDataExportHook = async (ctx) => {
|
|
|
28
35
|
};
|
|
29
36
|
};
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
38
|
+
// Both strategies erase the address. The DEK shredding of a forget covers KMS deployments; the
|
|
39
|
+
// erase event also covers plaintext mode, and unlike a read-side UPDATE it survives a projection
|
|
40
|
+
// rebuild. Rows without an address are skipped, which makes a second run a no-op.
|
|
41
|
+
export const deliveryAttemptDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
42
|
+
// skip: delivery not mounted — its table doesn't exist, nothing to erase.
|
|
43
|
+
if (!featureMounted(ctx, "delivery")) return;
|
|
44
|
+
const rows = await ctx.db.selectMany<{ id: string; recipientAddress: string | null }>(
|
|
45
|
+
deliveryAttemptsTable,
|
|
46
|
+
{
|
|
47
|
+
tenantId: ctx.tenantId,
|
|
48
|
+
recipientId: ctx.userId,
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
if (row.recipientAddress === null) continue;
|
|
53
|
+
await appendEventInTenantDb(
|
|
54
|
+
ctx.db,
|
|
55
|
+
{
|
|
56
|
+
aggregateType: "deliveryAttempt",
|
|
57
|
+
aggregateId: row.id,
|
|
58
|
+
expectedVersion: await getStreamVersionInTenantDb(ctx.db, row.id),
|
|
59
|
+
type: DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
|
|
60
|
+
payload: {},
|
|
61
|
+
metadata: { userId: "system" },
|
|
62
|
+
},
|
|
63
|
+
{ registry: ctx.registry },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
36
66
|
};
|
|
@@ -306,7 +306,7 @@ export const ASSET_TEMPLATES: ReadonlyArray<AssetTemplate> = [
|
|
|
306
306
|
// treffen ohne Sonderfall. Wird nur für status==="lent" konsultiert.
|
|
307
307
|
export const OWNERS: ReadonlyArray<string> = [
|
|
308
308
|
"",
|
|
309
|
-
"
|
|
309
|
+
"Alex Morgan",
|
|
310
310
|
"Anna Weber",
|
|
311
311
|
"Lars Bergmann",
|
|
312
312
|
"Sina Klein",
|
|
@@ -435,7 +435,7 @@ export const REPORTERS: ReadonlyArray<string> = [
|
|
|
435
435
|
// unzugewiesen damit die Liste nicht zu uniform aussieht.
|
|
436
436
|
export const ASSIGNEES: ReadonlyArray<string> = [
|
|
437
437
|
"",
|
|
438
|
-
"
|
|
438
|
+
"Alex Morgan",
|
|
439
439
|
"IT-Team",
|
|
440
440
|
"Sysadmin",
|
|
441
441
|
"Helpdesk-L1",
|
|
@@ -58,7 +58,7 @@ export function AppShell({
|
|
|
58
58
|
],
|
|
59
59
|
])
|
|
60
60
|
}
|
|
61
|
-
sidebarFooter={<SidebarUser name="
|
|
61
|
+
sidebarFooter={<SidebarUser name="Alex Morgan" email="alex@example.com" />}
|
|
62
62
|
>
|
|
63
63
|
{activeTarget !== undefined ? <EditorPanel resolvers={resolvers} /> : children}
|
|
64
64
|
</DefaultAppShell>
|