@cosmicdrift/kumiko-bundled-features 0.63.0 → 0.65.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 +6 -6
- package/src/config/__tests__/write-helpers.test.ts +152 -0
- package/src/config/read-redaction.ts +0 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -4
- package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/entity.ts +10 -3
- package/src/custom-fields/events.ts +4 -1
- package/src/custom-fields/feature.ts +1 -5
- package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
- package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
- package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
- package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
- package/src/custom-fields/wire-for-entity.ts +7 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
- package/src/files-provider-s3/s3-provider.ts +2 -4
- package/src/managed-pages/handlers/set.write.ts +4 -11
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
- package/src/sessions/feature.ts +16 -3
- package/src/tags/__tests__/tags.integration.test.ts +30 -1
- package/src/tags/entity.ts +8 -0
- package/src/tags/handlers/assign-tag.write.ts +20 -5
- package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
- package/src/tags/web/i18n.ts +6 -2
- package/src/tags/web/tag-section.tsx +87 -76
- package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
- package/src/tier-engine/__tests__/trial.test.ts +27 -0
- package/src/tier-engine/entity.ts +8 -0
- package/src/tier-engine/feature.ts +49 -9
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
- package/src/tier-engine/index.ts +1 -0
- package/src/tier-engine/trial.ts +26 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
- package/src/user-data-rights/constants.ts +48 -0
- package/src/user-data-rights/feature.ts +15 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
- package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
- package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
- package/src/user-data-rights/index.ts +3 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
- package/src/user-data-rights/run-forget-cleanup.ts +3 -2
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
- package/src/user-data-rights/web/client-plugin.tsx +30 -0
- package/src/user-data-rights/web/i18n.ts +95 -0
- package/src/user-data-rights/web/index.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// TagSection — drop-in tag manager for ANY entity
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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 =
|
|
147
|
+
const createAndAssign = (): void => {
|
|
123
148
|
const name = newName.trim();
|
|
124
149
|
if (name === "") return;
|
|
125
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
}
|
|
145
|
-
setBusy(false);
|
|
146
|
-
}
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
147
168
|
};
|
|
148
169
|
|
|
149
170
|
return (
|
|
150
171
|
<div data-testid="tags-section">
|
|
151
|
-
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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="
|
|
200
|
+
variant="secondary"
|
|
190
201
|
disabled={busy || newName.trim() === ""}
|
|
191
|
-
onClick={() =>
|
|
202
|
+
onClick={() => createAndAssign()}
|
|
192
203
|
testId="tags-section-create"
|
|
193
204
|
>
|
|
194
205
|
{busy ? t("tags.section.working") : t("tags.section.create")}
|
|
@@ -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
|
+
};
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
type TierResolverPlugin,
|
|
63
63
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
64
64
|
import { getAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
|
|
65
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
65
66
|
import { z } from "zod";
|
|
66
67
|
import { tierAssignmentAggregateId } from "./aggregate-id";
|
|
67
68
|
import type { TierMap } from "./compose-app";
|
|
@@ -70,6 +71,7 @@ import { tierAssignmentEntity } from "./entity";
|
|
|
70
71
|
import { getActiveTierQuery } from "./handlers/active-tier.query";
|
|
71
72
|
import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
|
|
72
73
|
import { createSetTenantTierWrite } from "./handlers/set-tenant-tier.write";
|
|
74
|
+
import { isTrialActive, type TrialPolicy } from "./trial";
|
|
73
75
|
|
|
74
76
|
// Drizzle-table for the tier-assignment-entity. Built once at module-load
|
|
75
77
|
// from the entity definition — same shape buildEntityTable would produce
|
|
@@ -115,6 +117,15 @@ export type CreateTierEngineOptions<TCaps extends Readonly<Record<string, unknow
|
|
|
115
117
|
* oder eigene resolution-logic nutzen (legacy-pattern).
|
|
116
118
|
*/
|
|
117
119
|
readonly tierMap?: TierMap<TCaps>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Optionale Trial-Phase: jeder Tenant bekommt für `durationHours` ab seinem
|
|
123
|
+
* Anlage-Datum zusätzlich die Features von `trial.tier` freigeschaltet,
|
|
124
|
+
* danach fällt er automatisch auf sein gespeichertes Tier zurück. Erfordert
|
|
125
|
+
* `tierMap` (der Trial-Tier muss ein Key sein). Zeit-abgeleitet aus
|
|
126
|
+
* inserted_at — kein Stored-Flag, kein Scheduler.
|
|
127
|
+
*/
|
|
128
|
+
readonly trial?: TrialPolicy;
|
|
118
129
|
};
|
|
119
130
|
|
|
120
131
|
/**
|
|
@@ -242,6 +253,17 @@ export function createTierEngineFeature<
|
|
|
242
253
|
// Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
|
|
243
254
|
const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
|
|
244
255
|
|
|
256
|
+
// Trial-State: tenantId → inserted_at als epochMilliseconds (Anlage-Datum
|
|
257
|
+
// der Assignment ≈ Signup, rebuild-stabil). Trial wird at-resolve-time aus
|
|
258
|
+
// (jetzt vs startedAt + durationHours) berechnet, NICHT gecacht — anders als
|
|
259
|
+
// das Feature-Set ändert sich der Trial-Status mit der Zeit. trialFeatures
|
|
260
|
+
// ist die fixe Feature-Menge des Trial-Tiers (einmal aufgelöst).
|
|
261
|
+
const trialClock = new Map<TenantId, number>();
|
|
262
|
+
const trialFeatures: ReadonlySet<string> = opts.trial
|
|
263
|
+
? featuresForTier(tierMap, opts.trial.tier)
|
|
264
|
+
: new Set();
|
|
265
|
+
const nowMs = (): number => getTemporal().Now.instant().epochMilliseconds;
|
|
266
|
+
|
|
245
267
|
// set-tenant-tier schreibt direkt über den Executor → der postSave-Hook
|
|
246
268
|
// unten feuert dabei NICHT. Diese Funktion repliziert den Cache-Update
|
|
247
269
|
// des Hooks, damit ein manueller Grant das effektive Feature-Set sofort
|
|
@@ -249,6 +271,9 @@ export function createTierEngineFeature<
|
|
|
249
271
|
// Semantik wie der Hook.
|
|
250
272
|
onTierAssigned.fn = (tenantId, tier) => {
|
|
251
273
|
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
|
|
274
|
+
// Trial-Uhr nur setzen, wenn unbekannt: ein manueller Grant ändert nicht
|
|
275
|
+
// das Signup-Datum eines bestehenden Tenants (build() hat es bereits).
|
|
276
|
+
if (!trialClock.has(tenantId)) trialClock.set(tenantId, nowMs());
|
|
252
277
|
};
|
|
253
278
|
|
|
254
279
|
// Invalidation: tier-assignment events update the cache.
|
|
@@ -261,10 +286,12 @@ export function createTierEngineFeature<
|
|
|
261
286
|
// throwing — der lifecycle-pipeline darf nicht durch hook-fehler
|
|
262
287
|
// blocken (afterCommit-pattern, side-effect-best-effort).
|
|
263
288
|
if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
)
|
|
289
|
+
const tenantId = data.tenantId as TenantId;
|
|
290
|
+
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)));
|
|
291
|
+
// Erstes Assignment eines Tenants = Signup → Trial-Uhr startet jetzt
|
|
292
|
+
// (inserted_at der frisch erzeugten Row ≈ now). Spätere Tier-Wechsel
|
|
293
|
+
// lassen die Uhr unberührt, sonst würde ein Upgrade das Fenster verlängern.
|
|
294
|
+
if (!trialClock.has(tenantId)) trialClock.set(tenantId, nowMs());
|
|
268
295
|
});
|
|
269
296
|
r.entityHook("postDelete", "tier-assignment", async (payload) => {
|
|
270
297
|
const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
|
|
@@ -385,15 +412,18 @@ export function createTierEngineFeature<
|
|
|
385
412
|
// typischerweise <100k tenants — single-pass scan akzeptabel.
|
|
386
413
|
// Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
|
|
387
414
|
// Bedürfnis entsteht.
|
|
388
|
-
type AssignmentRow = { tenantId: string; tier: string };
|
|
415
|
+
type AssignmentRow = { tenantId: string; tier: string; insertedAt: Temporal.Instant };
|
|
389
416
|
const rows = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
|
|
390
417
|
for (const row of rows) {
|
|
391
418
|
cache.set(
|
|
392
419
|
row.tenantId as TenantId,
|
|
393
420
|
mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
|
|
394
421
|
);
|
|
422
|
+
trialClock.set(row.tenantId as TenantId, row.insertedAt.epochMilliseconds);
|
|
395
423
|
}
|
|
396
424
|
|
|
425
|
+
const trial = opts.trial;
|
|
426
|
+
|
|
397
427
|
// Synchronous resolver-callback for dispatcher hot-path.
|
|
398
428
|
return (tenantId: TenantId): ReadonlySet<string> => {
|
|
399
429
|
// Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
|
|
@@ -401,15 +431,25 @@ export function createTierEngineFeature<
|
|
|
401
431
|
if (tenantId === SYSTEM_TENANT_ID) {
|
|
402
432
|
return mergeAlwaysOn(computedAlwaysOn, unionAllTierFeatures(tierMap));
|
|
403
433
|
}
|
|
404
|
-
const cached = cache.get(tenantId);
|
|
405
|
-
if (cached !== undefined) return cached;
|
|
406
434
|
// Cache-miss: tenant ist noch nicht im cache (z.B. brandneu nach
|
|
407
435
|
// boot, oder defaultTier-hook hat noch nicht gefired). Default-Set
|
|
408
436
|
// ist least-privileged — typisch Free-Tier-features. Memory
|
|
409
437
|
// `feedback_security_default_on`: secure-by-default.
|
|
410
438
|
const fallbackTier = opts.defaultTier;
|
|
411
|
-
|
|
412
|
-
|
|
439
|
+
const base =
|
|
440
|
+
cache.get(tenantId) ??
|
|
441
|
+
(fallbackTier === undefined
|
|
442
|
+
? computedAlwaysOn
|
|
443
|
+
: mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier)));
|
|
444
|
+
// Trial: innerhalb des Fensters ab Signup zusätzlich die Trial-Tier-
|
|
445
|
+
// Features. Zeit-abgeleitet → pro Request geprüft, nie gecacht.
|
|
446
|
+
if (trial !== undefined) {
|
|
447
|
+
const startedMs = trialClock.get(tenantId);
|
|
448
|
+
if (startedMs !== undefined && isTrialActive(startedMs, nowMs(), trial.durationHours)) {
|
|
449
|
+
return mergeAlwaysOn(base, trialFeatures);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return base;
|
|
413
453
|
};
|
|
414
454
|
},
|
|
415
455
|
};
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
7
|
import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { tierAssignmentEntity } from "../entity";
|
|
9
|
+
import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
|
|
10
10
|
|
|
11
11
|
// Liest das Tier-Assignment eines BELIEBIGEN Tenants (cross-tenant) für den
|
|
12
12
|
// tier-admin-Screen. SystemAdmin-only. get-active-tier liest nur den eigenen
|
|
@@ -15,14 +15,6 @@ import { tierAssignmentEntity } from "../entity";
|
|
|
15
15
|
|
|
16
16
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
17
17
|
|
|
18
|
-
type TierAssignmentRow = {
|
|
19
|
-
readonly id: string;
|
|
20
|
-
readonly version: number;
|
|
21
|
-
readonly tier: string;
|
|
22
|
-
readonly source: string | null;
|
|
23
|
-
readonly tenantId: string;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
18
|
export const getTenantTierQuery = defineQueryHandler({
|
|
27
19
|
name: "get-tenant-tier",
|
|
28
20
|
schema: z.object({ tenantId: z.string().min(1) }),
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { tierAssignmentAggregateId } from "../aggregate-id";
|
|
11
|
-
import { tierAssignmentEntity } from "../entity";
|
|
11
|
+
import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
|
|
12
12
|
|
|
13
13
|
// SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
|
|
14
14
|
// Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
|
|
@@ -39,14 +39,6 @@ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEnt
|
|
|
39
39
|
entityName: "tier-assignment",
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
type TierAssignmentRow = {
|
|
43
|
-
readonly id: string;
|
|
44
|
-
readonly version: number;
|
|
45
|
-
readonly tier: string;
|
|
46
|
-
readonly source: string | null;
|
|
47
|
-
readonly tenantId: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
42
|
export type SetTenantTierOptions = {
|
|
51
43
|
/** Nach erfolgreichem Write aufgerufen, damit feature.ts den Resolver-
|
|
52
44
|
* Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
|
package/src/tier-engine/index.ts
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Trial-Phase: ein neuer Tenant bekommt für eine Karenzzeit ab seinem
|
|
2
|
+
// Anlage-Datum (inserted_at der tier-assignment-Row — rebuild-stabil aus dem
|
|
3
|
+
// Create-Event) zusätzlich die Features eines höheren Tiers, unabhängig vom
|
|
4
|
+
// gespeicherten Tier. Rein zeit-abgeleitet: kein Stored-Flag, kein Scheduler,
|
|
5
|
+
// automatischer Ablauf. Die App definiert die Policy (welcher Tier, wie lange);
|
|
6
|
+
// die tier-engine wendet sie im Resolver an.
|
|
7
|
+
|
|
8
|
+
export interface TrialPolicy {
|
|
9
|
+
// Tier, dessen Features während der Trial-Phase zusätzlich freigeschaltet
|
|
10
|
+
// werden (muss ein Key der tierMap sein, sonst greift kein Feature).
|
|
11
|
+
readonly tier: string;
|
|
12
|
+
// Länge der Trial-Phase ab inserted_at, in Stunden (720 = 30 Tage). Stunden
|
|
13
|
+
// statt Tage: Temporal.Instant kennt keine Kalender-Tage, 720h ist die
|
|
14
|
+
// ehrliche, DST-unabhängige Dauer.
|
|
15
|
+
readonly durationHours: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Reine Millis-Arithmetik auf epochMilliseconds (die beide Seiten — Projektions-
|
|
19
|
+
// Row und Now — liefern). Kein Date, keine TZ.
|
|
20
|
+
export function isTrialActive(
|
|
21
|
+
startedAtEpochMs: number,
|
|
22
|
+
nowEpochMs: number,
|
|
23
|
+
durationHours: number,
|
|
24
|
+
): boolean {
|
|
25
|
+
return nowEpochMs < startedAtEpochMs + durationHours * 3_600_000;
|
|
26
|
+
}
|