@cosmicdrift/kumiko-samples 0.359.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 (51) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +9 -2
  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/billing-foundation/consumer-protection/termination-pages.ts +4 -4
  9. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  10. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  11. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +11 -6
  12. package/packages/bundled-features/src/folders-user-data/hooks.ts +4 -2
  13. package/packages/bundled-features/src/form-draft-user-data/hooks.ts +4 -1
  14. package/packages/bundled-features/src/jobs/changes.json +6 -0
  15. package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -10
  16. package/packages/bundled-features/src/renderer-pdf/README.md +27 -0
  17. package/packages/bundled-features/src/renderer-pdf/changes.json +8 -0
  18. package/packages/bundled-features/src/renderer-pdf/feature.ts +48 -0
  19. package/packages/bundled-features/src/renderer-pdf/index.ts +9 -0
  20. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +433 -0
  21. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +2 -2
  22. package/packages/bundled-features/src/shared/index.ts +1 -0
  23. package/packages/bundled-features/src/shared/redact-pii-ciphertext.ts +16 -0
  24. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +7 -2
  25. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +2 -1
  26. package/packages/bundled-features/src/user/changes.json +6 -0
  27. package/packages/bundled-features/src/user/handlers/me.query.ts +5 -1
  28. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +2 -1
  29. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +6 -1
  30. package/packages/bundled-features/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +3 -2
  31. package/packages/bundled-features/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +3 -3
  32. package/packages/bundled-features/src/user-profile/changes.json +6 -0
  33. package/packages/bundled-features/src/user-profile/feature.ts +21 -11
  34. package/packages/bundled-features/src/user-profile/index.ts +1 -1
  35. package/samples/apps/styleguide/src/features/examples/feature.ts +52 -3
  36. package/samples/apps/styleguide/src/features/examples/i18n.ts +3 -0
  37. package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +3 -2
  38. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +11 -2
  39. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +17 -5
  40. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +12 -1
  41. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +46 -1
  42. package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +2 -0
  43. package/samples/apps/ui-walkthrough/src/features/vehicles/i18n.ts +3 -1
  44. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +18 -0
  45. package/samples/apps/use-all-bundled/feature-manifest.json +28 -2
  46. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  47. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  48. package/samples/recipes/files-post-processing/src/feature.ts +11 -1
  49. package/samples/recipes/pdf-audit-export/README.md +22 -0
  50. package/samples/recipes/pdf-audit-export/package.json +12 -0
  51. package/samples/recipes/pdf-audit-export/src/feature.ts +114 -0
@@ -12,13 +12,21 @@ import {
12
12
  } from "./constants.js";
13
13
  import { changeEmailWrite } from "./handlers/change-email.write.js";
14
14
 
15
- export function createUserProfileFeature(): FeatureDefinition {
15
+ export type UserProfileFeatureOptions = {
16
+ /** Default true. false drops the change-email handler and the email
17
+ * section — for apps whose login email is managed elsewhere (SSO, admin-set). */
18
+ readonly allowEmailChange?: boolean;
19
+ };
20
+
21
+ export function createUserProfileFeature(options?: UserProfileFeatureOptions): FeatureDefinition {
22
+ const allowEmailChange = options?.allowEmailChange ?? true;
16
23
  return defineFeature("user-profile", (r) => {
17
24
  r.describe(
18
25
  "Self-service account page (fw#2312: declarative `projectionDetail` bound to " +
19
26
  "the signed-in user's own `me` row) plus its `change-email` write handler " +
20
27
  "(re-auth via current password, uniqueness check, resets emailVerified and " +
21
- "expects the app to trigger the verification flow). Change-password and " +
28
+ "expects the app to trigger the verification flow; opt out with " +
29
+ "`allowEmailChange: false`, which drops handler and section). Change-password and " +
22
30
  "change-email stay custom `EditExtensionSection` components (re-auth flows a " +
23
31
  "declarative action can't express); account deletion (user-data-rights " +
24
32
  "request/cancel with grace period) is declarative fields + actions. Apps " +
@@ -38,9 +46,7 @@ export function createUserProfileFeature(): FeatureDefinition {
38
46
  r.requires("user-data-rights");
39
47
  r.requires("user-data-rights-defaults");
40
48
 
41
- const handlers = {
42
- changeEmail: r.writeHandler(changeEmailWrite),
43
- };
49
+ const handlers = allowEmailChange ? { changeEmail: r.writeHandler(changeEmailWrite) } : {};
44
50
 
45
51
  // Boot-validator requires a "screen:<id>.title" key per r.screen(), plus
46
52
  // one entry per i18nKey()-marked dot-form label below — kept inline (not
@@ -100,12 +106,16 @@ export function createUserProfileFeature(): FeatureDefinition {
100
106
  },
101
107
  layout: {
102
108
  sections: [
103
- {
104
- kind: "extension",
105
- title: i18nKey("profile.email.title"),
106
- component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
107
- entityName: "user",
108
- },
109
+ ...(allowEmailChange
110
+ ? [
111
+ {
112
+ kind: "extension" as const,
113
+ title: i18nKey("profile.email.title"),
114
+ component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
115
+ entityName: "user",
116
+ },
117
+ ]
118
+ : []),
109
119
  {
110
120
  kind: "extension",
111
121
  title: i18nKey("profile.password.title"),
@@ -8,4 +8,4 @@ export {
8
8
  UserProfileHandlers,
9
9
  UserProfileQueries,
10
10
  } from "./constants.js";
11
- export { createUserProfileFeature } from "./feature.js";
11
+ export { createUserProfileFeature, type UserProfileFeatureOptions } from "./feature.js";
@@ -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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
3
- "featureCount": 81,
3
+ "featureCount": 82,
4
4
  "features": [
5
5
  {
6
6
  "name": "admin-shell",
@@ -2072,6 +2072,32 @@
2072
2072
  "recommended": false
2073
2073
  }
2074
2074
  },
2075
+ {
2076
+ "name": "renderer-pdf",
2077
+ "description": "Renderer plugin for `kind=\"document-pdf\"` that draws a table document (title, meta lines, weighted columns, repeating header, page footer) as a PDF in pure JavaScript, without a browser or Puppeteer service. `payload.variables` must be a `PdfTableDocument`. Requires `renderer-foundation`; mount a Puppeteer-based plugin instead when you need HTML/CSS layouts.",
2078
+ "toggleableDefault": null,
2079
+ "requires": [
2080
+ "renderer-foundation"
2081
+ ],
2082
+ "optionalRequires": [],
2083
+ "configReads": [],
2084
+ "exposesApis": [],
2085
+ "usesApis": [],
2086
+ "extensionsUsed": [
2087
+ {
2088
+ "extensionName": "renderer",
2089
+ "entityName": "pdf"
2090
+ }
2091
+ ],
2092
+ "configKeys": [],
2093
+ "secrets": [],
2094
+ "writeHandlers": [],
2095
+ "uiHints": {
2096
+ "displayLabel": "Renderer · PDF tables",
2097
+ "category": "notifications",
2098
+ "recommended": false
2099
+ }
2100
+ },
2075
2101
  {
2076
2102
  "name": "renderer-simple",
2077
2103
  "description": "Default renderer plugin for `kind=\"notification\"`: takes a structured `EmailTemplateData` variable map (with `header`, `sections[]` of text/button objects, and optional `footer`; falls back to `title`/`body` if no structured fields are present) and returns rendered HTML with inline CSS. Requires `renderer-foundation`; sufficient for plain notification emails — swap it for `renderer-mail-html` if you need MJML/Markdown layouts.",
@@ -3054,7 +3080,7 @@
3054
3080
  },
3055
3081
  {
3056
3082
  "name": "user-profile",
3057
- "description": "Self-service account page (fw#2312: declarative `projectionDetail` bound to the signed-in user's own `me` row) plus its `change-email` write handler (re-auth via current password, uniqueness check, resets emailVerified and expects the app to trigger the verification flow). Change-password and change-email stay custom `EditExtensionSection` components (re-auth flows a declarative action can't express); account deletion (user-data-rights request/cancel with grace period) is declarative fields + actions. Apps place the screen (id \"profile\") in their logged-in area via r.nav — no `type: \"custom\"` / `__component` registration needed on the app side anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` (so the GDPR boot-validator finds export/delete hooks for `user`'s PII fields once user-data-rights is mounted).",
3083
+ "description": "Self-service account page (fw#2312: declarative `projectionDetail` bound to the signed-in user's own `me` row) plus its `change-email` write handler (re-auth via current password, uniqueness check, resets emailVerified and expects the app to trigger the verification flow; opt out with `allowEmailChange: false`, which drops handler and section). Change-password and change-email stay custom `EditExtensionSection` components (re-auth flows a declarative action can't express); account deletion (user-data-rights request/cancel with grace period) is declarative fields + actions. Apps place the screen (id \"profile\") in their logged-in area via r.nav — no `type: \"custom\"` / `__component` registration needed on the app side anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` (so the GDPR boot-validator finds export/delete hooks for `user`'s PII fields once user-data-rights is mounted).",
3058
3084
  "toggleableDefault": null,
3059
3085
  "requires": [
3060
3086
  "user",
@@ -322,6 +322,11 @@ const FEATURE_IMPORT_REGISTRY: Record<string, FeatureImport> = {
322
322
  path: "@cosmicdrift/kumiko-bundled-features/renderer-simple",
323
323
  factory: "createRendererSimpleFeature",
324
324
  },
325
+ "renderer-pdf": {
326
+ kind: "factory",
327
+ path: "@cosmicdrift/kumiko-bundled-features/renderer-pdf",
328
+ factory: "createRendererPdfFeature",
329
+ },
325
330
  "rate-limiting": {
326
331
  kind: "factory",
327
332
  path: "@cosmicdrift/kumiko-bundled-features/rate-limiting",
@@ -76,6 +76,7 @@ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-f
76
76
  import { securityBaselineFeatures } from "@cosmicdrift/kumiko-bundled-features/presets";
77
77
  import { readinessFeature } from "@cosmicdrift/kumiko-bundled-features/readiness";
78
78
  import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
79
+ import { createRendererPdfFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
79
80
  import { createRendererSimpleFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
80
81
  import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
81
82
  import { createSeoFeature } from "@cosmicdrift/kumiko-bundled-features/seo";
@@ -336,6 +337,7 @@ export const APP_FEATURES = [
336
337
  collectionLabelsFeature,
337
338
  createRendererFoundationFeature(),
338
339
  createRendererSimpleFeature(),
340
+ createRendererPdfFeature(),
339
341
 
340
342
  // managed-pages: requires config (auto-bundled). Smoke resolver never serves
341
343
  // (boot-only). allowCustomCss:true boot-validates the CSS code path + emits
@@ -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(
@@ -0,0 +1,22 @@
1
+ # PDF Audit Export
2
+
3
+ A write handler exports tenant data as a PDF and keeps it as a FileRef.
4
+
5
+ ## What it shows
6
+
7
+ - `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
8
+ - `createFileRefFromBytes` (`@cosmicdrift/kumiko-framework/files`) stores the bytes like an upload (validation, tenant-prefixed storage key, `fileRef.created` event); the tenant comes from the caller.
9
+ - The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
10
+
11
+ ## Source
12
+
13
+ Feature entry point: `src/feature.ts`. The export reads at most 200 inspections per call.
14
+
15
+ ## Tests
16
+
17
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
18
+
19
+ ```bash
20
+ cd samples/recipes/pdf-audit-export
21
+ bun test
22
+ ```
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-sample-pdf-audit-export",
3
+ "description": "Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes.",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@cosmicdrift/kumiko-bundled-features": "workspace:*",
7
+ "@cosmicdrift/kumiko-framework": "workspace:*"
8
+ },
9
+ "devDependencies": {
10
+ "unpdf": "^1.8.1"
11
+ }
12
+ }