@cosmicdrift/kumiko-bundled-features 0.105.1 → 0.108.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 +8 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +112 -1
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +8 -2
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +76 -1
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +81 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +86 -0
- package/src/folders/web/folder-manager.tsx +27 -15
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +10 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +22 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +45 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +29 -8
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/__tests__/user.integration.test.ts +36 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +44 -2
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +2 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +69 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +42 -0
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +16 -2
- package/src/user-data-rights/screens.ts +7 -6
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +40 -5
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
// auch zu loggedInHref damit der invitee in seinem neuen Tenant landet.
|
|
18
18
|
|
|
19
19
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
20
|
-
import { type FormEvent, type ReactNode, useState } from "react";
|
|
20
|
+
import { type FormEvent, type ReactNode, useContext, useState } from "react";
|
|
21
21
|
import { csrfHeader } from "./auth-client";
|
|
22
22
|
import { AuthCard, authMutedLinkClass, parseUrlToken } from "./auth-form-primitives";
|
|
23
|
-
import {
|
|
23
|
+
import { SessionContext, UNAUTHENTICATED } from "./session";
|
|
24
24
|
|
|
25
25
|
export type InviteAcceptScreenProps = {
|
|
26
26
|
readonly title?: string;
|
|
@@ -42,7 +42,11 @@ export function InviteAcceptScreen({
|
|
|
42
42
|
}: InviteAcceptScreenProps): ReactNode {
|
|
43
43
|
const t = useTranslation();
|
|
44
44
|
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
45
|
-
|
|
45
|
+
// NOT useSession(): InviteAcceptScreen is a public route (anon invite-links
|
|
46
|
+
// are the documented use case) — useSession() throws without a
|
|
47
|
+
// <SessionProvider> ancestor. Read the context directly and fall back to
|
|
48
|
+
// the anonymous state when no provider wraps this screen (632/1).
|
|
49
|
+
const session = useContext(SessionContext) ?? UNAUTHENTICATED;
|
|
46
50
|
const [token] = useState(() => tokenProp ?? parseUrlToken());
|
|
47
51
|
const [mode, setMode] = useState<Mode>(() =>
|
|
48
52
|
session.status === "authenticated" ? "loggedin" : "anon-existing",
|
|
@@ -44,7 +44,11 @@ export type SessionApi = SessionState & {
|
|
|
44
44
|
readonly switchTenant: (tenantId: string) => Promise<void>;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// Exported so screens that render on BOTH authenticated and anonymous public
|
|
48
|
+
// routes (e.g. InviteAcceptScreen) can read `.status` without requiring a
|
|
49
|
+
// <SessionProvider> ancestor — see useContext(SessionContext) ?? UNAUTHENTICATED
|
|
50
|
+
// in invite-accept-screen.tsx (632/1).
|
|
51
|
+
export const UNAUTHENTICATED: SessionState = {
|
|
48
52
|
status: "unauthenticated",
|
|
49
53
|
user: null,
|
|
50
54
|
activeTenantId: null,
|
|
@@ -64,7 +64,7 @@ export function UserMenu({ children, variant = "pill" }: UserMenuProps): ReactNo
|
|
|
64
64
|
<DropdownMenuContent align="end" aria-label={t("auth.user.menu.label")}>
|
|
65
65
|
<DropdownMenuLabel className="text-xs">
|
|
66
66
|
<div className="font-medium text-foreground truncate">{displayName}</div>
|
|
67
|
-
<div className="truncate">{user.email}</div>
|
|
67
|
+
{hasName && <div className="truncate">{user.email}</div>}
|
|
68
68
|
</DropdownMenuLabel>
|
|
69
69
|
<DropdownMenuSeparator />
|
|
70
70
|
{children}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { deserializeValue } from "../resolver";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// refactor can't quietly change them: a stored value whose JSON type disagrees
|
|
8
|
-
// with the declared type is NOT rejected — it is coerced (number via Number(),
|
|
9
|
-
// boolean only via literal true / the string "true", text via String()).
|
|
4
|
+
// Type mismatches are coerced, not rejected (number via Number(), boolean
|
|
5
|
+
// only via literal true/"true", text via String()) — pinned so a refactor
|
|
6
|
+
// can't quietly change that.
|
|
10
7
|
|
|
11
8
|
describe("deserializeValue", () => {
|
|
12
9
|
test("null raw short-circuits to undefined before any parse", () => {
|
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
SYSTEM_TENANT_ID,
|
|
23
23
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
24
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
25
|
+
import {
|
|
26
|
+
createProjectionStateTable,
|
|
27
|
+
rebuildProjection,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
25
29
|
import {
|
|
26
30
|
createTestUser,
|
|
27
31
|
setupTestStack,
|
|
@@ -84,6 +88,7 @@ beforeAll(async () => {
|
|
|
84
88
|
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
85
89
|
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
86
90
|
await createEventsTable(stack.db);
|
|
91
|
+
await createProjectionStateTable(stack.db);
|
|
87
92
|
});
|
|
88
93
|
|
|
89
94
|
afterAll(async () => {
|
|
@@ -919,3 +924,63 @@ describe("custom-fields integration — update-tenant-field (Bug-Bash D2)", () =
|
|
|
919
924
|
expect(causeMessage).toContain("update-tenant-field");
|
|
920
925
|
});
|
|
921
926
|
});
|
|
927
|
+
|
|
928
|
+
describe("custom-fields integration — official host-entity rebuild (#759)", () => {
|
|
929
|
+
test("rebuildProjection replays customField.set/cleared + fieldDefinition.deleted", async () => {
|
|
930
|
+
await defineField("property", "color", "text");
|
|
931
|
+
await defineField("property", "doomed", "text");
|
|
932
|
+
const propId = "dddddddd-dddd-4000-8000-00000000000d";
|
|
933
|
+
await createProperty(propId, "RebuildOfficial");
|
|
934
|
+
await setCustomField("property", propId, "color", "blue");
|
|
935
|
+
await setCustomField("property", propId, "doomed", "bye");
|
|
936
|
+
// fieldDef-delete cascade removes the key — the rebuild must preserve the
|
|
937
|
+
// removal (fieldDefinition.deleted rides the extra "field-definition" source).
|
|
938
|
+
await stack.http.writeOk(
|
|
939
|
+
"custom-fields:write:delete-tenant-field",
|
|
940
|
+
{ entityName: "property", fieldKey: "doomed" },
|
|
941
|
+
admin,
|
|
942
|
+
);
|
|
943
|
+
await stack.eventDispatcher?.runOnce();
|
|
944
|
+
|
|
945
|
+
let row = (await listProperties()).rows.find((r) => r["id"] === propId);
|
|
946
|
+
expect(row?.["color"]).toBe("blue");
|
|
947
|
+
expect(row?.["doomed"]).toBeUndefined();
|
|
948
|
+
|
|
949
|
+
// The routine schema-migration path: full official rebuild of the host
|
|
950
|
+
// entity's implicit projection. Pre-#759 this reset every custom_fields
|
|
951
|
+
// jsonb to its default '{}' with no recovery path.
|
|
952
|
+
const result = await rebuildProjection("property-test:projection:property-entity", {
|
|
953
|
+
db: stack.db,
|
|
954
|
+
registry: stack.registry,
|
|
955
|
+
});
|
|
956
|
+
expect(result.eventsProcessed).toBeGreaterThan(0);
|
|
957
|
+
|
|
958
|
+
row = (await listProperties()).rows.find((r) => r["id"] === propId);
|
|
959
|
+
expect(row?.["name"]).toBe("RebuildOfficial");
|
|
960
|
+
expect(row?.["color"]).toBe("blue");
|
|
961
|
+
expect(row?.["doomed"]).toBeUndefined();
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test("clear-custom-field removal survives the official rebuild", async () => {
|
|
965
|
+
await defineField("property", "flag", "text");
|
|
966
|
+
await defineField("property", "keep", "text");
|
|
967
|
+
const propId = "eeeeeeee-eeee-4000-8000-00000000000e";
|
|
968
|
+
await createProperty(propId, "ClearSurvives");
|
|
969
|
+
await setCustomField("property", propId, "flag", "on");
|
|
970
|
+
await setCustomField("property", propId, "keep", "yes");
|
|
971
|
+
await clearCustomField("property", propId, "flag");
|
|
972
|
+
await stack.eventDispatcher?.runOnce();
|
|
973
|
+
|
|
974
|
+
await rebuildProjection("property-test:projection:property-entity", {
|
|
975
|
+
db: stack.db,
|
|
976
|
+
registry: stack.registry,
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const row = (await listProperties()).rows.find((r) => r["id"] === propId);
|
|
980
|
+
// Kontrollfeld beweist dass der Rebuild die set-Events real replayt hat —
|
|
981
|
+
// ein leeres {} wuerde "flag undefined" trivial erfuellen.
|
|
982
|
+
expect(row?.["keep"]).toBe("yes");
|
|
983
|
+
expect(row?.["flag"]).toBeUndefined();
|
|
984
|
+
expect(row?.["name"]).toBe("ClearSurvives");
|
|
985
|
+
});
|
|
986
|
+
});
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// (strip runs, host nulls the owner).
|
|
23
23
|
|
|
24
24
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
25
|
-
import { asRawClient
|
|
25
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
26
26
|
import {
|
|
27
27
|
buildEntityTable,
|
|
28
28
|
createEventStoreExecutor,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
TestUsers,
|
|
47
47
|
unsafeCreateEntityTable,
|
|
48
48
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
49
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
49
50
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
50
51
|
import { z } from "zod";
|
|
51
52
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
@@ -241,7 +242,7 @@ async function readRow(id: string): Promise<Record<string, unknown> | undefined>
|
|
|
241
242
|
// the export runner iterates their data. Status active = NOT picked up by
|
|
242
243
|
// runForgetCleanup.
|
|
243
244
|
async function seedActiveUserWithMembership(): Promise<void> {
|
|
244
|
-
await
|
|
245
|
+
await seedRow(stack.db, userTable, {
|
|
245
246
|
id: admin.id,
|
|
246
247
|
tenantId: TENANT,
|
|
247
248
|
email: `admin@example.com`,
|
|
@@ -258,7 +259,7 @@ async function seedActiveUserWithMembership(): Promise<void> {
|
|
|
258
259
|
// Seed the acting admin as DeletionRequested + grace expired so
|
|
259
260
|
// runForgetCleanup picks them up, with a membership in TENANT.
|
|
260
261
|
async function seedForgetUserWithMembership(): Promise<void> {
|
|
261
|
-
await
|
|
262
|
+
await seedRow(stack.db, userTable, {
|
|
262
263
|
id: admin.id,
|
|
263
264
|
tenantId: TENANT,
|
|
264
265
|
email: `admin@example.com`,
|
|
@@ -31,6 +31,7 @@ export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
|
|
|
31
31
|
// Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
|
|
32
32
|
// these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
|
|
33
33
|
// through r.defineEvent/toKebab, so the dot MUST stay.
|
|
34
|
+
export const FIELD_DEFINITION_AGGREGATE_TYPE = "field-definition";
|
|
34
35
|
export const FIELD_DEFINITION_CREATED_EVENT = "field-definition.created";
|
|
35
36
|
export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition.updated";
|
|
36
37
|
// Qualified at registration via r.defineEvent — final name is
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
3
|
+
import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants";
|
|
3
4
|
import { fieldDefinitionExecutor } from "../executor";
|
|
4
5
|
import { customFieldsFeature } from "../feature";
|
|
5
6
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
@@ -30,7 +31,7 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
|
|
|
30
31
|
if (result.isSuccess) {
|
|
31
32
|
await ctx.unsafeAppendEvent({
|
|
32
33
|
aggregateId,
|
|
33
|
-
aggregateType:
|
|
34
|
+
aggregateType: FIELD_DEFINITION_AGGREGATE_TYPE,
|
|
34
35
|
type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
|
|
35
36
|
payload: {
|
|
36
37
|
entityName: payload.entityName,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
3
|
+
import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants";
|
|
3
4
|
import { fieldDefinitionExecutor } from "../executor";
|
|
4
5
|
import { customFieldsFeature } from "../feature";
|
|
5
6
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
@@ -38,7 +39,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
|
|
|
38
39
|
if (result.isSuccess) {
|
|
39
40
|
await ctx.unsafeAppendEvent({
|
|
40
41
|
aggregateId,
|
|
41
|
-
aggregateType:
|
|
42
|
+
aggregateType: FIELD_DEFINITION_AGGREGATE_TYPE,
|
|
42
43
|
type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
|
|
43
44
|
payload: { entityName: payload.entityName, fieldKey: payload.fieldKey, tenantId },
|
|
44
45
|
});
|
|
@@ -300,11 +300,17 @@ describe("CustomFieldsFormSection — clear-Pfad", () => {
|
|
|
300
300
|
);
|
|
301
301
|
|
|
302
302
|
const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
|
|
303
|
+
const saveBtn = screen.getByTestId("custom-fields-form-save") as HTMLButtonElement;
|
|
303
304
|
// Tippen + zurück auf den Bestandswert → nicht dirty, kein Write.
|
|
304
305
|
await user.type(vendorInput, "2");
|
|
305
306
|
await user.type(vendorInput, "{Backspace}");
|
|
306
|
-
|
|
307
|
-
|
|
307
|
+
// Positiver Anker: erst abwarten, dass der Save-Button wieder disabled ist
|
|
308
|
+
// (beweist, dass die dirty-Neuberechnung durchgelaufen ist), statt direkt
|
|
309
|
+
// eine negative Assertion in waitFor zu pollen (die beim ersten Tick
|
|
310
|
+
// trivial besteht und keine ausstehenden Async-Effekte abwartet).
|
|
311
|
+
await waitFor(() => expect(saveBtn.disabled).toBe(true));
|
|
312
|
+
await user.click(saveBtn);
|
|
313
|
+
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
308
314
|
});
|
|
309
315
|
});
|
|
310
316
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
createJsonbField,
|
|
4
4
|
type FeatureRegistrar,
|
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
type JsonbFieldDef,
|
|
7
7
|
type TenantId,
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
-
import {
|
|
9
|
+
import type { StoredEvent } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
|
+
import { CUSTOM_FIELDS_EXTENSION, FIELD_DEFINITION_AGGREGATE_TYPE } from "./constants";
|
|
10
11
|
import {
|
|
11
12
|
clearCustomFieldKey,
|
|
12
13
|
removeCustomFieldKeyForTenant,
|
|
@@ -36,8 +37,9 @@ export function customFieldsField(): JsonbFieldDef {
|
|
|
36
37
|
|
|
37
38
|
// Vollständige integration der custom-fields-Bundle für eine spezifische
|
|
38
39
|
// host-entity. Eine einzige Aufruf-Stelle pro consumer registriert ALLE
|
|
39
|
-
// wiring-Aspekte: extension-tracking, MSP für value-projection,
|
|
40
|
-
// hook für API-flatten,
|
|
40
|
+
// wiring-Aspekte: extension-tracking, MSP für value-projection, rebuild-
|
|
41
|
+
// replay via extendEntityProjection, postQuery-hook für API-flatten,
|
|
42
|
+
// search-payload-extension für indexable customFields.
|
|
41
43
|
//
|
|
42
44
|
// Consumer-side:
|
|
43
45
|
//
|
|
@@ -61,15 +63,20 @@ export function customFieldsField(): JsonbFieldDef {
|
|
|
61
63
|
// emittiert haben. Updated entityTable.customFields jsonb über
|
|
62
64
|
// jsonb_set / jsonb-key-removal.
|
|
63
65
|
//
|
|
64
|
-
// 3. r.
|
|
66
|
+
// 3. r.extendEntityProjection — hängt dieselben apply-Handler in die
|
|
67
|
+
// implicit projection der host-entity, damit rebuildProjection die
|
|
68
|
+
// customField-Events mit-replayt. Ohne das fällt customFields bei
|
|
69
|
+
// jedem Entity-Rebuild (Schema-Migration!) auf `{}` zurück (#759).
|
|
70
|
+
//
|
|
71
|
+
// 4. r.entityHook("postQuery", entity, flatten-fn) — bei JEDEM Read auf
|
|
65
72
|
// diese entity wird `row.customFields` jsonb auf root-level expanded
|
|
66
73
|
// damit die API-response wie Stammfelder aussieht.
|
|
67
74
|
//
|
|
68
|
-
//
|
|
75
|
+
// 5. r.searchPayloadExtension(entity, contributor) — searchable
|
|
69
76
|
// customFields-keys werden flach ins Meilisearch-Index-Doc beigetragen
|
|
70
77
|
// (F3-wiring).
|
|
71
78
|
//
|
|
72
|
-
//
|
|
79
|
+
// 6. fieldDefinition.deleted-Event-Handler im selben MSP — bei delete
|
|
73
80
|
// einer fieldDefinition werden orphan values aus allen entity-rows
|
|
74
81
|
// entfernt (key-removal pro fieldKey).
|
|
75
82
|
export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
@@ -87,80 +94,100 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
87
94
|
const clearedEventType = customFieldsFeature.exports.clearedEvent.name;
|
|
88
95
|
const fieldDefDeletedType = customFieldsFeature.exports.fieldDefinitionDeletedEvent.name;
|
|
89
96
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// Geteilte apply-Bodies: die MSP liefert sie live (async cursor), die
|
|
98
|
+
// entity-projection-extension replayt sie in rebuildProjection — gleiche
|
|
99
|
+
// SQL-Helper, beide idempotent. 2-Param-Signatur ist die gemeinsame
|
|
100
|
+
// Teilmenge von MultiStreamApplyFn und SingleStreamApplyFn.
|
|
101
|
+
const applyCustomFieldSet = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
|
|
102
|
+
// skip: feuert für ALLE aggregate-types die customField.set emittieren —
|
|
103
|
+
// wir wollen nur die unserer wired host-entity. Andere consumers haben
|
|
104
|
+
// eigene Wirings für ihre Entities.
|
|
105
|
+
if (event.aggregateType !== entityName) return;
|
|
106
|
+
const payload = event.payload as CustomFieldSetPayload; // @cast-boundary engine-payload
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
// skip: sensitive fields self-project in the write handler (see
|
|
109
|
+
// set-custom-field) and persist a value-less event so PII never enters
|
|
110
|
+
// the log. Such events arrive here with value === undefined — skipping
|
|
111
|
+
// is correct both live (the handler already wrote the row) and on replay
|
|
112
|
+
// (the value is intentionally gone, the accepted rebuild-loss).
|
|
113
|
+
if (payload.value === undefined) return;
|
|
106
114
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
[clearedEventType]: async (event, tx) => {
|
|
121
|
-
// skip: MSP feuert für alle aggregate-types — nur unsere host-entity
|
|
122
|
-
// verarbeiten.
|
|
123
|
-
if (event.aggregateType !== entityName) return;
|
|
124
|
-
const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
|
|
115
|
+
// jsonb_set: setze key auf value. Wenn key noch nicht existiert →
|
|
116
|
+
// wird angelegt (create_missing=true ist default). value muss als
|
|
117
|
+
// jsonb-literal kommen.
|
|
118
|
+
const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
|
|
119
|
+
await setCustomFieldValue(
|
|
120
|
+
tx,
|
|
121
|
+
tableName,
|
|
122
|
+
payload.fieldKey,
|
|
123
|
+
payload.value,
|
|
124
|
+
event.aggregateId,
|
|
125
|
+
event.tenantId,
|
|
126
|
+
);
|
|
127
|
+
};
|
|
125
128
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
129
|
+
const applyCustomFieldCleared = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
|
|
130
|
+
// skip: feuert für alle aggregate-types — nur unsere host-entity
|
|
131
|
+
// verarbeiten.
|
|
132
|
+
if (event.aggregateType !== entityName) return;
|
|
133
|
+
const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
|
|
134
|
+
|
|
135
|
+
// jsonb minus operator (`-`) entfernt key aus jsonb-object.
|
|
136
|
+
const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
|
|
137
|
+
await clearCustomFieldKey(tx, tableName, payload.fieldKey, event.aggregateId, event.tenantId);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const applyFieldDefinitionDeleted = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
|
|
141
|
+
// fieldDefinition.deleted fires nur einmal pro fieldDef-delete
|
|
142
|
+
// (NICHT per-entity). Wir entfernen den key aus ALLEN rows der host-
|
|
143
|
+
// entity falls die deleted-fieldDef für diese entity galt.
|
|
144
|
+
const payload = event.payload as {
|
|
145
|
+
entityName: string;
|
|
146
|
+
fieldKey: string;
|
|
147
|
+
tenantId?: TenantId;
|
|
148
|
+
}; // @cast-boundary engine-payload
|
|
149
|
+
// skip: fieldDefinition.deleted feuert für ALLE fieldDefs cross-entity;
|
|
150
|
+
// nur wenn die deleted-fieldDef diese host-entity betraf, cleanen wir
|
|
151
|
+
// ihre Rows.
|
|
152
|
+
if (payload.entityName !== entityName) return;
|
|
153
|
+
|
|
154
|
+
const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
|
|
155
|
+
// Scope cleanup to the deleted definition's owning tenant. System-scope
|
|
156
|
+
// definitions apply to every tenant → cascade across all rows; tenant-
|
|
157
|
+
// scope deletions must only touch that tenant's rows, else deleting one
|
|
158
|
+
// tenant's field strips the same kebab key from every tenant (data loss).
|
|
159
|
+
// Fallback to the event's stream tenantId for events appended before the
|
|
160
|
+
// payload carried tenantId.
|
|
161
|
+
const defTenantId = payload.tenantId ?? event.tenantId;
|
|
162
|
+
if (isSystemTenant(defTenantId)) {
|
|
163
|
+
await removeCustomFieldKeyFromAllTenants(tx, tableName, payload.fieldKey);
|
|
164
|
+
} else {
|
|
165
|
+
await removeCustomFieldKeyForTenant(tx, tableName, payload.fieldKey, defTenantId);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
149
168
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
r.multiStreamProjection({
|
|
170
|
+
name: `custom-fields-${entityName}-projection`,
|
|
171
|
+
apply: {
|
|
172
|
+
[setEventType]: applyCustomFieldSet,
|
|
173
|
+
[clearedEventType]: applyCustomFieldCleared,
|
|
174
|
+
[fieldDefDeletedType]: applyFieldDefinitionDeleted,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Rebuild-wiring (#759): ohne das replayt rebuildProjection nur die
|
|
179
|
+
// entity-lifecycle-Events und resettet jede customFields-jsonb auf ihr
|
|
180
|
+
// Default `{}` — die Werte existieren ausschließlich als customField.set/
|
|
181
|
+
// .cleared-Events. set/cleared reiten auf dem HOST-entity-Stream
|
|
182
|
+
// (aggregateType = entityName, von source schon abgedeckt);
|
|
183
|
+
// fieldDefinition.deleted lebt auf dem "field-definition"-Stream und
|
|
184
|
+
// kommt deshalb als extra source dazu.
|
|
185
|
+
r.extendEntityProjection(entityName, {
|
|
186
|
+
sources: [FIELD_DEFINITION_AGGREGATE_TYPE],
|
|
187
|
+
apply: {
|
|
188
|
+
[setEventType]: applyCustomFieldSet,
|
|
189
|
+
[clearedEventType]: applyCustomFieldCleared,
|
|
190
|
+
[fieldDefDeletedType]: applyFieldDefinitionDeleted,
|
|
164
191
|
},
|
|
165
192
|
});
|
|
166
193
|
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { describe, expect, spyOn, test } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
|
|
3
3
|
|
|
4
|
+
// A mid-test assertion throw skips the trailing `warn.mockRestore()` in that
|
|
5
|
+
// test, leaving the spy live for every test after it (551/1) — this backstop
|
|
6
|
+
// restores unconditionally regardless of how the test exited.
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
const warn = console.warn as unknown as { mockRestore?: () => void };
|
|
9
|
+
warn.mockRestore?.();
|
|
10
|
+
});
|
|
11
|
+
|
|
4
12
|
// parseRetentionOverrideOrNull is the read boundary for a tenant's stored
|
|
5
13
|
// data-retention override (DSGVO-relevant policy in a config column). It must
|
|
6
14
|
// never let a corrupt or schema-violating value reach the retention decision:
|
|
@@ -31,19 +39,17 @@ describe("parseRetentionOverrideOrNull", () => {
|
|
|
31
39
|
});
|
|
32
40
|
|
|
33
41
|
test("corrupt JSON returns null without throwing", () => {
|
|
34
|
-
|
|
42
|
+
spyOn(console, "warn").mockImplementation(() => {});
|
|
35
43
|
expect(() => parse("{not json")).not.toThrow();
|
|
36
44
|
expect(parse("{not json")).toBeNull();
|
|
37
|
-
warn.mockRestore();
|
|
38
45
|
});
|
|
39
46
|
|
|
40
47
|
test("JSON that parses but violates the schema is dropped to null — never leaked through", () => {
|
|
41
|
-
|
|
48
|
+
spyOn(console, "warn").mockImplementation(() => {});
|
|
42
49
|
expect(parse('{"strategy":"delete"}')).toBeNull(); // enum drift
|
|
43
50
|
expect(parse('{"keepFor":"30days"}')).toBeNull(); // keepFor format drift
|
|
44
51
|
expect(parse('{"keepFor":42}')).toBeNull(); // wrong type
|
|
45
52
|
expect(parse('{"unknownKey":1}')).toBeNull(); // strict() rejects extra keys
|
|
46
|
-
warn.mockRestore();
|
|
47
53
|
});
|
|
48
54
|
|
|
49
55
|
test("each dropped value surfaces exactly one operator warning", () => {
|
|
@@ -51,6 +57,5 @@ describe("parseRetentionOverrideOrNull", () => {
|
|
|
51
57
|
parse("{not json");
|
|
52
58
|
parse('{"strategy":"delete"}');
|
|
53
59
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
54
|
-
warn.mockRestore();
|
|
55
60
|
});
|
|
56
61
|
});
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
15
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
17
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
18
|
import {
|
|
18
19
|
setupTestStack,
|
|
19
20
|
type TestStack,
|
|
20
21
|
unsafeCreateEntityTable,
|
|
21
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
24
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
23
25
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../feature";
|
|
24
26
|
import { runRetentionCleanup } from "../run-retention-cleanup";
|
|
@@ -52,13 +54,37 @@ const staleEntity = createEntity({
|
|
|
52
54
|
retention: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
53
55
|
});
|
|
54
56
|
|
|
57
|
+
// blockDelete = Aufbewahrungspflicht (HGB/DSGVO-Presets: invoice/booking/
|
|
58
|
+
// contract). Der Cleanup-Job muss diese Rows IGNORIEREN — nur user-forget
|
|
59
|
+
// darf sie anonymisieren. Ein Regress im switch-Default oder im blockDelete-
|
|
60
|
+
// Case, der sie trotzdem löscht, wäre schwerwiegender als ein verpasstes
|
|
61
|
+
// hardDelete (Aufbewahrungspflicht-Verletzung statt nur "zu spät aufgeräumt").
|
|
62
|
+
const retainedEntity = createEntity({
|
|
63
|
+
table: "read_c7_retained",
|
|
64
|
+
fields: {
|
|
65
|
+
label: createTextField({ required: true, anonymize: () => "[ANONYMIZED]" }),
|
|
66
|
+
},
|
|
67
|
+
retention: { keepFor: "30d", strategy: "blockDelete" },
|
|
68
|
+
});
|
|
69
|
+
|
|
55
70
|
const c7Feature = defineFeature("c7-retention-fixtures", (r) => {
|
|
56
71
|
r.entity("c7-widget", widgetEntity);
|
|
57
72
|
r.entity("c7-gadget", gadgetEntity);
|
|
58
73
|
r.entity("c7-plain", plainEntity);
|
|
59
74
|
r.entity("c7-stale", staleEntity);
|
|
75
|
+
r.entity("c7-retained", retainedEntity);
|
|
60
76
|
});
|
|
61
77
|
|
|
78
|
+
const noopLogger: JobContext["log"] = {
|
|
79
|
+
info() {},
|
|
80
|
+
warn() {},
|
|
81
|
+
error() {},
|
|
82
|
+
debug() {},
|
|
83
|
+
child() {
|
|
84
|
+
return noopLogger;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
62
88
|
const T1 = "11111111-1111-1111-1111-111111111111";
|
|
63
89
|
const T2 = "22222222-2222-2222-2222-222222222222";
|
|
64
90
|
|
|
@@ -75,6 +101,7 @@ beforeAll(async () => {
|
|
|
75
101
|
gadgetEntity,
|
|
76
102
|
plainEntity,
|
|
77
103
|
staleEntity,
|
|
104
|
+
retainedEntity,
|
|
78
105
|
]) {
|
|
79
106
|
await unsafeCreateEntityTable(stack.db, e);
|
|
80
107
|
}
|
|
@@ -111,7 +138,13 @@ async function liveGadgetLabels(tenantId: string): Promise<string[]> {
|
|
|
111
138
|
}
|
|
112
139
|
|
|
113
140
|
beforeEach(async () => {
|
|
114
|
-
for (const t of [
|
|
141
|
+
for (const t of [
|
|
142
|
+
"read_c7_widget",
|
|
143
|
+
"read_c7_gadget",
|
|
144
|
+
"read_c7_plain",
|
|
145
|
+
"read_c7_stale",
|
|
146
|
+
"read_c7_retained",
|
|
147
|
+
]) {
|
|
115
148
|
await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
|
|
116
149
|
}
|
|
117
150
|
});
|
|
@@ -185,4 +218,46 @@ describe("runRetentionCleanup :: real postgres", () => {
|
|
|
185
218
|
});
|
|
186
219
|
expect(result.hardDeleted).toBe(0);
|
|
187
220
|
});
|
|
221
|
+
|
|
222
|
+
test("the registered job handler resolves ctx.systemUser.tenantId and actually cleans up (not runRetentionCleanup called directly)", async () => {
|
|
223
|
+
// cleanup-cron-registration.test.ts only pins job metadata; runRetentionCleanup
|
|
224
|
+
// tests above call it directly, bypassing the handler body entirely — a bug in
|
|
225
|
+
// the ctx.systemUser?.tenantId ?? ctx._tenantId resolution or the throw-guard
|
|
226
|
+
// would be invisible to both. Invoke the ACTUAL registered handler.
|
|
227
|
+
await seed("read_c7_widget", T1, "expired-via-handler", pastIso);
|
|
228
|
+
await seed("read_c7_widget", T1, "fresh-via-handler", withinIso);
|
|
229
|
+
|
|
230
|
+
const job = stack.registry.getJob("data-retention:job:retention-cleanup");
|
|
231
|
+
expect(job).toBeDefined();
|
|
232
|
+
if (!job) return;
|
|
233
|
+
|
|
234
|
+
const ctx: JobContext = {
|
|
235
|
+
db: stack.db,
|
|
236
|
+
registry: stack.registry,
|
|
237
|
+
systemUser: { id: "system", tenantId: T1, roles: ["all"] },
|
|
238
|
+
log: noopLogger,
|
|
239
|
+
triggeredBy: null,
|
|
240
|
+
...bridgeStub(),
|
|
241
|
+
};
|
|
242
|
+
await job.handler({}, ctx);
|
|
243
|
+
|
|
244
|
+
expect(await labels("read_c7_widget", T1)).toEqual(["fresh-via-handler"]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("blockDelete: past-cutoff rows are never touched by the cleanup job", async () => {
|
|
248
|
+
await seed("read_c7_retained", T1, "should-survive", pastIso);
|
|
249
|
+
|
|
250
|
+
const result = await runRetentionCleanup({
|
|
251
|
+
db: stack.db,
|
|
252
|
+
registry: stack.registry,
|
|
253
|
+
tenantId: T1,
|
|
254
|
+
tenantPreset: null,
|
|
255
|
+
now,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(await labels("read_c7_retained", T1)).toEqual(["should-survive"]);
|
|
259
|
+
expect(result.hardDeleted).toBe(0);
|
|
260
|
+
expect(result.softDeleted).toBe(0);
|
|
261
|
+
expect(result.anonymizeDeferred).not.toContain("c7-retained");
|
|
262
|
+
});
|
|
188
263
|
});
|