@cosmicdrift/kumiko-bundled-features 0.312.0 → 0.313.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__/extension-points-typed.test.ts +4 -0
- package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
- package/src/agent-tools/agent-manifest.ts +7 -1
- package/src/agent-tools/changes.json +8 -1
- package/src/audit/changes.json +6 -0
- package/src/audit/feature.ts +1 -2
- package/src/audit/i18n.ts +3 -0
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
- package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
- package/src/auth-email-password/signup-token-store.ts +87 -1
- package/src/auth-email-password/web/auth-client.ts +10 -1
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/run-retention-cleanup.ts +160 -18
- package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
- package/src/delivery/address-opt-out.ts +87 -0
- package/src/delivery/changes.json +7 -0
- package/src/delivery/db/queries/address-opt-outs.ts +23 -0
- package/src/delivery/delivery-service.ts +32 -0
- package/src/delivery/feature.ts +9 -1
- package/src/delivery/index.ts +5 -0
- package/src/delivery/tables.ts +37 -0
- package/src/delivery/unsubscribe.ts +101 -16
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +5 -0
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
- package/src/shared/index.ts +10 -1
- package/src/shared/run-in-sub-transaction.ts +35 -0
- package/src/shared/signup-handover.ts +67 -0
- package/src/shared/single-use-token-store.ts +25 -5
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
- package/src/subscription-stripe/changes.json +9 -1
- package/src/subscription-stripe/feature.ts +13 -1
- package/src/subscription-stripe/plugin-methods.ts +17 -2
- package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant/index.ts +1 -0
- package/src/tenant/is-tenant-serving-public-content.ts +13 -0
- package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
- package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
- package/src/tenant/web/member-roles-cell.tsx +11 -2
- package/src/tenant/web/member-status-cell.tsx +6 -2
- package/src/tenant/web/translate-or-raw.ts +13 -0
- package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
- package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-handover/events.ts +6 -0
- package/src/tenant-handover/feature.ts +11 -0
- package/src/tenant-handover/handlers/claim.write.ts +8 -25
- package/src/tenant-handover/move-entity-graph.ts +30 -1
- package/src/tenant-handover/root-anchor.ts +50 -0
- package/src/tenant-handover/signup-handover-provider.ts +62 -0
- package/src/tenant-handover/transfer-graph.ts +6 -2
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +1 -2
- package/src/user-data-rights/i18n.ts +3 -0
- package/src/user-data-rights/run-forget-cleanup.ts +1 -38
|
@@ -143,9 +143,53 @@ describe("createStripeCheckoutSession", () => {
|
|
|
143
143
|
payment_intent_data: {
|
|
144
144
|
metadata: { tenantId: "tenant-003" },
|
|
145
145
|
},
|
|
146
|
+
// Default-on: without the runtime option, mode:"payment" gets a
|
|
147
|
+
// Stripe invoice.
|
|
148
|
+
invoice_creation: { enabled: true },
|
|
146
149
|
});
|
|
147
150
|
});
|
|
148
151
|
|
|
152
|
+
test("mode='payment' + paymentInvoiceCreation:false: invoice_creation.enabled is false", async () => {
|
|
153
|
+
const stripe = buildStripe();
|
|
154
|
+
const createMock = spyOn(stripe.checkout.sessions, "create")
|
|
155
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
156
|
+
.mockResolvedValue({ url: "https://checkout.stripe.com/c/pay/topup" } as any);
|
|
157
|
+
|
|
158
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe), {
|
|
159
|
+
paymentInvoiceCreation: false,
|
|
160
|
+
});
|
|
161
|
+
await checkout(stubCtx, {
|
|
162
|
+
priceId: "price_credits_topup",
|
|
163
|
+
tenantId: "tenant-003",
|
|
164
|
+
successUrl: "https://example.com/success",
|
|
165
|
+
cancelUrl: "https://example.com/cancel",
|
|
166
|
+
mode: "payment",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(createMock).toHaveBeenCalledWith(
|
|
170
|
+
expect.objectContaining({ invoice_creation: { enabled: false } }),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("mode='subscription': NIE invoice_creation — Stripe rejects es außerhalb payment-mode", async () => {
|
|
175
|
+
const stripe = buildStripe();
|
|
176
|
+
const createMock = spyOn(stripe.checkout.sessions, "create")
|
|
177
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
178
|
+
.mockResolvedValue({ url: "https://x" } as any);
|
|
179
|
+
|
|
180
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
|
|
181
|
+
await checkout(stubCtx, {
|
|
182
|
+
priceId: "price_x",
|
|
183
|
+
tenantId: "tenant-005",
|
|
184
|
+
successUrl: "https://x/s",
|
|
185
|
+
cancelUrl: "https://x/c",
|
|
186
|
+
mode: "subscription",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const callArgs = createMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
190
|
+
expect(callArgs).not.toHaveProperty("invoice_creation");
|
|
191
|
+
});
|
|
192
|
+
|
|
149
193
|
test("mode omitted defaults to 'subscription' (Regression-Pin für bestehende Aufrufer)", async () => {
|
|
150
194
|
const stripe = buildStripe();
|
|
151
195
|
const createMock = spyOn(stripe.checkout.sessions, "create")
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.313.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "subscription-stripe mode:\"payment\" checkouts now get a Stripe invoice by default",
|
|
6
|
+
"detail": "createCheckoutSession's mode:\"payment\" call (one-off top-ups etc.) never\npassed invoice_creation, so Stripe never generated an invoice document for\nthose payments. It now defaults to `invoice_creation: { enabled: true }`\nfor mode:\"payment\" — mode:\"subscription\" is unaffected, Stripe rejects\ninvoice_creation there. Stripe Invoicing charges a per-invoice fee on top\nof the payment itself, so an existing high-volume, low-value payment flow\nmay see new fees appear.",
|
|
7
|
+
"migration": "Pass `createSubscriptionStripeFeature({ paymentInvoiceCreation: false })`\nto opt out and keep the old no-invoice behaviour for mode:\"payment\" checkouts."
|
|
8
|
+
}
|
|
9
|
+
]
|
|
@@ -95,6 +95,14 @@ export type SubscriptionStripeOptions = {
|
|
|
95
95
|
* App-spezifisch → bleibt factory-option. Fehlt die price-id im Mapping
|
|
96
96
|
* → null (event ignored). */
|
|
97
97
|
readonly priceToTier?: Readonly<Record<string, string>>;
|
|
98
|
+
/** Whether a mode:"payment" checkout (one-off top-ups etc.) gets a Stripe
|
|
99
|
+
* invoice — a receipt/invoice document Stripe generates and sends to the
|
|
100
|
+
* customer for that payment. Default true. Stripe Invoicing charges a
|
|
101
|
+
* per-invoice fee on top of the payment itself, so a high-volume,
|
|
102
|
+
* low-value payment flow may want `false` to opt out. Never applied in
|
|
103
|
+
* mode:"subscription" — Stripe rejects invoice_creation there; the
|
|
104
|
+
* subscription's own invoicing covers it. */
|
|
105
|
+
readonly paymentInvoiceCreation?: boolean;
|
|
98
106
|
};
|
|
99
107
|
|
|
100
108
|
/**
|
|
@@ -185,7 +193,11 @@ export function createSubscriptionStripeFeature(
|
|
|
185
193
|
verifyAndParseWebhook: verifyAndParseStripeWebhook(runtimes.webhook, {
|
|
186
194
|
priceToTier: options.priceToTier ?? {},
|
|
187
195
|
}),
|
|
188
|
-
createCheckoutSession: createStripeCheckoutSession(runtimes.ctx
|
|
196
|
+
createCheckoutSession: createStripeCheckoutSession(runtimes.ctx, {
|
|
197
|
+
...(options.paymentInvoiceCreation !== undefined && {
|
|
198
|
+
paymentInvoiceCreation: options.paymentInvoiceCreation,
|
|
199
|
+
}),
|
|
200
|
+
}),
|
|
189
201
|
createPortalSession: createStripePortalSession(runtimes.ctx),
|
|
190
202
|
cancelSubscription: createStripeCancelSubscription(runtimes.ctx),
|
|
191
203
|
};
|
|
@@ -32,7 +32,16 @@ export type StripeCheckoutOptions = Parameters<
|
|
|
32
32
|
NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>
|
|
33
33
|
>[1];
|
|
34
34
|
|
|
35
|
-
export
|
|
35
|
+
export type StripeCheckoutSessionRuntimeOptions = {
|
|
36
|
+
/** Whether a mode:"payment" checkout gets a Stripe invoice. Default true.
|
|
37
|
+
* Never applied in mode:"subscription" — Stripe rejects invoice_creation there. */
|
|
38
|
+
readonly paymentInvoiceCreation?: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function createStripeCheckoutSession(
|
|
42
|
+
runtime: StripeCtxRuntime,
|
|
43
|
+
{ paymentInvoiceCreation = true }: StripeCheckoutSessionRuntimeOptions = {},
|
|
44
|
+
) {
|
|
36
45
|
return async (ctx: HandlerContext, options: StripeCheckoutOptions): Promise<{ url: string }> => {
|
|
37
46
|
// #104-Invariante: ohne billing-live darf keine Stripe-Session
|
|
38
47
|
// entstehen (sk_test_-Keys in prod erzeugen sonst einen Test-Mode-
|
|
@@ -52,9 +61,15 @@ export function createStripeCheckoutSession(runtime: StripeCtxRuntime) {
|
|
|
52
61
|
// payment-mode) — the subsequent webhook reads metadata.tenantId off
|
|
53
62
|
// the subscription it creates. payment-mode has no subscription, so
|
|
54
63
|
// tenantId travels via payment_intent_data.metadata instead.
|
|
64
|
+
// invoice_creation is payment-mode-only for the same reason — Stripe
|
|
65
|
+
// rejects it in subscription-mode, which invoices via the subscription
|
|
66
|
+
// itself.
|
|
55
67
|
...(mode === "subscription"
|
|
56
68
|
? { subscription_data: { metadata: { tenantId: options.tenantId } } }
|
|
57
|
-
: {
|
|
69
|
+
: {
|
|
70
|
+
payment_intent_data: { metadata: { tenantId: options.tenantId } },
|
|
71
|
+
invoice_creation: { enabled: paymentInvoiceCreation },
|
|
72
|
+
}),
|
|
58
73
|
...(options.providerCustomerId && { customer: options.providerCustomerId }),
|
|
59
74
|
});
|
|
60
75
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isTenantServingPublicContent } from "../is-tenant-serving-public-content";
|
|
3
|
+
import { TENANT_LIFECYCLE_STATUSES, type TenantLifecycleStatus } from "../schema/tenant";
|
|
4
|
+
|
|
5
|
+
describe("isTenantServingPublicContent", () => {
|
|
6
|
+
test("true only for an enabled, active tenant", () => {
|
|
7
|
+
expect(isTenantServingPublicContent({ isEnabled: true, status: "active" })).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("false when disabled, even if active", () => {
|
|
11
|
+
expect(isTenantServingPublicContent({ isEnabled: false, status: "active" })).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test.each(
|
|
15
|
+
TENANT_LIFECYCLE_STATUSES.filter(
|
|
16
|
+
(status): status is Exclude<TenantLifecycleStatus, "active"> => status !== "active",
|
|
17
|
+
),
|
|
18
|
+
)("false for enabled tenant with status '%s'", (status) => {
|
|
19
|
+
expect(isTenantServingPublicContent({ isEnabled: true, status })).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
package/src/tenant/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.313.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Team member status and roles now translate instead of showing the raw enum value",
|
|
6
|
+
"detail": "member-status-cell and member-roles-cell rendered the raw status/role\nenum values (e.g. \"active\", \"TenantAdmin\") straight into the table —\nnon-English admin UIs showed English words next to translated column\nlabels. Both cells now resolve through useTranslation against the\nexisting tenant.members.filter.status.option.<status> and\ntenant:entity:__action-form__:field:roles:option:<role> keys, falling\nback to the raw value for any status/role not covered by those keys."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.296.0",
|
|
4
10
|
"type": "improvement",
|
package/src/tenant/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
tenantInvitationEntity,
|
|
9
9
|
tenantInvitationsTable,
|
|
10
10
|
} from "./invitation-table";
|
|
11
|
+
export { isTenantServingPublicContent } from "./is-tenant-serving-public-content";
|
|
11
12
|
export { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
|
|
12
13
|
export {
|
|
13
14
|
TENANT_LIFECYCLE_STATUSES,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TenantLifecycleStatus } from "./schema/tenant";
|
|
2
|
+
|
|
3
|
+
/** Whether a tenant still serves content to anonymous/public callers.
|
|
4
|
+
* `destroyRequested` is deliberately blocked too, not just `destroying`
|
|
5
|
+
* onward — secure by default: the tenant already asked to be removed, so
|
|
6
|
+
* its data should stop being publicly reachable immediately rather than
|
|
7
|
+
* during the grace period. */
|
|
8
|
+
export function isTenantServingPublicContent(row: {
|
|
9
|
+
readonly isEnabled: boolean;
|
|
10
|
+
readonly status: TenantLifecycleStatus;
|
|
11
|
+
}): boolean {
|
|
12
|
+
return row.isEnabled && row.status === "active";
|
|
13
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
translationsByLocaleFromKeys,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
|
+
import { render, screen } from "@testing-library/react";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { TENANT_I18N } from "../../i18n";
|
|
12
|
+
import { MemberRolesCell } from "../member-roles-cell";
|
|
13
|
+
|
|
14
|
+
const fallbackBundles = [translationsByLocaleFromKeys(TENANT_I18N)];
|
|
15
|
+
|
|
16
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
17
|
+
return (
|
|
18
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={fallbackBundles}>
|
|
19
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
20
|
+
</LocaleProvider>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("MemberRolesCell", () => {
|
|
25
|
+
test("translates known roles, joined by comma", () => {
|
|
26
|
+
render(
|
|
27
|
+
<Wrapper>
|
|
28
|
+
<MemberRolesCell
|
|
29
|
+
value={["TenantAdmin", "Editor"]}
|
|
30
|
+
row={{ roles: ["TenantAdmin", "Editor"] }}
|
|
31
|
+
column={{ field: "roles" }}
|
|
32
|
+
/>
|
|
33
|
+
</Wrapper>,
|
|
34
|
+
);
|
|
35
|
+
expect(screen.getByText("Tenant Admin, Editor")).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("falls back to the raw name for an unknown, app-defined role", () => {
|
|
39
|
+
render(
|
|
40
|
+
<Wrapper>
|
|
41
|
+
<MemberRolesCell
|
|
42
|
+
value={["AppCustomRole"]}
|
|
43
|
+
row={{ roles: ["AppCustomRole"] }}
|
|
44
|
+
column={{ field: "roles" }}
|
|
45
|
+
/>
|
|
46
|
+
</Wrapper>,
|
|
47
|
+
);
|
|
48
|
+
expect(screen.getByText("AppCustomRole")).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("renders nothing for a non-array roles value", () => {
|
|
52
|
+
render(
|
|
53
|
+
<Wrapper>
|
|
54
|
+
<MemberRolesCell value={undefined} row={{}} column={{ field: "roles" }} />
|
|
55
|
+
</Wrapper>,
|
|
56
|
+
);
|
|
57
|
+
expect(screen.queryByText(/./)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
translationsByLocaleFromKeys,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
|
+
import { render, screen } from "@testing-library/react";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { TENANT_I18N } from "../../i18n";
|
|
12
|
+
import { MemberStatusCell } from "../member-status-cell";
|
|
13
|
+
|
|
14
|
+
const fallbackBundles = [translationsByLocaleFromKeys(TENANT_I18N)];
|
|
15
|
+
|
|
16
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
17
|
+
return (
|
|
18
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={fallbackBundles}>
|
|
19
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
20
|
+
</LocaleProvider>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("MemberStatusCell", () => {
|
|
25
|
+
test("translates a known status", () => {
|
|
26
|
+
render(
|
|
27
|
+
<Wrapper>
|
|
28
|
+
<MemberStatusCell value="active" row={{ status: "active" }} column={{ field: "status" }} />
|
|
29
|
+
</Wrapper>,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.getByText("Active")).toBeTruthy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("falls back to the raw value for an unknown status", () => {
|
|
35
|
+
render(
|
|
36
|
+
<Wrapper>
|
|
37
|
+
<MemberStatusCell
|
|
38
|
+
value="app-custom"
|
|
39
|
+
row={{ status: "app-custom" }}
|
|
40
|
+
column={{ field: "status" }}
|
|
41
|
+
/>
|
|
42
|
+
</Wrapper>,
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByText("app-custom")).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
// `readonly string[]` on TeamRow (see team-list.query.ts), and DataTable has
|
|
4
4
|
// no default array-to-cell formatting, so this joins it into one string.
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
import type { ReactNode } from "react";
|
|
8
|
+
import { translateOrRaw } from "./translate-or-raw";
|
|
8
9
|
|
|
9
10
|
export function MemberRolesCell({ row }: ColumnRendererProps): ReactNode {
|
|
11
|
+
const t = useTranslation();
|
|
10
12
|
const roles = row["roles"];
|
|
11
|
-
|
|
13
|
+
if (!Array.isArray(roles)) return "";
|
|
14
|
+
return roles
|
|
15
|
+
.map((role) =>
|
|
16
|
+
typeof role === "string"
|
|
17
|
+
? translateOrRaw(t, `tenant:entity:__action-form__:field:roles:option:${role}`, role)
|
|
18
|
+
: "",
|
|
19
|
+
)
|
|
20
|
+
.join(", ");
|
|
12
21
|
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// screens.ts). The list frame itself is the generic declarative
|
|
5
5
|
// projectionList renderer — only this one cell stays TSX.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
|
+
import { translateOrRaw } from "./translate-or-raw";
|
|
10
11
|
|
|
11
12
|
const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
|
|
12
13
|
active: "ok",
|
|
@@ -14,10 +15,13 @@ const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
|
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export function MemberStatusCell({ row }: ColumnRendererProps): ReactNode {
|
|
18
|
+
const t = useTranslation();
|
|
17
19
|
const status = typeof row["status"] === "string" ? row["status"] : "";
|
|
20
|
+
const label =
|
|
21
|
+
status === "" ? "" : translateOrRaw(t, `tenant.members.filter.status.option.${status}`, status);
|
|
18
22
|
return (
|
|
19
23
|
<StatusBadge tone={STATUS_TONE[status] ?? "muted"} testId="member-status-cell">
|
|
20
|
-
{
|
|
24
|
+
{label}
|
|
21
25
|
</StatusBadge>
|
|
22
26
|
);
|
|
23
27
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import type { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
|
|
4
|
+
type Translate = ReturnType<typeof useTranslation>;
|
|
5
|
+
|
|
6
|
+
/** `t(key)` returns the key itself when the key is missing from every
|
|
7
|
+
* bundle — the convention every cell that translates an open-ended,
|
|
8
|
+
* app-extensible value (a status, a role name) relies on to fall back to
|
|
9
|
+
* the raw value instead of showing a dot-form key to the user. */
|
|
10
|
+
export function translateOrRaw(t: Translate, key: string, raw: string): string {
|
|
11
|
+
const translated = t(key);
|
|
12
|
+
return translated === key ? raw : translated;
|
|
13
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// tenant-handover's `claim` moving a fileRef's OWN event history + its share
|
|
2
|
+
// of the tenant-storage-usage counters (item 4): before this, moveFileRefs
|
|
3
|
+
// only flipped the file_refs row's tenant_id, so the fileRef aggregate's
|
|
4
|
+
// events in kumiko_events stayed under the source tenant — a later write
|
|
5
|
+
// against the moved fileRef couldn't load its stream, and a projection
|
|
6
|
+
// rebuild put the row back into the source tenant. See ../move-entity-graph.ts
|
|
7
|
+
// and framework/src/files/storage-tracking.ts for the fix.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
asRawClient,
|
|
12
|
+
buildEntityTable,
|
|
13
|
+
createEventStoreExecutor,
|
|
14
|
+
createTenantDb,
|
|
15
|
+
selectMany,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
17
|
+
import {
|
|
18
|
+
createEntity,
|
|
19
|
+
createSystemUser,
|
|
20
|
+
createTextField,
|
|
21
|
+
defineFeature,
|
|
22
|
+
type EntityDefinition,
|
|
23
|
+
type TenantId,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
+
import {
|
|
26
|
+
createFilesFeature,
|
|
27
|
+
fileRefEntity,
|
|
28
|
+
fileRefsTable,
|
|
29
|
+
filesStorageTrackingFeature,
|
|
30
|
+
tenantStorageUsageTable,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
32
|
+
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
33
|
+
import {
|
|
34
|
+
createTestUser,
|
|
35
|
+
setupTestStack,
|
|
36
|
+
type TestStack,
|
|
37
|
+
testTenantId,
|
|
38
|
+
unsafeCreateEntityTable,
|
|
39
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
40
|
+
import { signTenantHandoverGrant } from "../grant";
|
|
41
|
+
import { createTenantHandoverFeature } from "../index";
|
|
42
|
+
|
|
43
|
+
const CLAIM = "tenant-handover:write:claim";
|
|
44
|
+
const SECRET = "tenant-handover-storage-usage-secret";
|
|
45
|
+
// files feature -> r.entity("fileRef", ...) -> implicit projection name
|
|
46
|
+
// (registry-validate.ts's buildImplicitProjections naming convention).
|
|
47
|
+
const FILE_REF_PROJECTION = "files:projection:file-ref-entity";
|
|
48
|
+
|
|
49
|
+
const runEntity: EntityDefinition = createEntity({
|
|
50
|
+
table: "handover_storage_run",
|
|
51
|
+
idType: "uuid",
|
|
52
|
+
transferable: true,
|
|
53
|
+
fields: {
|
|
54
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const handoverStorageFixturesFeature = defineFeature("handover-storage-fixtures", (r) => {
|
|
59
|
+
r.entity("run", runEntity);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const runTable = buildEntityTable("run", runEntity);
|
|
63
|
+
const runCrud = createEventStoreExecutor(runTable, runEntity, { entityName: "run" });
|
|
64
|
+
|
|
65
|
+
const fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
66
|
+
entityName: "fileRef",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let stack: TestStack;
|
|
70
|
+
|
|
71
|
+
const SOURCE_TENANT = testTenantId(4);
|
|
72
|
+
let nextUserId = 1000;
|
|
73
|
+
|
|
74
|
+
function destinationUser(tenantN: number) {
|
|
75
|
+
return createTestUser({ id: nextUserId++, tenantId: testTenantId(tenantN), roles: ["User"] });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
stack = await setupTestStack({
|
|
80
|
+
features: [
|
|
81
|
+
createTenantHandoverFeature({ grantSecret: SECRET }),
|
|
82
|
+
handoverStorageFixturesFeature,
|
|
83
|
+
createFilesFeature(),
|
|
84
|
+
filesStorageTrackingFeature,
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
await unsafeCreateEntityTable(stack.db, runEntity, "run");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(async () => {
|
|
91
|
+
await stack.cleanup();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
beforeEach(async () => {
|
|
95
|
+
stack.events.reset();
|
|
96
|
+
await asRawClient(stack.db).unsafe(
|
|
97
|
+
`TRUNCATE kumiko_events, kumiko_snapshots, kumiko_event_consumers, handover_storage_run, file_refs, read_tenant_storage_usage RESTART IDENTITY CASCADE`,
|
|
98
|
+
);
|
|
99
|
+
await stack.eventDispatcher?.ensureRegistered();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function seedRun(tenantId: TenantId, name: string): Promise<string> {
|
|
103
|
+
const user = createSystemUser(tenantId);
|
|
104
|
+
const db = createTenantDb(stack.db, tenantId, "system");
|
|
105
|
+
const result = await runCrud.create({ name }, user, db);
|
|
106
|
+
if (!result.isSuccess) throw new Error(`seedRun failed: ${result.error.message}`);
|
|
107
|
+
return String(result.data.id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function attachFile(tenantId: TenantId, entityId: string, size: number): Promise<string> {
|
|
111
|
+
const user = createSystemUser(tenantId);
|
|
112
|
+
const db = createTenantDb(stack.db, tenantId, "system");
|
|
113
|
+
const result = await fileRefCrud.create(
|
|
114
|
+
{
|
|
115
|
+
storageKey: `${tenantId}/run/${entityId}/photo/x.jpg`,
|
|
116
|
+
fileName: "photo.jpg",
|
|
117
|
+
mimeType: "image/jpeg",
|
|
118
|
+
size,
|
|
119
|
+
entityType: "run",
|
|
120
|
+
entityId,
|
|
121
|
+
fieldName: "photo",
|
|
122
|
+
},
|
|
123
|
+
user,
|
|
124
|
+
db,
|
|
125
|
+
);
|
|
126
|
+
if (!result.isSuccess) throw new Error(`attachFile failed: ${result.error.message}`);
|
|
127
|
+
return String(result.data.id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function grantFor(rowId: string): string {
|
|
131
|
+
return signTenantHandoverGrant({
|
|
132
|
+
entityType: "run",
|
|
133
|
+
rowId,
|
|
134
|
+
sourceTenantId: SOURCE_TENANT,
|
|
135
|
+
ttlMinutes: 30,
|
|
136
|
+
secret: SECRET,
|
|
137
|
+
}).token;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function usageFor(tenantId: string): Promise<{ totalBytes: number; fileCount: number }> {
|
|
141
|
+
const [row] = await selectMany(stack.db, tenantStorageUsageTable, { tenantId });
|
|
142
|
+
return row
|
|
143
|
+
? { totalBytes: Number(row["totalBytes"]) ?? 0, fileCount: Number(row["fileCount"]) ?? 0 }
|
|
144
|
+
: { totalBytes: 0, fileCount: 0 };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("tenant-handover :: claim moves fileRef event history + storage usage", () => {
|
|
148
|
+
test("moves the fileRef aggregate's own event history and storage-usage counters, leaving the moved fileRef writable and rebuild-stable", async () => {
|
|
149
|
+
const runId = await seedRun(SOURCE_TENANT, "my run");
|
|
150
|
+
const FILE_SIZE = 42;
|
|
151
|
+
const fileId = await attachFile(SOURCE_TENANT, runId, FILE_SIZE);
|
|
152
|
+
|
|
153
|
+
// Let the dispatcher apply fileRef.created before the claim, so the
|
|
154
|
+
// counters this test asserts on are the MSP's real applied state, not
|
|
155
|
+
// just the write-tx's own effect.
|
|
156
|
+
await stack.eventDispatcher?.runOnce();
|
|
157
|
+
expect(await usageFor(SOURCE_TENANT)).toEqual({ totalBytes: FILE_SIZE, fileCount: 1 });
|
|
158
|
+
|
|
159
|
+
const dest = destinationUser(1);
|
|
160
|
+
const data = await stack.http.writeOk<{ movedEntities: Record<string, number> }>(
|
|
161
|
+
CLAIM,
|
|
162
|
+
{ token: grantFor(runId), entityType: "run" },
|
|
163
|
+
dest,
|
|
164
|
+
);
|
|
165
|
+
expect(data.movedEntities).toEqual({ run: 1, fileRef: 1 });
|
|
166
|
+
|
|
167
|
+
// Let the dispatcher settle again — the transfer already moved the
|
|
168
|
+
// counters synchronously inside the claim's own transaction, so this
|
|
169
|
+
// pass must be a no-op, not a double-apply.
|
|
170
|
+
await stack.eventDispatcher?.runOnce();
|
|
171
|
+
|
|
172
|
+
expect(await usageFor(SOURCE_TENANT)).toEqual({ totalBytes: 0, fileCount: 0 });
|
|
173
|
+
expect(await usageFor(dest.tenantId)).toEqual({ totalBytes: FILE_SIZE, fileCount: 1 });
|
|
174
|
+
|
|
175
|
+
// The fileRef aggregate's OWN event history moved too — a write against
|
|
176
|
+
// it in the destination tenant must succeed (it couldn't load its
|
|
177
|
+
// stream before this fix).
|
|
178
|
+
const deleteUser = createSystemUser(dest.tenantId as TenantId);
|
|
179
|
+
const deleteDb = createTenantDb(stack.db, dest.tenantId as TenantId, "system");
|
|
180
|
+
const deleteResult = await fileRefCrud.delete({ id: fileId }, deleteUser, deleteDb);
|
|
181
|
+
expect(deleteResult.isSuccess).toBe(true);
|
|
182
|
+
|
|
183
|
+
// A projection rebuild must not resurrect the row under the source
|
|
184
|
+
// tenant — before the fix, the fileRef.created event still carried the
|
|
185
|
+
// source tenant_id and the rebuild would put file_refs.tenant_id back.
|
|
186
|
+
await rebuildProjection(FILE_REF_PROJECTION, { db: stack.db, registry: stack.registry });
|
|
187
|
+
|
|
188
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
189
|
+
`SELECT tenant_id AS "tenantId", is_deleted AS "isDeleted" FROM file_refs WHERE id = $1`,
|
|
190
|
+
[fileId],
|
|
191
|
+
);
|
|
192
|
+
const row = (rows as Array<{ tenantId: string; isDeleted: boolean }>)[0];
|
|
193
|
+
expect(row?.tenantId).toBe(dest.tenantId);
|
|
194
|
+
expect(row?.isDeleted).toBe(true);
|
|
195
|
+
|
|
196
|
+
// Independently of the read-model rebuild above, the dispatcher applies
|
|
197
|
+
// the pending fileRef.deleted event against the tenant-storage-usage MSP.
|
|
198
|
+
await stack.eventDispatcher?.runOnce();
|
|
199
|
+
expect(await usageFor(dest.tenantId)).toEqual({ totalBytes: 0, fileCount: 0 });
|
|
200
|
+
});
|
|
201
|
+
});
|