@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/details.query.ts +2 -1
- package/packages/bundled-features/src/audit/handlers/list.query.ts +4 -5
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +39 -3
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +11 -6
- package/packages/bundled-features/src/folders-user-data/hooks.ts +4 -2
- package/packages/bundled-features/src/form-draft-user-data/hooks.ts +4 -1
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +2 -2
- package/packages/bundled-features/src/shared/index.ts +1 -0
- package/packages/bundled-features/src/shared/redact-pii-ciphertext.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +7 -2
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +2 -1
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/handlers/me.query.ts +5 -1
- package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +2 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +6 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +3 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +3 -3
- package/samples/apps/styleguide/src/features/examples/feature.ts +52 -3
- package/samples/apps/styleguide/src/features/examples/i18n.ts +3 -0
- package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +3 -2
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +11 -2
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +17 -5
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +12 -1
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +46 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/vehicles/i18n.ts +3 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +18 -0
- 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.
|
|
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.
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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 {
|
|
14
|
-
|
|
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
|
-
|
|
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",
|
|
@@ -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 —
|
|
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,
|
|
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
|
|
289
|
-
//
|
|
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) =>
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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];
|
package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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({
|
|
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,
|
|
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: "
|
|
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
|
-
|
|
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: {
|
|
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": "
|
|
32
|
+
"rental.action.terminate": "Kündigung erfassen",
|
|
33
33
|
"rental.action.editLease": "Vertragsdaten bearbeiten",
|
|
34
|
-
"rental.action.terminateConfirm": "
|
|
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: "
|
|
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": "
|
|
144
|
+
"rental.action.terminate": "Record notice",
|
|
138
145
|
"rental.action.editLease": "Edit lease details",
|
|
139
|
-
"rental.action.terminateConfirm": "
|
|
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
|
-
|
|
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: "
|
|
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(
|