@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -7
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +7 -6
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +20 -11
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +16 -2
- package/src/tenant/i18n.ts +2 -3
- package/src/tenant/screens.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +23 -6
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/i18n.ts +0 -6
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { TAGS_EDIT_SCREEN_ID, TAGS_SCREEN_ID } from "../constants";
|
|
4
|
+
import { createTagsFeature } from "../feature";
|
|
5
|
+
|
|
6
|
+
// QN convention: entityList loads tags:query:tag:list; entityEdit loads
|
|
7
|
+
// tags:query:tag:detail and saves via tags:write:tag:{create,update,delete}.
|
|
8
|
+
// Legacy create-tag/update-tag/delete-tag stay registered for TagManager.
|
|
9
|
+
|
|
10
|
+
describe("tags catalog screens (entityList + entityEdit)", () => {
|
|
11
|
+
const feature = createTagsFeature();
|
|
12
|
+
|
|
13
|
+
test("boot-validates with declarative tag-list + tag-edit", () => {
|
|
14
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("tag-list is entityList gated by default tag roles", () => {
|
|
18
|
+
const list = feature.screens[TAGS_SCREEN_ID];
|
|
19
|
+
expect(list?.type).toBe("entityList");
|
|
20
|
+
expect(list?.access).toEqual({ roles: ["TenantAdmin", "TenantMember"] });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("tag-edit is entityEdit with matching access", () => {
|
|
24
|
+
const edit = feature.screens[TAGS_EDIT_SCREEN_ID];
|
|
25
|
+
expect(edit?.type).toBe("entityEdit");
|
|
26
|
+
expect(edit?.access).toEqual({ roles: ["TenantAdmin", "TenantMember"] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("convention handlers sit beside legacy create-tag/update-tag/delete-tag", () => {
|
|
30
|
+
expect(Object.keys(feature.writeHandlers)).toEqual(
|
|
31
|
+
expect.arrayContaining([
|
|
32
|
+
"create-tag",
|
|
33
|
+
"update-tag",
|
|
34
|
+
"delete-tag",
|
|
35
|
+
"tag:create",
|
|
36
|
+
"tag:update",
|
|
37
|
+
"tag:delete",
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
expect(Object.keys(feature.queryHandlers)).toEqual(
|
|
41
|
+
expect.arrayContaining(["tag:list", "tag:detail", "tag-assignment:list"]),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("createTagsFeature({ roles }) threads access onto both screens", () => {
|
|
46
|
+
const custom = createTagsFeature({ roles: ["Admin"] });
|
|
47
|
+
expect(custom.screens[TAGS_SCREEN_ID]?.access).toEqual({ roles: ["Admin"] });
|
|
48
|
+
expect(custom.screens[TAGS_EDIT_SCREEN_ID]?.access).toEqual({ roles: ["Admin"] });
|
|
49
|
+
expect(custom.writeHandlers["tag:delete"]?.access).toEqual({ roles: ["Admin"] });
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/tags/constants.ts
CHANGED
|
@@ -25,9 +25,10 @@ export const TAGS_FILTER_EXTENSION_NAME = "TagFilter";
|
|
|
25
25
|
// after mounting tagsClient() (which registers it under clientFeatures.columnRenderers).
|
|
26
26
|
export const TAGS_COLUMN_RENDERER_NAME = "TagsCell";
|
|
27
27
|
|
|
28
|
-
// Screen-
|
|
29
|
-
//
|
|
28
|
+
// Screen-ids for the declarative tag catalog (entityList + entityEdit).
|
|
29
|
+
// Qualified list = "tags:screen:tag-list"; the app places it via r.nav.
|
|
30
30
|
export const TAGS_SCREEN_ID = "tag-list";
|
|
31
|
+
export const TAGS_EDIT_SCREEN_ID = "tag-edit";
|
|
31
32
|
|
|
32
33
|
// Qualified handler names (QN format: scope:type:name). Clients reference the
|
|
33
34
|
// object instead of magic strings (mirror custom-fields' Handlers/Queries).
|
package/src/tags/entity.ts
CHANGED
package/src/tags/feature.ts
CHANGED
|
@@ -14,15 +14,19 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Handlers: create-tag, update-tag (rename/recolor/re-scope), delete-tag
|
|
16
16
|
// (cascades over assignments), assign-tag, remove-tag, list tags, list assignments.
|
|
17
|
+
// Convention aliases tag:{create,update,delete} + tag:detail back the
|
|
18
|
+
// entityList/entityEdit catalog screens (legacy QNs stay for TagManager).
|
|
17
19
|
// Deferred: optional host-projection decoration (`wireTagsFor`), search indexing.
|
|
18
20
|
|
|
19
21
|
import {
|
|
20
22
|
type AccessRule,
|
|
23
|
+
defineEntityDetailHandler,
|
|
21
24
|
defineEntityListHandler,
|
|
25
|
+
defineEntityUpdateHandler,
|
|
22
26
|
defineFeature,
|
|
23
27
|
type FeatureRegistrar,
|
|
24
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
-
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME
|
|
29
|
+
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants";
|
|
26
30
|
import { tagAssignmentEntity, tagEntity } from "./entity";
|
|
27
31
|
import { createAssignTagHandler } from "./handlers/assign-tag.write";
|
|
28
32
|
import { createCreateTagHandler } from "./handlers/create-tag.write";
|
|
@@ -30,6 +34,7 @@ import { createDeleteTagHandler } from "./handlers/delete-tag.write";
|
|
|
30
34
|
import { createRemoveTagHandler } from "./handlers/remove-tag.write";
|
|
31
35
|
import { createUpdateTagHandler } from "./handlers/update-tag.write";
|
|
32
36
|
import { TAGS_FEATURE_I18N } from "./i18n";
|
|
37
|
+
import { createTagEditScreen, createTagListScreen } from "./screens";
|
|
33
38
|
|
|
34
39
|
// Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
|
|
35
40
|
// dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
|
|
@@ -45,7 +50,7 @@ function registerTags(
|
|
|
45
50
|
toggleable: TagsToggleable | undefined,
|
|
46
51
|
): void {
|
|
47
52
|
r.describe(
|
|
48
|
-
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN.
|
|
53
|
+
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
|
|
49
54
|
);
|
|
50
55
|
r.uiHints({
|
|
51
56
|
displayLabel: "Tags",
|
|
@@ -66,18 +71,22 @@ function registerTags(
|
|
|
66
71
|
r.writeHandler(createAssignTagHandler(access));
|
|
67
72
|
r.writeHandler(createRemoveTagHandler(access));
|
|
68
73
|
|
|
74
|
+
// Convention aliases for entityList/entityEdit — create stays flat-payload
|
|
75
|
+
// (payloadMode=values); update must accept the {id,version,changes} envelope
|
|
76
|
+
// entityEdit sends (legacy update-tag stays for TagManager). delete stays
|
|
77
|
+
// legacy so assignment cascade is preserved (convention delete would orphan).
|
|
78
|
+
r.writeHandler(createCreateTagHandler(access, "tag:create"));
|
|
79
|
+
r.writeHandler(defineEntityUpdateHandler("tag", tagEntity, { access }));
|
|
80
|
+
r.writeHandler(createDeleteTagHandler(access, "tag:delete"));
|
|
81
|
+
|
|
69
82
|
r.queryHandler(defineEntityListHandler("tag", tagEntity, { access }));
|
|
70
83
|
r.queryHandler(defineEntityListHandler("tag-assignment", tagAssignmentEntity, { access }));
|
|
84
|
+
r.queryHandler(defineEntityDetailHandler("tag", tagEntity, { access }));
|
|
71
85
|
|
|
72
|
-
// Standalone
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
r.screen(
|
|
76
|
-
id: TAGS_SCREEN_ID,
|
|
77
|
-
type: "custom",
|
|
78
|
-
renderer: { react: { __component: "TagsScreen" } },
|
|
79
|
-
access,
|
|
80
|
-
});
|
|
86
|
+
// Standalone catalog: entityList + entityEdit. App navs via
|
|
87
|
+
// r.nav("tags:screen:tag-list"). TagManager stays for picker/section only.
|
|
88
|
+
r.screen(createTagListScreen(access));
|
|
89
|
+
r.screen(createTagEditScreen(access));
|
|
81
90
|
r.translations({ keys: TAGS_FEATURE_I18N });
|
|
82
91
|
}
|
|
83
92
|
|
|
@@ -7,9 +7,12 @@ import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
|
|
|
7
7
|
// UUIDv7 id (no explicit id passed). Tag names are not unique by design: the
|
|
8
8
|
// catalog is a free list and dedup is a UI concern (autocomplete from existing
|
|
9
9
|
// tags). Editing is update-tag.write.ts; delete (with cascade) is delete-tag.write.ts.
|
|
10
|
-
export function createCreateTagHandler(
|
|
10
|
+
export function createCreateTagHandler(
|
|
11
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
12
|
+
name = "create-tag",
|
|
13
|
+
): WriteHandlerDef {
|
|
11
14
|
return {
|
|
12
|
-
name
|
|
15
|
+
name,
|
|
13
16
|
schema: createTagPayloadSchema,
|
|
14
17
|
access,
|
|
15
18
|
handler: async (event, ctx) => {
|
|
@@ -16,9 +16,12 @@ const CASCADE_PAGE = 200;
|
|
|
16
16
|
// hides isDeleted rows, so a keyset cursor over the shrinking result set would
|
|
17
17
|
// silently skip rows. Re-reading the head always returns the remaining live
|
|
18
18
|
// rows and terminates when none are left.
|
|
19
|
-
export function createDeleteTagHandler(
|
|
19
|
+
export function createDeleteTagHandler(
|
|
20
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
21
|
+
name = "delete-tag",
|
|
22
|
+
): WriteHandlerDef {
|
|
20
23
|
return {
|
|
21
|
-
name
|
|
24
|
+
name,
|
|
22
25
|
schema: deleteTagPayloadSchema,
|
|
23
26
|
access,
|
|
24
27
|
handler: async (event, ctx) => {
|
|
@@ -8,9 +8,12 @@ import { type UpdateTagPayload, updateTagPayloadSchema } from "../schemas";
|
|
|
8
8
|
// the fields present in the payload go into `changes`, and the executor merges
|
|
9
9
|
// shallowly, so any omitted field is preserved (color/scope accept "" to clear).
|
|
10
10
|
// A stale version returns version_conflict; the UI refetches and retries.
|
|
11
|
-
export function createUpdateTagHandler(
|
|
11
|
+
export function createUpdateTagHandler(
|
|
12
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
13
|
+
name = "update-tag",
|
|
14
|
+
): WriteHandlerDef {
|
|
12
15
|
return {
|
|
13
|
-
name
|
|
16
|
+
name,
|
|
14
17
|
schema: updateTagPayloadSchema,
|
|
15
18
|
access,
|
|
16
19
|
handler: async (event, ctx) => {
|
package/src/tags/i18n.ts
CHANGED
|
@@ -2,4 +2,8 @@ type LocalizedString = { readonly en: string };
|
|
|
2
2
|
|
|
3
3
|
export const TAGS_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
4
|
"screen:tag-list.title": { en: "Tags" },
|
|
5
|
+
"screen:tag-edit.title": { en: "Tag" },
|
|
6
|
+
"tags:entity:tag:field:name": { en: "Name" },
|
|
7
|
+
"tags:entity:tag:field:color": { en: "Color" },
|
|
8
|
+
"tags:entity:tag:field:scope": { en: "Scope" },
|
|
5
9
|
};
|
package/src/tags/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
DEFAULT_TAG_ACCESS,
|
|
4
4
|
DEFAULT_TAG_ROLES,
|
|
5
5
|
TAGS_COLUMN_RENDERER_NAME,
|
|
6
|
+
TAGS_EDIT_SCREEN_ID,
|
|
6
7
|
TAGS_FEATURE_NAME,
|
|
7
8
|
TAGS_FILTER_EXTENSION_NAME,
|
|
8
9
|
TAGS_SCREEN_ID,
|
|
@@ -44,3 +45,4 @@ export {
|
|
|
44
45
|
type UpdateTagPayload,
|
|
45
46
|
updateTagPayloadSchema,
|
|
46
47
|
} from "./schemas";
|
|
48
|
+
export { createTagEditScreen, createTagListScreen } from "./screens";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccessRule,
|
|
3
|
+
EntityEditScreenDefinition,
|
|
4
|
+
EntityListScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { DEFAULT_TAG_ACCESS, TAGS_EDIT_SCREEN_ID, TAGS_SCREEN_ID } from "./constants";
|
|
7
|
+
|
|
8
|
+
// Declarative catalog screens (Phase-3 / #2312). Access matches create/update/
|
|
9
|
+
// delete so createTagsFeature({ access|roles }) stays consistent. TagManager
|
|
10
|
+
// remains for TagPicker/TagSection — only the standalone admin screen moved.
|
|
11
|
+
|
|
12
|
+
export function createTagListScreen(
|
|
13
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
14
|
+
): EntityListScreenDefinition {
|
|
15
|
+
return {
|
|
16
|
+
id: TAGS_SCREEN_ID,
|
|
17
|
+
type: "entityList",
|
|
18
|
+
entity: "tag",
|
|
19
|
+
columns: ["name", "color", "scope"],
|
|
20
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
21
|
+
searchable: true,
|
|
22
|
+
rowActions: [
|
|
23
|
+
{
|
|
24
|
+
kind: "navigate",
|
|
25
|
+
id: "edit",
|
|
26
|
+
label: "kumiko.actions.edit",
|
|
27
|
+
screen: TAGS_EDIT_SCREEN_ID,
|
|
28
|
+
entityId: "id",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
kind: "writeHandler",
|
|
32
|
+
id: "delete",
|
|
33
|
+
label: "kumiko.actions.delete",
|
|
34
|
+
// Convention QN — same cascade body as legacy tags:write:delete-tag.
|
|
35
|
+
handler: "tags:write:tag:delete",
|
|
36
|
+
payload: { pick: ["id"] },
|
|
37
|
+
confirm: "kumiko.actions.delete-confirm",
|
|
38
|
+
style: "danger",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
access,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createTagEditScreen(
|
|
46
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
47
|
+
): EntityEditScreenDefinition {
|
|
48
|
+
return {
|
|
49
|
+
id: TAGS_EDIT_SCREEN_ID,
|
|
50
|
+
type: "entityEdit",
|
|
51
|
+
entity: "tag",
|
|
52
|
+
layout: {
|
|
53
|
+
sections: [
|
|
54
|
+
{
|
|
55
|
+
columns: 2,
|
|
56
|
+
fields: [
|
|
57
|
+
{ field: "name", span: 2 },
|
|
58
|
+
{ field: "color", span: 1 },
|
|
59
|
+
{ field: "scope", span: 1 },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
access,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -5,12 +5,10 @@ import {
|
|
|
5
5
|
TAGS_COLUMN_RENDERER_NAME,
|
|
6
6
|
TAGS_FEATURE_NAME,
|
|
7
7
|
TAGS_FILTER_EXTENSION_NAME,
|
|
8
|
-
TAGS_SCREEN_ID,
|
|
9
8
|
TAGS_SECTION_EXTENSION_NAME,
|
|
10
9
|
} from "../constants";
|
|
11
10
|
import { defaultTranslations } from "./i18n";
|
|
12
11
|
import { TagFilter } from "./tag-filter";
|
|
13
|
-
import { TagManager } from "./tag-manager";
|
|
14
12
|
import { TagSection } from "./tag-section";
|
|
15
13
|
import { TagsCell } from "./tags-cell";
|
|
16
14
|
|
|
@@ -26,10 +24,7 @@ export function tagsClient(): ClientFeatureDefinition {
|
|
|
26
24
|
columnRenderers: {
|
|
27
25
|
[TAGS_COLUMN_RENDERER_NAME]: TagsCell,
|
|
28
26
|
},
|
|
29
|
-
//
|
|
30
|
-
components: {
|
|
31
|
-
[TAGS_SCREEN_ID]: TagManager,
|
|
32
|
-
},
|
|
27
|
+
// Catalog screen is declarative entityList — no custom TagsScreen mount.
|
|
33
28
|
translations: defaultTranslations,
|
|
34
29
|
};
|
|
35
30
|
}
|
package/src/tags/web/index.ts
CHANGED
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
15
|
import { TEXT_BLOCK_KIND } from "../constants";
|
|
16
16
|
import { createTemplateResolverFeature } from "../feature";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type LegalContentBlock,
|
|
19
|
+
type SeedSystemTemplateOptions,
|
|
20
|
+
seedLegalContentFromJson,
|
|
21
|
+
seedSystemTemplate,
|
|
22
|
+
} from "../seeding";
|
|
18
23
|
import { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "../table";
|
|
19
24
|
|
|
20
25
|
// Pins seedLegalContentFromJson: seeds into SYSTEM_TENANT_ID by default and
|
|
@@ -44,6 +49,15 @@ function read(slug: string, locale: string) {
|
|
|
44
49
|
});
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
function readSystemTemplate(slug: string, locale: string, kind: SeedSystemTemplateOptions["kind"]) {
|
|
53
|
+
return fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
54
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
55
|
+
slug,
|
|
56
|
+
kind,
|
|
57
|
+
locale,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
describe("seedLegalContentFromJson", () => {
|
|
48
62
|
test("seeds all blocks into SYSTEM_TENANT_ID as SYSTEM_USER", async () => {
|
|
49
63
|
const blocks: LegalContentBlock[] = [
|
|
@@ -82,6 +96,30 @@ describe("seedLegalContentFromJson", () => {
|
|
|
82
96
|
expect(row.modifiedById).toBe(SYSTEM_USER_ID);
|
|
83
97
|
});
|
|
84
98
|
|
|
99
|
+
test("re-seed of a healthy row (existing events) keeps id and appends, does not delete+recreate", async () => {
|
|
100
|
+
await seedLegalContentFromJson(db, [
|
|
101
|
+
{ slug: "healthy-block", locale: "de", title: "Titel", content: "v1" },
|
|
102
|
+
]);
|
|
103
|
+
const before = await read("healthy-block", "de");
|
|
104
|
+
expect(before).not.toBeNull();
|
|
105
|
+
const eventsBefore = await selectMany(db, eventsTable, { aggregateId: String(before!.id) });
|
|
106
|
+
expect(eventsBefore.map((e) => e.type)).toEqual(["template-resource.created"]);
|
|
107
|
+
|
|
108
|
+
await seedLegalContentFromJson(db, [
|
|
109
|
+
{ slug: "healthy-block", locale: "de", title: "Titel", content: "v2" },
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const after = await read("healthy-block", "de");
|
|
113
|
+
expect(after).not.toBeNull();
|
|
114
|
+
expect(after!.id).toBe(before!.id);
|
|
115
|
+
expect(after).toMatchObject({ content: "v2" });
|
|
116
|
+
|
|
117
|
+
const eventsAfter = await selectMany(db, eventsTable, { aggregateId: String(before!.id) });
|
|
118
|
+
expect(eventsAfter.filter((e) => e.type === "template-resource.created")).toHaveLength(1);
|
|
119
|
+
expect(eventsAfter.filter((e) => e.type === "template-resource.updated")).toHaveLength(1);
|
|
120
|
+
expect(eventsAfter).toHaveLength(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
85
123
|
test("orphan projection (row without events) is recreated on ifExists:update", async () => {
|
|
86
124
|
await seedLegalContentFromJson(db, [
|
|
87
125
|
{ slug: "orphan-privacy", locale: "de", title: "DS", content: "old" },
|
|
@@ -108,6 +146,31 @@ describe("seedLegalContentFromJson", () => {
|
|
|
108
146
|
expect(events.some((e) => e.type === "template-resource.created")).toBe(true);
|
|
109
147
|
});
|
|
110
148
|
|
|
149
|
+
test("orphan projection (row without events) is recreated on ifExists:update — seedSystemTemplate path", async () => {
|
|
150
|
+
const opts: SeedSystemTemplateOptions = {
|
|
151
|
+
slug: "orphan-notification",
|
|
152
|
+
kind: "notification",
|
|
153
|
+
locale: "de",
|
|
154
|
+
content: "old",
|
|
155
|
+
contentFormat: "plain",
|
|
156
|
+
ifExists: "update",
|
|
157
|
+
};
|
|
158
|
+
await seedSystemTemplate(db, opts);
|
|
159
|
+
const before = await readSystemTemplate(opts.slug, opts.locale, opts.kind);
|
|
160
|
+
expect(before).not.toBeNull();
|
|
161
|
+
|
|
162
|
+
// Same desync as prod: projection left behind, event stream empty.
|
|
163
|
+
await deleteMany(db, eventsTable, { aggregateId: String(before!.id) });
|
|
164
|
+
expect(await readSystemTemplate(opts.slug, opts.locale, opts.kind)).not.toBeNull();
|
|
165
|
+
|
|
166
|
+
await seedSystemTemplate(db, { ...opts, content: "new from template" });
|
|
167
|
+
const after = await readSystemTemplate(opts.slug, opts.locale, opts.kind);
|
|
168
|
+
expect(after).toMatchObject({ content: "new from template" });
|
|
169
|
+
const events2 = await selectMany(db, eventsTable, { aggregateId: String(after!.id) });
|
|
170
|
+
expect(events2.length).toBeGreaterThan(0);
|
|
171
|
+
expect(events2.some((e) => e.type === "template-resource.created")).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
111
174
|
test("identical re-seed is a no-op (no update event, version/modifiedAt stable)", async () => {
|
|
112
175
|
const blocks: LegalContentBlock[] = [
|
|
113
176
|
{ slug: "terms", locale: "de", title: "AGB", content: "stable" },
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type DbConnection,
|
|
16
16
|
deleteMany,
|
|
17
17
|
type EntityTableMeta,
|
|
18
|
+
type TenantDb,
|
|
18
19
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
20
|
import {
|
|
20
21
|
createSystemUser,
|
|
@@ -22,18 +23,19 @@ import {
|
|
|
22
23
|
SYSTEM_TENANT_ID,
|
|
23
24
|
type TenantId,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
getStreamVersion,
|
|
28
|
+
getUnscopedStreamMaxVersionForSeed,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
26
30
|
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
27
31
|
import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants";
|
|
28
32
|
import { executor } from "./handlers/shared";
|
|
29
33
|
import { type TemplateResourceRow, templateResourcesTable } from "./table";
|
|
30
34
|
|
|
31
|
-
/**
|
|
32
|
-
* projection, stream currentVersion=0). Drop the orphan and treat as create
|
|
33
|
-
* so boot seeds (legal ifExists:update) survive desync. Stream version is the
|
|
34
|
-
* optimistic-lock source of truth when events exist. */
|
|
35
|
+
/** Drop projection orphans (no events anywhere) so boot seeds can recreate. */
|
|
35
36
|
async function resolveExistingForEventStoreSeed(
|
|
36
37
|
db: DbConnection,
|
|
38
|
+
tdb: TenantDb,
|
|
37
39
|
existingRow: { readonly id: string | number; readonly version: number } | null,
|
|
38
40
|
tenantId: TenantId,
|
|
39
41
|
): Promise<{ id: string; version: number } | null> {
|
|
@@ -41,7 +43,17 @@ async function resolveExistingForEventStoreSeed(
|
|
|
41
43
|
const id = String(existingRow.id);
|
|
42
44
|
const streamVersion = await getStreamVersion(db, id, tenantId);
|
|
43
45
|
if (streamVersion === 0) {
|
|
44
|
-
|
|
46
|
+
// Tenant-scoped 0 can still hide a live stream under another tenant —
|
|
47
|
+
// only delete when unscoped is also empty (true orphan).
|
|
48
|
+
const unscoped = await getUnscopedStreamMaxVersionForSeed(db, id);
|
|
49
|
+
if (unscoped !== 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`seed orphan check: projection ${id} has no events for tenant ${tenantId} but unscoped stream version is ${unscoped} — refusing delete/recreate to avoid wiping a live stream`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
// @cast-boundary orphan projection cleanup: empty stream, no executor verb;
|
|
55
|
+
// plain table ident trips guard-direct-entity-writes (infra EXCLUDE follow-up).
|
|
56
|
+
await deleteMany(tdb, templateResourcesTable as EntityTableMeta, { id, tenantId });
|
|
45
57
|
return null;
|
|
46
58
|
}
|
|
47
59
|
return { id, version: streamVersion };
|
|
@@ -76,6 +88,7 @@ export async function seedSystemTemplate(
|
|
|
76
88
|
});
|
|
77
89
|
const existing = await resolveExistingForEventStoreSeed(
|
|
78
90
|
db,
|
|
91
|
+
tdb,
|
|
79
92
|
existingRow ? { id: existingRow.id, version: existingRow.version } : null,
|
|
80
93
|
tenantId,
|
|
81
94
|
);
|
|
@@ -105,6 +118,34 @@ export async function seedSystemTemplate(
|
|
|
105
118
|
create: async () => {
|
|
106
119
|
const result = await executor.create({ ...rowFields, tenantId }, by, tdb);
|
|
107
120
|
if (!result.isSuccess) {
|
|
121
|
+
// Parallel boot pods can both delete the orphan and race on create —
|
|
122
|
+
// unique on (tenantId, slug, kind, locale) → resolve winner and update.
|
|
123
|
+
if (result.error?.code === "unique_violation") {
|
|
124
|
+
const again = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
125
|
+
tenantId,
|
|
126
|
+
slug: opts.slug,
|
|
127
|
+
kind: opts.kind,
|
|
128
|
+
locale: opts.locale,
|
|
129
|
+
});
|
|
130
|
+
const resolved = await resolveExistingForEventStoreSeed(
|
|
131
|
+
db,
|
|
132
|
+
tdb,
|
|
133
|
+
again ? { id: again.id, version: again.version } : null,
|
|
134
|
+
tenantId,
|
|
135
|
+
);
|
|
136
|
+
if (resolved == null) {
|
|
137
|
+
throw new Error(`seedSystemTemplate create unique_violation but row missing`);
|
|
138
|
+
}
|
|
139
|
+
const upd = await executor.update(
|
|
140
|
+
{ id: resolved.id, version: resolved.version, changes: rowFields },
|
|
141
|
+
by,
|
|
142
|
+
tdb,
|
|
143
|
+
);
|
|
144
|
+
if (!upd.isSuccess) {
|
|
145
|
+
throw new Error(`seedSystemTemplate race-update failed: ${JSON.stringify(upd)}`);
|
|
146
|
+
}
|
|
147
|
+
return { id: resolved.id };
|
|
148
|
+
}
|
|
108
149
|
throw new Error(`seedSystemTemplate create failed: ${JSON.stringify(result)}`);
|
|
109
150
|
}
|
|
110
151
|
const data = result.data as Partial<TemplateResourceRow>;
|
|
@@ -159,6 +200,7 @@ export async function seedTextBlock(
|
|
|
159
200
|
});
|
|
160
201
|
const existing = await resolveExistingForEventStoreSeed(
|
|
161
202
|
db,
|
|
203
|
+
tdb,
|
|
162
204
|
existingRow ? { id: existingRow.id, version: existingRow.version } : null,
|
|
163
205
|
opts.tenantId,
|
|
164
206
|
);
|
|
@@ -184,6 +226,36 @@ export async function seedTextBlock(
|
|
|
184
226
|
create: async () => {
|
|
185
227
|
const result = await executor.create({ ...fields, tenantId: opts.tenantId }, by, tdb);
|
|
186
228
|
if (!result.isSuccess) {
|
|
229
|
+
if (result.error?.code === "unique_violation") {
|
|
230
|
+
const again = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
231
|
+
tenantId: opts.tenantId,
|
|
232
|
+
slug: opts.slug,
|
|
233
|
+
kind: TEXT_BLOCK_KIND,
|
|
234
|
+
locale: opts.locale,
|
|
235
|
+
});
|
|
236
|
+
const resolved = await resolveExistingForEventStoreSeed(
|
|
237
|
+
db,
|
|
238
|
+
tdb,
|
|
239
|
+
again ? { id: again.id, version: again.version } : null,
|
|
240
|
+
opts.tenantId,
|
|
241
|
+
);
|
|
242
|
+
if (resolved == null) {
|
|
243
|
+
throw new Error("seedTextBlock create unique_violation but row missing");
|
|
244
|
+
}
|
|
245
|
+
const upd = await executor.update(
|
|
246
|
+
{
|
|
247
|
+
id: resolved.id,
|
|
248
|
+
version: resolved.version,
|
|
249
|
+
changes: { title: fields.title, content: fields.content, folder: fields.folder },
|
|
250
|
+
},
|
|
251
|
+
by,
|
|
252
|
+
tdb,
|
|
253
|
+
);
|
|
254
|
+
if (!upd.isSuccess) {
|
|
255
|
+
throw new Error(`seedTextBlock race-update failed: ${JSON.stringify(upd)}`);
|
|
256
|
+
}
|
|
257
|
+
return { id: resolved.id };
|
|
258
|
+
}
|
|
187
259
|
throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
|
|
188
260
|
}
|
|
189
261
|
// @cast-boundary db-row — executor.create returns the inserted row from
|
|
@@ -22,9 +22,9 @@ mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () =
|
|
|
22
22
|
useShellUser: mock(),
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
|
-
const
|
|
25
|
+
const actualRenderer = await import("@cosmicdrift/kumiko-renderer");
|
|
26
26
|
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
27
|
-
...
|
|
27
|
+
...actualRenderer,
|
|
28
28
|
useDispatcher: mock(() => ({ write: mock(), query: mock() })),
|
|
29
29
|
useQuery: mock(() => ({
|
|
30
30
|
data: {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
PlainContentEditor,
|
|
33
33
|
RichContentEditor,
|
|
34
34
|
} from "@cosmicdrift/kumiko-renderer-web";
|
|
35
|
+
import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
|
|
35
36
|
import { type FormEvent, type ReactNode, useEffect, useId, useState } from "react";
|
|
36
37
|
import {
|
|
37
38
|
collectionHandlerName,
|
|
@@ -226,7 +227,7 @@ function findCollection(
|
|
|
226
227
|
function findContentFormat(
|
|
227
228
|
features: readonly FeatureSchema[],
|
|
228
229
|
collectionId?: string,
|
|
229
|
-
):
|
|
230
|
+
): ContentEditorFormat | undefined {
|
|
230
231
|
return findCollection(features, collectionId)?.contentFormat;
|
|
231
232
|
}
|
|
232
233
|
|
|
@@ -23,7 +23,30 @@ function membersFacetLabelKeys(): readonly string[] {
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Column labels of the members screen itself — deliberately scoped to this
|
|
27
|
+
// one screen, not a generic "walk every column of every screen" helper.
|
|
28
|
+
function membersColumnLabelKeys(): readonly string[] {
|
|
29
|
+
const screen = createTenantFeature().screens[MEMBERS_SCREEN_ID];
|
|
30
|
+
if (screen?.type !== "projectionList") {
|
|
31
|
+
throw new Error("expected members screen to be projectionList");
|
|
32
|
+
}
|
|
33
|
+
return screen.columns.flatMap((col) =>
|
|
34
|
+
typeof col === "string" || col.label === undefined ? [] : [col.label],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Nav-menu-entry label(s) that point at the members screen — same
|
|
39
|
+
// single-screen scope as the facet/column helpers above.
|
|
40
|
+
function membersNavLabelKeys(): readonly string[] {
|
|
41
|
+
const feature = createTenantFeature();
|
|
42
|
+
const membersScreenTarget = `${feature.name}:screen:${MEMBERS_SCREEN_ID}`;
|
|
43
|
+
return Object.values(feature.navs)
|
|
44
|
+
.filter((nav) => nav.screen === membersScreenTarget)
|
|
45
|
+
.map((nav) => nav.label);
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
const facetKeys = membersFacetLabelKeys();
|
|
49
|
+
const membersScreenI18nKeys = [...facetKeys, ...membersColumnLabelKeys(), ...membersNavLabelKeys()];
|
|
27
50
|
|
|
28
51
|
const localePacks: ReadonlyArray<readonly [string, Readonly<Record<string, string>>]> = [
|
|
29
52
|
["es", localeEsBundle],
|
|
@@ -31,12 +54,21 @@ const localePacks: ReadonlyArray<readonly [string, Readonly<Record<string, strin
|
|
|
31
54
|
];
|
|
32
55
|
|
|
33
56
|
describe("members status filter labels are localized in every shipped locale (fw#2376)", () => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
// A hardcoded toEqual() array here would fail with a confusing "array
|
|
58
|
+
// mismatch" the moment a facet/column/nav-label key is added to the
|
|
59
|
+
// members screen — instead of the clear "key not translated" failure a
|
|
60
|
+
// missing en copy should produce. Reading the keys off the live screen
|
|
61
|
+
// definition and checking each one resolves catches the real failure
|
|
62
|
+
// mode (fw#2376) without hardcoding an ever-growing key list.
|
|
63
|
+
test("every facet/column/nav-label key of the members screen has English copy", () => {
|
|
64
|
+
expect(membersScreenI18nKeys.length).toBeGreaterThan(0);
|
|
65
|
+
const translations = createTenantFeature().translations ?? {};
|
|
66
|
+
for (const key of membersScreenI18nKeys) {
|
|
67
|
+
const label = translations[key]?.["en"];
|
|
68
|
+
expect(label).toBeString();
|
|
69
|
+
expect(label).not.toBe(key);
|
|
70
|
+
expect((label ?? "").trim()).not.toBe("");
|
|
71
|
+
}
|
|
40
72
|
});
|
|
41
73
|
|
|
42
74
|
test("every facet label has English copy in the tenant feature itself", () => {
|