@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.66.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.
Files changed (60) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  3. package/src/config/__tests__/write-helpers.test.ts +152 -0
  4. package/src/config/handlers/readiness.query.ts +1 -0
  5. package/src/config/read-redaction.ts +0 -1
  6. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
  7. package/src/custom-fields/__tests__/feature.test.ts +1 -4
  8. package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
  9. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
  10. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
  11. package/src/custom-fields/db/queries/quota.ts +3 -1
  12. package/src/custom-fields/entity.ts +10 -3
  13. package/src/custom-fields/events.ts +4 -1
  14. package/src/custom-fields/feature.ts +1 -5
  15. package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
  16. package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
  17. package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
  18. package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
  19. package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
  20. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
  21. package/src/custom-fields/wire-for-entity.ts +7 -0
  22. package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
  23. package/src/files-provider-s3/s3-provider.ts +2 -4
  24. package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
  25. package/src/legal-pages/web/client-plugin.ts +9 -10
  26. package/src/managed-pages/handlers/set.write.ts +4 -11
  27. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
  28. package/src/sessions/feature.ts +16 -3
  29. package/src/tags/__tests__/tags.integration.test.ts +30 -1
  30. package/src/tags/entity.ts +8 -0
  31. package/src/tags/handlers/assign-tag.write.ts +20 -5
  32. package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
  33. package/src/tags/web/i18n.ts +6 -2
  34. package/src/tags/web/tag-section.tsx +87 -76
  35. package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
  36. package/src/text-content/web/client-plugin.tsx +16 -13
  37. package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
  38. package/src/tier-engine/__tests__/trial.test.ts +27 -0
  39. package/src/tier-engine/entity.ts +8 -0
  40. package/src/tier-engine/feature.ts +49 -9
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
  43. package/src/tier-engine/index.ts +1 -0
  44. package/src/tier-engine/trial.ts +26 -0
  45. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
  46. package/src/user-data-rights/constants.ts +48 -0
  47. package/src/user-data-rights/feature.ts +15 -0
  48. package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
  49. package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
  50. package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
  51. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
  52. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
  53. package/src/user-data-rights/index.ts +3 -0
  54. package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
  55. package/src/user-data-rights/run-forget-cleanup.ts +3 -2
  56. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
  57. package/src/user-data-rights/web/client-plugin.tsx +30 -0
  58. package/src/user-data-rights/web/i18n.ts +95 -0
  59. package/src/user-data-rights/web/index.ts +2 -0
  60. package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
@@ -9,7 +9,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
9
9
  import type { ReactNode } from "react";
10
10
  import { TagsHandlers, TagsQueries } from "../../constants";
11
11
  import { defaultTranslations } from "../i18n";
12
- import { TagSection } from "../tag-section";
12
+ import { TagSection, tagSelectionDelta } from "../tag-section";
13
13
 
14
14
  type TagRow = { id: string; name: string };
15
15
  type AssignmentRow = { tagId: string; entityType: string; entityId: string };
@@ -17,14 +17,12 @@ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
17
17
  let catalogRows: readonly TagRow[] = [];
18
18
  let assignmentRows: readonly AssignmentRow[] = [];
19
19
 
20
- // createTag returns the new id; assign/remove return data-less success.
21
20
  const dispatchSpy = mock(async (type: string) =>
22
21
  type === TagsHandlers.createTag
23
22
  ? { isSuccess: true, data: { id: "tag-new" } }
24
23
  : { isSuccess: true, data: undefined },
25
24
  );
26
25
 
27
- // useQuery is called twice (catalog + assignments) — branch on the QN.
28
26
  const useQuerySpy = mock((type: string) => ({
29
27
  data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
30
28
  loading: false,
@@ -47,14 +45,33 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
47
45
  );
48
46
  }
49
47
 
48
+ // The combobox's assign/remove toggle drives onChange with the full new
49
+ // selection; the component diffs it against the current tags via this helper.
50
+ // Popover interaction itself (cmdk + Radix in jsdom) is covered by the
51
+ // combobox primitive's own tests + e2e — here we pin the diff that turns a
52
+ // selection into assign/remove calls.
53
+ describe("tagSelectionDelta", () => {
54
+ test("addition only", () => {
55
+ expect(tagSelectionDelta(["a"], ["a", "b"])).toEqual({ added: ["b"], removed: [] });
56
+ });
57
+ test("removal only", () => {
58
+ expect(tagSelectionDelta(["a", "b"], ["a"])).toEqual({ added: [], removed: ["b"] });
59
+ });
60
+ test("simultaneous add + remove", () => {
61
+ expect(tagSelectionDelta(["a"], ["b"])).toEqual({ added: ["b"], removed: ["a"] });
62
+ });
63
+ test("no change", () => {
64
+ expect(tagSelectionDelta(["a", "b"], ["b", "a"])).toEqual({ added: [], removed: [] });
65
+ });
66
+ });
67
+
50
68
  describe("TagSection", () => {
51
- test("shows assigned + available tags and dispatches assign/remove with the right QN + payload", async () => {
69
+ test("renders assigned tags as combobox chips", () => {
52
70
  catalogRows = [
53
71
  { id: "t1", name: "important" },
54
72
  { id: "t2", name: "project-x" },
55
73
  ];
56
74
  assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
57
- dispatchSpy.mockClear();
58
75
 
59
76
  render(
60
77
  <Wrapper>
@@ -62,28 +79,10 @@ describe("TagSection", () => {
62
79
  </Wrapper>,
63
80
  );
64
81
 
65
- // Assigned tag → remove button; unassigned catalog tag → assign button.
66
- expect(screen.getByTestId("tags-section-remove-t1")).toBeTruthy();
67
- expect(screen.getByTestId("tags-section-assign-t2")).toBeTruthy();
68
- expect(screen.queryByTestId("tags-section-assign-t1")).toBeNull();
69
-
70
- fireEvent.click(screen.getByTestId("tags-section-assign-t2"));
71
- await waitFor(() =>
72
- expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
73
- tagId: "t2",
74
- entityType: "note",
75
- entityId: "note-1",
76
- }),
77
- );
78
-
79
- fireEvent.click(screen.getByTestId("tags-section-remove-t1"));
80
- await waitFor(() =>
81
- expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
82
- tagId: "t1",
83
- entityType: "note",
84
- entityId: "note-1",
85
- }),
86
- );
82
+ expect(screen.getByTestId("combobox-tags-section-select")).toBeTruthy();
83
+ // assigned → chip shown in the trigger; unassigned t2 lives in the (closed) dropdown
84
+ expect(screen.getByText("important")).toBeTruthy();
85
+ expect(screen.queryByText("project-x")).toBeNull();
87
86
  });
88
87
 
89
88
  test("create-and-attach dispatches create-tag, then assign-tag with the new id", async () => {
@@ -9,7 +9,9 @@ export const defaultTranslations: TranslationsByLocale = {
9
9
  de: {
10
10
  "tags.section.createMode": "Speichere zuerst den Eintrag, um Tags zu setzen.",
11
11
  "tags.section.loading": "Lädt…",
12
- "tags.section.none": "Keine Tags.",
12
+ "tags.section.label": "Tags",
13
+ "tags.section.placeholder": "Tags auswählen…",
14
+ "tags.section.empty": "Keine Tags gefunden.",
13
15
  "tags.section.newLabel": "Neuer Tag",
14
16
  "tags.section.create": "Tag anlegen & zuweisen",
15
17
  "tags.section.working": "Speichert…",
@@ -17,7 +19,9 @@ export const defaultTranslations: TranslationsByLocale = {
17
19
  en: {
18
20
  "tags.section.createMode": "Save the entity first to add tags.",
19
21
  "tags.section.loading": "Loading…",
20
- "tags.section.none": "No tags.",
22
+ "tags.section.label": "Tags",
23
+ "tags.section.placeholder": "Select tags…",
24
+ "tags.section.empty": "No tags found.",
21
25
  "tags.section.newLabel": "New tag",
22
26
  "tags.section.create": "Create & attach tag",
23
27
  "tags.section.working": "Saving…",
@@ -1,9 +1,9 @@
1
1
  // @runtime client
2
- // TagSection — drop-in tag manager for ANY entity. Given an entityName +
3
- // entityId it shows the entity's current tags and lets the user attach an
4
- // existing tag, create-and-attach a new one, or detach a tag. Tag writes are
5
- // immediate (assign/remove are idempotent), so the section owns its own state
6
- // and refetches after each action — it is NOT part of a host form's save.
2
+ // TagSection — drop-in tag manager for ANY entity, GitLab-labels style: one
3
+ // searchable multi-combobox showing the entity's tags as chips, with a compact
4
+ // row below to create-and-attach a brand-new tag. Tag writes are immediate
5
+ // (assign/remove are idempotent), so the section owns its state and refetches
6
+ // after each action — it is NOT part of a host form's save.
7
7
  //
8
8
  // Two ways to mount (both need tagsClient() registered once, for i18n):
9
9
  // - standalone: <TagSection entityName="note" entityId={noteId} />
@@ -29,12 +29,20 @@ type AssignmentRow = {
29
29
  type TagListResponse = { readonly rows: readonly TagRow[] };
30
30
  type AssignmentListResponse = { readonly rows: readonly AssignmentRow[] };
31
31
 
32
- // Structural shape of a dispatcher write result for the generic action wrapper.
33
- // The real WriteResult (a discriminated union) is assignable to this; narrowing
34
- // on `isSuccess` reaches `error.i18nKey` without importing server-side types.
35
- type ActionResult =
36
- | { readonly isSuccess: true }
37
- | { readonly isSuccess: false; readonly error: { readonly i18nKey: string } };
32
+ // What changed between the entity's current tags and the combobox's new
33
+ // selection. A single combobox toggle yields one add or one remove; the diff
34
+ // stays correct for a batch selection too.
35
+ export function tagSelectionDelta(
36
+ prev: readonly string[],
37
+ next: readonly string[],
38
+ ): { readonly added: readonly string[]; readonly removed: readonly string[] } {
39
+ const prevSet = new Set(prev);
40
+ const nextSet = new Set(next);
41
+ return {
42
+ added: next.filter((id) => !prevSet.has(id)),
43
+ removed: prev.filter((id) => !nextSet.has(id)),
44
+ };
45
+ }
38
46
 
39
47
  export function TagSection({
40
48
  entityName,
@@ -84,98 +92,101 @@ export function TagSection({
84
92
  }
85
93
 
86
94
  const catalogTags = catalog.data?.rows ?? [];
87
- const byId = new Map(catalogTags.map((tg) => [tg.id, tg]));
88
- const assignedRows = (assignments.data?.rows ?? []).filter((r) => r.entityType === entityName);
89
- const assignedIds = new Set(assignedRows.map((r) => r.tagId));
90
- const assignedTags = assignedRows.map((r) => byId.get(r.tagId) ?? { id: r.tagId, name: r.tagId });
91
- const available = catalogTags.filter((tg) => !assignedIds.has(tg.id));
95
+ const assignedIds = (assignments.data?.rows ?? [])
96
+ .filter((r) => r.entityType === entityName)
97
+ .map((r) => r.tagId);
98
+ // Catalog drives the options; an assigned tag missing from the catalog (none
99
+ // in v1 — no delete-tag yet) is appended so it stays removable.
100
+ const nameById = new Map(catalogTags.map((tg) => [tg.id, tg.name]));
101
+ const options = [...new Set([...catalogTags.map((tg) => tg.id), ...assignedIds])].map((id) => ({
102
+ value: id,
103
+ label: nameById.get(id) ?? id,
104
+ }));
92
105
 
93
106
  const refetch = async (): Promise<void> => {
94
107
  await Promise.all([catalog.refetch(), assignments.refetch()]);
95
108
  };
96
109
 
97
- const run = async (action: () => Promise<ActionResult>): Promise<void> => {
110
+ // Runs a write-sequence (each step returns false + sets errorKey on failure,
111
+ // stopping the sequence) and refetches to server-truth when it completes.
112
+ const apply = async (writes: () => Promise<boolean>): Promise<void> => {
98
113
  setBusy(true);
99
114
  setErrorKey(null);
100
115
  try {
101
- const result = await action();
102
- if (!result.isSuccess) {
103
- setErrorKey(result.error.i18nKey);
104
- return;
105
- }
106
- await refetch();
116
+ if (await writes()) await refetch();
107
117
  } finally {
108
118
  setBusy(false);
109
119
  }
110
120
  };
111
121
 
112
- const assign = (tagId: string): Promise<void> =>
113
- run(() =>
114
- dispatcher.write(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId }),
115
- );
122
+ const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
123
+ const result = await dispatcher.write(type, payload);
124
+ if (!result.isSuccess) {
125
+ setErrorKey(result.error.i18nKey);
126
+ return false;
127
+ }
128
+ return true;
129
+ };
116
130
 
117
- const detach = (tagId: string): Promise<void> =>
118
- run(() =>
119
- dispatcher.write(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId }),
120
- );
131
+ const onSelectionChange = (next: readonly string[]): void => {
132
+ const { added, removed } = tagSelectionDelta(assignedIds, next);
133
+ if (added.length === 0 && removed.length === 0) return;
134
+ void apply(async () => {
135
+ for (const tagId of added) {
136
+ if (!(await writeOk(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId })))
137
+ return false;
138
+ }
139
+ for (const tagId of removed) {
140
+ if (!(await writeOk(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId })))
141
+ return false;
142
+ }
143
+ return true;
144
+ });
145
+ };
121
146
 
122
- const createAndAssign = async (): Promise<void> => {
147
+ const createAndAssign = (): void => {
123
148
  const name = newName.trim();
124
149
  if (name === "") return;
125
- setBusy(true);
126
- setErrorKey(null);
127
- try {
150
+ void apply(async () => {
128
151
  const created = await dispatcher.write<{ id: string }>(TagsHandlers.createTag, { name });
129
152
  if (!created.isSuccess) {
130
153
  setErrorKey(created.error.i18nKey);
131
- return;
154
+ return false;
132
155
  }
133
- const assigned = await dispatcher.write(TagsHandlers.assignTag, {
134
- tagId: created.data.id,
135
- entityType: entityName,
136
- entityId,
137
- });
138
- if (!assigned.isSuccess) {
139
- setErrorKey(assigned.error.i18nKey);
140
- return;
156
+ if (
157
+ !(await writeOk(TagsHandlers.assignTag, {
158
+ tagId: created.data.id,
159
+ entityType: entityName,
160
+ entityId,
161
+ }))
162
+ ) {
163
+ return false;
141
164
  }
142
165
  setNewName("");
143
- await refetch();
144
- } finally {
145
- setBusy(false);
146
- }
166
+ return true;
167
+ });
147
168
  };
148
169
 
149
170
  return (
150
171
  <div data-testid="tags-section">
151
- {assignedTags.length === 0 ? (
152
- <Text>{t("tags.section.none")}</Text>
153
- ) : (
154
- assignedTags.map((tg) => (
155
- <Button
156
- key={tg.id}
157
- variant="secondary"
158
- disabled={busy}
159
- onClick={() => void detach(tg.id)}
160
- testId={`tags-section-remove-${tg.id}`}
161
- >
162
- {`${tg.name} ✕`}
163
- </Button>
164
- ))
165
- )}
166
-
167
- {available.map((tg) => (
168
- <Button
169
- key={tg.id}
170
- variant="secondary"
172
+ <Field id="tags-section-select" label={t("tags.section.label")}>
173
+ <Input
174
+ kind="combobox"
175
+ multiple
176
+ id="tags-section-select"
177
+ name="tags"
178
+ options={options}
179
+ value={assignedIds}
180
+ onChange={onSelectionChange}
171
181
  disabled={busy}
172
- onClick={() => void assign(tg.id)}
173
- testId={`tags-section-assign-${tg.id}`}
174
- >
175
- {`+ ${tg.name}`}
176
- </Button>
177
- ))}
182
+ placeholder={t("tags.section.placeholder")}
183
+ emptyText={t("tags.section.empty")}
184
+ />
185
+ </Field>
178
186
 
187
+ {/* ponytail: separate create row — the shared combobox has no create-on-type
188
+ affordance. Fold create into the dropdown's Command.Empty if/when the
189
+ renderer-web combobox grows a freeSolo/onCreate prop. */}
179
190
  <Field id="tags-section-new" label={t("tags.section.newLabel")}>
180
191
  <Input
181
192
  kind="text"
@@ -186,9 +197,9 @@ export function TagSection({
186
197
  />
187
198
  </Field>
188
199
  <Button
189
- variant="primary"
200
+ variant="secondary"
190
201
  disabled={busy || newName.trim() === ""}
191
- onClick={() => void createAndAssign()}
202
+ onClick={() => createAndAssign()}
192
203
  testId="tags-section-create"
193
204
  >
194
205
  {busy ? t("tags.section.working") : t("tags.section.create")}
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { textContentClient } from "../client-plugin";
4
+
5
+ // Deckt die drei neuen Migrations-Pfade (advisor-Gap): navId-Attach + SSE-
6
+ // Entities, no-leak ohne navId (conditional-spread), und der Unwrap (Provider
7
+ // emittiert die Folder/Leaves direkt, NICHT unter dem "Content"-Wrapper).
8
+ // Der Provider fetcht → fetch wird gemockt.
9
+
10
+ describe("textContentClient — shape", () => {
11
+ test("ohne navId: kein navProvider/navEntities (no-leak), aber Resolver bleibt", () => {
12
+ const def = textContentClient();
13
+ expect(def.name).toBe("text-content");
14
+ expect(def.navProviders).toBeUndefined();
15
+ expect(def.navEntities).toBeUndefined();
16
+ expect(def.resolvers?.["text-content:edit"]).toBeDefined();
17
+ });
18
+
19
+ test("mit navId: Provider + SSE-Entities unter exakt dieser QN", () => {
20
+ const navId = "publicstatus:nav:content";
21
+ const def = textContentClient({ navId });
22
+ expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
23
+ expect(def.navEntities?.[navId]).toEqual(["text-block"]);
24
+ });
25
+ });
26
+
27
+ describe("textContentClient — Provider unwrappt den Content-Container", () => {
28
+ const origFetch = globalThis.fetch;
29
+ afterEach(() => {
30
+ globalThis.fetch = origFetch;
31
+ });
32
+
33
+ test("emittiert Folder/Leaves direkt, kein 'Content'-Wrapper-Knoten", async () => {
34
+ const blocks = [
35
+ { slug: "imprint", lang: "de", title: "Imprint", body: "x", folder: null, updatedAt: "" },
36
+ { slug: "hero", lang: "de", title: "Hero", body: null, folder: "page", updatedAt: "" },
37
+ ];
38
+ // Test-Mock-Grenze: bun-Mock deckt nicht die volle fetch-Signatur
39
+ // (preconnect etc.) — Double-Cast bewusst, nur dieser Test ruft fetch.
40
+ globalThis.fetch = mock(
41
+ async () =>
42
+ new Response(JSON.stringify({ data: { blocks } }), {
43
+ status: 200,
44
+ headers: { "content-type": "application/json" },
45
+ }),
46
+ ) as unknown as typeof fetch;
47
+
48
+ const navId = "x:nav:content";
49
+ const provider = textContentClient({ navId }).navProviders?.[navId];
50
+ if (provider === undefined) throw new Error("provider missing");
51
+
52
+ let emitted: readonly TreeNode[] | undefined;
53
+ provider()((nodes) => {
54
+ emitted = nodes;
55
+ });
56
+ // fetch().then(...) ist async → eine Makrotask abwarten bis emit lief.
57
+ await new Promise((r) => setTimeout(r, 0));
58
+
59
+ expect(emitted).toBeDefined();
60
+ const labels = (emitted ?? []).map((n) => n.label).sort();
61
+ // Kein "Content"-Wrapper — root-leaf "Imprint" + folder "page" direkt.
62
+ expect(labels).not.toContain("Content");
63
+ expect(labels).toEqual(["Imprint", "page"]);
64
+ });
65
+ });
@@ -157,8 +157,10 @@ const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
157
157
  return r.json();
158
158
  })
159
159
  .then((data: ByTenantResponse) => {
160
- const nodes = groupBlocksByFolder(data.data.blocks);
161
- emit(nodes);
160
+ // Der App-seitige r.nav-Knoten IST der "Content"-Container → die
161
+ // Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
162
+ const content = groupBlocksByFolder(data.data.blocks)[0];
163
+ emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
162
164
  })
163
165
  .catch((e) => {
164
166
  // V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
@@ -346,19 +348,20 @@ function TextContentEditor({
346
348
  );
347
349
  }
348
350
 
349
- export function textContentClient(): ClientFeatureDefinition {
351
+ // `navId` = die QN des r.nav({ provider: true })-Knotens, den die App für den
352
+ // Content-Tree registriert (z.B. "publicstatus:nav:content"). Die App besitzt
353
+ // Label/Icon/Access des Knotens (managed-pages-Konvention) — das Feature
354
+ // liefert nur die Kinder + den Editor. Ohne navId: nur der Resolver, kein
355
+ // Sidebar-Knoten (server-only-Consumer wie money-horse leaken nichts).
356
+ export function textContentClient(opts?: { readonly navId?: string }): ClientFeatureDefinition {
357
+ const navId = opts?.navId;
350
358
  return {
351
359
  name: "text-content",
352
- treeProvider,
353
- // V.1.5b: SSE-driven Tree-Refresh. Bei jedem text-block-Event
354
- // (created/updated/deleted) ruft ProviderBranch den treeProvider
355
- // neu auf → Tree-State spiegelt save sofort wider (Stale-Tree-Fix).
356
- treeEntities: ["text-block"],
357
- treeActions: {
358
- edit: { args: { slug: "" as string, lang: "" as string } },
359
- list: {},
360
- create: { args: { folder: "" as string } },
361
- },
360
+ ...(navId !== undefined && {
361
+ navProviders: { [navId]: treeProvider },
362
+ // SSE-Refresh: jedes text-block-Event re-fired den Provider → Tree live.
363
+ navEntities: { [navId]: ["text-block"] },
364
+ }),
362
365
  resolvers: {
363
366
  "text-content:edit": TextContentEditor,
364
367
  },
@@ -71,6 +71,20 @@ const features = composeFeatures(
71
71
  { includeBundled: true },
72
72
  );
73
73
 
74
+ // Zweite Komposition MIT Trial-Option: jeder Tenant bekommt 30 Tage ab
75
+ // inserted_at die "pro"-Features (feat-pro), unabhängig vom gespeicherten Tier.
76
+ const TRIAL_HOURS = 30 * 24;
77
+ const featuresWithTrial = composeFeatures(
78
+ [
79
+ createTierEngineFeature({
80
+ tierMap: TEST_TIER_MAP,
81
+ trial: { tier: "pro", durationHours: TRIAL_HOURS },
82
+ }),
83
+ featProFeature,
84
+ ],
85
+ { includeBundled: true },
86
+ );
87
+
74
88
  let stack: TestStack;
75
89
  const tenantA = "00000000-0000-4000-8000-0000000000a1" as TenantId;
76
90
  const tenantB = "00000000-0000-4000-8000-0000000000b2" as TenantId;
@@ -212,3 +226,56 @@ describe("createTierEngineFeature — per-tenant resolver", () => {
212
226
  expect(systemSet.size).toBeGreaterThanOrEqual(2);
213
227
  });
214
228
  });
229
+
230
+ describe("createTierEngineFeature — Trial-Phase (zeit-abgeleitet)", () => {
231
+ function sysUser(tenantId: TenantId, id: string) {
232
+ return createTestUser({ id, tenantId, roles: ["SystemAdmin", "TenantAdmin"] });
233
+ }
234
+
235
+ test("neuer 'free'-Tenant sieht im Fenster die Trial-Features (feat-pro)", async () => {
236
+ const usage = findTierResolverUsage(featuresWithTrial);
237
+ if (!usage) throw new Error("setup failure: no trial resolver");
238
+ const plugin = usage.options as TierResolverPlugin;
239
+
240
+ // Gespeicherter Tier ist free (keine feat-pro), inserted_at = jetzt → Trial aktiv.
241
+ await stack.http.writeOk(
242
+ "tier-engine:write:tier-assignment:create",
243
+ { tier: "free" },
244
+ sysUser(tenantA, "trial-sys-1"),
245
+ );
246
+ const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
247
+ expect(resolver(tenantA).has("feat-pro")).toBe(true);
248
+ });
249
+
250
+ test("Tenant außerhalb des Fensters (inserted_at > 30 Tage) fällt auf free zurück", async () => {
251
+ const usage = findTierResolverUsage(featuresWithTrial);
252
+ if (!usage) throw new Error("setup failure: no trial resolver");
253
+ const plugin = usage.options as TierResolverPlugin;
254
+
255
+ await stack.http.writeOk(
256
+ "tier-engine:write:tier-assignment:create",
257
+ { tier: "free" },
258
+ sysUser(tenantB, "trial-sys-2"),
259
+ );
260
+ // Anlage-Datum künstlich 31 Tage zurückdrehen → Trial abgelaufen. tenantB ist
261
+ // eine fixe Test-UUID (kein User-Input) → inline-Interpolation unkritisch.
262
+ await asRawClient(stack.db).unsafe(
263
+ `UPDATE read_tier_assignments SET inserted_at = now() - interval '31 days' WHERE tenant_id = '${tenantB}'::uuid`,
264
+ );
265
+ const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
266
+ expect(resolver(tenantB).has("feat-pro")).toBe(false);
267
+ });
268
+
269
+ test("ohne Trial-Option ist der Resolver unverändert (free = keine feat-pro)", async () => {
270
+ const usage = findTierResolverUsage(features);
271
+ if (!usage) throw new Error("setup failure");
272
+ const plugin = usage.options as TierResolverPlugin;
273
+ await stack.http.writeOk(
274
+ "tier-engine:write:tier-assignment:create",
275
+ { tier: "free" },
276
+ sysUser(tenantA, "no-trial-sys"),
277
+ );
278
+ const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
279
+ expect(resolver(tenantA).has("feat-pro")).toBe(false);
280
+ });
281
+ });
@@ -0,0 +1,27 @@
1
+ // Trial-Fenster: reine epochMs-Arithmetik, Rand inklusive.
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { isTrialActive } from "../trial";
5
+
6
+ const HOUR_MS = 3_600_000;
7
+ const start = 1_700_000_000_000;
8
+
9
+ describe("isTrialActive", () => {
10
+ test("innerhalb des Fensters → aktiv", () => {
11
+ expect(isTrialActive(start, start + 10 * 24 * HOUR_MS, 720)).toBe(true);
12
+ expect(isTrialActive(start, start, 720)).toBe(true);
13
+ });
14
+
15
+ test("exakt am Fenster-Ende → nicht mehr aktiv (halb-offen)", () => {
16
+ expect(isTrialActive(start, start + 720 * HOUR_MS, 720)).toBe(false);
17
+ });
18
+
19
+ test("nach dem Fenster → inaktiv", () => {
20
+ expect(isTrialActive(start, start + 721 * HOUR_MS, 720)).toBe(false);
21
+ expect(isTrialActive(start, start + 31 * 24 * HOUR_MS, 720)).toBe(false);
22
+ });
23
+
24
+ test("Dauer 0 → nie aktiv", () => {
25
+ expect(isTrialActive(start, start, 0)).toBe(false);
26
+ });
27
+ });
@@ -33,3 +33,11 @@ export const tierAssignmentEntity = createEntity({
33
33
  source: createTextField({ required: false, maxLength: 20 }),
34
34
  },
35
35
  });
36
+
37
+ export type TierAssignmentRow = {
38
+ readonly id: string;
39
+ readonly version: number;
40
+ readonly tier: string;
41
+ readonly source: string | null;
42
+ readonly tenantId: string;
43
+ };