@cosmicdrift/kumiko-bundled-features 0.35.0 → 0.38.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 +5 -5
- package/src/auth-email-password/__tests__/auth-mailer.test.ts +138 -0
- package/src/auth-email-password/auth-mailer.ts +137 -0
- package/src/auth-email-password/email-templates.ts +7 -13
- package/src/auth-email-password/errors.ts +84 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -10
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -19
- package/src/auth-email-password/handlers/invite-accept.write.ts +15 -28
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -14
- package/src/auth-email-password/handlers/login.write.ts +7 -51
- package/src/auth-email-password/handlers/reset-password.write.ts +3 -10
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -14
- package/src/auth-email-password/handlers/verify-email.write.ts +3 -10
- package/src/auth-email-password/index.ts +9 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +24 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +1 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +2 -1
- package/src/cap-counter/enforce-cap.ts +5 -0
- package/src/compliance-profiles/README.md +1 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -1
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +4 -4
- package/src/custom-fields/db/queries/retention.ts +1 -0
- package/src/custom-fields/lib/parse-serialized-field.ts +11 -0
- package/src/custom-fields/run-retention.ts +4 -22
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +148 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +26 -12
- package/src/custom-fields/wire-for-entity.ts +4 -12
- package/src/custom-fields/wire-user-data-rights.ts +3 -22
- package/src/data-retention/__tests__/data-retention.integration.test.ts +2 -2
- package/src/file-foundation/feature.ts +13 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +4 -7
- package/src/file-provider-s3/__tests__/feature.test.ts +4 -6
- package/src/files/README.md +1 -1
- package/src/legal-pages/markdown.ts +1 -13
- package/src/renderer-simple/simple-renderer.ts +1 -8
- package/src/subscription-stripe/feature.ts +5 -2
- package/src/template-resolver/feature.ts +1 -2
- package/src/template-resolver/handlers/list.query.ts +7 -14
- package/src/template-resolver/handlers/toggle-status.write.ts +37 -0
- package/src/tenant/command-schemas.ts +1 -1
- package/src/tenant/feature.ts +1 -2
- package/src/tenant/handlers/toggle-enabled.write.ts +23 -0
- package/src/user-data-rights/README.md +8 -8
- package/src/template-resolver/handlers/archive.write.ts +0 -39
- package/src/template-resolver/handlers/publish.write.ts +0 -42
- package/src/tenant/handlers/disable.write.ts +0 -18
- package/src/tenant/handlers/enable.write.ts +0 -20
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
type SessionUser,
|
|
5
5
|
type TenantId,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
7
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
8
|
import { z } from "zod";
|
|
10
9
|
import { USER_STATUS, UserQueries } from "../../user";
|
|
@@ -12,60 +11,17 @@ import { parseAuthUserRow } from "../auth-user-row";
|
|
|
12
11
|
import {
|
|
13
12
|
AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
|
|
14
13
|
AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
|
|
15
|
-
AuthErrors,
|
|
16
14
|
} from "../constants";
|
|
15
|
+
import {
|
|
16
|
+
accountLocked,
|
|
17
|
+
accountRestricted,
|
|
18
|
+
emailNotVerified,
|
|
19
|
+
invalidCredentials,
|
|
20
|
+
noMembership,
|
|
21
|
+
} from "../errors";
|
|
17
22
|
import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
|
|
18
23
|
import { verifyPassword } from "../password-hashing";
|
|
19
24
|
|
|
20
|
-
function invalidCredentials() {
|
|
21
|
-
return writeFailure(
|
|
22
|
-
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
23
|
-
i18nKey: "auth.errors.invalidCredentials",
|
|
24
|
-
}),
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function noMembership() {
|
|
29
|
-
return writeFailure(
|
|
30
|
-
new UnprocessableError(AuthErrors.noMembership, {
|
|
31
|
-
i18nKey: "auth.errors.noMembership",
|
|
32
|
-
}),
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function emailNotVerified() {
|
|
37
|
-
return writeFailure(
|
|
38
|
-
new UnprocessableError(AuthErrors.emailNotVerified, {
|
|
39
|
-
i18nKey: "auth.errors.emailNotVerified",
|
|
40
|
-
}),
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function accountLocked(retryAfterSeconds: number) {
|
|
45
|
-
return writeFailure(
|
|
46
|
-
new UnprocessableError(AuthErrors.accountLocked, {
|
|
47
|
-
i18nKey: "auth.errors.accountLocked",
|
|
48
|
-
// Seconds until auto-unlock — UI renders a countdown, clients can
|
|
49
|
-
// schedule a retry. Rounded up so the UI never shows 0 while the
|
|
50
|
-
// lock is still active.
|
|
51
|
-
details: { retryAfterSeconds },
|
|
52
|
-
}),
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// S2.U6 — DSGVO Art. 18 Account-Freeze. Distinct error code (nicht zu
|
|
57
|
-
// invalid_credentials collapsen) damit UI klar sagen kann "Account ist
|
|
58
|
-
// pausiert, hier klicken zum Aufheben". User weiss schon dass sein Konto
|
|
59
|
-
// restricted ist (er hat selbst die Restriction gesetzt), also kein
|
|
60
|
-
// Enumeration-Leak.
|
|
61
|
-
function accountRestricted() {
|
|
62
|
-
return writeFailure(
|
|
63
|
-
new UnprocessableError(AuthErrors.accountRestricted, {
|
|
64
|
-
i18nKey: "auth.errors.accountRestricted",
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
25
|
export type LoginHandlerOptions = {
|
|
70
26
|
// When true, a valid (email + password) login fails with email_not_verified
|
|
71
27
|
// if the user row's emailVerified flag is false. Enumeration-leak is
|
|
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { AuthErrors } from "../constants";
|
|
5
|
+
import { invalidResetToken } from "../errors";
|
|
5
6
|
import { hashPassword } from "../password-hashing";
|
|
6
7
|
import { verifyResetToken } from "../reset-token";
|
|
7
8
|
import { runConfirmTokenFlow } from "./confirm-token-flow";
|
|
@@ -10,14 +11,6 @@ export type ResetPasswordOptions = {
|
|
|
10
11
|
readonly hmacSecret: string;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
|
-
function invalidToken() {
|
|
14
|
-
return writeFailure(
|
|
15
|
-
new UnprocessableError(AuthErrors.invalidResetToken, {
|
|
16
|
-
i18nKey: "auth.errors.invalidResetToken",
|
|
17
|
-
}),
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
14
|
// Confirm step of the reset flow. Token-verify happens inline; the
|
|
22
15
|
// post-verify pipeline (burn, load user, memberships, try-all-tenants,
|
|
23
16
|
// burn-release-on-failure) lives in confirm-token-flow to stay in sync
|
|
@@ -45,12 +38,12 @@ export function createResetPasswordHandler(opts: ResetPasswordOptions) {
|
|
|
45
38
|
// the same invalid_reset_token error — a probing caller can't
|
|
46
39
|
// distinguish tampered from stale from random-string.
|
|
47
40
|
const verify = verifyResetToken(event.payload.token, opts.hmacSecret);
|
|
48
|
-
if (!verify.ok) return
|
|
41
|
+
if (!verify.ok) return invalidResetToken();
|
|
49
42
|
|
|
50
43
|
return runConfirmTokenFlow(ctx, verify.userId, verify.expiresAtMs, {
|
|
51
44
|
purpose: "reset",
|
|
52
45
|
redisRequiredMessage: "password-reset requires ctx.redis to enforce token single-use",
|
|
53
|
-
invalidToken,
|
|
46
|
+
invalidToken: invalidResetToken,
|
|
54
47
|
buildChanges: async () => ({
|
|
55
48
|
passwordHash: await hashPassword(event.payload.newPassword),
|
|
56
49
|
}),
|
|
@@ -26,17 +26,13 @@ import {
|
|
|
26
26
|
type SessionUser,
|
|
27
27
|
type TenantId,
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
-
import {
|
|
30
|
-
InternalError,
|
|
31
|
-
UnprocessableError,
|
|
32
|
-
writeFailure,
|
|
33
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
29
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
34
30
|
import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
|
|
35
31
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
36
32
|
import { z } from "zod";
|
|
37
33
|
// kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
|
|
38
34
|
import { tenantTable } from "../../tenant/schema/tenant";
|
|
39
|
-
import {
|
|
35
|
+
import { invalidSignupToken } from "../errors";
|
|
40
36
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
41
37
|
import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
|
|
42
38
|
import {
|
|
@@ -63,14 +59,6 @@ export type SignupConfirmData = {
|
|
|
63
59
|
readonly tenantKey: string;
|
|
64
60
|
};
|
|
65
61
|
|
|
66
|
-
function invalidSignupToken() {
|
|
67
|
-
return writeFailure(
|
|
68
|
-
new UnprocessableError(AuthErrors.invalidSignupToken, {
|
|
69
|
-
i18nKey: "auth.errors.invalidSignupToken",
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
62
|
export function createSignupConfirmHandler() {
|
|
75
63
|
return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
|
|
76
64
|
name: "signup-confirm",
|
|
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { AuthErrors } from "../constants";
|
|
5
|
+
import { invalidVerificationToken } from "../errors";
|
|
5
6
|
import { verifyVerificationToken } from "../verification-token";
|
|
6
7
|
import { runConfirmTokenFlow } from "./confirm-token-flow";
|
|
7
8
|
|
|
@@ -15,14 +16,6 @@ const VerifyEmailSchema = z.object({
|
|
|
15
16
|
|
|
16
17
|
export type VerifyEmailData = { readonly kind: "verified" } | { readonly kind: "already-verified" };
|
|
17
18
|
|
|
18
|
-
function invalidToken() {
|
|
19
|
-
return writeFailure(
|
|
20
|
-
new UnprocessableError(AuthErrors.invalidVerificationToken, {
|
|
21
|
-
i18nKey: "auth.errors.invalidVerificationToken",
|
|
22
|
-
}),
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
19
|
// Sets user.emailVerified = true on a valid token. Idempotent via the
|
|
27
20
|
// `alreadyDone` short-circuit — when the row already reads verified
|
|
28
21
|
// (reached through another flow), we skip the write but keep the burn
|
|
@@ -44,12 +37,12 @@ export function createVerifyEmailHandler(opts: VerifyEmailOptions) {
|
|
|
44
37
|
}
|
|
45
38
|
|
|
46
39
|
const verify = verifyVerificationToken(event.payload.token, opts.hmacSecret);
|
|
47
|
-
if (!verify.ok) return
|
|
40
|
+
if (!verify.ok) return invalidVerificationToken();
|
|
48
41
|
|
|
49
42
|
return runConfirmTokenFlow<VerifyEmailData>(ctx, verify.userId, verify.expiresAtMs, {
|
|
50
43
|
purpose: "verify",
|
|
51
44
|
redisRequiredMessage: "email-verification requires ctx.redis to enforce token single-use",
|
|
52
|
-
invalidToken,
|
|
45
|
+
invalidToken: invalidVerificationToken,
|
|
53
46
|
buildChanges: async () => ({ emailVerified: true }),
|
|
54
47
|
successData: { kind: "verified" },
|
|
55
48
|
alreadyDone: {
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
// Factory für app-spezifische Auth-Mail-Configs. Baut passwordReset,
|
|
2
|
+
// emailVerification, signup und invite Setups gegen mailSender + Render-
|
|
3
|
+
// Funktionen — eliminiert Duplikate zwischen kumiko-studio, publicstatus
|
|
4
|
+
// und solon (jede App hatte identische send*Email-Wrapper kopiert).
|
|
5
|
+
export {
|
|
6
|
+
type AuthMailerConfig,
|
|
7
|
+
type CreateAuthMailerConfigArgs,
|
|
8
|
+
createAuthMailerConfig,
|
|
9
|
+
} from "./auth-mailer";
|
|
1
10
|
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
|
|
2
11
|
// Default-HTML-Renderer für die Reset-Password + Verify-Email Mails.
|
|
3
12
|
// Apps wiren die `sendResetEmail` / `sendVerificationEmail` callbacks
|
|
@@ -111,3 +111,27 @@ describe("TenantSwitcher", () => {
|
|
|
111
111
|
expect(session.switchTenant).not.toHaveBeenCalled();
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
describe("TenantSwitcher — key-only-Fallback im geöffneten Dropdown", () => {
|
|
116
|
+
test("Membership ohne name rendert ihren key als Dropdown-Label", async () => {
|
|
117
|
+
const user = userEvent.setup();
|
|
118
|
+
const session = makeSessionApi({
|
|
119
|
+
activeTenantId: "00000000-0000-4000-8000-000000000001",
|
|
120
|
+
tenants: [
|
|
121
|
+
{
|
|
122
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
123
|
+
roles: ["Admin"],
|
|
124
|
+
name: "Status",
|
|
125
|
+
key: "status",
|
|
126
|
+
},
|
|
127
|
+
{ tenantId: "00000000-0000-4000-8000-000000000002", roles: ["Admin"], key: "demo" },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
renderWithProviders(<TenantSwitcher />, { session });
|
|
131
|
+
await user.click(screen.getByRole("button", { name: /Status/ }));
|
|
132
|
+
// Der key-only-Pfad (kein name) muss im Dropdown sichtbar werden —
|
|
133
|
+
// nicht das UUID-Präfix "00000000".
|
|
134
|
+
expect(screen.getByText("demo")).toBeTruthy();
|
|
135
|
+
expect(screen.queryByText("00000000")).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -34,6 +34,7 @@ export function ForgotPasswordScreen({
|
|
|
34
34
|
const [done, setDone] = useState(false);
|
|
35
35
|
const [error, setError] = useState<string | null>(null);
|
|
36
36
|
|
|
37
|
+
// guard:dup-ok — gleiches Submit-Muster wie signup-screen, aber verschiedene API-Endpoints und State
|
|
37
38
|
const doSubmit = async (): Promise<void> => {
|
|
38
39
|
setSubmitting(true);
|
|
39
40
|
setError(null);
|
|
@@ -63,7 +63,8 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
|
|
|
63
63
|
const nameOf = (tenantId: string): string => {
|
|
64
64
|
if (tenantName !== undefined) return tenantName(tenantId);
|
|
65
65
|
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
66
|
-
|
|
66
|
+
// || statt ??: ein leerer name/key-String darf nicht als Label durchrutschen.
|
|
67
|
+
return membership?.name || membership?.key || tenantId.slice(0, 8);
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
// Rendering-Gate: kein User → nix; nur ein Tenant → auch nix
|
|
@@ -413,6 +413,11 @@ export type StockCapResult =
|
|
|
413
413
|
* (ein Delete gibt den Slot sofort frei), braucht keine Counter-Tabelle und
|
|
414
414
|
* kein Increment/Decrement-Bookkeeping. Misst einen Bestand, keinen Fluss.
|
|
415
415
|
*
|
|
416
|
+
* **TOCTOU-Caveat:** Zählen und Schreiben sind nicht atomar — zwei parallele
|
|
417
|
+
* Creates können beide `ok` sehen und das Limit um eins überschreiten (gleiche
|
|
418
|
+
* Race wie bei {@link enforceCap}). Exakt harte Slots brauchen zusätzliche
|
|
419
|
+
* Serialisierung im Create-Pfad (z.B. Unique-Index auf tenantId+slot).
|
|
420
|
+
*
|
|
416
421
|
* Reine Funktion — wirft NICHT und mappt KEINEN HTTP-Status. Ein erreichtes
|
|
417
422
|
* Stock-Limit heißt „Upgrade nötig", nicht „retry later" (429): der Caller
|
|
418
423
|
* entscheidet die Reaktion, typisch ein app-eigener 422/`upgrade_required`
|
|
@@ -78,7 +78,7 @@ explizit als eigene Entity.
|
|
|
78
78
|
|
|
79
79
|
## Tests
|
|
80
80
|
|
|
81
|
-
`__tests__/compliance-profiles.integration.ts` — 9 full-stack Tests via
|
|
81
|
+
`__tests__/compliance-profiles.integration.test.ts` — 9 full-stack Tests via
|
|
82
82
|
`setupTestStack` + echte HTTP-Calls (Memory: `feedback_no_fake_dispatcher`):
|
|
83
83
|
list-profiles, for-tenant ohne/mit Setting, set-profile als TenantAdmin /
|
|
84
84
|
Member (403) / mit Override / mit invalidem JSON / mit Array statt Object /
|
|
@@ -12,7 +12,7 @@ describe("createCustomFieldsFeature shape", () => {
|
|
|
12
12
|
test("registers field-definition entity + 6 write-handlers + 1 query-handler", () => {
|
|
13
13
|
const feature = createCustomFieldsFeature();
|
|
14
14
|
|
|
15
|
-
expect(Object.keys(feature.entities)).toContain("field-definition");
|
|
15
|
+
expect(Object.keys(feature.entities ?? {})).toContain("field-definition");
|
|
16
16
|
|
|
17
17
|
const writeHandlerNames = Object.keys(feature.writeHandlers);
|
|
18
18
|
expect(writeHandlerNames).toEqual(
|
|
@@ -40,7 +40,7 @@ describe("wireCustomFieldsFor", () => {
|
|
|
40
40
|
);
|
|
41
41
|
|
|
42
42
|
// 3. postQuery entity-hook on "property"
|
|
43
|
-
expect(feature.entityHooks
|
|
43
|
+
expect(feature.entityHooks?.postQuery?.["property"]).toHaveLength(1);
|
|
44
44
|
|
|
45
45
|
// 4. search-payload-extension on "property"
|
|
46
46
|
expect(feature.searchPayloadExtensions!["property"]).toHaveLength(1);
|
|
@@ -52,7 +52,7 @@ describe("wireCustomFieldsFor", () => {
|
|
|
52
52
|
wireCustomFieldsFor(r, "property", propertyTable);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
const hook = feature.entityHooks
|
|
55
|
+
const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
|
|
56
56
|
expect(hook).toBeDefined();
|
|
57
57
|
const result = await hook?.(
|
|
58
58
|
{
|
|
@@ -82,7 +82,7 @@ describe("wireCustomFieldsFor", () => {
|
|
|
82
82
|
wireCustomFieldsFor(r, "property", propertyTable);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
const hook = feature.entityHooks
|
|
85
|
+
const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
|
|
86
86
|
const result = await hook?.(
|
|
87
87
|
{
|
|
88
88
|
entityName: "property",
|
|
@@ -111,7 +111,7 @@ describe("wireCustomFieldsFor", () => {
|
|
|
111
111
|
wireCustomFieldsFor(r, "property", propertyTable);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
const hook = feature.entityHooks
|
|
114
|
+
const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
|
|
115
115
|
const result = await hook?.(
|
|
116
116
|
{
|
|
117
117
|
entityName: "property",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
|
+
// guard:dup-ok — andere SQL als selectFieldDefinitionsForEntity; gleiche Bezeichner, verschiedene Queries
|
|
4
5
|
export async function selectFieldDefinitionsWithSerialized(
|
|
5
6
|
db: DbRunner,
|
|
6
7
|
entityName: string,
|
|
@@ -43,3 +43,14 @@ export function parseSerializedField(raw: unknown): SerializedFieldShape | null
|
|
|
43
43
|
const parsed = typeof raw === "string" ? parseJsonSafe<unknown>(raw, null) : raw;
|
|
44
44
|
return isShape(parsed) ? parsed : null;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
export interface FieldDefinitionRow {
|
|
48
|
+
readonly field_key: string;
|
|
49
|
+
readonly serialized_field: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
|
|
53
|
+
if (!value || typeof value !== "object") return false;
|
|
54
|
+
if (!("field_key" in value)) return false;
|
|
55
|
+
return typeof value.field_key === "string";
|
|
56
|
+
}
|
|
@@ -16,22 +16,14 @@
|
|
|
16
16
|
// jsonb shape, which would be a breaking schema change.
|
|
17
17
|
|
|
18
18
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
+
import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
19
20
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
20
21
|
import {
|
|
21
22
|
applyRetentionRemovals,
|
|
22
23
|
selectFieldDefinitionsWithSerialized,
|
|
23
24
|
selectHostRowsWithCustomFields,
|
|
24
25
|
} from "./db/queries/retention";
|
|
25
|
-
import { parseSerializedField } from "./lib/parse-serialized-field";
|
|
26
|
-
|
|
27
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
28
|
-
function getTableName(table: unknown): string {
|
|
29
|
-
if (typeof table === "object" && table !== null) {
|
|
30
|
-
const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
31
|
-
if (typeof sym === "string") return sym;
|
|
32
|
-
}
|
|
33
|
-
throw new Error("custom-fields/run-retention: table missing kumiko:schema:Name symbol");
|
|
34
|
-
}
|
|
26
|
+
import { isFieldDefinitionRow, parseSerializedField } from "./lib/parse-serialized-field";
|
|
35
27
|
|
|
36
28
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
37
29
|
|
|
@@ -87,7 +79,7 @@ export async function runCustomFieldsRetention(
|
|
|
87
79
|
return { rowsScanned: 0, rowsUpdated: 0, removalsByFieldKey: {} };
|
|
88
80
|
}
|
|
89
81
|
|
|
90
|
-
const tableName =
|
|
82
|
+
const tableName = extractTableName(opts.entityTable, "custom-fields/run-retention");
|
|
91
83
|
const rows = await selectHostRowsWithCustomFields(opts.db, tableName, opts.tenantId);
|
|
92
84
|
|
|
93
85
|
const removalsByFieldKey: Record<string, number> = {};
|
|
@@ -159,17 +151,6 @@ function asHostRow(value: unknown): HostRow | null {
|
|
|
159
151
|
};
|
|
160
152
|
}
|
|
161
153
|
|
|
162
|
-
interface FieldDefinitionRow {
|
|
163
|
-
readonly field_key: string;
|
|
164
|
-
readonly serialized_field: unknown;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
|
|
168
|
-
if (!value || typeof value !== "object") return false;
|
|
169
|
-
if (!("field_key" in value)) return false;
|
|
170
|
-
return typeof value.field_key === "string";
|
|
171
|
-
}
|
|
172
|
-
|
|
173
154
|
async function loadRetentionPolicies(
|
|
174
155
|
db: DbRunner,
|
|
175
156
|
tenantId: string,
|
|
@@ -192,6 +173,7 @@ interface InstantLike {
|
|
|
192
173
|
readonly epochMilliseconds: number;
|
|
193
174
|
}
|
|
194
175
|
|
|
176
|
+
// guard:dup-ok — false positive: gleiche TypeGuard-Struktur wie isFieldDefinitionRow, völlig andere Semantik
|
|
195
177
|
function isInstantLike(value: unknown): value is InstantLike {
|
|
196
178
|
if (!value || typeof value !== "object") return false;
|
|
197
179
|
if (!("epochMilliseconds" in value)) return false;
|
|
@@ -234,3 +234,151 @@ describe("CustomFieldsFormSection", () => {
|
|
|
234
234
|
expect(saveBtn.textContent).toBe("Save custom fields");
|
|
235
235
|
});
|
|
236
236
|
});
|
|
237
|
+
|
|
238
|
+
describe("CustomFieldsFormSection — clear-Pfad", () => {
|
|
239
|
+
test("Leeren eines gespeicherten Werts dispatched clear-custom-field (nicht skip)", async () => {
|
|
240
|
+
mockedQueryRows = [
|
|
241
|
+
{
|
|
242
|
+
id: "f1",
|
|
243
|
+
entityName: "component",
|
|
244
|
+
fieldKey: "vendor",
|
|
245
|
+
type: "text",
|
|
246
|
+
required: false,
|
|
247
|
+
displayOrder: 1,
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
dispatchSpy.mockClear();
|
|
251
|
+
|
|
252
|
+
render(
|
|
253
|
+
<Wrapper>
|
|
254
|
+
<CustomFieldsFormSection
|
|
255
|
+
entityName="component"
|
|
256
|
+
entityId="row-42"
|
|
257
|
+
initialValues={{ vendor: "Hetzner" }}
|
|
258
|
+
/>
|
|
259
|
+
</Wrapper>,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
|
|
263
|
+
expect(vendorInput.value).toBe("Hetzner");
|
|
264
|
+
|
|
265
|
+
fireEvent.change(vendorInput, { target: { value: "" } });
|
|
266
|
+
fireEvent.click(screen.getByTestId("custom-fields-form-save"));
|
|
267
|
+
await Promise.resolve();
|
|
268
|
+
await Promise.resolve();
|
|
269
|
+
|
|
270
|
+
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
|
271
|
+
expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:clear-custom-field", {
|
|
272
|
+
entityName: "component",
|
|
273
|
+
entityId: "row-42",
|
|
274
|
+
fieldKey: "vendor",
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("unveränderter Bestandswert wird beim Save NICHT erneut geschrieben", async () => {
|
|
279
|
+
mockedQueryRows = [
|
|
280
|
+
{
|
|
281
|
+
id: "f1",
|
|
282
|
+
entityName: "component",
|
|
283
|
+
fieldKey: "vendor",
|
|
284
|
+
type: "text",
|
|
285
|
+
required: false,
|
|
286
|
+
displayOrder: 1,
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
dispatchSpy.mockClear();
|
|
290
|
+
|
|
291
|
+
render(
|
|
292
|
+
<Wrapper>
|
|
293
|
+
<CustomFieldsFormSection
|
|
294
|
+
entityName="component"
|
|
295
|
+
entityId="row-42"
|
|
296
|
+
initialValues={{ vendor: "Hetzner" }}
|
|
297
|
+
/>
|
|
298
|
+
</Wrapper>,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
|
|
302
|
+
// Tippen + zurück auf den Bestandswert → nicht dirty, kein Write.
|
|
303
|
+
fireEvent.change(vendorInput, { target: { value: "Hetzner2" } });
|
|
304
|
+
fireEvent.change(vendorInput, { target: { value: "Hetzner" } });
|
|
305
|
+
fireEvent.click(screen.getByTestId("custom-fields-form-save"));
|
|
306
|
+
await Promise.resolve();
|
|
307
|
+
await Promise.resolve();
|
|
308
|
+
|
|
309
|
+
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
|
|
314
|
+
test("boolean: Bestand wird als true/false-String angezeigt, Save coerced zu boolean", async () => {
|
|
315
|
+
mockedQueryRows = [
|
|
316
|
+
{
|
|
317
|
+
id: "f1",
|
|
318
|
+
entityName: "component",
|
|
319
|
+
fieldKey: "active",
|
|
320
|
+
type: "boolean",
|
|
321
|
+
required: false,
|
|
322
|
+
displayOrder: 1,
|
|
323
|
+
},
|
|
324
|
+
];
|
|
325
|
+
dispatchSpy.mockClear();
|
|
326
|
+
|
|
327
|
+
render(
|
|
328
|
+
<Wrapper>
|
|
329
|
+
<CustomFieldsFormSection
|
|
330
|
+
entityName="component"
|
|
331
|
+
entityId="row-42"
|
|
332
|
+
initialValues={{ active: true }}
|
|
333
|
+
/>
|
|
334
|
+
</Wrapper>,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// boolean rendert als Checkbox — Bestand steckt in checked, nicht value.
|
|
338
|
+
const input = document.getElementById("custom-field-active") as HTMLInputElement;
|
|
339
|
+
expect(input.checked).toBe(true);
|
|
340
|
+
|
|
341
|
+
fireEvent.click(input);
|
|
342
|
+
fireEvent.click(screen.getByTestId("custom-fields-form-save"));
|
|
343
|
+
await Promise.resolve();
|
|
344
|
+
await Promise.resolve();
|
|
345
|
+
|
|
346
|
+
expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
|
|
347
|
+
entityName: "component",
|
|
348
|
+
entityId: "row-42",
|
|
349
|
+
fieldKey: "active",
|
|
350
|
+
value: false,
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("date: Bestand erreicht den DateInput-Trigger (lokalisierte Anzeige)", () => {
|
|
355
|
+
mockedQueryRows = [
|
|
356
|
+
{
|
|
357
|
+
id: "f1",
|
|
358
|
+
entityName: "component",
|
|
359
|
+
fieldKey: "launchedAt",
|
|
360
|
+
type: "date",
|
|
361
|
+
required: false,
|
|
362
|
+
displayOrder: 1,
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
dispatchSpy.mockClear();
|
|
366
|
+
|
|
367
|
+
render(
|
|
368
|
+
<Wrapper>
|
|
369
|
+
<CustomFieldsFormSection
|
|
370
|
+
entityName="component"
|
|
371
|
+
entityId="row-42"
|
|
372
|
+
initialValues={{ launchedAt: "2026-01-15" }}
|
|
373
|
+
/>
|
|
374
|
+
</Wrapper>,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// DateInput ist ein Kalender-Trigger-Button (kein <input value>) mit
|
|
378
|
+
// lokalisierter Anzeige — der Kalender-Flow selbst ist jsdom-untauglich
|
|
379
|
+
// (Popover), hier zählt: der Bestand kommt im Trigger an, nicht "—".
|
|
380
|
+
const trigger = document.getElementById("custom-field-launchedAt") as HTMLButtonElement;
|
|
381
|
+
expect(trigger.textContent).toContain("2026");
|
|
382
|
+
expect(trigger.textContent).not.toContain("—");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -92,20 +92,35 @@ export function CustomFieldsFormSection({
|
|
|
92
92
|
);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Dirty heißt: weicht vom GESPEICHERTEN Wert ab — nicht von "". Sonst ist
|
|
96
|
+
// das Leeren eines gespeicherten Werts unsichtbar (Button disabled) und
|
|
97
|
+
// handleSave würde es überspringen statt zu clearen.
|
|
98
|
+
const initialDisplay = (field: (typeof matchingFields)[number]): string =>
|
|
99
|
+
displayValue(field.type, initialValues?.[field.fieldKey]);
|
|
100
|
+
const changedFields = matchingFields.filter((field) => {
|
|
101
|
+
const raw = pending[field.fieldKey];
|
|
102
|
+
return raw !== undefined && raw !== initialDisplay(field);
|
|
103
|
+
});
|
|
104
|
+
|
|
95
105
|
const handleSave = async (): Promise<void> => {
|
|
96
106
|
setSaving(true);
|
|
97
107
|
setErrorKey(null);
|
|
98
108
|
try {
|
|
99
|
-
for (const field of
|
|
100
|
-
const raw = pending[field.fieldKey];
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
for (const field of changedFields) {
|
|
110
|
+
const raw = pending[field.fieldKey] ?? "";
|
|
111
|
+
const result =
|
|
112
|
+
raw === ""
|
|
113
|
+
? await dispatcher.write(CustomFieldsHandlers.clearCustomField, {
|
|
114
|
+
entityName,
|
|
115
|
+
entityId,
|
|
116
|
+
fieldKey: field.fieldKey,
|
|
117
|
+
})
|
|
118
|
+
: await dispatcher.write(CustomFieldsHandlers.setCustomField, {
|
|
119
|
+
entityName,
|
|
120
|
+
entityId,
|
|
121
|
+
fieldKey: field.fieldKey,
|
|
122
|
+
value: coerceValue(field.type, raw),
|
|
123
|
+
});
|
|
109
124
|
if (!result.isSuccess) {
|
|
110
125
|
setErrorKey(result.error?.i18nKey ?? "custom-fields.errors.saveFailed");
|
|
111
126
|
return;
|
|
@@ -117,7 +132,7 @@ export function CustomFieldsFormSection({
|
|
|
117
132
|
}
|
|
118
133
|
};
|
|
119
134
|
|
|
120
|
-
const dirty =
|
|
135
|
+
const dirty = changedFields.length > 0;
|
|
121
136
|
|
|
122
137
|
return (
|
|
123
138
|
<div data-testid="custom-fields-form-section">
|
|
@@ -204,6 +219,5 @@ function coerceValue(type: string, raw: string): unknown {
|
|
|
204
219
|
function displayValue(type: string, value: unknown): string {
|
|
205
220
|
if (value === undefined || value === null) return "";
|
|
206
221
|
if (type === "boolean") return value === true ? "true" : "false";
|
|
207
|
-
if (type === "number") return typeof value === "number" ? String(value) : String(value);
|
|
208
222
|
return String(value);
|
|
209
223
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import {
|
|
2
3
|
createJsonbField,
|
|
3
4
|
type FeatureRegistrar,
|
|
@@ -13,15 +14,6 @@ import {
|
|
|
13
14
|
setCustomFieldValue,
|
|
14
15
|
} from "./db/queries/projection";
|
|
15
16
|
|
|
16
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
17
|
-
function getTableName(table: unknown): string {
|
|
18
|
-
if (typeof table === "object" && table !== null) {
|
|
19
|
-
const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
20
|
-
if (typeof sym === "string") return sym;
|
|
21
|
-
}
|
|
22
|
-
throw new Error("wire-for-entity: table missing kumiko:schema:Name symbol");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
17
|
import type { CustomFieldClearedPayload, CustomFieldSetPayload } from "./events";
|
|
26
18
|
import { customFieldsFeature } from "./feature";
|
|
27
19
|
|
|
@@ -107,7 +99,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
107
99
|
// jsonb_set: setze key auf value. Wenn key noch nicht existiert →
|
|
108
100
|
// wird angelegt (create_missing=true ist default). value muss als
|
|
109
101
|
// jsonb-literal kommen.
|
|
110
|
-
const tableName =
|
|
102
|
+
const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
|
|
111
103
|
await setCustomFieldValue(
|
|
112
104
|
tx,
|
|
113
105
|
tableName,
|
|
@@ -124,7 +116,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
124
116
|
const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
|
|
125
117
|
|
|
126
118
|
// jsonb minus operator (`-`) entfernt key aus jsonb-object.
|
|
127
|
-
const tableName =
|
|
119
|
+
const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
|
|
128
120
|
await clearCustomFieldKey(
|
|
129
121
|
tx,
|
|
130
122
|
tableName,
|
|
@@ -147,7 +139,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
147
139
|
// ihre Rows.
|
|
148
140
|
if (payload.entityName !== entityName) return;
|
|
149
141
|
|
|
150
|
-
const tableName =
|
|
142
|
+
const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
|
|
151
143
|
// Scope cleanup to the deleted definition's owning tenant. System-scope
|
|
152
144
|
// definitions apply to every tenant → cascade across all rows; tenant-
|
|
153
145
|
// scope deletions must only touch that tenant's rows, else deleting one
|