@cosmicdrift/kumiko-samples 0.360.0 → 0.361.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 (34) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/details.query.ts +2 -1
  5. package/packages/bundled-features/src/audit/handlers/list.query.ts +4 -5
  6. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +6 -0
  7. package/packages/bundled-features/src/auth-email-password/web/session.tsx +39 -3
  8. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  9. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  10. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +11 -6
  11. package/packages/bundled-features/src/folders-user-data/hooks.ts +4 -2
  12. package/packages/bundled-features/src/form-draft-user-data/hooks.ts +4 -1
  13. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +2 -2
  14. package/packages/bundled-features/src/shared/index.ts +1 -0
  15. package/packages/bundled-features/src/shared/redact-pii-ciphertext.ts +16 -0
  16. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +7 -2
  17. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +2 -1
  18. package/packages/bundled-features/src/user/changes.json +6 -0
  19. package/packages/bundled-features/src/user/handlers/me.query.ts +5 -1
  20. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +2 -1
  21. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +6 -1
  22. package/packages/bundled-features/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +3 -2
  23. package/packages/bundled-features/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +3 -3
  24. package/samples/apps/styleguide/src/features/examples/feature.ts +52 -3
  25. package/samples/apps/styleguide/src/features/examples/i18n.ts +3 -0
  26. package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +3 -2
  27. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +11 -2
  28. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +17 -5
  29. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +12 -1
  30. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +46 -1
  31. package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +2 -0
  32. package/samples/apps/ui-walkthrough/src/features/vehicles/i18n.ts +3 -1
  33. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +18 -0
  34. package/samples/recipes/files-post-processing/src/feature.ts +11 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.360.0",
3
+ "version": "0.361.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.360.0",
3
+ "version": "0.361.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "fix",
5
+ "title": "Audit log works under an active subject KMS",
6
+ "detail": "audit list, audit details and user-data-rights my-audit-log returned event payloads as stored. With a subject KMS configured the payload carried kumiko-pii ciphertext and the API leak guard answered 500 pii_ciphertext_leak. Every PII ciphertext string in the payload is now replaced by REDACTED_PII_PLACEHOLDER (\"[[personal data]]\"); the audit log still never shows plaintext PII."
7
+ },
2
8
  {
3
9
  "version": "0.353.0",
4
10
  "type": "fix",
@@ -7,6 +7,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
7
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
9
  import * as z from "zod";
10
+ import { redactPiiCiphertext } from "../../shared/index.js";
10
11
  import { AUDIT_SCOPE_VALUES } from "../constants.js";
11
12
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
12
13
 
@@ -51,7 +52,7 @@ export const detailsQuery = defineQueryHandler({
51
52
  aggregateType: row.aggregateType,
52
53
  version: row.version,
53
54
  type: row.type,
54
- payload: row.payload,
55
+ payload: redactPiiCiphertext(row.payload),
55
56
  metadata: row.metadata,
56
57
  createdAt: row.createdAt,
57
58
  createdBy: row.createdBy,
@@ -9,16 +9,15 @@
9
9
  // A SystemAdmin can pass scope "system" to read the app-instance system events
10
10
  // (e.g. app.started); no other cross-tenant read exists.
11
11
  //
12
- // Sensitive field-values are ciphertext inside the event payload (the log
13
- // carries them encrypted); stripSensitive only strips the event echo. This
14
- // query returns payloads as stored, so a sensitive value surfaces as ciphertext,
15
- // never as plaintext.
12
+ // Event payloads hold PII as subject-KMS ciphertext; it is replaced by
13
+ // REDACTED_PII_PLACEHOLDER before returning, since the API leak guard refuses ciphertext.
16
14
 
17
15
  import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
18
16
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
19
17
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
18
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
21
19
  import * as z from "zod";
20
+ import { redactPiiCiphertext } from "../../shared/index.js";
22
21
  import { AUDIT_SCOPE_VALUES } from "../constants.js";
23
22
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
24
23
 
@@ -132,7 +131,7 @@ export const listQuery = defineQueryHandler({
132
131
  aggregateType: r.aggregateType,
133
132
  version: r.version,
134
133
  type: r.type,
135
- payload: r.payload,
134
+ payload: redactPiiCiphertext(r.payload),
136
135
  metadata: r.metadata,
137
136
  createdAt: r.createdAt,
138
137
  createdBy: r.createdBy,
@@ -452,6 +452,8 @@ export type CurrentUserProfile = {
452
452
  readonly email: string;
453
453
  readonly displayName: string;
454
454
  readonly locale?: string;
455
+ /** Effective IANA zone (user override, else tenant, else UTC). */
456
+ readonly effectiveTimeZone?: string;
455
457
  readonly globalRoles: readonly string[];
456
458
  };
457
459
 
@@ -477,6 +479,7 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
477
479
  email: string;
478
480
  displayName: string;
479
481
  locale?: string;
482
+ effectiveTimeZone?: string;
480
483
  // JSON-encoded string[] — siehe userEntity.roles. Default "[]" wenn
481
484
  // keine globalen Rollen.
482
485
  roles?: string;
@@ -488,6 +491,9 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
488
491
  email: body.data.email,
489
492
  displayName: body.data.displayName,
490
493
  ...(body.data.locale !== undefined && { locale: body.data.locale }),
494
+ ...(body.data.effectiveTimeZone !== undefined && {
495
+ effectiveTimeZone: body.data.effectiveTimeZone,
496
+ }),
491
497
  globalRoles: parseRoles(body.data.roles),
492
498
  };
493
499
  }
@@ -10,8 +10,20 @@
10
10
  // und die Transitions.
11
11
 
12
12
  import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
13
- import { useSessionEndedSignal } from "@cosmicdrift/kumiko-renderer";
14
- import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
13
+ import {
14
+ type CurrentUser,
15
+ CurrentUserProvider,
16
+ useSessionEndedSignal,
17
+ } from "@cosmicdrift/kumiko-renderer";
18
+ import {
19
+ createContext,
20
+ type ReactNode,
21
+ useCallback,
22
+ useContext,
23
+ useEffect,
24
+ useMemo,
25
+ useState,
26
+ } from "react";
15
27
  import {
16
28
  AuthRequestError,
17
29
  type CurrentUserProfile,
@@ -232,7 +244,31 @@ export function SessionProvider({
232
244
  }, []);
233
245
 
234
246
  const api: SessionApi = { ...state, login, logout, switchTenant, refresh: doRefresh };
235
- return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>;
247
+ // Keyed on the primitives, not the profile object: every refresh (tenant
248
+ // switch, session bootstrap retry) builds a new profile object, and a new
249
+ // CurrentUser reference would re-seed a create form and drop typed input.
250
+ const profile = state.user;
251
+ const userId = profile?.id;
252
+ const userEmail = profile?.email;
253
+ const userDisplayName = profile?.displayName;
254
+ const userTimeZone = profile?.effectiveTimeZone;
255
+ const currentUser = useMemo<CurrentUser | undefined>(
256
+ () =>
257
+ userId === undefined || userEmail === undefined || userDisplayName === undefined
258
+ ? undefined
259
+ : {
260
+ id: userId,
261
+ email: userEmail,
262
+ displayName: userDisplayName,
263
+ ...(userTimeZone !== undefined && { timeZone: userTimeZone }),
264
+ },
265
+ [userId, userEmail, userDisplayName, userTimeZone],
266
+ );
267
+ return (
268
+ <SessionContext.Provider value={api}>
269
+ <CurrentUserProvider user={currentUser}>{children}</CurrentUserProvider>
270
+ </SessionContext.Provider>
271
+ );
236
272
  }
237
273
 
238
274
  export function useSession(): SessionApi {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "improvement",
5
+ "title": "cap-counter exports capReservationsTable and capReservationsTableMeta",
6
+ "detail": "The reservation table store_cap_reservations was only reachable through internal imports, so kumiko-enterprise redeclared it in a test. The cap-counter entry now exports capReservationsTable and capReservationsTableMeta, following the feature-toggles pattern."
7
+ },
2
8
  {
3
9
  "version": "0.358.0",
4
10
  "type": "improvement",
@@ -49,6 +49,7 @@ export {
49
49
  type StockCapGuard,
50
50
  type StockCapSpec,
51
51
  } from "./stock-cap-guard.js";
52
+ export { capReservationsTable, capReservationsTableMeta } from "./tables.js";
52
53
  export {
53
54
  createValueCapGuard,
54
55
  type ValueCapGuard,
@@ -55,6 +55,7 @@ import {
55
55
  nullBlindIndexesForSubject,
56
56
  type TenantDb,
57
57
  type WhereObject,
58
+ WritePurposes,
58
59
  } from "@cosmicdrift/kumiko-framework/db";
59
60
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
60
61
  import {
@@ -259,14 +260,14 @@ async function forgetRowAndEraseKeys(args: {
259
260
  type FileFieldPlan = {
260
261
  /** type file|image — stores the fileRef-id directly in the entity column. */
261
262
  readonly singleFields: readonly string[];
262
- /** type files|images — no entity column, resolved via file_refs. */
263
+ /** type files|images — resolved via file_refs (bound by entityType/entityId/fieldName). */
263
264
  readonly multiFields: readonly string[];
264
265
  };
265
266
 
266
267
  const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
267
268
 
268
269
  // file/image = single-column fields (fileRef-id lives directly in the entity
269
- // row); files/images = plural, no column, resolved via file_refs. Same set
270
+ // row); files/images = plural, resolved via file_refs. Same set
270
271
  // as boot-validator/entity-handler.ts FILE_FIELD_TYPES and engine's
271
272
  // isFileField — inlined here rather than importing across the package
272
273
  // barrel since this module already needs the single/multi split, not just
@@ -285,8 +286,8 @@ function planFileFields(fields: Readonly<Record<string, unknown>>): FileFieldPla
285
286
  }
286
287
 
287
288
  // Every fileRef-id a row's file/image/files/images-fields reference: single
288
- // fields hold the id in their own column, plural fields have no column and
289
- // resolve via file_refs (tenantId, entityType, entityId, fieldName).
289
+ // fields hold the id in their own column, plural fields resolve
290
+ // via file_refs (tenantId, entityType, entityId, fieldName).
290
291
  async function collectFileRefIds(
291
292
  db: DbRunner,
292
293
  tenantId: TenantId,
@@ -771,7 +772,8 @@ export async function runRetentionCleanup(
771
772
  proj.table,
772
773
  { ...where, isDeleted: false },
773
774
  batchLimit,
774
- (id) => executor.delete({ id }, systemUser, tdb),
775
+ (id) =>
776
+ executor.delete({ id }, systemUser, tdb, { writePurpose: WritePurposes.retention }),
775
777
  );
776
778
  break;
777
779
  }
@@ -803,7 +805,10 @@ export async function runRetentionCleanup(
803
805
  batchLimit,
804
806
  targets,
805
807
  (id, changes) =>
806
- executor.update({ id, changes }, systemUser, tdb, { skipOptimisticLock: true }),
808
+ executor.update({ id, changes }, systemUser, tdb, {
809
+ skipOptimisticLock: true,
810
+ writePurpose: WritePurposes.retention,
811
+ }),
807
812
  );
808
813
  break;
809
814
  }
@@ -4,7 +4,7 @@
4
4
  // Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
5
5
  // (a folder name is tenant data, not per-user PII).
6
6
 
7
- import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
7
+ import { type EventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
9
9
  createEntityExecutor,
10
10
  createSystemUser,
@@ -64,7 +64,9 @@ function tenantScopedDelete(
64
64
  const systemUser = createSystemUser(ctx.tenantId);
65
65
  const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
66
66
  for (const row of rows) {
67
- await executor.delete({ id: row.id }, systemUser, ctx.db);
67
+ await executor.delete({ id: row.id }, systemUser, ctx.db, {
68
+ writePurpose: WritePurposes.erasure,
69
+ });
68
70
  }
69
71
  };
70
72
  }
@@ -3,6 +3,7 @@
3
3
  // consumers without the user-data-rights pipeline don't pull a hard
4
4
  // dependency.
5
5
 
6
+ import { WritePurposes } from "@cosmicdrift/kumiko-framework/db";
6
7
  import {
7
8
  createSystemUser,
8
9
  type UserDataDeleteHook,
@@ -42,7 +43,9 @@ export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
42
43
  const systemUser = createSystemUser(ctx.tenantId);
43
44
  const failures: string[] = [];
44
45
  for (const row of rows) {
45
- const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
46
+ const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db, {
47
+ writePurpose: WritePurposes.erasure,
48
+ });
46
49
  if (!result.isSuccess) {
47
50
  failures.push(`${row.id}: ${result.error.message}`);
48
51
  }
@@ -1,6 +1,6 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import {
3
- collectPiiSubjectFields,
3
+ collectPiiEncryptedFields,
4
4
  configuredPiiSubjectKms,
5
5
  encryptPiiFieldValues,
6
6
  KeyErasedError,
@@ -24,7 +24,7 @@ export async function encryptForDirectWrite(
24
24
  return await encryptPiiFieldValues(
25
25
  row,
26
26
  entity,
27
- collectPiiSubjectFields(entity),
27
+ collectPiiEncryptedFields(entity),
28
28
  kms,
29
29
  { requestId: requestContext.get()?.requestId ?? fallbackRequestId },
30
30
  { entityName },
@@ -33,6 +33,7 @@ export {
33
33
  redactErrorText,
34
34
  redactUrls,
35
35
  } from "./redact.js";
36
+ export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
36
37
  export { requireForTenant } from "./require-for-tenant.js";
37
38
  export {
38
39
  type RowBoundGrantResult,
@@ -0,0 +1,16 @@
1
+ import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
2
+
3
+ export const REDACTED_PII_PLACEHOLDER = "[[personal data]]" as const;
4
+
5
+ // Event payloads store PII fields as subject-KMS ciphertext; the API leak guard
6
+ // rejects any response carrying it, so payload reads swap it for a placeholder.
7
+ export function redactPiiCiphertext(value: unknown): unknown {
8
+ if (isPiiCiphertext(value)) return REDACTED_PII_PLACEHOLDER;
9
+ if (Array.isArray(value)) return value.map(redactPiiCiphertext);
10
+ if (typeof value === "object" && value !== null) {
11
+ return Object.fromEntries(
12
+ Object.entries(value).map(([key, inner]) => [key, redactPiiCiphertext(inner)]),
13
+ );
14
+ }
15
+ return value;
16
+ }
@@ -1,7 +1,11 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
2
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import type { DbRunner, TenantDb, WhereObject } from "@cosmicdrift/kumiko-framework/db";
4
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ createEventStoreExecutor,
6
+ createTenantDb,
7
+ WritePurposes,
8
+ } from "@cosmicdrift/kumiko-framework/db";
5
9
  import {
6
10
  createSystemUser,
7
11
  type EscapeHatchAuditSink,
@@ -177,7 +181,7 @@ async function markTenantDestroyFailed(db: DbRunner, tenantId: TenantId): Promis
177
181
  },
178
182
  user,
179
183
  scopedDb,
180
- { skipOptimisticLock: true },
184
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
181
185
  );
182
186
  invalidateTenantLifecycleGate(tenantId);
183
187
  }
@@ -436,6 +440,7 @@ export async function runTenantDestructionSweep(args: {
436
440
  { id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } },
437
441
  user,
438
442
  scopedDb,
443
+ { writePurpose: WritePurposes.erasure },
439
444
  );
440
445
  if (!result.isSuccess) continue;
441
446
  invalidateTenantLifecycleGate(tenantId);
@@ -8,6 +8,7 @@ import {
8
8
  createEventStoreExecutor,
9
9
  createTenantDb,
10
10
  type DbRunner,
11
+ WritePurposes,
11
12
  } from "@cosmicdrift/kumiko-framework/db";
12
13
  import {
13
14
  createSystemUser,
@@ -233,7 +234,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<StageRunOut
233
234
  },
234
235
  user,
235
236
  db,
236
- { skipOptimisticLock: true },
237
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
237
238
  );
238
239
  invalidateTenantLifecycleGate(ctx.tenantId);
239
240
  return STAGE_DONE;
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "improvement",
5
+ "title": "user:me returns effectiveTimeZone, SessionProvider provides the current user",
6
+ "detail": "The me query adds effectiveTimeZone (ctx.tz.user), CurrentUserProfile.effectiveTimeZone is parsed by fetchCurrentUser and SessionProvider wraps its children in CurrentUserProvider."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "fix",
@@ -8,6 +8,7 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
8
8
 
9
9
  // Returns the currently signed-in user's profile. Field-level read access
10
10
  // strips out the passwordHash automatically (configured on the entity).
11
+ // `effectiveTimeZone` is the effective IANA zone of the caller.
11
12
  export const meQuery = defineQueryHandler({
12
13
  name: "user:me",
13
14
  schema: z.object({}),
@@ -33,6 +34,9 @@ export const meQuery = defineQueryHandler({
33
34
  const db = ctx.systemDb.acknowledgeCrossTenant(
34
35
  "user rows are tenant-agnostic identity records; self-lookup by id needs no tenant filter",
35
36
  );
36
- return crud.detail({ id: query.user.id }, query.user, db); // @wrapper-known semantic-alias
37
+ const row = await crud.detail({ id: query.user.id }, query.user, db);
38
+ // `timezone` on the row is only the stored override (often unset); clients
39
+ // need the effective zone (override, else tenant, else UTC) to compute "today".
40
+ return row === null ? row : { ...row, effectiveTimeZone: ctx.tz.user };
37
41
  },
38
42
  });
@@ -3,6 +3,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  import * as z from "zod";
6
+ import { redactPiiCiphertext } from "../../shared/index.js";
6
7
 
7
8
  // DSGVO Art. 15 Selbstauskunft — User reads HIS OWN audit-log.
8
9
  // WHERE createdBy = ctx.user.id ist hard-coded (kein userId-Param,
@@ -75,7 +76,7 @@ export const myAuditLogQuery = defineQueryHandler({
75
76
  aggregateType: r["aggregate_type"],
76
77
  version: r["version"],
77
78
  type: r["type"],
78
- payload: r["payload"],
79
+ payload: redactPiiCiphertext(r["payload"]),
79
80
  createdAt: r["created_at"],
80
81
  }));
81
82
  const last = serialised[serialised.length - 1];
@@ -1,4 +1,8 @@
1
- import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ createEventStoreExecutor,
3
+ type TenantDb,
4
+ WritePurposes,
5
+ } from "@cosmicdrift/kumiko-framework/db";
2
6
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
3
7
  import {
4
8
  createSystemUser,
@@ -206,6 +210,7 @@ async function severPersonLink(
206
210
  if (typeof id !== "string") continue;
207
211
  await crud.update({ id, changes: { insertedById: null } }, systemUser, tdb, {
208
212
  skipOptimisticLock: true,
213
+ writePurpose: WritePurposes.erasure,
209
214
  });
210
215
  }
211
216
  }
@@ -1,4 +1,4 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createSystemUser,
4
4
  type UserDataDeleteHook,
@@ -117,7 +117,7 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
117
117
  { id, changes: { email: `forgotten-${id}@anonymized.invalid` } },
118
118
  systemUser,
119
119
  ctx.db,
120
- { skipOptimisticLock: true },
120
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
121
121
  );
122
122
  }
123
123
  }
@@ -138,6 +138,7 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
138
138
  if (typeof id !== "string") continue;
139
139
  await crud.update({ id, changes: { invitedBy: INVITED_BY_ANONYMIZED } }, systemUser, ctx.db, {
140
140
  skipOptimisticLock: true,
141
+ writePurpose: WritePurposes.erasure,
141
142
  });
142
143
  }
143
144
  throwIfErasureFailed(failures);
@@ -1,4 +1,4 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createSystemUser,
4
4
  type UserDataDeleteHook,
@@ -102,7 +102,7 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
102
102
  },
103
103
  systemUser,
104
104
  ctx.db,
105
- { skipOptimisticLock: true },
105
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
106
106
  );
107
107
  } else {
108
108
  // anonymize: PII raus, aber Row bleibt active (damit FK-References
@@ -120,7 +120,7 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
120
120
  },
121
121
  systemUser,
122
122
  ctx.db,
123
- { skipOptimisticLock: true },
123
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
124
124
  );
125
125
  }
126
126
  };
@@ -37,14 +37,19 @@ export const shippingEntity = createEntity({
37
37
  });
38
38
 
39
39
  // Profile — testet das Avatar-Image-Upload-Feld (createImageField) in der
40
- // Auto-Form: runde Preview + "Change"-Button, Upload an /api/files.
40
+ // Auto-Form: rechteckige Preview + "Change"-Button, Upload an /api/files.
41
41
  export const profileEntity = createEntity({
42
42
  table: "read_examples_profile",
43
43
  fields: {
44
44
  avatar: createImageField({ maxSize: "5mb", accept: ["jpg", "jpeg", "png"] }),
45
45
  fullName: createTextField({ personal: { of: "id" }, find: "none", required: true }),
46
46
  // Demo-Daten, kein echtes PII-Encryption-Setup → Plaintext bewusst erlaubt.
47
- email: createTextField({ required: true, personal: false, reason: "is_business_data" }),
47
+ email: createTextField({
48
+ required: true,
49
+ personal: false,
50
+ reason: "is_business_data",
51
+ sortable: true,
52
+ }),
48
53
  bio: createTextField({ personal: { of: "id" }, find: "none", multiline: { rows: 3 } }),
49
54
  },
50
55
  });
@@ -105,7 +110,51 @@ export const examplesFeature = defineFeature("examples", (r) => {
105
110
  },
106
111
  });
107
112
 
108
- r.crud("profile", profileEntity, editFormOnly);
113
+ r.crud("profile", profileEntity, {
114
+ ...editFormOnly,
115
+ verbs: { delete: false, restore: false },
116
+ });
117
+
118
+ r.screen({
119
+ id: "profile-list",
120
+ type: "entityList",
121
+ entity: "profile",
122
+ dormant: true,
123
+ columns: ["avatar", "fullName", "email"],
124
+ searchable: true,
125
+ defaultSort: { field: "email", dir: "asc" },
126
+ rowActions: [
127
+ {
128
+ kind: "navigate",
129
+ id: "view",
130
+ label: "examples:profile:view",
131
+ screen: "profile-view",
132
+ entityId: "id",
133
+ rowClick: true,
134
+ },
135
+ ],
136
+ access: { openToAll: { reason: openReason } },
137
+ });
138
+
139
+ r.screen({
140
+ id: "profile-view",
141
+ type: "entityEdit",
142
+ entity: "profile",
143
+ dormant: true,
144
+ listScreenId: "profile-list",
145
+ layout: {
146
+ sections: [
147
+ {
148
+ columns: 2,
149
+ fields: [
150
+ { field: "avatar", span: 2, readOnly: true },
151
+ { field: "fullName", readOnly: true },
152
+ { field: "email", readOnly: true },
153
+ ],
154
+ },
155
+ ],
156
+ },
157
+ });
109
158
 
110
159
  r.screen({
111
160
  id: "profile-edit",
@@ -38,6 +38,9 @@ export const EXAMPLES_I18N: Readonly<Record<string, LocalizedString>> = {
38
38
  de: "Aktualisiere deine persönlichen Daten und wie andere dich sehen.",
39
39
  en: "Update your personal information and how others see you.",
40
40
  },
41
+ "screen:profile-list.title": { de: "Profile", en: "Profiles" },
42
+ "screen:profile-view.title": { de: "Profil ansehen", en: "View profile" },
43
+ "examples:profile:view": { de: "Ansehen", en: "View" },
41
44
  "examples:profile:submit": { de: "Änderungen speichern", en: "Save changes" },
42
45
  "examples:entity:profile:field:avatar": { de: "Avatar", en: "Avatar" },
43
46
  "examples:entity:profile:field:fullName": { de: "Vollständiger Name", en: "Full name" },
@@ -163,6 +163,7 @@ const BOARD_LEASES: readonly BoardLease[] = [
163
163
 
164
164
  const MAX_NACHMIETER_INDEX = 2;
165
165
  const GENERATED_LEASE_COUNT = 300;
166
+ const TERMINATED_LEASE_ID = "00000000-0000-4000-8000-000000003999";
166
167
  const FIRST_NAMES = [
167
168
  "Anna",
168
169
  "Ben",
@@ -204,7 +205,7 @@ function generatedLease(index: number): BoardLease & { readonly terminated: bool
204
205
  einheit: `WE-${String(100 + index)}`,
205
206
  liegenschaft: PROPERTIES[index % PROPERTIES.length] ?? "Haus Eichenweg",
206
207
  beginn: `${year}-${month}-01`,
207
- ...(terminated && { ende: "2025-12-31" }),
208
+ ...(terminated && { ende: "2027-03-31" }),
208
209
  miete: 500 + (index % 20) * 15,
209
210
  terminated,
210
211
  };
@@ -240,7 +241,7 @@ export const seedRental: SeedFn = async (stack) => {
240
241
  }
241
242
  for (let index = 0; index < GENERATED_LEASE_COUNT; index++) {
242
243
  const lease = generatedLease(index);
243
- await createLease(lease, lease.terminated);
244
+ await createLease(lease, lease.terminated, lease.terminated ? TERMINATED_LEASE_ID : undefined);
244
245
  }
245
246
 
246
247
  const maxId = boardLeaseId(MAX_NACHMIETER_INDEX);
@@ -11,6 +11,7 @@ import {
11
11
  germanStatusLabel,
12
12
  idPayloadSchema,
13
13
  leaseExecutor,
14
+ noticePeriodEnd,
14
15
  parseLeaseRow,
15
16
  partyExecutor,
16
17
  positionExecutor,
@@ -22,9 +23,11 @@ import {
22
23
  } from "./lease-support";
23
24
  import {
24
25
  adjustRentScreen,
26
+ leaseDetailGapsScreen,
25
27
  leaseDetailScreen,
26
28
  leaseEditScreen,
27
29
  leaseHubScreen,
30
+ leaseListEmptyScreen,
28
31
  leaseListScreen,
29
32
  leaseListShortScreen,
30
33
  partyEditScreen,
@@ -86,7 +89,8 @@ export const rentalFeature = defineFeature("rental", (r) => {
86
89
  return {
87
90
  id: lease.id,
88
91
  mieter: lease.mieter,
89
- statusLabel: germanStatusLabel(lease.status),
92
+ status: lease.status,
93
+ statusLabel: germanStatusLabel(lease.status, lease.ende),
90
94
  grundmiete: formatGermanMoney(lease.grundmiete),
91
95
  beginn: formatGermanDate(lease.beginn),
92
96
  kuendigungsfrist: lease.kuendigungsfrist ?? "",
@@ -152,7 +156,10 @@ export const rentalFeature = defineFeature("rental", (r) => {
152
156
  leaseExecutor.update(
153
157
  {
154
158
  id: event.payload.id,
155
- changes: { status: "terminated", ende: ctx.tz.today(ctx.tz.tenant).toString() },
159
+ changes: {
160
+ status: "terminated",
161
+ ende: noticePeriodEnd(ctx.tz.today(ctx.tz.tenant).toString()),
162
+ },
156
163
  },
157
164
  event.user,
158
165
  ctx.db,
@@ -232,6 +239,8 @@ export const rentalFeature = defineFeature("rental", (r) => {
232
239
  r.screen(partyEditScreen);
233
240
  r.screen(leaseDetailScreen);
234
241
  r.screen(leaseHubScreen);
242
+ r.screen(leaseDetailGapsScreen);
243
+ r.screen(leaseListEmptyScreen);
235
244
  r.screen(rentalDashboardScreen);
236
245
  r.screen(adjustRentScreen);
237
246
  r.screen(recordPositionsScreen);
@@ -29,15 +29,22 @@ export const rentalTranslations: TranslationsByLocale = {
29
29
  "rental.tab.notes": "Notizen",
30
30
 
31
31
  "rental.action.open": "Öffnen",
32
- "rental.action.terminate": "Mietvertrag kündigen",
32
+ "rental.action.terminate": "Kündigung erfassen",
33
33
  "rental.action.editLease": "Vertragsdaten bearbeiten",
34
- "rental.action.terminateConfirm": "Soll dieser Mietvertrag gekündigt werden?",
34
+ "rental.action.terminateConfirm": "Kündigung zum Ende der Kündigungsfrist erfassen?",
35
35
  "rental.action.addPosition": "Position hinzufügen",
36
36
  "rental.action.adjustRent": "Miete anpassen",
37
37
  "rental.adjustRent.hint":
38
38
  "Die alte Position endet am Vortag, die neue beginnt am Stichtag. Art, Menge und Einheit bleiben dabei unverändert, so bleibt die Mietgeschichte nachvollziehbar.",
39
+ "rental.leaseListEmpty.description":
40
+ "Gekündigte Verträge, die aktuell als aktiv geführt werden.",
41
+ "rental.leaseListEmpty.emptyTitle": "Alles in Ordnung",
42
+ "rental.leaseListEmpty.emptyDescription":
43
+ "Es gibt keine gekündigten Verträge, die noch als aktiv geführt werden.",
44
+ "screen:lease-detail-gaps.title": "Mietvertrag",
45
+ "screen:lease-list-empty.title": "Mietverträge (Prüfliste)",
39
46
  "rental.action.createLease": "Mietvertrag abschließen",
40
- "rental:entity:lease:noun": { one: "Mietvertrag", other: "Mietverträgen" },
47
+ "rental:entity:lease:noun": { one: "Mietvertrag", other: "Mietverträge" },
41
48
  "rental.search.placeholder": "Mieter, Einheit oder Liegenschaft",
42
49
  "rental.positions.description": "Alle Positionen dieses Vertrags mit ihrem Verlauf.",
43
50
  "rental.positions.noun": { one: "Position", other: "Positionen" },
@@ -134,13 +141,18 @@ export const rentalTranslations: TranslationsByLocale = {
134
141
  "rental.tab.notes": "Notes",
135
142
 
136
143
  "rental.action.open": "Open",
137
- "rental.action.terminate": "Terminate lease",
144
+ "rental.action.terminate": "Record notice",
138
145
  "rental.action.editLease": "Edit lease details",
139
- "rental.action.terminateConfirm": "Terminate this lease?",
146
+ "rental.action.terminateConfirm": "Record notice to the end of the notice period?",
140
147
  "rental.action.addPosition": "Add position",
141
148
  "rental.action.adjustRent": "Adjust rent",
142
149
  "rental.adjustRent.hint":
143
150
  "The old position ends the day before, the new one starts on the effective date. Type, quantity and unit stay unchanged, so the rent history stays traceable.",
151
+ "rental.leaseListEmpty.description": "Terminated leases that are still marked active.",
152
+ "rental.leaseListEmpty.emptyTitle": "All clear",
153
+ "rental.leaseListEmpty.emptyDescription": "No terminated leases are still marked active.",
154
+ "screen:lease-detail-gaps.title": "Lease",
155
+ "screen:lease-list-empty.title": "Leases (review list)",
144
156
  "rental.action.createLease": "Sign lease",
145
157
  "rental:entity:lease:noun": { one: "lease", other: "leases" },
146
158
  "rental.search.placeholder": "Tenant, unit or property",
@@ -61,6 +61,7 @@ const leaseRowSchema = z.object({
61
61
  liegenschaft: z.string().nullish(),
62
62
  beginn: readDateSchema,
63
63
  status: z.string(),
64
+ ende: readDateSchema.nullish(),
64
65
  grundmiete: moneySchema,
65
66
  kuendigungsfrist: z.string().nullish(),
66
67
  zahltag: z.number().nullish(),
@@ -90,10 +91,20 @@ const GERMAN_STATUS_LABEL: Readonly<Record<string, string>> = {
90
91
  terminated: "Gekündigt",
91
92
  };
92
93
 
93
- export function germanStatusLabel(status: string): string {
94
+ export function germanStatusLabel(status: string, ende?: string | null): string {
95
+ if (status === "terminated" && ende) return `Gekündigt zum ${formatGermanDate(ende)}`;
94
96
  return GERMAN_STATUS_LABEL[status] ?? status;
95
97
  }
96
98
 
99
+ // Tenant notice (§ 573c BGB, simplified to the 3rd calendar day instead of the
100
+ // 3rd working day): notice received by then ends the lease after the month after
101
+ // next, later notice one month later.
102
+ export function noticePeriodEnd(isoToday: string): string {
103
+ const today = getTemporal().PlainDate.from(isoToday);
104
+ const target = today.add({ months: today.day <= 3 ? 2 : 3 });
105
+ return target.with({ day: target.daysInMonth }).toString();
106
+ }
107
+
97
108
  export function formatGermanDate(isoDate: string): string {
98
109
  const [year, month, day] = isoDate.split("-");
99
110
  return `${day}.${month}.${year}`;
@@ -165,7 +165,8 @@ export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
165
165
  { field: "liegenschaft", navigate: { screen: "lease-list" } },
166
166
  ],
167
167
  status: "statusLabel",
168
- statusTones: { Aktiv: "ok", Gekündigt: "bad" },
168
+ statusToneField: "status",
169
+ statusTones: { active: "ok", terminated: "bad" },
169
170
  },
170
171
  metrics: ["grundmiete", "beginn", "kuendigungsfrist", "zahltag", "mieter"],
171
172
  fieldLabels: {
@@ -286,6 +287,7 @@ export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
286
287
  handler: RENTAL_WRITES.leaseTerminate,
287
288
  payload: { pick: ["id"] },
288
289
  confirm: "rental.action.terminateConfirm",
290
+ visible: { field: "status", eq: "active" },
289
291
  },
290
292
  {
291
293
  kind: "navigate",
@@ -452,3 +454,46 @@ export const recordPositionsScreen: ActionFormScreenDefinition = {
452
454
  submitLabel: "rental.action.recordPositions",
453
455
  access: OPEN_ACCESS,
454
456
  };
457
+
458
+ // Fixtures for the UI-gap screenshots: a confirm-gated primary action that is
459
+ // visible only while the lease is active (a terminated lease falls back to edit
460
+ // as the main button), and a list that is empty because of its default filter.
461
+ export const leaseDetailGapsScreen: ProjectionDetailScreenDefinition = {
462
+ ...leaseDetailScreen,
463
+ id: "lease-detail-gaps",
464
+ detailFor: "lease",
465
+ primaryAction: "terminate",
466
+ actions: [
467
+ {
468
+ kind: "writeHandler",
469
+ id: "terminate",
470
+ icon: "x",
471
+ label: "rental.action.terminate",
472
+ handler: RENTAL_WRITES.leaseTerminate,
473
+ payload: { pick: ["id"] },
474
+ confirm: "rental.action.terminateConfirm",
475
+ style: "primary",
476
+ visible: { field: "status", eq: "active" },
477
+ },
478
+ ],
479
+ };
480
+
481
+ export const leaseListEmptyScreen: EntityListScreenDefinition = {
482
+ id: "lease-list-empty",
483
+ type: "entityList",
484
+ entity: "lease",
485
+ dormant: true,
486
+ description: "rental.leaseListEmpty.description",
487
+ emptyState: {
488
+ title: "rental.leaseListEmpty.emptyTitle",
489
+ description: "rental.leaseListEmpty.emptyDescription",
490
+ },
491
+ columns: [...OPEN_FILL_COLUMNS],
492
+ defaultSort: { field: "beginn", dir: "desc" },
493
+ filter: { field: "status", op: "eq", value: "terminated" },
494
+ defaultFilters: { status: ["active"] },
495
+ createLabel: "rental.action.createLease",
496
+ searchPlaceholder: "rental.search.placeholder",
497
+ rowActions: [openLeaseDetail],
498
+ access: OPEN_ACCESS,
499
+ };
@@ -13,6 +13,7 @@ import { vehiclesTranslations } from "./i18n";
13
13
  import {
14
14
  campaignEditScreen,
15
15
  campaignListScreen,
16
+ vehicleEditFlowScreen,
16
17
  vehicleEditScreen,
17
18
  vehicleListScreen,
18
19
  vehicleWizardScreen,
@@ -67,6 +68,7 @@ export const vehiclesFeature = defineFeature("vehicles", (r) => {
67
68
  r.screen(vehicleListScreen);
68
69
  r.screen(vehicleEditScreen);
69
70
  r.screen(vehicleWizardScreen);
71
+ r.screen(vehicleEditFlowScreen);
70
72
  r.screen(campaignListScreen);
71
73
  r.screen(campaignEditScreen);
72
74
 
@@ -10,6 +10,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
10
10
  "screen:vehicle-list.title": "Fahrzeuge",
11
11
  "screen:vehicle-edit.title": "Angaben bearbeiten",
12
12
  "screen:vehicle-wizard.title": "Fahrzeug bearbeiten",
13
+ "screen:vehicle-edit-flow.title": "Ausstattung bearbeiten",
13
14
  "screen:campaign-list.title": "Kampagnen",
14
15
 
15
16
  "vehicles.action.edit": "Bearbeiten",
@@ -17,7 +18,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
17
18
  "vehicles.action.details": "Details",
18
19
  "vehicles.action.saveChanges": "Änderungen speichern",
19
20
  "vehicles.search.placeholder": "Fahrzeug suchen",
20
- "vehicles:entity:vehicle:noun": { one: "Fahrzeug", other: "Fahrzeugen" },
21
+ "vehicles:entity:vehicle:noun": { one: "Fahrzeug", other: "Fahrzeuge" },
21
22
  "vehicles:entity:campaign:noun": { one: "Kampagne", other: "Kampagnen" },
22
23
 
23
24
  "vehicles.section.basics": "Basisdaten",
@@ -119,6 +120,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
119
120
  "screen:vehicle-list.title": "Vehicles",
120
121
  "screen:vehicle-edit.title": "Edit details",
121
122
  "screen:vehicle-wizard.title": "Edit vehicle",
123
+ "screen:vehicle-edit-flow.title": "Edit equipment",
122
124
  "screen:campaign-list.title": "Campaigns",
123
125
 
124
126
  "vehicles.action.edit": "Edit",
@@ -92,6 +92,24 @@ export const vehicleEditScreen: EntityEditScreenDefinition = {
92
92
  access: OPEN_ACCESS,
93
93
  };
94
94
 
95
+ export const vehicleEditFlowScreen: EntityEditScreenDefinition = {
96
+ id: "vehicle-edit-flow",
97
+ type: "entityEdit",
98
+ entity: "vehicle",
99
+ dormant: true,
100
+ listScreenId: "vehicle-list",
101
+ recordTitleField: "modell",
102
+ layout: {
103
+ sections: [
104
+ {
105
+ title: "vehicles.section.equipment",
106
+ fields: ["marke", "modell", { field: "ausstattung", span: 2 }],
107
+ },
108
+ ],
109
+ },
110
+ access: OPEN_ACCESS,
111
+ };
112
+
95
113
  export const vehicleWizardScreen: EntityEditScreenDefinition = {
96
114
  id: "vehicle-wizard",
97
115
  type: "entityEdit",
@@ -21,6 +21,9 @@ import type { DerivativePublicPredicateArgs } from "@cosmicdrift/kumiko-bundled-
21
21
  import {
22
22
  createEntity,
23
23
  createImageField,
24
+ defineEntityCreateHandler,
25
+ defineEntityDetailHandler,
26
+ defineEntityUpdateHandler,
24
27
  defineFeature,
25
28
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
26
29
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -32,7 +35,7 @@ import { z } from "zod";
32
35
  // the public route's fixed presets (thumb/card/hero/full, entry point 3)
33
36
  // share the name `thumb` but are a completely separate spec — same label,
34
37
  // different whitelist, different actual pixels.
35
- const photoEntity = createEntity({
38
+ export const photoEntity = createEntity({
36
39
  table: "files_post_processing_photos",
37
40
  fields: {
38
41
  original: createImageField({
@@ -59,6 +62,13 @@ export const filesPostProcessingFeature = defineFeature("files-post-processing",
59
62
  r.requires("file-derivatives");
60
63
  r.entity("photo", photoEntity);
61
64
 
65
+ // Uploading a file to a photo's field needs the same rights as saving the
66
+ // photo, so the photo must exist and the uploader needs update access.
67
+ const photoAccess = { access: { roles: ["Admin"] } } as const;
68
+ r.writeHandler(defineEntityCreateHandler("photo", photoEntity, photoAccess));
69
+ r.writeHandler(defineEntityUpdateHandler("photo", photoEntity, photoAccess));
70
+ r.queryHandler(defineEntityDetailHandler("photo", photoEntity, photoAccess));
71
+
62
72
  // Entry point 2 — imperative. A real app would gate this behind real
63
73
  // roles; `openToAll` keeps the recipe's HTTP wiring to one line.
64
74
  r.writeHandler(