@cosmicdrift/kumiko-samples 0.360.0 → 0.362.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/document-ingest-foundation/aggregate-id.ts +10 -0
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/document-ingest-foundation/events.ts +3 -1
- package/packages/bundled-features/src/document-ingest-foundation/feature.ts +5 -3
- package/packages/bundled-features/src/document-ingest-foundation/index.ts +1 -0
- 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/changes.json +7 -0
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +5 -1
- package/packages/bundled-features/src/user-data-rights/handlers/list-download-attempts.query.ts +13 -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/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +6 -12
- package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +10 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/download-attempt.userdata-hook.ts +58 -0
- 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/apps/use-all-bundled/feature-manifest.json +8 -2
- package/samples/apps/use-all-bundled/kumiko/migrations/0044_download-attempt-ip-bidx.rebuild.json +4 -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.362.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.362.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
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
|
|
3
|
+
// Fixed UUID namespace for document-ingest stream derivation. Frozen: changing
|
|
4
|
+
// it re-keys every existing ingest stream.
|
|
5
|
+
const DOCUMENT_INGEST_NAMESPACE = "5c1e8b7a-3d94-4f2a-b6e0-9a7d1c4f8e23";
|
|
6
|
+
|
|
7
|
+
// @wrapper-known uuid-domain
|
|
8
|
+
export function documentIngestAggregateId(tenantId: string, fileRefId: string): string {
|
|
9
|
+
return uuidv5(`${tenantId}|${fileRefId}`, DOCUMENT_INGEST_NAMESPACE);
|
|
10
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.362.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "document-ingest events live on their own stream",
|
|
6
|
+
"detail": "documentIngest.requested/skipped were appended to the fileRef's own event stream (aggregateId = fileRefId), so the request-ingest projection racing a save that binds a pending upload broke the fileRef's optimistic lock (409 version_conflict). They now use documentIngestAggregateId(tenantId, fileRefId); payload.fileRefId is unchanged."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
|
|
3
3
|
// documentIngest — own aggregate-type for the ingest-request stream, one
|
|
4
|
-
// stream per fileRef
|
|
4
|
+
// stream per fileRef with aggregateId derived from the fileRefId
|
|
5
|
+
// (documentIngestAggregateId), never the fileRefId itself: foreign events on
|
|
6
|
+
// the fileRef stream would break the fileRef's optimistic lock. Not backed by an r.entity;
|
|
5
7
|
// this is a pure saga-anchor event (kumiko-framework#1497) that the Phase-2
|
|
6
8
|
// worker job (kumiko-enterprise LiteParse provider) subscribes to.
|
|
7
9
|
export const DOCUMENT_INGEST_AGGREGATE_TYPE = "document-ingest";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import { normalizeMimeType } from "@cosmicdrift/kumiko-framework/files";
|
|
16
16
|
import * as z from "zod";
|
|
17
|
+
import { documentIngestAggregateId } from "./aggregate-id.js";
|
|
17
18
|
import { validateDocumentIngestProviderWiring } from "./boot-checks.js";
|
|
18
19
|
import { documentExtractEntity } from "./entity.js";
|
|
19
20
|
import {
|
|
@@ -66,13 +67,14 @@ type FileRefIngestFields = z.infer<typeof fileRefIngestFieldsSchema>;
|
|
|
66
67
|
// delete→restore round-trip re-requests ingest instead of leaving the file
|
|
67
68
|
// without an extract.
|
|
68
69
|
async function requestIngestForFileRef(
|
|
69
|
-
event: { readonly aggregateId: string },
|
|
70
|
+
event: { readonly aggregateId: string; readonly tenantId: string },
|
|
70
71
|
fileRef: FileRefIngestFields,
|
|
71
72
|
ctx: MspApplyContext,
|
|
72
73
|
): Promise<void> {
|
|
74
|
+
const ingestAggregateId = documentIngestAggregateId(event.tenantId, event.aggregateId);
|
|
73
75
|
const skip = (reason: "file-too-large" | "unsupported-mime-type") =>
|
|
74
76
|
ctx.unsafeAppendEvent({
|
|
75
|
-
aggregateId:
|
|
77
|
+
aggregateId: ingestAggregateId,
|
|
76
78
|
aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
77
79
|
type: DOCUMENT_INGEST_SKIPPED_EVENT_QN,
|
|
78
80
|
payload: {
|
|
@@ -105,7 +107,7 @@ async function requestIngestForFileRef(
|
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
await ctx.unsafeAppendEvent({
|
|
108
|
-
aggregateId:
|
|
110
|
+
aggregateId: ingestAggregateId,
|
|
109
111
|
aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
110
112
|
type: DOCUMENT_INGEST_REQUESTED_EVENT_QN,
|
|
111
113
|
payload: {
|
|
@@ -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
|
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.362.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "download-attempt.ip and userAgent are encrypted personal data instead of plaintext",
|
|
6
|
+
"detail": "download-attempt.ip and userAgent are declared personal: { of: \"attemptedByUserId\", whenAbsent: \"tenant\" }, ip with find \"exact\" and userAgent with find \"none\". With a KMS configured, rows written by the logged-in job path are encrypted under the requesting user's key (a user forget makes IP and user agent unreadable), rows from the anonymous token path under the tenant key. The new ip_bidx column keeps the equality filter of list-download-attempts working across both kinds of rows, and the query now returns the decrypted values (an erased user's values come back as the erased sentinel). user-data-rights-defaults registers an EXT_USER_DATA hook for download-attempt: export lists the user's own attempts, delete purges the user's own attempts via the forget verb (projection row and blind index included, replayed on rebuild); owner-less token-path rows expire with the 90d hardDelete retention.",
|
|
7
|
+
"migration": "Apps that mount user-data-rights run kumiko-schema generate and apply: the migration adds the ip_bidx column and its index to read_download_attempts. Apps that mount user-data-rights without user-data-rights-defaults must register their own EXT_USER_DATA hook for the download-attempt entity, otherwise boot fails the PII hook coverage check. The annotation fingerprint of the boot PII backfill changes, so the first boot after the bump does one full pass: it encrypts existing download-attempt events (tenant key or user key) and rebuilds the projection including ip_bidx (KUMIKO_SKIP_PII_BACKFILL=1 turns it off)."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.354.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -76,7 +76,11 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
76
76
|
})
|
|
77
77
|
.optional(),
|
|
78
78
|
}),
|
|
79
|
-
|
|
79
|
+
// public-intake: rejected attempts store the anonymous caller's IP in the audit row.
|
|
80
|
+
access: {
|
|
81
|
+
roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"],
|
|
82
|
+
personalData: "public-intake",
|
|
83
|
+
},
|
|
80
84
|
agent: { expose: false },
|
|
81
85
|
// Brute-Force-Schutz fuer Token-Hash-Probing. Anonymous-Endpoint mit
|
|
82
86
|
// 32-byte-Random-Token = 256 Bit Search-Space, aber rate-limit als
|
package/packages/bundled-features/src/user-data-rights/handlers/list-download-attempts.query.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
+
import { decryptStoredPii } from "../../shared/index.js";
|
|
5
6
|
import { downloadAttemptsTable } from "../schema/download-attempt.js";
|
|
6
7
|
|
|
7
8
|
// Operator-Query: invalid Download-Attempts (S2.U7).
|
|
8
9
|
// DPO-Sicht fuer Brute-Force-Detection. Tenant-isolated via WHERE.
|
|
9
10
|
|
|
10
11
|
const MAX_LIMIT = 100;
|
|
12
|
+
const REQUEST_ID = "user-data-rights:list-download-attempts";
|
|
11
13
|
|
|
12
14
|
export const listDownloadAttemptsQuery = defineQueryHandler({
|
|
13
15
|
name: "list-download-attempts",
|
|
@@ -44,6 +46,16 @@ export const listDownloadAttemptsQuery = defineQueryHandler({
|
|
|
44
46
|
limit: p.limit,
|
|
45
47
|
});
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
const decryptedRows = await Promise.all(
|
|
50
|
+
rows.map(async (row) => ({
|
|
51
|
+
...row,
|
|
52
|
+
ip: row.ip === null ? null : await decryptStoredPii(row.ip, "ip", REQUEST_ID),
|
|
53
|
+
userAgent:
|
|
54
|
+
row.userAgent === null
|
|
55
|
+
? null
|
|
56
|
+
: await decryptStoredPii(row.userAgent, "userAgent", REQUEST_ID),
|
|
57
|
+
})),
|
|
58
|
+
);
|
|
59
|
+
return { rows: decryptedRows };
|
|
48
60
|
},
|
|
49
61
|
});
|
|
@@ -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];
|
|
@@ -25,6 +25,7 @@ export type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup.js";
|
|
|
25
25
|
export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup.js";
|
|
26
26
|
export type { UserExportBundle } from "./run-user-export.js";
|
|
27
27
|
export { runUserExport } from "./run-user-export.js";
|
|
28
|
+
export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.js";
|
|
28
29
|
export {
|
|
29
30
|
ACTIVE_JOB_CONSTRAINT,
|
|
30
31
|
EXPORT_JOB_STATUS,
|
|
@@ -35,23 +35,17 @@ export const downloadAttemptEntity = createEntity({
|
|
|
35
35
|
jobId: createTextField({ personal: false, reason: "technical_reference" }),
|
|
36
36
|
// User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
|
|
37
37
|
attemptedByUserId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// `personal: { of: "attemptedByUserId" }` would throw SubjectResolutionError
|
|
41
|
-
// on every such row once a KMS is configured
|
|
42
|
-
// (crypto/subject-resolver.ts resolveUserOwnedSubject).
|
|
43
|
-
// The job path knows the requester and an IP is personal data, so this is a
|
|
44
|
-
// deliberate waiver, not business data: a user forget does not shred these
|
|
45
|
-
// values, the 90d hardDelete below is what removes them.
|
|
38
|
+
// The anonymous token path has no owner, so those rows fall back to the tenant key;
|
|
39
|
+
// find "exact" on ip because list-download-attempts filters by IP.
|
|
46
40
|
ip: createTextField({
|
|
47
41
|
maxLength: 64,
|
|
48
|
-
personal:
|
|
49
|
-
|
|
42
|
+
personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
|
|
43
|
+
find: "exact",
|
|
50
44
|
}),
|
|
51
45
|
userAgent: createTextField({
|
|
52
46
|
maxLength: 256,
|
|
53
|
-
personal:
|
|
54
|
-
|
|
47
|
+
personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
|
|
48
|
+
find: "none",
|
|
55
49
|
}),
|
|
56
50
|
attemptedAt: createTimestampField({ required: true }),
|
|
57
51
|
},
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
deliveryAttemptDeleteHook,
|
|
13
13
|
deliveryAttemptExportHook,
|
|
14
14
|
} from "./hooks/delivery-attempt.userdata-hook.js";
|
|
15
|
+
import {
|
|
16
|
+
downloadAttemptDeleteHook,
|
|
17
|
+
downloadAttemptExportHook,
|
|
18
|
+
} from "./hooks/download-attempt.userdata-hook.js";
|
|
15
19
|
import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook.js";
|
|
16
20
|
import {
|
|
17
21
|
inAppMessageDeleteHook,
|
|
@@ -57,7 +61,7 @@ import {
|
|
|
57
61
|
export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
58
62
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
59
63
|
r.describe(
|
|
60
|
-
"Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding. Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
64
|
+
"Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding, and `download-attempt` (rejected export-download attempts of the user: result, path, source IP, user agent, time; delete purges the user's own attempts via the forget verb, token-path rows without an owner expire by retention). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
61
65
|
);
|
|
62
66
|
r.uiHints({
|
|
63
67
|
displayLabel: "User Data Rights · Default Hooks",
|
|
@@ -128,6 +132,11 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
|
128
132
|
delete: deliveryAttemptDeleteHook,
|
|
129
133
|
});
|
|
130
134
|
|
|
135
|
+
r.useExtension(EXT_USER_DATA, "download-attempt", {
|
|
136
|
+
export: downloadAttemptExportHook,
|
|
137
|
+
delete: downloadAttemptDeleteHook,
|
|
138
|
+
});
|
|
139
|
+
|
|
131
140
|
r.useExtension(EXT_USER_DATA, "job-run", {
|
|
132
141
|
export: jobRunExportHook,
|
|
133
142
|
delete: jobRunDeleteHook,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createSystemUser,
|
|
4
|
+
type UserDataDeleteHook,
|
|
5
|
+
type UserDataExportHook,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { collectErasureFailure, throwIfErasureFailed } from "../../shared/index.js";
|
|
8
|
+
import { downloadAttemptEntity, downloadAttemptsTable } from "../../user-data-rights/index.js";
|
|
9
|
+
|
|
10
|
+
// Event-sourced entity: forget goes through the executor so the erasure replays
|
|
11
|
+
// on rebuild and removes the projection row including ip_bidx.
|
|
12
|
+
const crud = createEventStoreExecutor(downloadAttemptsTable, downloadAttemptEntity, {
|
|
13
|
+
entityName: "download-attempt",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Only job-path attempts name a user; token-path rows have no owner and sit
|
|
17
|
+
// under the tenant key. ip may be ciphertext (kumiko-pii:), which the export
|
|
18
|
+
// runner's central decrypt sweep resolves.
|
|
19
|
+
export const downloadAttemptExportHook: UserDataExportHook = async (ctx) => {
|
|
20
|
+
const rows = await ctx.db.selectMany<Record<string, unknown>>(downloadAttemptsTable, {
|
|
21
|
+
tenantId: ctx.tenantId,
|
|
22
|
+
attemptedByUserId: ctx.userId,
|
|
23
|
+
});
|
|
24
|
+
if (rows.length === 0) return null;
|
|
25
|
+
return {
|
|
26
|
+
entity: "download-attempt",
|
|
27
|
+
rows: rows.map((r) => ({
|
|
28
|
+
result: r["result"],
|
|
29
|
+
via: r["via"],
|
|
30
|
+
ip: r["ip"],
|
|
31
|
+
userAgent: r["userAgent"],
|
|
32
|
+
attemptedAt: r["attemptedAt"],
|
|
33
|
+
})),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Both strategies purge: key erasure alone leaves attemptedByUserId and the
|
|
38
|
+
// blind index linking the user to the row until retention, and not every forget
|
|
39
|
+
// path shreds the subject key.
|
|
40
|
+
export const downloadAttemptDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
41
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
42
|
+
const rows = await ctx.db.selectMany<Record<string, unknown>>(downloadAttemptsTable, {
|
|
43
|
+
tenantId: ctx.tenantId,
|
|
44
|
+
attemptedByUserId: ctx.userId,
|
|
45
|
+
});
|
|
46
|
+
const failures: string[] = [];
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
const id = row["id"];
|
|
49
|
+
if (typeof id !== "string") continue;
|
|
50
|
+
collectErasureFailure(
|
|
51
|
+
await crud.forget({ id }, systemUser, ctx.db),
|
|
52
|
+
"download-attempt",
|
|
53
|
+
id,
|
|
54
|
+
failures,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
throwIfErasureFailed(failures);
|
|
58
|
+
};
|
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: "circle-stop",
|
|
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",
|
|
@@ -1143,7 +1143,9 @@
|
|
|
1143
1143
|
"extensionsUsed": [],
|
|
1144
1144
|
"configKeys": [],
|
|
1145
1145
|
"secrets": [],
|
|
1146
|
-
"writeHandlers": [
|
|
1146
|
+
"writeHandlers": [
|
|
1147
|
+
"files:write:bind-referenced-files"
|
|
1148
|
+
],
|
|
1147
1149
|
"uiHints": {
|
|
1148
1150
|
"displayLabel": "Files · Metadata",
|
|
1149
1151
|
"category": "storage",
|
|
@@ -3008,7 +3010,7 @@
|
|
|
3008
3010
|
},
|
|
3009
3011
|
{
|
|
3010
3012
|
"name": "user-data-rights-defaults",
|
|
3011
|
-
"description": "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding. Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
3013
|
+
"description": "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding, and `download-attempt` (rejected export-download attempts of the user: result, path, source IP, user agent, time; delete purges the user's own attempts via the forget verb, token-path rows without an owner expire by retention). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
3012
3014
|
"toggleableDefault": null,
|
|
3013
3015
|
"requires": [
|
|
3014
3016
|
"user",
|
|
@@ -3064,6 +3066,10 @@
|
|
|
3064
3066
|
"extensionName": "userData",
|
|
3065
3067
|
"entityName": "delivery-attempt"
|
|
3066
3068
|
},
|
|
3069
|
+
{
|
|
3070
|
+
"extensionName": "userData",
|
|
3071
|
+
"entityName": "download-attempt"
|
|
3072
|
+
},
|
|
3067
3073
|
{
|
|
3068
3074
|
"extensionName": "userData",
|
|
3069
3075
|
"entityName": "job-run"
|
|
@@ -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(
|