@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.26.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/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
- package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
- 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__/user-data-rights.integration.test.ts +196 -75
- 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/field-access.ts +1 -1
- package/src/custom-fields/db/queries/projection.ts +13 -5
- package/src/custom-fields/db/queries/quota.ts +1 -1
- 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/file-provider-inmemory/__tests__/feature.test.ts +55 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
- package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
- package/src/foundation-shared/config-helpers.ts +7 -3
- 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/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
- package/src/tier-engine/feature.ts +8 -2
- 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
|
@@ -28,13 +28,14 @@ export const CustomFieldsQueries = {
|
|
|
28
28
|
// `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`.
|
|
29
29
|
export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
// Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
|
|
32
|
+
// these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
|
|
33
|
+
// through r.defineEvent/toKebab, so the dot MUST stay.
|
|
34
|
+
export const FIELD_DEFINITION_CREATED_EVENT = "field-definition.created";
|
|
35
|
+
export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition.updated";
|
|
36
|
+
// Qualified at registration via r.defineEvent — final name is
|
|
37
|
+
// `custom-fields:event:field-definition-deleted`. Short-name MUST be kebab
|
|
38
|
+
// (no dots): qualifyEntityName runs toKebab which collapses dots to dashes.
|
|
38
39
|
export const FIELD_DEFINITION_DELETED_EVENT = "field-definition-deleted";
|
|
39
40
|
|
|
40
41
|
// Custom-field-VALUE events. Live auf host-aggregate stream (ES-Option-B).
|
|
@@ -11,6 +11,6 @@ export async function selectSerializedFieldDefinition(
|
|
|
11
11
|
"SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
|
|
12
12
|
[entityName, fieldKey, tenantId],
|
|
13
13
|
);
|
|
14
|
-
const first = (rows as ReadonlyArray<Record<string, unknown>>)[0];
|
|
14
|
+
const first = (rows as ReadonlyArray<Record<string, unknown>>)[0]; // @cast-boundary db-row
|
|
15
15
|
return first ? (first["serialized_field"] ?? null) : null;
|
|
16
16
|
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
|
|
4
5
|
function quoteTable(tableName: string): string {
|
|
5
6
|
return `"${tableName.replace(/"/g, '""')}"`;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
|
|
9
|
-
//
|
|
10
|
-
|
|
10
|
+
// Scalar JSON primitives can't bind directly to ::jsonb — Postgres rejects a
|
|
11
|
+
// bound boolean/number with "cannot cast type boolean/integer to jsonb" (and
|
|
12
|
+
// Bun.SQL infers boolean[] candidates). Route them through ::text::jsonb with
|
|
13
|
+
// a JSON-encoded literal. Objects/arrays/strings already bind as ::jsonb.
|
|
14
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
11
15
|
return { sql: "$1::text::jsonb", bound: JSON.stringify(value) };
|
|
12
16
|
}
|
|
17
|
+
// JSON.stringify throws on bigint; its decimal string is a valid JSON number literal.
|
|
18
|
+
if (typeof value === "bigint") {
|
|
19
|
+
return { sql: "$1::text::jsonb", bound: value.toString() };
|
|
20
|
+
}
|
|
13
21
|
return { sql: "$1::jsonb", bound: value };
|
|
14
22
|
}
|
|
15
23
|
|
|
@@ -21,7 +29,7 @@ export async function setCustomFieldValue(
|
|
|
21
29
|
fieldKey: string,
|
|
22
30
|
value: unknown,
|
|
23
31
|
aggregateId: string,
|
|
24
|
-
tenantId:
|
|
32
|
+
tenantId: TenantId,
|
|
25
33
|
): Promise<void> {
|
|
26
34
|
const tbl = quoteTable(tableName);
|
|
27
35
|
const escapedKey = fieldKey.replace(/'/g, "''");
|
|
@@ -37,7 +45,7 @@ export async function clearCustomFieldKey(
|
|
|
37
45
|
tableName: string,
|
|
38
46
|
fieldKey: string,
|
|
39
47
|
aggregateId: string,
|
|
40
|
-
tenantId:
|
|
48
|
+
tenantId: TenantId,
|
|
41
49
|
): Promise<void> {
|
|
42
50
|
const tbl = quoteTable(tableName);
|
|
43
51
|
await asRawClient(db).unsafe(
|
|
@@ -54,7 +62,7 @@ export async function removeCustomFieldKeyForTenant(
|
|
|
54
62
|
db: DbRunner,
|
|
55
63
|
tableName: string,
|
|
56
64
|
fieldKey: string,
|
|
57
|
-
tenantId:
|
|
65
|
+
tenantId: TenantId,
|
|
58
66
|
): Promise<void> {
|
|
59
67
|
const tbl = quoteTable(tableName);
|
|
60
68
|
await asRawClient(db).unsafe(
|
|
@@ -6,7 +6,7 @@ export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string
|
|
|
6
6
|
"SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1",
|
|
7
7
|
[tenantId],
|
|
8
8
|
);
|
|
9
|
-
const rows = rowsResult as ReadonlyArray<Record<string, unknown>>;
|
|
9
|
+
const rows = rowsResult as ReadonlyArray<Record<string, unknown>>; // @cast-boundary db-row
|
|
10
10
|
const first = rows[0];
|
|
11
11
|
if (!first) return 0;
|
|
12
12
|
const n = first["n"];
|
|
@@ -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
|
}
|