@cosmicdrift/kumiko-samples 0.363.0 → 0.364.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 +7 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
- package/packages/bundled-features/src/delivery/changes.json +18 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +19 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +68 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
- package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
- package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/index.ts +12 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
- package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
|
@@ -3,14 +3,74 @@ import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framew
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { TenantQueries } from "../../tenant/index.js";
|
|
5
5
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
6
|
+
import { servedVersionOf } from "../template-draft.js";
|
|
7
|
+
import { buildOverrideDiff } from "./override-diff.js";
|
|
6
8
|
import { kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
|
|
7
9
|
|
|
8
10
|
const TENANT_OVERRIDES_REASON =
|
|
9
|
-
"SystemAdmin template overview
|
|
11
|
+
"SystemAdmin template overview lists which tenants override one system template; it reads tenant-scope template rows of every tenant (and the system row for the optional per-tenant diff), which the tenant-scoped db cannot reach";
|
|
10
12
|
|
|
11
13
|
const tenantIdsSchema = z.array(z.string());
|
|
14
|
+
const directorySchema = z.object({
|
|
15
|
+
rows: z.array(z.object({ id: z.string(), label: z.string() })),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type OverrideStatus = "active" | "draft";
|
|
19
|
+
|
|
20
|
+
function labelsOf(raw: unknown): ReadonlyMap<string, string> {
|
|
21
|
+
return new Map(directorySchema.parse(raw).rows.map((row) => [row.id, row.label]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// One row per tenant: its active override wins over a draft, then the newest version.
|
|
25
|
+
function isBetterRow(candidate: TemplateResourceRow, current: TemplateResourceRow): boolean {
|
|
26
|
+
if ((candidate.status === "active") !== (current.status === "active")) {
|
|
27
|
+
return candidate.status === "active";
|
|
28
|
+
}
|
|
29
|
+
return candidate.version > current.version;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statusOf(row: TemplateResourceRow): OverrideStatus {
|
|
33
|
+
return row.status === "active" ? "active" : "draft";
|
|
34
|
+
}
|
|
12
35
|
|
|
13
|
-
|
|
36
|
+
function isCountedTenantRow(
|
|
37
|
+
row: TemplateResourceRow,
|
|
38
|
+
enabledTenantIds: ReadonlySet<string> | null,
|
|
39
|
+
): boolean {
|
|
40
|
+
if (row.tenantId === SYSTEM_TENANT_ID || row.status === "archived") return false;
|
|
41
|
+
return enabledTenantIds === null || enabledTenantIds.has(row.tenantId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pickBestRowPerTenant(
|
|
45
|
+
found: readonly TemplateResourceRow[],
|
|
46
|
+
enabledTenantIds: ReadonlySet<string> | null,
|
|
47
|
+
): Map<string, TemplateResourceRow> {
|
|
48
|
+
const bestByTenant = new Map<string, TemplateResourceRow>();
|
|
49
|
+
for (const row of found) {
|
|
50
|
+
if (!isCountedTenantRow(row, enabledTenantIds)) continue;
|
|
51
|
+
const current = bestByTenant.get(row.tenantId);
|
|
52
|
+
if (current === undefined || isBetterRow(row, current)) bestByTenant.set(row.tenantId, row);
|
|
53
|
+
}
|
|
54
|
+
return bestByTenant;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toOverrideRow(
|
|
58
|
+
row: TemplateResourceRow,
|
|
59
|
+
tenantLabels: ReadonlyMap<string, string>,
|
|
60
|
+
userLabels: ReadonlyMap<string, string>,
|
|
61
|
+
) {
|
|
62
|
+
const changedById = row.modifiedById ?? row.insertedById;
|
|
63
|
+
return {
|
|
64
|
+
tenantId: row.tenantId,
|
|
65
|
+
tenantName: tenantLabels.get(row.tenantId) ?? row.tenantId,
|
|
66
|
+
templateId: String(row.id),
|
|
67
|
+
locale: row.locale,
|
|
68
|
+
status: statusOf(row),
|
|
69
|
+
version: servedVersionOf(row),
|
|
70
|
+
changedAt: (row.modifiedAt ?? row.insertedAt).toString(),
|
|
71
|
+
changedBy: changedById === null ? null : (userLabels.get(changedById) ?? null),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
14
74
|
|
|
15
75
|
export const tenantOverridesQuery = defineQueryHandler({
|
|
16
76
|
name: "tenant-overrides",
|
|
@@ -18,14 +78,17 @@ export const tenantOverridesQuery = defineQueryHandler({
|
|
|
18
78
|
slug: templateSlugSchema,
|
|
19
79
|
kind: kindSchema,
|
|
20
80
|
locale: localeSchema.optional(),
|
|
81
|
+
/** Adds the block diff of this tenant's override against the system template. */
|
|
82
|
+
diffTenantId: z.string().min(1).optional(),
|
|
21
83
|
}),
|
|
22
84
|
access: { roles: ["SystemAdmin"] },
|
|
23
85
|
description:
|
|
24
|
-
"
|
|
86
|
+
"Lists the enabled tenants that override one template with an active or a draft version (tenant name, status, version, last change and who made it), next to the counts and the number of enabled tenants; use it before changing a system template to see who keeps their own copy. With diffTenantId it also returns the block diff of that tenant's override against the system template.",
|
|
25
87
|
escapeHatch: { grants: ["unsafeRaw"], reason: TENANT_OVERRIDES_REASON },
|
|
26
88
|
handler: async (query, ctx) => {
|
|
27
|
-
const { slug, kind, locale } = query.payload;
|
|
28
|
-
const
|
|
89
|
+
const { slug, kind, locale, diffTenantId } = query.payload;
|
|
90
|
+
const raw = ctx.db.unsafeRaw();
|
|
91
|
+
const found = await selectMany<TemplateResourceRow>(raw, templateResourcesTable, {
|
|
29
92
|
slug,
|
|
30
93
|
kind,
|
|
31
94
|
scope: "tenant",
|
|
@@ -38,18 +101,37 @@ export const tenantOverridesQuery = defineQueryHandler({
|
|
|
38
101
|
? new Set(tenantIdsSchema.parse(await ctx.query(TenantQueries.activeTenantIds, {})))
|
|
39
102
|
: null;
|
|
40
103
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
104
|
+
const bestByTenant = pickBestRowPerTenant(found, enabledTenantIds);
|
|
105
|
+
const best = [...bestByTenant.values()];
|
|
106
|
+
const tenantLabels = hasTenantFeature
|
|
107
|
+
? labelsOf(await ctx.query(TenantQueries.tenantDirectory, {}))
|
|
108
|
+
: new Map<string, string>();
|
|
109
|
+
const userLabels = hasTenantFeature
|
|
110
|
+
? labelsOf(await ctx.query(TenantQueries.memberDirectory, {}))
|
|
111
|
+
: new Map<string, string>();
|
|
112
|
+
|
|
113
|
+
const rows = best
|
|
114
|
+
.map((row) => toOverrideRow(row, tenantLabels, userLabels))
|
|
115
|
+
.sort((a, b) => a.tenantName.localeCompare(b.tenantName));
|
|
116
|
+
|
|
117
|
+
let diff: ReturnType<typeof buildOverrideDiff> | null = null;
|
|
118
|
+
const diffRow = diffTenantId === undefined ? undefined : bestByTenant.get(diffTenantId);
|
|
119
|
+
if (diffRow !== undefined) {
|
|
120
|
+
const [systemRow] = await selectMany<TemplateResourceRow>(raw, templateResourcesTable, {
|
|
121
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
122
|
+
slug,
|
|
123
|
+
kind,
|
|
124
|
+
locale: diffRow.locale,
|
|
125
|
+
});
|
|
126
|
+
if (systemRow !== undefined) diff = buildOverrideDiff(diffRow, systemRow);
|
|
47
127
|
}
|
|
48
|
-
|
|
128
|
+
|
|
49
129
|
return {
|
|
50
|
-
activeOverrides:
|
|
51
|
-
draftOverrides:
|
|
130
|
+
activeOverrides: best.filter((row) => row.status === "active").length,
|
|
131
|
+
draftOverrides: best.filter((row) => row.status !== "active").length,
|
|
52
132
|
totalTenants: enabledTenantIds === null ? null : enabledTenantIds.size,
|
|
133
|
+
rows,
|
|
134
|
+
diff,
|
|
53
135
|
};
|
|
54
136
|
},
|
|
55
137
|
});
|
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import {
|
|
3
3
|
defineWriteHandler,
|
|
4
|
-
type
|
|
4
|
+
type HandlerContext,
|
|
5
5
|
SYSTEM_TENANT_ID,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import {
|
|
8
|
-
type FieldIssue,
|
|
9
8
|
NotFoundError,
|
|
10
9
|
ValidationError,
|
|
10
|
+
type WriteFailure,
|
|
11
11
|
writeFailure,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
13
13
|
import * as z from "zod";
|
|
14
14
|
import { requireForTenant } from "../../shared/index.js";
|
|
15
15
|
import type { TemplateResourceRow } from "../table.js";
|
|
16
16
|
import { templateResourcesTable } from "../table.js";
|
|
17
|
+
import { activationChanges, parseTemplateDraft, toWorkingRow } from "../template-draft.js";
|
|
17
18
|
import { executor } from "./shared.js";
|
|
18
19
|
import { publishGateIssues } from "./template-gates.js";
|
|
19
20
|
|
|
20
21
|
type TemplateStatus = "active" | "archived";
|
|
21
|
-
type StatusTarget = "own" | "system";
|
|
22
|
+
export type StatusTarget = "own" | "system";
|
|
22
23
|
|
|
23
24
|
function isRowOfTarget(row: TemplateResourceRow, target: StatusTarget, callerTenantId: string) {
|
|
24
25
|
return target === "system"
|
|
@@ -26,41 +27,69 @@ function isRowOfTarget(row: TemplateResourceRow, target: StatusTarget, callerTen
|
|
|
26
27
|
: row.tenantId === callerTenantId && row.scope === "tenant";
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
export function writeAccessFor(target: StatusTarget) {
|
|
31
|
+
return { roles: target === "system" ? ["SystemAdmin"] : ["TenantAdmin", "SystemAdmin"] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The tenant db also reads system rows; the row's own tenant must match the
|
|
35
|
+
// target or the executor would append to a foreign event stream.
|
|
36
|
+
export async function loadTargetRow(
|
|
37
|
+
ctx: HandlerContext,
|
|
38
|
+
target: StatusTarget,
|
|
39
|
+
user: { readonly tenantId: string },
|
|
40
|
+
id: string,
|
|
41
|
+
) {
|
|
42
|
+
const scoped = target === "system" ? requireForTenant(ctx, SYSTEM_TENANT_ID) : null;
|
|
43
|
+
const db = scoped?.db ?? ctx.db;
|
|
44
|
+
const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, { id });
|
|
45
|
+
return { db, scoped, row: row && isRowOfTarget(row, target, user.tenantId) ? row : null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type StatusPlan =
|
|
49
|
+
| { readonly ok: true; readonly changes: Record<string, unknown> }
|
|
50
|
+
| { readonly ok: false; readonly failure: WriteFailure };
|
|
51
|
+
|
|
52
|
+
// Publishing promotes an open draft (gated as the version that goes live); a row
|
|
53
|
+
// without one is activated as stored. Archiving leaves the draft standing.
|
|
54
|
+
function planStatusChange(
|
|
55
|
+
ctx: HandlerContext,
|
|
56
|
+
row: TemplateResourceRow,
|
|
57
|
+
status: TemplateStatus,
|
|
58
|
+
): StatusPlan {
|
|
59
|
+
if (status === "archived") return { ok: true, changes: { status } };
|
|
60
|
+
const issues = publishGateIssues(ctx.registry, toWorkingRow(row, row.tenantId));
|
|
61
|
+
if (issues.length > 0) {
|
|
62
|
+
return { ok: false, failure: writeFailure(new ValidationError({ fields: [...issues] })) };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, changes: { ...parseTemplateDraft(row.draft), ...activationChanges(row) } };
|
|
65
|
+
}
|
|
66
|
+
|
|
29
67
|
function createStatusUpdateHandler(
|
|
30
68
|
name: string,
|
|
31
69
|
target: StatusTarget,
|
|
32
70
|
status: TemplateStatus,
|
|
33
71
|
description: string,
|
|
34
|
-
gate?: (registry: Registry, row: TemplateResourceRow) => readonly FieldIssue[],
|
|
35
72
|
) {
|
|
36
73
|
return defineWriteHandler({
|
|
37
74
|
name,
|
|
38
75
|
schema: z.object({ id: z.string().min(1) }),
|
|
39
|
-
access:
|
|
76
|
+
access: writeAccessFor(target),
|
|
40
77
|
// A system template change reaches every tenant without an override.
|
|
41
78
|
...(target === "system" ? { agent: { risk: "high" as const } } : {}),
|
|
42
79
|
description,
|
|
43
80
|
handler: async (event, ctx) => {
|
|
44
|
-
const scoped
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
49
|
-
id: event.payload.id,
|
|
50
|
-
});
|
|
51
|
-
if (!existing || !isRowOfTarget(existing, target, event.user.tenantId)) {
|
|
52
|
-
return writeFailure(new NotFoundError("template-resource", event.payload.id));
|
|
53
|
-
}
|
|
54
|
-
const issues = gate?.(ctx.registry, existing) ?? [];
|
|
55
|
-
if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
|
|
81
|
+
const { db, scoped, row } = await loadTargetRow(ctx, target, event.user, event.payload.id);
|
|
82
|
+
if (!row) return writeFailure(new NotFoundError("template-resource", event.payload.id));
|
|
83
|
+
const plan = planStatusChange(ctx, row, status);
|
|
84
|
+
if (!plan.ok) return plan.failure;
|
|
56
85
|
const result = await executor.update(
|
|
57
|
-
{ id:
|
|
86
|
+
{ id: row.id, version: row.version, changes: plan.changes },
|
|
58
87
|
event.user,
|
|
59
88
|
db,
|
|
60
89
|
scoped ? { streamTenantId: scoped.streamTenantId } : undefined,
|
|
61
90
|
);
|
|
62
91
|
if (!result.isSuccess) return result;
|
|
63
|
-
return { isSuccess: true as const, data: { id: String(
|
|
92
|
+
return { isSuccess: true as const, data: { id: String(row.id), status } };
|
|
64
93
|
},
|
|
65
94
|
});
|
|
66
95
|
}
|
|
@@ -69,14 +98,13 @@ export const archiveWrite = createStatusUpdateHandler(
|
|
|
69
98
|
"archive",
|
|
70
99
|
"own",
|
|
71
100
|
"archived",
|
|
72
|
-
"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.",
|
|
101
|
+
"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.",
|
|
73
102
|
);
|
|
74
103
|
export const publishWrite = createStatusUpdateHandler(
|
|
75
104
|
"publish",
|
|
76
105
|
"own",
|
|
77
106
|
"active",
|
|
78
|
-
"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, or to bring an archived template back; a
|
|
79
|
-
publishGateIssues,
|
|
107
|
+
"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.",
|
|
80
108
|
);
|
|
81
109
|
export const archiveSystemWrite = createStatusUpdateHandler(
|
|
82
110
|
"archive-system",
|
|
@@ -88,6 +116,5 @@ export const publishSystemWrite = createStatusUpdateHandler(
|
|
|
88
116
|
"publish-system",
|
|
89
117
|
"system",
|
|
90
118
|
"active",
|
|
91
|
-
"Sets one system-default template to active so the resolver serves it again to every tenant that has no override; SystemAdmin only, a
|
|
92
|
-
publishGateIssues,
|
|
119
|
+
"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.",
|
|
93
120
|
);
|
|
@@ -17,7 +17,7 @@ export const upsertTenantWrite = defineWriteHandler({
|
|
|
17
17
|
}),
|
|
18
18
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
19
19
|
description:
|
|
20
|
-
"Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live; it refuses a system-tenant target, which is what upsert-system is for.",
|
|
20
|
+
"Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live (over an active override the draft is stored beside it, the active version keeps being served until publish); it refuses a system-tenant target, which is what upsert-system is for.",
|
|
21
21
|
handler: async (event, ctx) => {
|
|
22
22
|
// @cast-boundary engine-payload — override aus Zod-parsed string,
|
|
23
23
|
// event.user.tenantId schon TenantId-branded; union als TenantId casten
|
|
@@ -1,17 +1,31 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import * as z from "zod";
|
|
3
4
|
import { TEMPLATE_KINDS } from "../constants.js";
|
|
5
|
+
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
4
6
|
import { templateSlugSchema } from "../template-meta.js";
|
|
5
7
|
import { collectTemplateUsage } from "../template-usage.js";
|
|
8
|
+
import { effectiveMailLayoutSlugs } from "./overview.query.js";
|
|
9
|
+
|
|
10
|
+
const MAIL_ROW_LIMIT = 5000;
|
|
6
11
|
|
|
7
12
|
export const usageQuery = defineQueryHandler({
|
|
8
13
|
name: "usage",
|
|
9
14
|
schema: z.object({ slug: templateSlugSchema, kind: z.enum(TEMPLATE_KINDS) }),
|
|
10
15
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
11
16
|
description:
|
|
12
|
-
"Lists the code-registered notifications that use one template: a mail template is used by the notification whose qualified name is its slug, a mail layout by every mail notification
|
|
17
|
+
"Lists the code-registered notifications that use one template: a mail template is used by the notification whose qualified name is its slug and that has a code template, a mail layout by every mail notification whose effective template (own override, else system) names it, or whose code meta does when no stored template exists, or that falls back to the default layout; use it to show what a change to a template affects.",
|
|
13
18
|
handler: async (query, ctx) => {
|
|
14
19
|
const { slug, kind } = query.payload;
|
|
15
|
-
return { usedBy: collectTemplateUsage(ctx.registry, slug, kind) };
|
|
20
|
+
if (kind !== "mail-layout") return { usedBy: collectTemplateUsage(ctx.registry, slug, kind) };
|
|
21
|
+
const mailRows = await selectMany<TemplateResourceRow>(
|
|
22
|
+
ctx.db,
|
|
23
|
+
templateResourcesTable,
|
|
24
|
+
{ kind: "mail" },
|
|
25
|
+
{ limit: MAIL_ROW_LIMIT },
|
|
26
|
+
);
|
|
27
|
+
return {
|
|
28
|
+
usedBy: collectTemplateUsage(ctx.registry, slug, kind, effectiveMailLayoutSlugs(mailRows)),
|
|
29
|
+
};
|
|
16
30
|
},
|
|
17
31
|
});
|
|
@@ -21,7 +21,12 @@ export {
|
|
|
21
21
|
FALLBACK_LOCALE,
|
|
22
22
|
NON_RENDER_KINDS,
|
|
23
23
|
type NonRenderKind,
|
|
24
|
+
PDF_DEFAULT_MARGIN_MM,
|
|
25
|
+
PDF_FOOTER_MARGIN_MM,
|
|
26
|
+
PDF_HEADER_MARGIN_MM,
|
|
27
|
+
PDF_ORIENTATIONS,
|
|
24
28
|
PDF_PAGE_FORMATS,
|
|
29
|
+
type PdfOrientation,
|
|
25
30
|
type PdfPageFormat,
|
|
26
31
|
RENDER_KINDS,
|
|
27
32
|
type RenderKind,
|
|
@@ -52,7 +57,6 @@ export {
|
|
|
52
57
|
DocumentDivider,
|
|
53
58
|
DocumentLink,
|
|
54
59
|
DocumentPageBreak,
|
|
55
|
-
DocumentRepeatRow,
|
|
56
60
|
DocumentResource,
|
|
57
61
|
DocumentTable,
|
|
58
62
|
DocumentTableCell,
|
|
@@ -77,6 +81,11 @@ export type {
|
|
|
77
81
|
OverviewUsage,
|
|
78
82
|
} from "./handlers/overview.query.js";
|
|
79
83
|
export { localeSchema } from "./handlers/shared.js";
|
|
84
|
+
export {
|
|
85
|
+
isHexColor,
|
|
86
|
+
type MailLayoutAppearance,
|
|
87
|
+
mailLayoutAppearance,
|
|
88
|
+
} from "./layout-appearance.js";
|
|
80
89
|
export {
|
|
81
90
|
type LayoutRegions,
|
|
82
91
|
readLayoutRegions,
|
|
@@ -116,10 +125,12 @@ export {
|
|
|
116
125
|
type LayoutTemplateMeta,
|
|
117
126
|
MAIL_CATEGORIES,
|
|
118
127
|
type MailCategory,
|
|
128
|
+
type MailLayoutMeta,
|
|
119
129
|
type MailTemplateMeta,
|
|
120
130
|
type PdfTemplateMeta,
|
|
121
131
|
parseTemplateMeta,
|
|
122
132
|
pdfAttachmentFileNamePatternSchema,
|
|
133
|
+
readMailLayoutMeta,
|
|
123
134
|
readPdfTemplateMeta,
|
|
124
135
|
type TemplateMeta,
|
|
125
136
|
type TemplateMetaIssue,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { HEX_COLOR_PATTERN } from "./constants.js";
|
|
2
|
+
import { type MailLayoutMeta, readMailLayoutMeta } from "./template-meta.js";
|
|
3
|
+
|
|
4
|
+
// Every value is a member of a closed set, so it can be written into MJML and inline styles as is.
|
|
5
|
+
export type MailLayoutAppearance = {
|
|
6
|
+
readonly width: number;
|
|
7
|
+
readonly fontFamily: string;
|
|
8
|
+
readonly outerBackground: string;
|
|
9
|
+
readonly accentLine: boolean;
|
|
10
|
+
readonly buttonStyle: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const FONT_FAMILIES = {
|
|
14
|
+
sans: "Arial, Helvetica, sans-serif",
|
|
15
|
+
serif: "Georgia, 'Times New Roman', serif",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const BUTTON_COLOR = "#1f5468";
|
|
19
|
+
const BUTTON_BASE = "display:inline-block;padding:10px 20px;font-weight:600;text-decoration:none";
|
|
20
|
+
|
|
21
|
+
const BUTTON_STYLES = {
|
|
22
|
+
"filled-rounded": `${BUTTON_BASE};border-radius:6px;background-color:${BUTTON_COLOR};color:#ffffff`,
|
|
23
|
+
"filled-square": `${BUTTON_BASE};background-color:${BUTTON_COLOR};color:#ffffff`,
|
|
24
|
+
outline: `${BUTTON_BASE};border:2px solid ${BUTTON_COLOR};border-radius:6px;color:${BUTTON_COLOR}`,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export function isHexColor(value: string): boolean {
|
|
28
|
+
return HEX_COLOR_PATTERN.test(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function mailLayoutAppearance(meta: MailLayoutMeta | unknown): MailLayoutAppearance {
|
|
32
|
+
const parsed = readMailLayoutMeta(meta);
|
|
33
|
+
return {
|
|
34
|
+
width: parsed.width ?? 600,
|
|
35
|
+
fontFamily: FONT_FAMILIES[parsed.font ?? "sans"],
|
|
36
|
+
outerBackground: parsed.outerBackground ?? "#f5f5f5",
|
|
37
|
+
accentLine: (parsed.accentLine ?? "none") === "branding",
|
|
38
|
+
buttonStyle: BUTTON_STYLES[parsed.buttonStyle ?? "filled-rounded"],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// No imports: web/ re-exports these, and constants.ts pulls in the framework engine.
|
|
3
|
+
export const PDF_PAGE_FORMATS = ["A4", "Letter"] as const;
|
|
4
|
+
export type PdfPageFormat = (typeof PDF_PAGE_FORMATS)[number];
|
|
5
|
+
|
|
6
|
+
export const PDF_ORIENTATIONS = ["portrait", "landscape"] as const;
|
|
7
|
+
export type PdfOrientation = (typeof PDF_ORIENTATIONS)[number];
|
|
8
|
+
|
|
9
|
+
// Page margins in mm; the header/footer values apply when the layout contributes that region and the template sets no margin itself.
|
|
10
|
+
export const PDF_DEFAULT_MARGIN_MM = 15;
|
|
11
|
+
export const PDF_HEADER_MARGIN_MM = 30;
|
|
12
|
+
export const PDF_FOOTER_MARGIN_MM = 20;
|
|
@@ -9,6 +9,8 @@ export const TemplateResolverHandlers = {
|
|
|
9
9
|
archive: "template-resolver:write:archive",
|
|
10
10
|
publishSystem: "template-resolver:write:publish-system",
|
|
11
11
|
archiveSystem: "template-resolver:write:archive-system",
|
|
12
|
+
discardDraft: "template-resolver:write:discard-draft",
|
|
13
|
+
discardDraftSystem: "template-resolver:write:discard-draft-system",
|
|
12
14
|
resetToSystem: "template-resolver:write:reset-to-system",
|
|
13
15
|
set: "template-resolver:write:set",
|
|
14
16
|
importBundle: "template-resolver:write:import-bundle",
|
|
@@ -53,10 +55,12 @@ export const TemplateResolverErrors = {
|
|
|
53
55
|
invalidSlug: "invalid_slug",
|
|
54
56
|
invalidLocale: "invalid_locale",
|
|
55
57
|
systemAdminRequired: "system_admin_required",
|
|
58
|
+
noDraft: "no_draft",
|
|
56
59
|
alreadyActive: "template_already_active",
|
|
57
60
|
alreadyArchived: "template_already_archived",
|
|
58
61
|
targetExists: "target_exists",
|
|
59
62
|
systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
|
|
63
|
+
bundleLocalesDiffer: "bundle_locales_differ",
|
|
60
64
|
resourceTooLarge: "template_resource_too_large",
|
|
61
65
|
resourceUnsupportedImage: "template_resource_unsupported_image",
|
|
62
66
|
attachmentUnsupportedType: "template_attachment_unsupported_type",
|
|
@@ -35,6 +35,7 @@ import { evaluateTemplateWrite } from "./handlers/template-gates.js";
|
|
|
35
35
|
import { TEMPLATE_RESOLVER_FEATURE } from "./qualified-names.js";
|
|
36
36
|
import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
|
|
37
37
|
import type { TemplateBundle } from "./template-bundle.js";
|
|
38
|
+
import { activeStampChanges, servedStampChanges } from "./template-draft.js";
|
|
38
39
|
import { templateSlugSchema } from "./template-meta.js";
|
|
39
40
|
import { variablesFromSchema } from "./template-validation.js";
|
|
40
41
|
|
|
@@ -159,7 +160,11 @@ export async function seedSystemTemplate(
|
|
|
159
160
|
return { id: String(row.id) };
|
|
160
161
|
}
|
|
161
162
|
const result = await executor.update(
|
|
162
|
-
{
|
|
163
|
+
{
|
|
164
|
+
id: row.id,
|
|
165
|
+
version: row.version,
|
|
166
|
+
changes: { ...rowFields, ...activeStampChanges(row) },
|
|
167
|
+
},
|
|
163
168
|
by,
|
|
164
169
|
tdb,
|
|
165
170
|
);
|
|
@@ -174,7 +179,13 @@ export async function seedSystemTemplate(
|
|
|
174
179
|
ifExists: opts.ifExists,
|
|
175
180
|
create: async () => {
|
|
176
181
|
const result = await executor.create(
|
|
177
|
-
{
|
|
182
|
+
{
|
|
183
|
+
...rowFields,
|
|
184
|
+
...activeStampChanges(null),
|
|
185
|
+
draft: null,
|
|
186
|
+
linkedResources: linkedResources ?? "{}",
|
|
187
|
+
tenantId,
|
|
188
|
+
},
|
|
178
189
|
by,
|
|
179
190
|
tdb,
|
|
180
191
|
);
|
|
@@ -366,7 +377,13 @@ export async function seedTextBlock(
|
|
|
366
377
|
{
|
|
367
378
|
id: row.id,
|
|
368
379
|
version: row.version,
|
|
369
|
-
changes: {
|
|
380
|
+
changes: {
|
|
381
|
+
title: fields.title,
|
|
382
|
+
content: fields.content,
|
|
383
|
+
folder: fields.folder,
|
|
384
|
+
// A re-seed must not revive an archived block; only a served one gets a new served version.
|
|
385
|
+
...(current?.status === "active" && servedStampChanges(row)),
|
|
386
|
+
},
|
|
370
387
|
},
|
|
371
388
|
by,
|
|
372
389
|
tdb,
|
|
@@ -381,7 +398,11 @@ export async function seedTextBlock(
|
|
|
381
398
|
existing,
|
|
382
399
|
ifExists: opts.ifExists,
|
|
383
400
|
create: async () => {
|
|
384
|
-
const result = await executor.create(
|
|
401
|
+
const result = await executor.create(
|
|
402
|
+
{ ...fields, ...activeStampChanges(null), draft: null, tenantId: opts.tenantId },
|
|
403
|
+
by,
|
|
404
|
+
tdb,
|
|
405
|
+
);
|
|
385
406
|
if (!result.isSuccess) {
|
|
386
407
|
if (result.error?.code === "unique_violation") {
|
|
387
408
|
const again = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
@@ -105,7 +105,7 @@ export async function storeTemplateResource(args: {
|
|
|
105
105
|
const created = await createFileRefFromBytes(
|
|
106
106
|
{
|
|
107
107
|
db,
|
|
108
|
-
files: fileContextForTenant(ctx, tenantId),
|
|
108
|
+
files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
|
|
109
109
|
registry: ctx.registry,
|
|
110
110
|
user: { ...user, tenantId },
|
|
111
111
|
},
|
|
@@ -242,7 +242,7 @@ async function createAttachmentFileRef(args: {
|
|
|
242
242
|
const created = await createFileRefFromBytes(
|
|
243
243
|
{
|
|
244
244
|
db,
|
|
245
|
-
files: fileContextForTenant(ctx, tenantId),
|
|
245
|
+
files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
|
|
246
246
|
registry: ctx.registry,
|
|
247
247
|
user: { ...user, tenantId },
|
|
248
248
|
},
|
|
@@ -2,8 +2,10 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import {
|
|
3
3
|
createEntity,
|
|
4
4
|
createLongTextField,
|
|
5
|
+
createNumberField,
|
|
5
6
|
createSelectField,
|
|
6
7
|
createTextField,
|
|
8
|
+
createTimestampField,
|
|
7
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
10
|
import type { EntityEventMigration } from "@cosmicdrift/kumiko-types/fields";
|
|
9
11
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
@@ -24,7 +26,9 @@ const renameMailHtmlKind: EntityEventMigration = {
|
|
|
24
26
|
// TemplateResource — strukturierte Template-Definition mit Tenant-
|
|
25
27
|
// Override-Hierarchie, Locale-Fallback und Resource-Linking via
|
|
26
28
|
// file-foundation. Pro (tenantId, slug, kind, locale) genau eine Row;
|
|
27
|
-
// scope/status/version differenzieren Lifecycle.
|
|
29
|
+
// scope/status/version differenzieren Lifecycle. Der Entwurf neben einer
|
|
30
|
+
// aktiven Fassung liegt in `draft`; die Hauptfelder bleiben die ausgelieferte
|
|
31
|
+
// Fassung.
|
|
28
32
|
//
|
|
29
33
|
// `variableSchema` + `linkedResources` sind JSON-Strings in longText
|
|
30
34
|
// (Pattern aus compliance-profiles — kein dedizierter jsonbField im
|
|
@@ -58,6 +62,11 @@ export const templateResourceEntity = createEntity({
|
|
|
58
62
|
scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
|
|
59
63
|
parentTemplateId: createTextField({ personal: false, reason: "technical_reference" }),
|
|
60
64
|
status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
|
|
65
|
+
// JSON of the unpublished edit beside the active version (see template-draft.ts).
|
|
66
|
+
draft: createLongTextField({ personal: false, reason: "is_business_data" }),
|
|
67
|
+
// `version` counts every save including drafts; these two describe the served version.
|
|
68
|
+
activeVersion: createNumberField({ integer: true, min: 1 }),
|
|
69
|
+
activeSince: createTimestampField({}),
|
|
61
70
|
},
|
|
62
71
|
indexes: [
|
|
63
72
|
{
|
|
@@ -101,4 +110,7 @@ export type TemplateResourceRow = {
|
|
|
101
110
|
readonly scope: string;
|
|
102
111
|
readonly parentTemplateId: string | null;
|
|
103
112
|
readonly status: string;
|
|
113
|
+
readonly draft: string | null;
|
|
114
|
+
readonly activeVersion: number | null;
|
|
115
|
+
readonly activeSince: Temporal.Instant | null;
|
|
104
116
|
} & EntityAuditColumns;
|