@cosmicdrift/kumiko-samples 0.371.1 → 0.373.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/auth-mfa/db/queries.ts +5 -7
  4. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +2 -1
  5. package/packages/bundled-features/src/config/web/index.ts +2 -0
  6. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  7. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +75 -34
  8. package/packages/bundled-features/src/crypto-shredding/resolve-file-bytes-deleter.ts +29 -0
  9. package/packages/bundled-features/src/data-retention/changes.json +6 -0
  10. package/packages/bundled-features/src/data-retention/index.ts +6 -0
  11. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +37 -213
  12. package/packages/bundled-features/src/delivery/changes.json +13 -0
  13. package/packages/bundled-features/src/delivery/delivery-service.ts +7 -2
  14. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +2 -2
  15. package/packages/bundled-features/src/delivery/index.ts +2 -0
  16. package/packages/bundled-features/src/delivery/jobs.ts +42 -23
  17. package/packages/bundled-features/src/delivery/recipient-address-seal.ts +72 -0
  18. package/packages/bundled-features/src/delivery/testing.ts +4 -1
  19. package/packages/bundled-features/src/delivery/unsubscribe.ts +23 -2
  20. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +2 -2
  21. package/packages/bundled-features/src/files/changes.json +7 -0
  22. package/packages/bundled-features/src/personal-access-tokens/changes.json +12 -0
  23. package/packages/bundled-features/src/personal-access-tokens/screens.ts +15 -9
  24. package/packages/bundled-features/src/shared/index.ts +14 -0
  25. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +206 -0
  26. package/packages/bundled-features/src/shared/purge-row-file-refs.ts +214 -0
  27. package/packages/bundled-features/src/template-resolver/changes.json +6 -0
  28. package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -4
  29. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +2 -1
  30. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +1 -9
  31. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
  32. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  33. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +46 -1
  34. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  35. package/samples/apps/styleguide/src/features/demo/feature.ts +1 -1
  36. package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +5 -0
  37. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +2 -0
  38. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +9 -0
  39. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +6 -0
  40. package/samples/apps/ui-walkthrough/src/features/rental/entities.ts +1 -0
  41. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +27 -1
  42. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +9 -0
  43. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +1 -0
  44. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +22 -1
  45. package/samples/apps/use-all-bundled/package.json +3 -1
  46. package/samples/recipes/custom-handlers/src/feature.ts +3 -5
  47. package/samples/recipes/embedded-entity-form/src/feature.ts +1 -1
  48. package/samples/recipes/pdf-audit-export/README.md +1 -1
  49. package/samples/recipes/rate-limiting/README.md +21 -0
  50. package/samples/recipes/rate-limiting/src/feature.ts +13 -0
  51. package/samples/recipes/user-data-rights/README.md +22 -0
  52. package/samples/recipes/user-data-rights/src/retained-docs.ts +35 -0
@@ -0,0 +1,214 @@
1
+ import { type DbRunner, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { EntityDefinition, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
4
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
+
6
+ type EntityFields = EntityDefinition["fields"];
7
+ type EntityTable = Parameters<typeof selectMany>[1];
8
+
9
+ export type FileFieldPlan = {
10
+ /** type file|image: the fileRef-id lives directly in the entity column. */
11
+ readonly singleFields: readonly string[];
12
+ /** type files|images: resolved via file_refs (bound by entityType/entityId/fieldName). */
13
+ readonly multiFields: readonly string[];
14
+ };
15
+
16
+ export type SingleFileHolder = {
17
+ readonly table: EntityTable;
18
+ readonly tableHasTenantId: boolean;
19
+ readonly singleFields: readonly string[];
20
+ };
21
+
22
+ export type FileRefPurgeOutcome = "purged" | "missing_file_storage" | "file_delete_failed";
23
+
24
+ const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
25
+
26
+ // Same set as boot-validator/entity-handler.ts FILE_FIELD_TYPES; the single/multi split
27
+ // matters here because only plural fields resolve through file_refs.
28
+ export function planFileFields(
29
+ fields: EntityFields,
30
+ fieldNames?: readonly string[],
31
+ ): FileFieldPlan {
32
+ const singleFields: string[] = [];
33
+ const multiFields: string[] = [];
34
+ for (const [name, field] of Object.entries(fields)) {
35
+ if (fieldNames !== undefined && !fieldNames.includes(name)) continue;
36
+ if (field.type === "file" || field.type === "image") singleFields.push(name);
37
+ else if (field.type === "files" || field.type === "images") multiFields.push(name);
38
+ }
39
+ return singleFields.length > 0 || multiFields.length > 0
40
+ ? { singleFields, multiFields }
41
+ : NO_FILE_FIELDS;
42
+ }
43
+
44
+ // Every implicit entity table with file/image fields: an unbound fileRef
45
+ // (POST /files without entityType/entityId) can sit in single fields of
46
+ // several entity types, so the sharing check must look beyond the purged
47
+ // row's own table.
48
+ export function collectSingleFileHolders(registry: Registry): readonly SingleFileHolder[] {
49
+ const holders: SingleFileHolder[] = [];
50
+ for (const proj of registry.getAllProjections().values()) {
51
+ if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
52
+ const entity = registry.getEntity(proj.source);
53
+ if (!entity) continue;
54
+ const { singleFields } = planFileFields(entity.fields);
55
+ if (singleFields.length === 0) continue;
56
+ holders.push({
57
+ table: proj.table,
58
+ tableHasTenantId: propertyOf(proj.table, "tenantId") !== undefined,
59
+ singleFields,
60
+ });
61
+ }
62
+ return holders;
63
+ }
64
+
65
+ // Every fileRef-id a row's file-fields reference: single fields hold the id in
66
+ // their own column, plural fields resolve via file_refs (tenantId, entityType,
67
+ // entityId, fieldName).
68
+ async function collectFileRefIds(
69
+ db: DbRunner,
70
+ tenantId: TenantId,
71
+ entityName: string,
72
+ row: Record<string, unknown>,
73
+ plan: FileFieldPlan,
74
+ ): Promise<readonly string[]> {
75
+ const ids = new Set<string>();
76
+ for (const field of plan.singleFields) {
77
+ const value = row[field];
78
+ if (typeof value === "string" && value.length > 0) ids.add(value);
79
+ }
80
+ if (plan.multiFields.length > 0) {
81
+ const refRows = await selectMany<{ id: string }>(db, fileRefsTable, {
82
+ tenantId,
83
+ entityType: entityName,
84
+ entityId: String(row["id"]),
85
+ fieldName: { in: plan.multiFields },
86
+ });
87
+ for (const r of refRows) ids.add(r.id);
88
+ }
89
+ return Array.from(ids);
90
+ }
91
+
92
+ // A fileRef is bound to a DIFFERENT entity/record (or, when only some fields are
93
+ // purged, to another field of this row) than the one being purged — its binding,
94
+ // not the row's own single-field column, decides ownership.
95
+ function isFileRefBoundElsewhere(
96
+ fileRef: Record<string, unknown>,
97
+ entityName: string,
98
+ rowId: string,
99
+ fieldNames: readonly string[] | undefined,
100
+ ): boolean {
101
+ const entityType = fileRef["entityType"];
102
+ const entityId = fileRef["entityId"];
103
+ const fieldName = fileRef["fieldName"];
104
+ if (typeof entityType === "string" && entityType !== entityName) return true;
105
+ if (typeof entityId === "string" && entityId !== rowId) return true;
106
+ if (
107
+ fieldNames !== undefined &&
108
+ typeof fieldName === "string" &&
109
+ !fieldNames.includes(fieldName)
110
+ ) {
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ // A single-field fileRef-id can be shared by more than one row, in the SAME
117
+ // table or in another entity's table; with a partial field set, also by another
118
+ // field of the same row. Purging its bytes would corrupt the holder that stays.
119
+ async function isFileRefSharedByOtherHolder(
120
+ db: DbRunner,
121
+ holders: readonly SingleFileHolder[],
122
+ ownTable: EntityTable,
123
+ tenantId: TenantId,
124
+ rowId: string,
125
+ fileRefId: string,
126
+ fieldNames: readonly string[] | undefined,
127
+ ): Promise<boolean> {
128
+ for (const holder of holders) {
129
+ for (const field of holder.singleFields) {
130
+ const where: WhereObject = { [field]: fileRefId };
131
+ if (holder.table === ownTable) {
132
+ const fieldIsPurged = fieldNames === undefined || fieldNames.includes(field);
133
+ where["id"] = fieldIsPurged ? { ne: rowId } : rowId;
134
+ }
135
+ if (holder.tableHasTenantId) where["tenantId"] = tenantId;
136
+ const others = await selectMany(db, holder.table, where, { limit: 1 });
137
+ if (others.length > 0) return true;
138
+ }
139
+ }
140
+ return false;
141
+ }
142
+
143
+ // Deletes the un-shared fileRefs a row's file-fields reference: bytes (incl.
144
+ // derivatives) first, then the fileRef rows — bytes before rows so a crash
145
+ // mid-way leaves an orphaned fileRef pointing at already-gone bytes (a later
146
+ // run's delete is idempotent) rather than a gone fileRef whose bytes leak forever.
147
+ // The entity row itself is the caller's business. `fieldNames` narrows the purge
148
+ // to those fields; without it every file-field of the entity is purged.
149
+ export async function purgeRowFileRefs(args: {
150
+ readonly db: DbRunner;
151
+ readonly tenantId: TenantId;
152
+ readonly entityName: string;
153
+ readonly entity: EntityDefinition;
154
+ readonly table: EntityTable;
155
+ readonly fileHolders: readonly SingleFileHolder[];
156
+ readonly row: Record<string, unknown>;
157
+ readonly fieldNames?: readonly string[];
158
+ /** Returns the keys whose delete failed; undefined = no storage reachable. */
159
+ readonly deleteBytes: ((storageKey: string) => Promise<readonly string[]>) | undefined;
160
+ readonly fileRefForget: (fileRefId: string) => Promise<boolean>;
161
+ readonly onBytesDeleteFailed?: (failedKeys: readonly string[]) => void;
162
+ }): Promise<FileRefPurgeOutcome> {
163
+ const rowId = String(args.row["id"]);
164
+ const plan = planFileFields(args.entity.fields, args.fieldNames);
165
+ const fileRefIds = await collectFileRefIds(
166
+ args.db,
167
+ args.tenantId,
168
+ args.entityName,
169
+ args.row,
170
+ plan,
171
+ );
172
+ if (fileRefIds.length === 0) return "purged";
173
+
174
+ const fileRefRows = await selectMany<Record<string, unknown>>(args.db, fileRefsTable, {
175
+ id: { in: fileRefIds },
176
+ tenantId: args.tenantId,
177
+ });
178
+
179
+ const toDelete: Record<string, unknown>[] = [];
180
+ for (const fileRef of fileRefRows) {
181
+ if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId, args.fieldNames)) continue;
182
+ const shared = await isFileRefSharedByOtherHolder(
183
+ args.db,
184
+ args.fileHolders,
185
+ args.table,
186
+ args.tenantId,
187
+ rowId,
188
+ String(fileRef["id"]),
189
+ args.fieldNames,
190
+ );
191
+ if (shared) continue;
192
+ toDelete.push(fileRef);
193
+ }
194
+ if (toDelete.length === 0) return "purged";
195
+
196
+ if (!args.deleteBytes) return "missing_file_storage";
197
+
198
+ let anyFailed = false;
199
+ for (const fileRef of toDelete) {
200
+ const key = fileRef["storageKey"];
201
+ if (typeof key !== "string" || key.length === 0) continue;
202
+ const failedKeys = await args.deleteBytes(key);
203
+ if (failedKeys.length > 0) {
204
+ anyFailed = true;
205
+ args.onBytesDeleteFailed?.(failedKeys);
206
+ }
207
+ }
208
+ if (anyFailed) return "file_delete_failed";
209
+
210
+ for (const fileRef of toDelete) {
211
+ if (!(await args.fileRefForget(String(fileRef["id"])))) return "file_delete_failed";
212
+ }
213
+ return "purged";
214
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "improvement",
5
+ "title": "Text examples of template variables follow the template's language",
6
+ "detail": "The `example` of a text or richText templateVariable is now read like `label`: an i18n key resolves to the translation of the edited template's locale, plain text stays as it is. Preview, the sample values bar and send-test therefore show German examples for a German template. Example values of other types (link, number, enum-like text values) stay as declared; put the explanation of such a value into the variable's label. Apps: register `templateVariable.text({ example: \"<app>:mail.example.x\" })` and add the key with de, en and es via `r.translations`."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "improvement",
@@ -57,11 +57,27 @@ const SAMPLE_LINK = "https://example.com";
57
57
  const SAMPLE_DATE = "2026-01-15";
58
58
  const SAMPLE_LIST_ROWS = 3;
59
59
 
60
- // A variable label is an i18n key or plain text; an unknown key reads as itself.
60
+ // A variable label, and the example of a text variable, is an i18n key or plain text; an unknown
61
+ // key reads as itself.
61
62
  type LabelText = (label: string) => string;
62
63
 
64
+ function isTranslatableExample(
65
+ def: TemplateScalarVariableDef,
66
+ example: unknown,
67
+ ): example is string {
68
+ return (def.type === "text" || def.type === "richText") && typeof example === "string";
69
+ }
70
+
71
+ function exampleFor(
72
+ def: TemplateScalarVariableDef,
73
+ example: unknown,
74
+ labelText: LabelText,
75
+ ): unknown {
76
+ return isTranslatableExample(def, example) ? labelText(example) : example;
77
+ }
78
+
63
79
  function sampleScalar(name: string, def: TemplateScalarVariableDef, labelText: LabelText): unknown {
64
- if (def.example !== undefined) return def.example;
80
+ if (def.example !== undefined) return exampleFor(def, def.example, labelText);
65
81
  const label = def.label === undefined ? name : labelText(def.label);
66
82
  switch (def.type) {
67
83
  case "text":
@@ -85,8 +101,12 @@ function sampleRow(
85
101
  given: Readonly<Record<string, unknown>> = {},
86
102
  ): Record<string, unknown> {
87
103
  const row: Record<string, unknown> = {};
88
- for (const [name, def] of Object.entries(item)) row[name] = sampleScalar(name, def, labelText);
89
- return { ...row, ...given };
104
+ for (const [name, def] of Object.entries(item)) {
105
+ row[name] = Object.hasOwn(given, name)
106
+ ? exampleFor(def, given[name], labelText)
107
+ : sampleScalar(name, def, labelText);
108
+ }
109
+ return { ...given, ...row };
90
110
  }
91
111
 
92
112
  function sampleList(def: TemplateListVariableDef, labelText: LabelText): Record<string, unknown>[] {
@@ -1,3 +1,4 @@
1
+ import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
1
2
  // @runtime client
2
3
  // Client feature factory for the text-block content tree. Apps mount it via
3
4
  // createKumikoApp({ clientFeatures: [textBlocksClient()] }); it loads the
@@ -399,7 +400,7 @@ function TextBlockEditor({
399
400
  <Form
400
401
  onSubmit={onSubmit}
401
402
  testId="text-block-editor"
402
- title={title || slug || "—"}
403
+ title={title || slug || EMPTY_VALUE_PLACEHOLDER}
403
404
  subtitle={locale !== "" ? `${slug} · (${locale})` : slug}
404
405
  actions={
405
406
  canWrite ? (
@@ -63,7 +63,6 @@ function repeatRowListAt(doc: ProseMirrorNode, position: number | undefined): st
63
63
  export function createVariableView(
64
64
  variableLabels: Readonly<Record<string, string>>,
65
65
  systemVariables: readonly string[],
66
- labelOnly = false,
67
66
  ): NodeViewRenderer {
68
67
  return ({ node, editor, getPos }) => {
69
68
  const name = String(node.attrs["name"]);
@@ -79,9 +78,6 @@ export function createVariableView(
79
78
  const hasOwnLabel = label !== undefined && label !== "" && label !== name;
80
79
  dom.append(document.createTextNode(hasOwnLabel ? label : name));
81
80
  if (hasOwnLabel) dom.title = name;
82
- if (!labelOnly && hasOwnLabel) {
83
- dom.append(createElementWithClass("small", "font-mono text-[10px] opacity-75", name));
84
- }
85
81
  return { dom };
86
82
  };
87
83
  }
@@ -218,11 +214,7 @@ export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Ex
218
214
  case DocumentVariable.name:
219
215
  return DocumentVariable.extend({
220
216
  addNodeView: () =>
221
- createVariableView(
222
- labels.variableLabels ?? {},
223
- labels.systemVariables ?? [],
224
- labels.blockLabels === true,
225
- ),
217
+ createVariableView(labels.variableLabels ?? {}, labels.systemVariables ?? []),
226
218
  });
227
219
  case DocumentResource.name:
228
220
  return DocumentResource.extend({ addNodeView: () => createResourceView(labels) });
@@ -96,13 +96,13 @@ function LineEditorBody({
96
96
  undoRedo: false,
97
97
  }),
98
98
  DocumentVariable.extend({
99
- addNodeView: () => createVariableView(variableLabels, systemVariables, flat),
99
+ addNodeView: () => createVariableView(variableLabels, systemVariables),
100
100
  }),
101
101
  UndoRedo,
102
102
  BlockLineBreaks,
103
103
  ...(placeholder === undefined ? [] : [Placeholder.configure({ placeholder })]),
104
104
  ],
105
- [variableLabels, systemVariables, flat, placeholder],
105
+ [variableLabels, systemVariables, placeholder],
106
106
  );
107
107
 
108
108
  const editor = useEditor({
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "fix",
5
+ "title": "Account forget-cleanup removes userOwned files uploaded by someone else",
6
+ "detail": "runForgetCleanup now purges the bytes, derivatives and file_refs rows behind every file field whose subject is the deleted user, not only files the user uploaded; a failing byte delete keeps the user in DeletionRequested so the next run retries."
7
+ },
2
8
  {
3
9
  "version": "0.369.0",
4
10
  "type": "fix",
@@ -50,6 +50,7 @@ import {
50
50
  selectMany,
51
51
  WritePurposes,
52
52
  } from "@cosmicdrift/kumiko-framework/db";
53
+ import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
53
54
  import {
54
55
  type EscapeHatchAuditSink,
55
56
  EXT_USER_DATA,
@@ -75,7 +76,12 @@ import {
75
76
  resolveRetentionPolicyForTenant,
76
77
  resolveTenantRetentionPreset,
77
78
  } from "../data-retention/index.ts";
78
- import { decryptStoredPii, runInSubTransaction } from "../shared/index.ts";
79
+ import {
80
+ type DeleteFileBytes,
81
+ decryptStoredPii,
82
+ purgeFilesReachingSubject,
83
+ runInSubTransaction,
84
+ } from "../shared/index.ts";
79
85
  import { tenantMembershipsTable } from "../tenant/index.ts";
80
86
  import { USER_STATUS, userTable } from "../user/index.ts";
81
87
  import { selectUsersDueForForgetCleanup } from "./db/queries/forget-cleanup.ts";
@@ -450,6 +456,25 @@ async function processUser(args: {
450
456
  );
451
457
  }
452
458
 
459
+ // Same ordering rationale: the files are found through the entity rows a
460
+ // hook may hard-delete, and a file whose uploader differs from the owner
461
+ // is never reached by the fileRef hook (it keys on insertedById).
462
+ await purgeFilesReachingSubject({
463
+ registry,
464
+ db: tx,
465
+ subject: { kind: "user", userId },
466
+ subjectKey: subjectIdToKey({ kind: "user", userId }),
467
+ resolveDeleteBytes: resolveForgetFileBytesDeleter(buildStorageProvider),
468
+ missingStorage: {
469
+ mode: "skip",
470
+ warn: (tenantId) =>
471
+ // biome-ignore lint/suspicious/noConsole: misconfiguration visibility, same as the fileRef delete hook
472
+ console.warn(
473
+ `[user-data-rights:run-forget-cleanup] no file provider resolvable for tenantId=${tenantId} - file binaries of userId=${userId} are NOT deleted (rows kept). Mount file-foundation + a file-provider-* feature so erasure can reach the binaries.`,
474
+ ),
475
+ },
476
+ });
477
+
453
478
  for (const tenantId of tenantList) {
454
479
  currentTenantId = tenantId;
455
480
  // Refine the app-level model to THIS tenant: "single-user" only if the
@@ -576,6 +601,26 @@ async function processUser(args: {
576
601
  };
577
602
  }
578
603
 
604
+ // A resolution failure collapses to "no storage" like in the fileRef delete hook; a failing
605
+ // byte delete throws through the sweep so the per-user sub-tx rolls back and the next run retries.
606
+ function resolveForgetFileBytesDeleter(
607
+ buildStorageProvider: RunForgetCleanupArgs["buildStorageProvider"],
608
+ ): (tenantId: TenantId) => Promise<DeleteFileBytes | undefined> {
609
+ return async (tenantId) => {
610
+ if (!buildStorageProvider) return undefined;
611
+ try {
612
+ const provider = await buildStorageProvider(tenantId);
613
+ return (
614
+ provider &&
615
+ ((key) => deleteStoredFileAndDerivatives(key, provider, "user-data-rights:forget-cleanup"))
616
+ );
617
+ } catch {
618
+ // skip: provider unresolvable (not configured) -> files skipped with a warn, like the hook
619
+ return undefined;
620
+ }
621
+ };
622
+ }
623
+
579
624
  // Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
580
625
  // Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
581
626
  // tenant-agnostische Hooks (z.B. user) operieren auf der globalen
@@ -1,3 +1,4 @@
1
+ import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
1
2
  // @runtime client
2
3
  // Export section (Art. 20) — the one part of user-data-rights' privacy-center
3
4
  // screen that stays custom (fw#2312). Registered as an EditExtensionSection
@@ -57,7 +58,7 @@ function failureKey(error: unknown): string {
57
58
  // Roher ISO-Instant → nur der Datums-Teil ist relevant; reiner String-Slice,
58
59
  // kein Date-API (no-date-api-Guard) und universell. Leer/null → "—".
59
60
  export function formatDate(iso: string | null | undefined): string {
60
- if (!iso) return "—";
61
+ if (!iso) return EMPTY_VALUE_PLACEHOLDER;
61
62
  const tIndex = iso.indexOf("T");
62
63
  return tIndex > 0 ? iso.slice(0, tIndex) : iso;
63
64
  }
@@ -102,7 +102,7 @@ export const demoFeature = defineFeature("styleguide", (r) => {
102
102
  if (!source) return failNotFound("item", event.payload.id);
103
103
  return itemExecutor.create(
104
104
  {
105
- name: `${source["name"] as string} (Copy)`,
105
+ name: `${source["name"]} (Copy)`,
106
106
  description: source["description"],
107
107
  quantity: source["quantity"],
108
108
  rating: source["rating"],
@@ -14,6 +14,7 @@ type BoardLease = {
14
14
  readonly beginn: string;
15
15
  readonly ende?: string;
16
16
  readonly miete: number;
17
+ readonly vorgaengerIndex?: number;
17
18
  };
18
19
 
19
20
  const BOARD_LEASES: readonly BoardLease[] = [
@@ -101,6 +102,7 @@ const BOARD_LEASES: readonly BoardLease[] = [
101
102
  beginn: "2026-01-01",
102
103
  ende: "2026-08-31",
103
104
  miete: 700,
105
+ vorgaengerIndex: 0,
104
106
  },
105
107
  {
106
108
  mieter: "Karl Positionsende",
@@ -229,6 +231,9 @@ export const seedRental: SeedFn = async (stack) => {
229
231
  grundmiete: eur(lease.miete),
230
232
  kuendigungsfrist: "3 Monate",
231
233
  zahltag: 3,
234
+ ...(lease.vorgaengerIndex !== undefined && {
235
+ vorgaenger: boardLeaseId(lease.vorgaengerIndex),
236
+ }),
232
237
  },
233
238
  TestUsers.admin,
234
239
  db,
@@ -226,6 +226,8 @@ const tenantInvoiceMeta = (subject: string) => ({
226
226
  ...invoiceIssuedMeta(subject),
227
227
  senderName: "Nordlicht Immobilien GmbH",
228
228
  replyTo: "buchhaltung@nordlicht-immobilien.de",
229
+ headerChip: "{{noticeLabel}}",
230
+ tone: "{{noticeTone}}",
229
231
  });
230
232
 
231
233
  // The platform default has a due date; the tenant drafts below drop it, which
@@ -54,6 +54,15 @@ const INVOICE_ISSUED_VARIABLES = {
54
54
  label: "billing:mail.variable.paymentReference",
55
55
  example: "RE-2026-0142 / Yilmaz",
56
56
  }),
57
+ // A text example given as i18n key follows the edited template's language.
58
+ noticeLabel: templateVariable.text({
59
+ label: "billing:mail.variable.noticeLabel",
60
+ example: "billing:mail.example.noticeLabel",
61
+ }),
62
+ noticeTone: templateVariable.text({
63
+ label: "billing:mail.variable.noticeTone",
64
+ example: "success",
65
+ }),
57
66
  } as const;
58
67
 
59
68
  const PAYMENT_REMINDER_VARIABLES = {
@@ -20,6 +20,9 @@ export const billingTranslations: TranslationsByLocale = {
20
20
  "billing:mail.variable.lines.amount": "Betrag",
21
21
  "billing:mail.variable.lines": "Rechnungspositionen",
22
22
  "billing:mail.variable.paymentReference": "Zahlungsreferenz",
23
+ "billing:mail.variable.noticeLabel": "Kennzeichen der Rechnung",
24
+ "billing:mail.example.noticeLabel": "Neue Rechnung",
25
+ "billing:mail.variable.noticeTone": "Farbe der Meldung (success, warning oder danger)",
23
26
  },
24
27
  en: {
25
28
  "billing:feature.label": "Invoices",
@@ -37,5 +40,8 @@ export const billingTranslations: TranslationsByLocale = {
37
40
  "billing:mail.variable.lines.amount": "Amount",
38
41
  "billing:mail.variable.lines": "Invoice lines",
39
42
  "billing:mail.variable.paymentReference": "Payment reference",
43
+ "billing:mail.variable.noticeLabel": "Invoice label",
44
+ "billing:mail.example.noticeLabel": "New invoice",
45
+ "billing:mail.variable.noticeTone": "Notice colour (success, warning or danger)",
40
46
  },
41
47
  };
@@ -19,6 +19,7 @@ export const leaseEntity = createEntity({
19
19
  liegenschaft: createTextField({ ...DEMO_DATA, searchable: true, sortable: true }),
20
20
  beginn: createDateField({ required: true, sortable: true, filterable: true }),
21
21
  ende: createDateField({ sortable: true }),
22
+ vorgaenger: { type: "reference", entity: "lease", labelField: "einheit" },
22
23
  status: createSelectField({
23
24
  options: LEASE_STATUSES,
24
25
  optionTones: { active: "ok", terminated: "bad" },
@@ -1,3 +1,4 @@
1
+ import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
2
  import { defineFeature, i18nKey } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { z } from "zod";
@@ -30,6 +31,7 @@ import {
30
31
  leaseListEmptyScreen,
31
32
  leaseListScreen,
32
33
  leaseListShortScreen,
34
+ leaseSuccessionListScreen,
33
35
  partyEditScreen,
34
36
  positionEditScreen,
35
37
  recordPositionsScreen,
@@ -69,6 +71,28 @@ export const rentalFeature = defineFeature("rental", (r) => {
69
71
 
70
72
  r.crud("lease", leaseEntity, { write: open, read: open });
71
73
  r.crud("leasePosition", leasePositionEntity, { write: open, read: open });
74
+ // Only the interactive create path is guarded; recordPositions and adjustRent
75
+ // write through the executor and compute their own date ranges.
76
+ r.hook({
77
+ type: "validation",
78
+ target: "lease-position:create",
79
+ handler: (data) => {
80
+ const { gueltigVon, gueltigBis } = data;
81
+ if (
82
+ typeof gueltigVon === "string" &&
83
+ typeof gueltigBis === "string" &&
84
+ gueltigBis < gueltigVon
85
+ ) {
86
+ // The "(root)" issue has no rendered field, so the form also shows its
87
+ // error banner next to the field error.
88
+ return [
89
+ { field: "gueltigBis", error: "valid_until_before_valid_from" },
90
+ { field: "(root)", error: "position_period_invalid" },
91
+ ];
92
+ }
93
+ return null;
94
+ },
95
+ });
72
96
  r.crud("leaseParty", leasePartyEntity, { write: open, read: open });
73
97
 
74
98
  r.queryHandler({
@@ -78,7 +102,7 @@ export const rentalFeature = defineFeature("rental", (r) => {
78
102
  const row = await leaseExecutor.detail({ id: query.payload.id }, query.user, ctx.db);
79
103
  if (!row) return failNotFound("lease", query.payload.id);
80
104
  const lease = parseLeaseRow(row);
81
- const countChildren = async (executor: typeof positionExecutor): Promise<number> => {
105
+ const countChildren = async (executor: EventStoreExecutor): Promise<number> => {
82
106
  const page = await executor.list(
83
107
  { filter: { field: "lease", op: "eq", value: lease.id }, limit: 1, totalCount: true },
84
108
  query.user,
@@ -98,6 +122,7 @@ export const rentalFeature = defineFeature("rental", (r) => {
98
122
  einheit: lease.einheit ?? "",
99
123
  liegenschaft: lease.liegenschaft ?? "",
100
124
  ende: "",
125
+ vorgaenger: lease.vorgaenger ?? "",
101
126
  kontostand: "0,00 €",
102
127
  notizen: "",
103
128
  partyCount: await countChildren(partyExecutor),
@@ -241,6 +266,7 @@ export const rentalFeature = defineFeature("rental", (r) => {
241
266
  r.screen(leaseHubScreen);
242
267
  r.screen(leaseDetailGapsScreen);
243
268
  r.screen(leaseListEmptyScreen);
269
+ r.screen(leaseSuccessionListScreen);
244
270
  r.screen(rentalDashboardScreen);
245
271
  r.screen(adjustRentScreen);
246
272
  r.screen(recordPositionsScreen);
@@ -7,6 +7,7 @@ export const rentalTranslations: TranslationsByLocale = {
7
7
 
8
8
  "screen:lease-list.title": "Mietverträge",
9
9
  "screen:lease-list-short.title": "Mietverträge (gekündigt)",
10
+ "screen:lease-succession-list.title": "Nachfolge",
10
11
  "screen:lease-edit.title": "Mietvertrag",
11
12
  "screen:lease-detail.title": "Mietvertrag",
12
13
  "screen:lease-hub.title": "Mietvertrag Hub",
@@ -56,6 +57,7 @@ export const rentalTranslations: TranslationsByLocale = {
56
57
  "rental:entity:lease:field:liegenschaft": "Liegenschaft",
57
58
  "rental:entity:lease:field:beginn": "Beginn",
58
59
  "rental:entity:lease:field:ende": "Ende",
60
+ "rental:entity:lease:field:vorgaenger": "Vorgänger",
59
61
  "rental:entity:lease:field:status": "Status",
60
62
  "rental:entity:lease:field:status:option:active": "Aktiv",
61
63
  "rental:entity:lease:field:status:option:terminated": "Gekündigt",
@@ -71,6 +73,9 @@ export const rentalTranslations: TranslationsByLocale = {
71
73
  "rental:entity:leasePosition:field:betrag": "Betrag",
72
74
  "rental:entity:leasePosition:field:gueltigVon": "Gültig von",
73
75
  "rental:entity:leasePosition:field:gueltigBis": "Gültig bis",
76
+ "errors.validation.position_period_invalid": "Der Zeitraum der Position ist ungültig.",
77
+ "errors.validation.valid_until_before_valid_from":
78
+ "Gültig bis darf nicht vor Gültig von liegen.",
74
79
 
75
80
  "rental:entity:leaseParty:field:lease": "Mietvertrag",
76
81
  "rental:entity:leaseParty:field:name": "Name",
@@ -119,6 +124,7 @@ export const rentalTranslations: TranslationsByLocale = {
119
124
 
120
125
  "screen:lease-list.title": "Leases",
121
126
  "screen:lease-list-short.title": "Leases (terminated)",
127
+ "screen:lease-succession-list.title": "Succession",
122
128
  "screen:lease-edit.title": "Lease",
123
129
  "screen:lease-detail.title": "Lease",
124
130
  "screen:lease-hub.title": "Lease hub",
@@ -166,6 +172,7 @@ export const rentalTranslations: TranslationsByLocale = {
166
172
  "rental:entity:lease:field:liegenschaft": "Property",
167
173
  "rental:entity:lease:field:beginn": "Start",
168
174
  "rental:entity:lease:field:ende": "End",
175
+ "rental:entity:lease:field:vorgaenger": "Predecessor",
169
176
  "rental:entity:lease:field:status": "Status",
170
177
  "rental:entity:lease:field:status:option:active": "Active",
171
178
  "rental:entity:lease:field:status:option:terminated": "Terminated",
@@ -181,6 +188,8 @@ export const rentalTranslations: TranslationsByLocale = {
181
188
  "rental:entity:leasePosition:field:betrag": "Amount",
182
189
  "rental:entity:leasePosition:field:gueltigVon": "Valid from",
183
190
  "rental:entity:leasePosition:field:gueltigBis": "Valid until",
191
+ "errors.validation.position_period_invalid": "The position period is invalid.",
192
+ "errors.validation.valid_until_before_valid_from": "Valid until must not be before valid from.",
184
193
 
185
194
  "rental:entity:leaseParty:field:lease": "Lease",
186
195
  "rental:entity:leaseParty:field:name": "Name",
@@ -62,6 +62,7 @@ const leaseRowSchema = z.object({
62
62
  beginn: readDateSchema,
63
63
  status: z.string(),
64
64
  ende: readDateSchema.nullish(),
65
+ vorgaenger: z.string().nullish(),
65
66
  grundmiete: moneySchema,
66
67
  kuendigungsfrist: z.string().nullish(),
67
68
  zahltag: z.number().nullish(),