@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.25.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/src/__tests__/env-schemas.test.ts +53 -11
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/projection.ts +13 -5
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/lib/field-access.ts +4 -0
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +1 -1
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
|
@@ -25,15 +25,29 @@ export async function selectHostRowsWithCustomFields(
|
|
|
25
25
|
return Array.isArray(rowsResult) ? rowsResult : [];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export async function
|
|
28
|
+
export async function applyRetentionRemovals(
|
|
29
29
|
db: DbRunner,
|
|
30
30
|
tableName: string,
|
|
31
|
-
|
|
31
|
+
deleteKeys: readonly string[],
|
|
32
|
+
anonymizeKeys: readonly string[],
|
|
32
33
|
rowId: string,
|
|
33
34
|
): Promise<void> {
|
|
34
35
|
const quoted = `"${tableName.replace(/"/g, '""')}"`;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// Atomic per-row jsonb edit instead of read-modify-write of the whole
|
|
37
|
+
// object: delete-keys are dropped (`- $1::text[]`), anonymize-keys are set
|
|
38
|
+
// to JSON null via a merge patch. Operating on the live row value preserves
|
|
39
|
+
// a concurrent set-custom-field on any *other* key — no lost update.
|
|
40
|
+
await asRawClient(db).unsafe(
|
|
41
|
+
`UPDATE ${quoted} SET custom_fields = CASE
|
|
42
|
+
WHEN jsonb_typeof(custom_fields) = 'object' THEN
|
|
43
|
+
(custom_fields - $1::text[])
|
|
44
|
+
|| COALESCE(
|
|
45
|
+
(SELECT jsonb_object_agg(k, 'null'::jsonb) FROM unnest($2::text[]) AS k),
|
|
46
|
+
'{}'::jsonb
|
|
47
|
+
)
|
|
48
|
+
ELSE custom_fields
|
|
49
|
+
END
|
|
50
|
+
WHERE id = $3`,
|
|
51
|
+
[deleteKeys, anonymizeKeys, rowId],
|
|
52
|
+
);
|
|
39
53
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { fieldDefinitionEntity } from "./entity";
|
|
3
|
+
|
|
4
|
+
// Single field-definition executor shared by the four define/delete handlers.
|
|
5
|
+
// createEntityExecutor is side-effect-free; instantiating it once keeps the
|
|
6
|
+
// table+executor pair in one place instead of rebuilding it per handler module.
|
|
7
|
+
export const { executor: fieldDefinitionExecutor } = createEntityExecutor(
|
|
8
|
+
"field-definition",
|
|
9
|
+
fieldDefinitionEntity,
|
|
10
|
+
);
|
|
@@ -37,7 +37,12 @@
|
|
|
37
37
|
// Value-Validation gegen serializedField → set-custom-field via fieldToZod —
|
|
38
38
|
// alle erledigt.)
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
defineEntityListHandler,
|
|
42
|
+
defineFeature,
|
|
43
|
+
type FeatureRegistrar,
|
|
44
|
+
type WriteHandlerDef,
|
|
45
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
41
46
|
import { z } from "zod";
|
|
42
47
|
import {
|
|
43
48
|
CUSTOM_FIELD_CLEARED_EVENT,
|
|
@@ -71,12 +76,14 @@ const fieldDefinitionDeletedSchema = z.object({
|
|
|
71
76
|
tenantId: z.string().optional(),
|
|
72
77
|
});
|
|
73
78
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
// Shared registration body for both the singleton and the quota-variant.
|
|
80
|
+
// Only the define-tenant-field handler differs (quota baked in or not); every
|
|
81
|
+
// other entity/event/handler is registered identically here so a new
|
|
82
|
+
// event/handler can never silently miss the quota variant.
|
|
83
|
+
function registerCustomFields(
|
|
84
|
+
r: FeatureRegistrar<typeof CUSTOM_FIELDS_FEATURE_NAME>,
|
|
85
|
+
defineTenantHandler: WriteHandlerDef,
|
|
86
|
+
) {
|
|
80
87
|
r.entity("field-definition", fieldDefinitionEntity);
|
|
81
88
|
|
|
82
89
|
// Event-types — qualified als "custom-fields:event:<short-name>".
|
|
@@ -101,7 +108,7 @@ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r)
|
|
|
101
108
|
r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
|
|
102
109
|
|
|
103
110
|
// Definition-CRUD handlers (B1).
|
|
104
|
-
r.writeHandler(
|
|
111
|
+
r.writeHandler(defineTenantHandler);
|
|
105
112
|
r.writeHandler(defineSystemFieldHandler);
|
|
106
113
|
r.writeHandler(deleteTenantFieldHandler);
|
|
107
114
|
r.writeHandler(deleteSystemFieldHandler);
|
|
@@ -116,7 +123,16 @@ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r)
|
|
|
116
123
|
);
|
|
117
124
|
|
|
118
125
|
return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
|
|
119
|
-
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Singleton feature-definition mit typed exports. Handler + wire-for-entity
|
|
129
|
+
// importieren diesen `customFieldsFeature` und greifen lazy in ihrer
|
|
130
|
+
// runtime-arrow-fn auf `.exports.<event>.name` zu — der module-cycle
|
|
131
|
+
// (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
|
|
132
|
+
// kein top-level-access stattfindet.
|
|
133
|
+
export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
134
|
+
registerCustomFields(r, defineTenantFieldHandler),
|
|
135
|
+
);
|
|
120
136
|
|
|
121
137
|
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
122
138
|
// host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
|
|
@@ -133,34 +149,11 @@ export function createCustomFieldsFeature(
|
|
|
133
149
|
if (opts.fieldDefinitionLimitPerTenant === undefined) {
|
|
134
150
|
return customFieldsFeature;
|
|
135
151
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
fieldDefinitionDeletedSchema,
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
|
|
147
|
-
|
|
148
|
-
r.writeHandler(
|
|
149
|
-
createDefineTenantFieldHandler({
|
|
150
|
-
fieldDefinitionLimitPerTenant: opts.fieldDefinitionLimitPerTenant,
|
|
151
|
-
}),
|
|
152
|
-
);
|
|
153
|
-
r.writeHandler(defineSystemFieldHandler);
|
|
154
|
-
r.writeHandler(deleteTenantFieldHandler);
|
|
155
|
-
r.writeHandler(deleteSystemFieldHandler);
|
|
156
|
-
|
|
157
|
-
r.writeHandler(setCustomFieldHandler);
|
|
158
|
-
r.writeHandler(clearCustomFieldHandler);
|
|
159
|
-
|
|
160
|
-
r.queryHandler(
|
|
161
|
-
defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
|
|
165
|
-
});
|
|
152
|
+
const limit = opts.fieldDefinitionLimitPerTenant;
|
|
153
|
+
return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
154
|
+
registerCustomFields(
|
|
155
|
+
r,
|
|
156
|
+
createDefineTenantFieldHandler({ fieldDefinitionLimitPerTenant: limit }),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
166
159
|
}
|
|
@@ -7,7 +7,11 @@ import { checkFieldAccessForWrite } from "../lib/field-access";
|
|
|
7
7
|
export const clearCustomFieldPayloadSchema = z.object({
|
|
8
8
|
entityName: z.string().min(1).max(64),
|
|
9
9
|
entityId: z.string().min(1),
|
|
10
|
-
fieldKey: z
|
|
10
|
+
fieldKey: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.max(64)
|
|
14
|
+
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
|
|
11
15
|
});
|
|
12
16
|
export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSchema>;
|
|
13
17
|
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntityExecutor,
|
|
3
|
-
SYSTEM_TENANT_ID,
|
|
4
|
-
type WriteHandlerDef,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
|
-
import {
|
|
3
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
4
|
+
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
8
5
|
import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
9
6
|
|
|
10
|
-
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
11
|
-
|
|
12
7
|
// define-system-field — SystemAdmin definiert eine system-weite Custom-Field-
|
|
13
8
|
// Definition die für ALLE Tenants gilt. tenantId wird auf SYSTEM_TENANT_ID
|
|
14
9
|
// gesetzt (NICHT vom Caller — SystemAdmin's event.user.tenantId würde sonst
|
|
@@ -38,20 +33,8 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
|
|
|
38
33
|
// — the row lives in the system-scope-stream.
|
|
39
34
|
const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
|
|
40
35
|
|
|
41
|
-
return
|
|
42
|
-
{
|
|
43
|
-
id: aggregateId,
|
|
44
|
-
entityName: payload.entityName,
|
|
45
|
-
fieldKey: payload.fieldKey,
|
|
46
|
-
type: payload.serializedField.type,
|
|
47
|
-
required: payload.required,
|
|
48
|
-
searchable: payload.searchable,
|
|
49
|
-
displayOrder: payload.displayOrder,
|
|
50
|
-
serializedField: JSON.stringify({
|
|
51
|
-
...payload.serializedField,
|
|
52
|
-
label: payload.label,
|
|
53
|
-
}),
|
|
54
|
-
},
|
|
36
|
+
return fieldDefinitionExecutor.create(
|
|
37
|
+
{ id: aggregateId, ...buildFieldDefinitionColumns(payload) },
|
|
55
38
|
systemUser,
|
|
56
39
|
ctx.db,
|
|
57
40
|
);
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntityExecutor,
|
|
3
|
-
isSystemTenant,
|
|
4
|
-
type WriteHandlerDef,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
8
|
-
import {
|
|
4
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
5
|
+
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
9
6
|
import { countTenantFieldDefinitions } from "../lib/quota";
|
|
10
7
|
import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
11
8
|
|
|
12
|
-
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
13
|
-
|
|
14
9
|
// define-tenant-field — TenantAdmin definiert eine Custom-Field-Definition
|
|
15
10
|
// für seinen eigenen Tenant. tenantId wird automatisch aus event.user.tenantId
|
|
16
11
|
// abgeleitet (NICHT vom Caller setzbar — verhindert Cross-Tenant-Mutation).
|
|
@@ -32,8 +27,15 @@ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEnt
|
|
|
32
27
|
// `unprocessable` + reason `cap_exceeded` BEFORE attempting the insert.
|
|
33
28
|
// The factory below closes over the limit; the legacy const-export keeps
|
|
34
29
|
// behavior unchanged for callers who didn't opt into a limit.
|
|
30
|
+
//
|
|
31
|
+
// Soft cap, not a hard guarantee: the count-then-insert is not serialized, so
|
|
32
|
+
// N concurrent defines with distinct fieldKeys can each read `current < limit`
|
|
33
|
+
// and overshoot by up to N. Acceptable here — defining fields is an admin-only,
|
|
34
|
+
// low-frequency action and the limit is not wired to billing/tier enforcement.
|
|
35
|
+
// If an exact cap is ever needed, serialize via advisory lock or a count
|
|
36
|
+
// constraint at the insert.
|
|
35
37
|
export interface DefineTenantFieldOptions {
|
|
36
|
-
/**
|
|
38
|
+
/** Soft cap — `>= limit` definitions per tenant rejects further defines (see header: concurrent defines may overshoot). */
|
|
37
39
|
readonly fieldDefinitionLimitPerTenant?: number;
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -73,20 +75,8 @@ export function createDefineTenantFieldHandler(
|
|
|
73
75
|
payload.fieldKey,
|
|
74
76
|
);
|
|
75
77
|
|
|
76
|
-
return
|
|
77
|
-
{
|
|
78
|
-
id: aggregateId,
|
|
79
|
-
entityName: payload.entityName,
|
|
80
|
-
fieldKey: payload.fieldKey,
|
|
81
|
-
type: payload.serializedField.type,
|
|
82
|
-
required: payload.required,
|
|
83
|
-
searchable: payload.searchable,
|
|
84
|
-
displayOrder: payload.displayOrder,
|
|
85
|
-
serializedField: JSON.stringify({
|
|
86
|
-
...payload.serializedField,
|
|
87
|
-
label: payload.label,
|
|
88
|
-
}),
|
|
89
|
-
},
|
|
78
|
+
return fieldDefinitionExecutor.create(
|
|
79
|
+
{ id: aggregateId, ...buildFieldDefinitionColumns(payload) },
|
|
90
80
|
event.user,
|
|
91
81
|
ctx.db,
|
|
92
82
|
);
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntityExecutor,
|
|
3
|
-
SYSTEM_TENANT_ID,
|
|
4
|
-
type WriteHandlerDef,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
|
-
import {
|
|
3
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
8
4
|
import { customFieldsFeature } from "../feature";
|
|
9
5
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
10
6
|
|
|
11
|
-
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
12
|
-
|
|
13
7
|
// delete-system-field — SystemAdmin entfernt eine system-weite Field-
|
|
14
8
|
// Definition. Konsequenz: KEIN Tenant kann mehr neue Werte dafür setzen,
|
|
15
9
|
// existing Werte in read_<entity>.customFields jsonb bleiben aber bestehen
|
|
@@ -29,7 +23,7 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
|
|
|
29
23
|
);
|
|
30
24
|
|
|
31
25
|
const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
|
|
32
|
-
const result = await
|
|
26
|
+
const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, systemUser, ctx.db);
|
|
33
27
|
|
|
34
28
|
// Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
|
|
35
29
|
// ihrer customFields jsonb. Im selben TX = atomic.
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntityExecutor,
|
|
3
|
-
isSystemTenant,
|
|
4
|
-
type WriteHandlerDef,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
|
-
import {
|
|
3
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
8
4
|
import { customFieldsFeature } from "../feature";
|
|
9
5
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
10
6
|
|
|
11
|
-
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
12
|
-
|
|
13
7
|
// delete-tenant-field — TenantAdmin löscht eigene Field-Definition.
|
|
14
8
|
// Spec-Promise (Plan-Doc v2 "wie Entity-Delete"): Events bleiben im event-
|
|
15
9
|
// store für Audit-Trail. Read-Projection-Row wird entfernt. B2 wird die
|
|
@@ -36,7 +30,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
|
|
|
36
30
|
|
|
37
31
|
const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
|
|
38
32
|
|
|
39
|
-
const result = await
|
|
33
|
+
const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, ctx.db);
|
|
40
34
|
|
|
41
35
|
// Emit cascade-cleanup-Event NACH erfolgreichem Delete. host-entity-MSPs
|
|
42
36
|
// (registriert via wireCustomFieldsFor) konsumieren das + entfernen orphan
|
|
@@ -34,6 +34,10 @@ export async function loadFieldDefinition(
|
|
|
34
34
|
|
|
35
35
|
// Pure access-check on an already-loaded definition. Returns the required
|
|
36
36
|
// roles when the caller is denied, or `null` when access is allowed.
|
|
37
|
+
//
|
|
38
|
+
// Contract: `fieldAccess.write` entries MUST be verbatim membership-system role
|
|
39
|
+
// names (e.g. "TenantAdmin", not "Admin") — match is exact like framework-wide
|
|
40
|
+
// `engine/access.ts`; no normalization, so a drifted name denies silently.
|
|
37
41
|
export function fieldWriteAccessDeniedRoles(
|
|
38
42
|
field: SerializedFieldShape | null,
|
|
39
43
|
userRoles: ReadonlyArray<string>,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DefineFieldPayload } from "../schemas";
|
|
2
|
+
|
|
3
|
+
export interface FieldDefinitionColumns {
|
|
4
|
+
readonly entityName: string;
|
|
5
|
+
readonly fieldKey: string;
|
|
6
|
+
readonly type: string;
|
|
7
|
+
readonly required: boolean;
|
|
8
|
+
readonly searchable: boolean;
|
|
9
|
+
readonly displayOrder: number;
|
|
10
|
+
readonly serializedField: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The required/searchable/displayOrder columns are a denormalized projection of
|
|
14
|
+
// serializedField — derive, don't default. Zod gives top-level `required` a
|
|
15
|
+
// `.default(false)`, so "caller omitted it" is indistinguishable from `false`
|
|
16
|
+
// post-parse; serializedField-present therefore wins to preserve caller intent.
|
|
17
|
+
export function buildFieldDefinitionColumns(payload: DefineFieldPayload): FieldDefinitionColumns {
|
|
18
|
+
const sf = payload.serializedField;
|
|
19
|
+
const required = typeof sf["required"] === "boolean" ? sf["required"] : payload.required;
|
|
20
|
+
const searchable = typeof sf["searchable"] === "boolean" ? sf["searchable"] : payload.searchable;
|
|
21
|
+
const displayOrder =
|
|
22
|
+
typeof sf["displayOrder"] === "number" ? sf["displayOrder"] : payload.displayOrder;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
entityName: payload.entityName,
|
|
26
|
+
fieldKey: payload.fieldKey,
|
|
27
|
+
type: sf.type,
|
|
28
|
+
required,
|
|
29
|
+
searchable,
|
|
30
|
+
displayOrder,
|
|
31
|
+
serializedField: JSON.stringify({ ...sf, label: payload.label }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
19
19
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
20
20
|
import {
|
|
21
|
+
applyRetentionRemovals,
|
|
21
22
|
selectFieldDefinitionsWithSerialized,
|
|
22
23
|
selectHostRowsWithCustomFields,
|
|
23
|
-
updateHostRowCustomFields,
|
|
24
24
|
} from "./db/queries/retention";
|
|
25
25
|
import { parseSerializedField } from "./lib/parse-serialized-field";
|
|
26
26
|
|
|
@@ -122,17 +122,18 @@ export async function runCustomFieldsRetention(
|
|
|
122
122
|
// skip: nothing on this row aged out — no UPDATE needed.
|
|
123
123
|
if (removals.length === 0) continue;
|
|
124
124
|
|
|
125
|
-
const
|
|
125
|
+
const deleteKeys: string[] = [];
|
|
126
|
+
const anonymizeKeys: string[] = [];
|
|
126
127
|
for (const { key, strategy } of removals) {
|
|
127
128
|
if (strategy === "delete") {
|
|
128
|
-
|
|
129
|
+
deleteKeys.push(key);
|
|
129
130
|
} else {
|
|
130
|
-
|
|
131
|
+
anonymizeKeys.push(key);
|
|
131
132
|
}
|
|
132
133
|
removalsByFieldKey[key] = (removalsByFieldKey[key] ?? 0) + 1;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
await
|
|
136
|
+
await applyRetentionRemovals(opts.db, tableName, deleteKeys, anonymizeKeys, row.id);
|
|
136
137
|
rowsUpdated++;
|
|
137
138
|
}
|
|
138
139
|
|
|
@@ -8,6 +8,7 @@ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
|
8
8
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { CustomFieldsFormSection } from "../custom-fields-form-section";
|
|
11
|
+
import { defaultTranslations } from "../i18n";
|
|
11
12
|
|
|
12
13
|
type FieldRow = {
|
|
13
14
|
id: string;
|
|
@@ -21,6 +22,13 @@ type FieldRow = {
|
|
|
21
22
|
const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
|
|
22
23
|
let mockedQueryRows: readonly FieldRow[] = [];
|
|
23
24
|
|
|
25
|
+
const useQuerySpy = mock((_type: string, _params: unknown, _options?: { enabled?: boolean }) => ({
|
|
26
|
+
data: { rows: mockedQueryRows },
|
|
27
|
+
loading: false,
|
|
28
|
+
error: null,
|
|
29
|
+
refetch: mock(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
24
32
|
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
25
33
|
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
26
34
|
...actual_renderer,
|
|
@@ -29,17 +37,12 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
|
29
37
|
query: mock(),
|
|
30
38
|
batch: mock(),
|
|
31
39
|
})),
|
|
32
|
-
useQuery:
|
|
33
|
-
data: { rows: mockedQueryRows },
|
|
34
|
-
loading: false,
|
|
35
|
-
error: null,
|
|
36
|
-
refetch: mock(),
|
|
37
|
-
})),
|
|
40
|
+
useQuery: useQuerySpy,
|
|
38
41
|
}));
|
|
39
42
|
|
|
40
43
|
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
41
44
|
return (
|
|
42
|
-
<LocaleProvider resolver={createStaticLocaleResolver()}>
|
|
45
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
43
46
|
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
44
47
|
</LocaleProvider>
|
|
45
48
|
);
|
|
@@ -107,8 +110,9 @@ describe("CustomFieldsFormSection", () => {
|
|
|
107
110
|
});
|
|
108
111
|
});
|
|
109
112
|
|
|
110
|
-
test("shows create-mode banner when entityId is null", () => {
|
|
113
|
+
test("shows create-mode banner when entityId is null and skips the fieldDefinition query", () => {
|
|
111
114
|
mockedQueryRows = [];
|
|
115
|
+
useQuerySpy.mockClear();
|
|
112
116
|
|
|
113
117
|
render(
|
|
114
118
|
<Wrapper>
|
|
@@ -116,8 +120,14 @@ describe("CustomFieldsFormSection", () => {
|
|
|
116
120
|
</Wrapper>,
|
|
117
121
|
);
|
|
118
122
|
|
|
119
|
-
|
|
123
|
+
const banner = screen.getByTestId("custom-fields-form-create-mode");
|
|
124
|
+
expect(banner).toBeTruthy();
|
|
120
125
|
expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
|
|
126
|
+
// The banner shows the translated string, not the raw i18n key.
|
|
127
|
+
expect(banner.textContent).toBe("Save the entity first to add custom field values.");
|
|
128
|
+
// create-mode discards the query result via the early return, so the
|
|
129
|
+
// fetch-on-mount must be disabled — no wasted server roundtrip.
|
|
130
|
+
expect(useQuerySpy.mock.calls[0]?.[2]).toEqual({ enabled: false });
|
|
121
131
|
});
|
|
122
132
|
|
|
123
133
|
test("shows empty banner when no fieldDefinitions match entityName", () => {
|
|
@@ -138,7 +148,32 @@ describe("CustomFieldsFormSection", () => {
|
|
|
138
148
|
</Wrapper>,
|
|
139
149
|
);
|
|
140
150
|
|
|
141
|
-
|
|
151
|
+
const banner = screen.getByTestId("custom-fields-form-empty");
|
|
152
|
+
expect(banner).toBeTruthy();
|
|
142
153
|
expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
|
|
154
|
+
// Translated + interpolated with the host entity name, not the raw key.
|
|
155
|
+
expect(banner.textContent).toBe('No custom fields defined for "component".');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("save button renders the translated label, not the raw i18n key", () => {
|
|
159
|
+
mockedQueryRows = [
|
|
160
|
+
{
|
|
161
|
+
id: "f1",
|
|
162
|
+
entityName: "component",
|
|
163
|
+
fieldKey: "vendor",
|
|
164
|
+
type: "text",
|
|
165
|
+
required: false,
|
|
166
|
+
displayOrder: 1,
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
render(
|
|
171
|
+
<Wrapper>
|
|
172
|
+
<CustomFieldsFormSection entityName="component" entityId="row-42" />
|
|
173
|
+
</Wrapper>,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const saveBtn = screen.getByTestId("custom-fields-form-save");
|
|
177
|
+
expect(saveBtn.textContent).toBe("Save custom fields");
|
|
143
178
|
});
|
|
144
179
|
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
15
15
|
import { CUSTOM_FIELDS_FEATURE_NAME, CUSTOM_FIELDS_FORM_EXTENSION_NAME } from "../constants";
|
|
16
16
|
import { CustomFieldsFormSection } from "./custom-fields-form-section";
|
|
17
|
+
import { defaultTranslations } from "./i18n";
|
|
17
18
|
|
|
18
19
|
export function customFieldsClient(): ClientFeatureDefinition {
|
|
19
20
|
return {
|
|
@@ -21,5 +22,6 @@ export function customFieldsClient(): ClientFeatureDefinition {
|
|
|
21
22
|
extensionSectionComponents: {
|
|
22
23
|
[CUSTOM_FIELDS_FORM_EXTENSION_NAME]: CustomFieldsFormSection,
|
|
23
24
|
},
|
|
25
|
+
translations: defaultTranslations,
|
|
24
26
|
};
|
|
25
27
|
}
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
// `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`
|
|
11
11
|
// referenziert.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
useDispatcher,
|
|
15
|
+
usePrimitives,
|
|
16
|
+
useQuery,
|
|
17
|
+
useTranslation,
|
|
18
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
14
19
|
import { type ReactNode, useState } from "react";
|
|
15
20
|
import { CustomFieldsHandlers, CustomFieldsQueries } from "../constants";
|
|
16
21
|
|
|
@@ -35,8 +40,13 @@ export function CustomFieldsFormSection({
|
|
|
35
40
|
readonly entityId: string | null;
|
|
36
41
|
}): ReactNode {
|
|
37
42
|
const { Banner, Button, Field, Input, Text } = usePrimitives();
|
|
43
|
+
const t = useTranslation();
|
|
38
44
|
const dispatcher = useDispatcher();
|
|
39
|
-
const query = useQuery<FieldDefinitionListResponse>(
|
|
45
|
+
const query = useQuery<FieldDefinitionListResponse>(
|
|
46
|
+
CustomFieldsQueries.fieldDefinitionList,
|
|
47
|
+
{},
|
|
48
|
+
{ enabled: entityId !== null },
|
|
49
|
+
);
|
|
40
50
|
const [pending, setPending] = useState<Readonly<Record<string, string>>>({});
|
|
41
51
|
const [saving, setSaving] = useState(false);
|
|
42
52
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
@@ -44,21 +54,21 @@ export function CustomFieldsFormSection({
|
|
|
44
54
|
if (entityId === null) {
|
|
45
55
|
return (
|
|
46
56
|
<Banner variant="info" testId="custom-fields-form-create-mode">
|
|
47
|
-
<Text>
|
|
57
|
+
<Text>{t("custom-fields.form.createMode")}</Text>
|
|
48
58
|
</Banner>
|
|
49
59
|
);
|
|
50
60
|
}
|
|
51
61
|
if (query.loading && query.data === null) {
|
|
52
62
|
return (
|
|
53
63
|
<Banner variant="loading" testId="custom-fields-form-loading">
|
|
54
|
-
<Text>
|
|
64
|
+
<Text>{t("custom-fields.form.loading")}</Text>
|
|
55
65
|
</Banner>
|
|
56
66
|
);
|
|
57
67
|
}
|
|
58
68
|
if (query.error) {
|
|
59
69
|
return (
|
|
60
70
|
<Banner variant="error" testId="custom-fields-form-error">
|
|
61
|
-
<Text>{query.error.i18nKey}</Text>
|
|
71
|
+
<Text>{t(query.error.i18nKey, query.error.i18nParams)}</Text>
|
|
62
72
|
</Banner>
|
|
63
73
|
);
|
|
64
74
|
}
|
|
@@ -71,7 +81,7 @@ export function CustomFieldsFormSection({
|
|
|
71
81
|
if (matchingFields.length === 0) {
|
|
72
82
|
return (
|
|
73
83
|
<Banner variant="info" testId="custom-fields-form-empty">
|
|
74
|
-
<Text>
|
|
84
|
+
<Text>{t("custom-fields.form.empty", { entityName })}</Text>
|
|
75
85
|
</Banner>
|
|
76
86
|
);
|
|
77
87
|
}
|
|
@@ -91,7 +101,7 @@ export function CustomFieldsFormSection({
|
|
|
91
101
|
value,
|
|
92
102
|
});
|
|
93
103
|
if (!result.isSuccess) {
|
|
94
|
-
setErrorKey(result.error?.i18nKey ?? "custom-fields
|
|
104
|
+
setErrorKey(result.error?.i18nKey ?? "custom-fields.errors.saveFailed");
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
97
107
|
}
|
|
@@ -123,11 +133,11 @@ export function CustomFieldsFormSection({
|
|
|
123
133
|
disabled={saving || !dirty}
|
|
124
134
|
testId="custom-fields-form-save"
|
|
125
135
|
>
|
|
126
|
-
{saving ? "
|
|
136
|
+
{saving ? t("custom-fields.form.saving") : t("custom-fields.form.save")}
|
|
127
137
|
</Button>
|
|
128
138
|
{errorKey !== null && (
|
|
129
139
|
<Banner variant="error" testId="custom-fields-form-save-error">
|
|
130
|
-
<Text>{errorKey}</Text>
|
|
140
|
+
<Text>{t(errorKey)}</Text>
|
|
131
141
|
</Banner>
|
|
132
142
|
)}
|
|
133
143
|
</div>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default translation bundle for the custom-fields UI. customFieldsClient()
|
|
3
|
+
// hangs it into the LocaleProvider as a fallback bundle — apps override
|
|
4
|
+
// individual keys via `customFieldsClient({ translations: { de: { ... } } })`.
|
|
5
|
+
//
|
|
6
|
+
// Keys follow `custom-fields.<area>.<slug>`. `custom-fields.errors.*` mirror
|
|
7
|
+
// the i18nKeys the server-side handlers emit (e.g. `custom-fields:save-failed`).
|
|
8
|
+
|
|
9
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
|
|
11
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
12
|
+
de: {
|
|
13
|
+
"custom-fields.form.createMode": "Speichere zuerst den Eintrag, um Custom-Felder zu setzen.",
|
|
14
|
+
"custom-fields.form.loading": "Lädt…",
|
|
15
|
+
"custom-fields.form.empty": 'Keine Custom-Felder für "{entityName}" definiert.',
|
|
16
|
+
"custom-fields.form.save": "Custom-Felder speichern",
|
|
17
|
+
"custom-fields.form.saving": "Speichert…",
|
|
18
|
+
"custom-fields.errors.loadFailed": "Custom-Felder konnten nicht geladen werden.",
|
|
19
|
+
"custom-fields.errors.saveFailed": "Speichern fehlgeschlagen.",
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
"custom-fields.form.createMode": "Save the entity first to add custom field values.",
|
|
23
|
+
"custom-fields.form.loading": "Loading…",
|
|
24
|
+
"custom-fields.form.empty": 'No custom fields defined for "{entityName}".',
|
|
25
|
+
"custom-fields.form.save": "Save custom fields",
|
|
26
|
+
"custom-fields.form.saving": "Saving…",
|
|
27
|
+
"custom-fields.errors.loadFailed": "Could not load custom fields.",
|
|
28
|
+
"custom-fields.errors.saveFailed": "Save failed.",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -171,8 +171,8 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
171
171
|
const customFields = row["customFields"];
|
|
172
172
|
if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
|
|
173
173
|
return {
|
|
174
|
-
...row,
|
|
175
174
|
...(customFields as Record<string, unknown>), // @cast-boundary db-row jsonb runtime-untyped
|
|
175
|
+
...row, // base fields win: a custom fieldKey named `id`/`name` must not shadow the real column
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
178
|
return row;
|