@cosmicdrift/kumiko-bundled-features 0.124.0 → 0.125.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 +14 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +199 -0
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +131 -0
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +33 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +33 -0
- package/src/admin-shell/constants.ts +11 -0
- package/src/admin-shell/feature.ts +134 -0
- package/src/admin-shell/i18n.ts +35 -0
- package/src/admin-shell/index.ts +17 -0
- package/src/admin-shell/overview-allowlist.ts +32 -0
- package/src/admin-shell/web/client-plugin.tsx +26 -0
- package/src/admin-shell/web/i18n.ts +16 -0
- package/src/admin-shell/web/index.ts +2 -0
- package/src/admin-shell/web/overview-layout.tsx +68 -0
- package/src/admin-shell/web/overview-query.ts +15 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +75 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
- package/src/audit/__tests__/audit-screens.boot.test.ts +29 -0
- package/src/audit/__tests__/audit-security.integration.test.ts +151 -0
- package/src/audit/__tests__/audit.integration.test.ts +0 -4
- package/src/audit/constants.ts +5 -2
- package/src/audit/feature.ts +24 -1
- package/src/audit/handlers/list.query.ts +2 -2
- package/src/audit/i18n.ts +18 -0
- package/src/audit/web/audit-log-screen.tsx +136 -0
- package/src/audit/web/client-plugin.tsx +20 -0
- package/src/audit/web/i18n.ts +12 -0
- package/src/audit/web/index.ts +4 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
- package/src/auth-email-password/web/use-shell-user.ts +5 -2
- package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
- package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
- package/src/cap-counter/constants.ts +2 -0
- package/src/cap-counter/feature.ts +20 -1
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
- package/src/compliance-profiles/constants.ts +15 -0
- package/src/compliance-profiles/feature.ts +19 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/web/client-plugin.tsx +22 -0
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
- package/src/compliance-profiles/web/i18n.ts +25 -0
- package/src/compliance-profiles/web/index.ts +6 -0
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
- package/src/delivery/constants.ts +13 -51
- package/src/delivery/feature.ts +19 -2
- package/src/delivery/handlers/log.query.ts +14 -6
- package/src/delivery/index.ts +1 -0
- package/src/delivery/public-names.ts +36 -0
- package/src/delivery/web/client-plugin.tsx +20 -0
- package/src/delivery/web/delivery-log-screen.tsx +103 -0
- package/src/delivery/web/i18n.ts +25 -0
- package/src/delivery/web/index.ts +3 -0
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
- package/src/feature-toggles/constants.ts +14 -0
- package/src/feature-toggles/feature.ts +14 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +1 -1
- package/src/feature-toggles/index.ts +6 -0
- package/src/feature-toggles/web/client-plugin.tsx +22 -0
- package/src/feature-toggles/web/i18n.ts +29 -0
- package/src/feature-toggles/web/index.ts +6 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
- package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
- package/src/jobs/constants.ts +8 -1
- package/src/jobs/feature.ts +26 -0
- package/src/jobs/i18n.ts +32 -0
- package/src/jobs/web/client-plugin.tsx +22 -0
- package/src/jobs/web/i18n.ts +12 -0
- package/src/jobs/web/index.ts +4 -0
- package/src/jobs/web/job-run-detail-screen.tsx +169 -0
- package/src/jobs/web/job-runs-screen.tsx +148 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +44 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +295 -0
- package/src/tenant/constants.ts +5 -0
- package/src/tenant/feature.ts +16 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
- package/src/tenant/handlers/invitations.query.ts +2 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/web/client-plugin.tsx +20 -0
- package/src/tenant/web/i18n.ts +43 -0
- package/src/tenant/web/index.ts +4 -0
- package/src/tenant/web/members-screen.tsx +265 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +7 -6
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
+
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
5
|
+
import { ComplianceProfileHandlers, ComplianceProfileQueries } from "../constants";
|
|
6
|
+
|
|
7
|
+
type ProfileSummary = {
|
|
8
|
+
readonly key: string;
|
|
9
|
+
readonly region: string;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly authorityContact: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type ListProfilesResponse = {
|
|
15
|
+
readonly profiles: readonly ProfileSummary[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type CurrentProfileResponse = {
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly label: string;
|
|
21
|
+
readonly region: string;
|
|
22
|
+
readonly warning?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type State =
|
|
26
|
+
| { readonly kind: "loading" }
|
|
27
|
+
| { readonly kind: "error"; readonly message: string }
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: "ready";
|
|
30
|
+
readonly profiles: readonly ProfileSummary[];
|
|
31
|
+
readonly current: CurrentProfileResponse | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function ComplianceProfileScreen(): ReactNode {
|
|
35
|
+
const t = useTranslation();
|
|
36
|
+
const { Banner, Button, Card, Field, Form, Heading, Input, Text } = usePrimitives();
|
|
37
|
+
const dispatcher = useDispatcher();
|
|
38
|
+
const [state, setState] = useState<State>({ kind: "loading" });
|
|
39
|
+
const [selected, setSelected] = useState("");
|
|
40
|
+
const [saving, setSaving] = useState(false);
|
|
41
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
42
|
+
|
|
43
|
+
const load = useCallback(async (): Promise<void> => {
|
|
44
|
+
setState({ kind: "loading" });
|
|
45
|
+
const [profilesRes, currentRes] = await Promise.all([
|
|
46
|
+
dispatcher.query<ListProfilesResponse>(ComplianceProfileQueries.listProfiles, {}),
|
|
47
|
+
dispatcher.query<CurrentProfileResponse>(ComplianceProfileQueries.forTenant, {}),
|
|
48
|
+
]);
|
|
49
|
+
if (!profilesRes.isSuccess) {
|
|
50
|
+
setState({ kind: "error", message: profilesRes.error.message });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!currentRes.isSuccess) {
|
|
54
|
+
setState({ kind: "error", message: currentRes.error.message });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setSelected(currentRes.data.key);
|
|
58
|
+
setState({ kind: "ready", profiles: profilesRes.data.profiles, current: currentRes.data });
|
|
59
|
+
}, [dispatcher]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
void load();
|
|
63
|
+
}, [load]);
|
|
64
|
+
|
|
65
|
+
const onSave = (): void => {
|
|
66
|
+
void (async (): Promise<void> => {
|
|
67
|
+
if (selected === "") return;
|
|
68
|
+
setActionError(null);
|
|
69
|
+
setSaving(true);
|
|
70
|
+
const res = await dispatcher.write(ComplianceProfileHandlers.setProfile, {
|
|
71
|
+
profileKey: selected,
|
|
72
|
+
});
|
|
73
|
+
setSaving(false);
|
|
74
|
+
if (!res.isSuccess) {
|
|
75
|
+
setActionError(res.error.message);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
await load();
|
|
79
|
+
})();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (state.kind === "loading") {
|
|
83
|
+
return (
|
|
84
|
+
<FormScreenShell testId="compliance-profile-screen">
|
|
85
|
+
<Text variant="small">{t("compliance.profile.loading")}</Text>
|
|
86
|
+
</FormScreenShell>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (state.kind === "error") {
|
|
91
|
+
return (
|
|
92
|
+
<FormScreenShell testId="compliance-profile-screen">
|
|
93
|
+
<Banner variant="error">{state.message}</Banner>
|
|
94
|
+
</FormScreenShell>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const profileOptions = state.profiles.map((profile) => ({
|
|
99
|
+
value: profile.key,
|
|
100
|
+
label: `${profile.label} (${profile.key})`,
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<FormScreenShell testId="compliance-profile-screen" className="flex flex-col gap-6">
|
|
105
|
+
<Heading variant="page">{t("compliance.profile.title")}</Heading>
|
|
106
|
+
|
|
107
|
+
{state.current !== null && (
|
|
108
|
+
<Banner variant="info" testId="compliance-current-profile">
|
|
109
|
+
{t("compliance.profile.current")}: {state.current.label} ({state.current.key})
|
|
110
|
+
</Banner>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<Form
|
|
114
|
+
onSubmit={onSave}
|
|
115
|
+
actions={
|
|
116
|
+
<Button
|
|
117
|
+
type="submit"
|
|
118
|
+
variant="primary"
|
|
119
|
+
disabled={saving || selected === ""}
|
|
120
|
+
loading={saving}
|
|
121
|
+
testId="compliance-profile-save"
|
|
122
|
+
>
|
|
123
|
+
{saving ? t("compliance.profile.saving") : t("compliance.profile.save")}
|
|
124
|
+
</Button>
|
|
125
|
+
}
|
|
126
|
+
>
|
|
127
|
+
<Field id="compliance-profile-select" label={t("compliance.profile.select")} required>
|
|
128
|
+
<Input
|
|
129
|
+
kind="select"
|
|
130
|
+
id="compliance-profile-select"
|
|
131
|
+
name="compliance-profile-select"
|
|
132
|
+
value={selected}
|
|
133
|
+
onChange={setSelected}
|
|
134
|
+
options={profileOptions}
|
|
135
|
+
required
|
|
136
|
+
/>
|
|
137
|
+
</Field>
|
|
138
|
+
{actionError !== null && <Banner variant="error">{actionError}</Banner>}
|
|
139
|
+
</Form>
|
|
140
|
+
|
|
141
|
+
<Card slots={{ title: t("compliance.profile.catalog") }}>
|
|
142
|
+
<ul className="flex flex-col gap-2 text-sm">
|
|
143
|
+
{state.profiles.map((profile) => (
|
|
144
|
+
<li key={profile.key} data-profile-key={profile.key}>
|
|
145
|
+
<strong>{profile.label}</strong> — {profile.region} — {profile.authorityContact}
|
|
146
|
+
</li>
|
|
147
|
+
))}
|
|
148
|
+
</ul>
|
|
149
|
+
</Card>
|
|
150
|
+
</FormScreenShell>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
|
|
4
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
5
|
+
de: {
|
|
6
|
+
"compliance.profile.title": "Compliance-Profil",
|
|
7
|
+
"compliance.profile.loading": "Lade Compliance-Profile…",
|
|
8
|
+
"compliance.profile.current": "Aktuell",
|
|
9
|
+
"compliance.profile.select": "Profil",
|
|
10
|
+
"compliance.profile.save": "Profil speichern",
|
|
11
|
+
"compliance.profile.saving": "Speichert…",
|
|
12
|
+
"compliance.profile.catalog": "Verfügbare Profile",
|
|
13
|
+
"compliance-profiles:nav.profilePicker": "Compliance",
|
|
14
|
+
},
|
|
15
|
+
en: {
|
|
16
|
+
"compliance.profile.title": "Compliance profile",
|
|
17
|
+
"compliance.profile.loading": "Loading compliance profiles…",
|
|
18
|
+
"compliance.profile.current": "Current",
|
|
19
|
+
"compliance.profile.select": "Profile",
|
|
20
|
+
"compliance.profile.save": "Save profile",
|
|
21
|
+
"compliance.profile.saving": "Saving…",
|
|
22
|
+
"compliance.profile.catalog": "Available profiles",
|
|
23
|
+
"compliance-profiles:nav.profilePicker": "Compliance",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { DELIVERY_LOG_SCREEN_ID } from "../constants";
|
|
5
|
+
import { createDeliveryFeature } from "../feature";
|
|
6
|
+
|
|
7
|
+
describe("delivery screens + handler access alignment", () => {
|
|
8
|
+
const features = [createDeliveryFeature()];
|
|
9
|
+
|
|
10
|
+
test("boot-validates with delivery-log screen registered", () => {
|
|
11
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("delivery-log screen is custom, access.admin-gated", () => {
|
|
15
|
+
const delivery = createDeliveryFeature();
|
|
16
|
+
const screen = delivery.screens[DELIVERY_LOG_SCREEN_ID];
|
|
17
|
+
expect(screen?.type).toBe("custom");
|
|
18
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
19
|
+
expect(screen.access.roles).toEqual(access.admin);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("delivery log handler shares access.admin", () => {
|
|
24
|
+
const delivery = createDeliveryFeature();
|
|
25
|
+
expect(rolesOf(delivery.queryHandlers["log"]?.access)).toEqual([...access.admin]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { insertMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { access, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import {
|
|
5
|
+
createTestUser,
|
|
6
|
+
setupTestStack,
|
|
7
|
+
type TestStack,
|
|
8
|
+
testTenantId,
|
|
9
|
+
unsafePushTables,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
+
import { DELIVERY_LOG_SCREEN_ID, DeliveryQueries } from "../constants";
|
|
13
|
+
import { createDeliveryFeature } from "../feature";
|
|
14
|
+
import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
15
|
+
|
|
16
|
+
let stack: TestStack;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
stack = await setupTestStack({
|
|
20
|
+
features: [createDeliveryFeature()],
|
|
21
|
+
});
|
|
22
|
+
await unsafePushTables(stack.db, { deliveryAttemptsTable, notificationPreferencesTable });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
await stack.cleanup();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("delivery access matrix", () => {
|
|
30
|
+
test("delivery log handler and screen share access.admin", () => {
|
|
31
|
+
const registry = createRegistry([createDeliveryFeature()]);
|
|
32
|
+
expect(rolesOf(registry.getQueryHandler(DeliveryQueries.log)?.access)).toEqual([
|
|
33
|
+
...access.admin,
|
|
34
|
+
]);
|
|
35
|
+
const delivery = createDeliveryFeature();
|
|
36
|
+
const screen = delivery.screens[DELIVERY_LOG_SCREEN_ID];
|
|
37
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
38
|
+
expect(screen.access.roles).toEqual(access.admin);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("delivery log HTTP access", () => {
|
|
44
|
+
test("TenantAdmin can query delivery log", async () => {
|
|
45
|
+
const user = createTestUser({ id: 11, roles: ["TenantAdmin"] });
|
|
46
|
+
const res = await stack.http.queryOk<{ rows: readonly unknown[] }>(
|
|
47
|
+
DeliveryQueries.log,
|
|
48
|
+
{},
|
|
49
|
+
user,
|
|
50
|
+
);
|
|
51
|
+
expect(Array.isArray(res.rows)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("historic Admin can query delivery log", async () => {
|
|
55
|
+
const user = createTestUser({ id: 12, roles: ["Admin"] });
|
|
56
|
+
const res = await stack.http.queryOk<{ rows: readonly unknown[] }>(
|
|
57
|
+
DeliveryQueries.log,
|
|
58
|
+
{},
|
|
59
|
+
user,
|
|
60
|
+
);
|
|
61
|
+
expect(Array.isArray(res.rows)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("regular User gets 403", async () => {
|
|
65
|
+
const user = createTestUser({ id: 13, roles: ["User"] });
|
|
66
|
+
expect((await stack.http.query(DeliveryQueries.log, {}, user)).status).toBe(403);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("delivery log tenant isolation", () => {
|
|
71
|
+
// delivery runs in system-scope, so the handler must filter by tenantId —
|
|
72
|
+
// otherwise a TenantAdmin reads every tenant's attempts (with decrypted PII).
|
|
73
|
+
test("TenantAdmin only sees their own tenant's delivery attempts", async () => {
|
|
74
|
+
const tenantA = testTenantId(1); // createTestUser default tenant
|
|
75
|
+
const tenantB = testTenantId(2);
|
|
76
|
+
await insertMany(stack.db, deliveryAttemptsTable, [
|
|
77
|
+
{
|
|
78
|
+
id: crypto.randomUUID(),
|
|
79
|
+
tenantId: tenantA,
|
|
80
|
+
notificationType: "welcome",
|
|
81
|
+
channel: "email",
|
|
82
|
+
recipientAddress: null,
|
|
83
|
+
status: "sent",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: crypto.randomUUID(),
|
|
87
|
+
tenantId: tenantB,
|
|
88
|
+
notificationType: "welcome",
|
|
89
|
+
channel: "email",
|
|
90
|
+
recipientAddress: null,
|
|
91
|
+
status: "sent",
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const adminA = createTestUser({ id: 21, roles: ["TenantAdmin"], tenantId: tenantA });
|
|
96
|
+
const res = await stack.http.queryOk<{ rows: readonly { tenantId: string }[] }>(
|
|
97
|
+
DeliveryQueries.log,
|
|
98
|
+
{},
|
|
99
|
+
adminA,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(res.rows.length).toBeGreaterThan(0);
|
|
103
|
+
expect(res.rows.every((r) => r.tenantId === tenantA)).toBe(true);
|
|
104
|
+
expect(res.rows.some((r) => r.tenantId === tenantB)).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import type { NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { QnTypes, qn } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
3
|
+
import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names";
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
DELIVERY_ATTEMPT_EVENT,
|
|
7
|
+
DELIVERY_FEATURE,
|
|
8
|
+
DELIVERY_LOG_SCREEN_ID,
|
|
9
|
+
DeliveryErrors,
|
|
10
|
+
DeliveryHandlers,
|
|
11
|
+
DeliveryJobNames,
|
|
12
|
+
DeliveryQueries,
|
|
13
|
+
DeliveryStatus,
|
|
14
|
+
type DeliveryStatusValue,
|
|
15
|
+
} from "./public-names";
|
|
6
16
|
|
|
7
17
|
// notify() priority → BullMQ job priority. Lower number = processed first; all
|
|
8
18
|
// > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
|
|
@@ -13,54 +23,6 @@ export const deliveryPriorityRank: Record<NotifyPriority, number> = {
|
|
|
13
23
|
low: 3,
|
|
14
24
|
};
|
|
15
25
|
|
|
16
|
-
// Qualified write handler names (QN format: scope:type:name)
|
|
17
|
-
export const DeliveryHandlers = {
|
|
18
|
-
setPreference: "delivery:write:set-preference",
|
|
19
|
-
} as const;
|
|
20
|
-
|
|
21
|
-
// Qualified query handler names (QN format: scope:type:name)
|
|
22
|
-
export const DeliveryQueries = {
|
|
23
|
-
log: "delivery:query:log",
|
|
24
|
-
preferences: "delivery:query:preferences",
|
|
25
|
-
} as const;
|
|
26
|
-
|
|
27
|
-
// Error codes
|
|
28
|
-
export const DeliveryErrors = {
|
|
29
|
-
noRecipient: "delivery_no_recipient",
|
|
30
|
-
channelFailed: "delivery_channel_failed",
|
|
31
|
-
} as const;
|
|
32
|
-
|
|
33
|
-
// Delivery status values. `queued` is the initial state of an async attempt
|
|
34
|
-
// (email/push) between dispatch and the worker running — it transitions to
|
|
35
|
-
// sent/failed once the delivery.send job finishes. Synchronous attempts
|
|
36
|
-
// (inApp, skips) never observe `queued`.
|
|
37
|
-
export const DeliveryStatus = {
|
|
38
|
-
queued: "queued",
|
|
39
|
-
sent: "sent",
|
|
40
|
-
failed: "failed",
|
|
41
|
-
skipped: "skipped",
|
|
42
|
-
} as const;
|
|
43
|
-
|
|
44
|
-
export type DeliveryStatusValue = (typeof DeliveryStatus)[keyof typeof DeliveryStatus];
|
|
45
|
-
|
|
46
|
-
// Qualified domain-event name. Emitted by the delivery-service on every
|
|
47
|
-
// attempt (sent / failed / skipped). A multi-stream-projection materialises
|
|
48
|
-
// each event into a row in deliveryAttemptsTable for the log-query handler.
|
|
49
|
-
export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt" as const;
|
|
50
|
-
|
|
51
|
-
// Background jobs that carry async (queued-mode) channels. render decouples the
|
|
52
|
-
// expensive template step from the send so each retries independently; render
|
|
53
|
-
// dispatches send on success. Channels without a render() (push) skip straight
|
|
54
|
-
// to send.
|
|
55
|
-
//
|
|
56
|
-
// Short names are what r.job() registers; the registry qualifies them to
|
|
57
|
-
// `scope:job:name`. Dispatch (notify + render→send chaining) must use the
|
|
58
|
-
// QUALIFIED name, so DeliveryJobs holds the qualified form via qn().
|
|
59
|
-
export const DeliveryJobNames = {
|
|
60
|
-
render: "render",
|
|
61
|
-
send: "send",
|
|
62
|
-
} as const;
|
|
63
|
-
|
|
64
26
|
export const DeliveryJobs = {
|
|
65
27
|
render: qn(DELIVERY_FEATURE, QnTypes.job, DeliveryJobNames.render),
|
|
66
28
|
send: qn(DELIVERY_FEATURE, QnTypes.job, DeliveryJobNames.send),
|
package/src/delivery/feature.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
defineFeature,
|
|
5
|
+
type FeatureDefinition,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
7
|
import type { z } from "zod";
|
|
4
|
-
import { DELIVERY_ATTEMPT_EVENT, DeliveryJobNames } from "./constants";
|
|
8
|
+
import { DELIVERY_ATTEMPT_EVENT, DELIVERY_LOG_SCREEN_ID, DeliveryJobNames } from "./constants";
|
|
5
9
|
import { deliveryAttemptSchema } from "./events";
|
|
6
10
|
import { logQuery } from "./handlers/log.query";
|
|
7
11
|
import { preferencesQuery } from "./handlers/preferences.query";
|
|
@@ -117,6 +121,19 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
117
121
|
preferences: r.queryHandler(preferencesQuery),
|
|
118
122
|
};
|
|
119
123
|
|
|
124
|
+
r.screen({
|
|
125
|
+
id: DELIVERY_LOG_SCREEN_ID,
|
|
126
|
+
type: "custom",
|
|
127
|
+
renderer: { react: { __component: "DeliveryLogScreen" } },
|
|
128
|
+
access: { roles: access.admin },
|
|
129
|
+
});
|
|
130
|
+
r.nav({
|
|
131
|
+
id: "delivery-log",
|
|
132
|
+
label: "delivery:nav.deliveryLog",
|
|
133
|
+
screen: "delivery:screen:delivery-log",
|
|
134
|
+
order: 40,
|
|
135
|
+
});
|
|
136
|
+
|
|
120
137
|
return { handlers, queries };
|
|
121
138
|
});
|
|
122
139
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { decryptStoredPii } from "../../shared";
|
|
5
5
|
import { deliveryAttemptsTable } from "../tables";
|
|
@@ -9,12 +9,20 @@ export const logQuery = defineQueryHandler({
|
|
|
9
9
|
schema: z.object({
|
|
10
10
|
limit: z.number().min(1).max(100).default(50),
|
|
11
11
|
}),
|
|
12
|
-
access: { roles:
|
|
12
|
+
access: { roles: access.admin },
|
|
13
13
|
handler: async (query, ctx) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
// delivery runs in system-scope (feature.ts r.systemScope()), so ctx.db is
|
|
15
|
+
// NOT auto-tenant-filtered — filter explicitly or a tenant admin reads every
|
|
16
|
+
// tenant's attempts (with decrypted recipient PII below).
|
|
17
|
+
const rows = await selectMany(
|
|
18
|
+
ctx.db,
|
|
19
|
+
deliveryAttemptsTable,
|
|
20
|
+
{ tenantId: query.user.tenantId },
|
|
21
|
+
{
|
|
22
|
+
orderBy: { col: "createdAt", direction: "desc" },
|
|
23
|
+
limit: query.payload.limit,
|
|
24
|
+
},
|
|
25
|
+
);
|
|
18
26
|
// recipientAddress is stored encrypted under the recipient's DEK (#799)
|
|
19
27
|
// — decrypt for the admin log view; forgotten subjects show [[erased]].
|
|
20
28
|
return {
|
package/src/delivery/index.ts
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// String-only delivery QNs and screen ids — safe for client bundles.
|
|
3
|
+
|
|
4
|
+
export const DELIVERY_FEATURE = "delivery" as const;
|
|
5
|
+
|
|
6
|
+
export const DeliveryHandlers = {
|
|
7
|
+
setPreference: "delivery:write:set-preference",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export const DeliveryQueries = {
|
|
11
|
+
log: "delivery:query:log",
|
|
12
|
+
preferences: "delivery:query:preferences",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export const DELIVERY_LOG_SCREEN_ID = "delivery-log" as const;
|
|
16
|
+
|
|
17
|
+
export const DeliveryErrors = {
|
|
18
|
+
noRecipient: "delivery_no_recipient",
|
|
19
|
+
channelFailed: "delivery_channel_failed",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export const DeliveryStatus = {
|
|
23
|
+
queued: "queued",
|
|
24
|
+
sent: "sent",
|
|
25
|
+
failed: "failed",
|
|
26
|
+
skipped: "skipped",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export type DeliveryStatusValue = (typeof DeliveryStatus)[keyof typeof DeliveryStatus];
|
|
30
|
+
|
|
31
|
+
export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt" as const;
|
|
32
|
+
|
|
33
|
+
export const DeliveryJobNames = {
|
|
34
|
+
render: "render",
|
|
35
|
+
send: "send",
|
|
36
|
+
} as const;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
+
import { DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID } from "../public-names";
|
|
5
|
+
import { DeliveryLogScreen } from "./delivery-log-screen";
|
|
6
|
+
import { defaultTranslations } from "./i18n";
|
|
7
|
+
|
|
8
|
+
export type DeliveryClientOptions = {
|
|
9
|
+
readonly translations?: TranslationsByLocale;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function deliveryClient(options?: DeliveryClientOptions): ClientFeatureDefinition {
|
|
13
|
+
return {
|
|
14
|
+
name: DELIVERY_FEATURE,
|
|
15
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
16
|
+
components: {
|
|
17
|
+
[DELIVERY_LOG_SCREEN_ID]: DeliveryLogScreen,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
+
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
5
|
+
import { DeliveryQueries } from "../public-names";
|
|
6
|
+
|
|
7
|
+
type DeliveryRow = {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly notificationType: string;
|
|
10
|
+
readonly channel: string;
|
|
11
|
+
readonly recipientAddress: string | null;
|
|
12
|
+
readonly status: string;
|
|
13
|
+
readonly error: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type DeliveryLogResponse = {
|
|
17
|
+
readonly rows: readonly DeliveryRow[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type State =
|
|
21
|
+
| { readonly kind: "loading" }
|
|
22
|
+
| { readonly kind: "error"; readonly message: string }
|
|
23
|
+
| { readonly kind: "ready"; readonly rows: readonly DeliveryRow[] };
|
|
24
|
+
|
|
25
|
+
export function DeliveryLogScreen(): ReactNode {
|
|
26
|
+
const t = useTranslation();
|
|
27
|
+
const { Banner, Card, DataTable, Heading, Text } = usePrimitives();
|
|
28
|
+
const dispatcher = useDispatcher();
|
|
29
|
+
const [state, setState] = useState<State>({ kind: "loading" });
|
|
30
|
+
|
|
31
|
+
const load = useCallback(async (): Promise<void> => {
|
|
32
|
+
setState({ kind: "loading" });
|
|
33
|
+
const res = await dispatcher.query<DeliveryLogResponse>(DeliveryQueries.log, { limit: 50 });
|
|
34
|
+
if (!res.isSuccess) {
|
|
35
|
+
setState({ kind: "error", message: res.error.message });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setState({ kind: "ready", rows: res.data.rows });
|
|
39
|
+
}, [dispatcher]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
void load();
|
|
43
|
+
}, [load]);
|
|
44
|
+
|
|
45
|
+
if (state.kind === "loading") {
|
|
46
|
+
return (
|
|
47
|
+
<FormScreenShell testId="delivery-log-screen">
|
|
48
|
+
<Text variant="small">{t("delivery.log.loading")}</Text>
|
|
49
|
+
</FormScreenShell>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (state.kind === "error") {
|
|
54
|
+
return (
|
|
55
|
+
<FormScreenShell testId="delivery-log-screen">
|
|
56
|
+
<Banner variant="error">{state.message}</Banner>
|
|
57
|
+
</FormScreenShell>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<FormScreenShell testId="delivery-log-screen" className="flex max-w-5xl flex-col gap-6">
|
|
63
|
+
<Heading variant="page">{t("delivery.log.title")}</Heading>
|
|
64
|
+
|
|
65
|
+
<Card options={{ padded: false }}>
|
|
66
|
+
<DataTable
|
|
67
|
+
testId="delivery-log-table"
|
|
68
|
+
columns={[
|
|
69
|
+
{ field: "type", label: t("delivery.log.col.type"), type: "string", sortable: false },
|
|
70
|
+
{
|
|
71
|
+
field: "channel",
|
|
72
|
+
label: t("delivery.log.col.channel"),
|
|
73
|
+
type: "string",
|
|
74
|
+
sortable: false,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
field: "recipient",
|
|
78
|
+
label: t("delivery.log.col.recipient"),
|
|
79
|
+
type: "string",
|
|
80
|
+
sortable: false,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
field: "status",
|
|
84
|
+
label: t("delivery.log.col.status"),
|
|
85
|
+
type: "string",
|
|
86
|
+
sortable: false,
|
|
87
|
+
},
|
|
88
|
+
]}
|
|
89
|
+
rows={state.rows.map((row) => ({
|
|
90
|
+
id: row.id,
|
|
91
|
+
values: {
|
|
92
|
+
type: row.notificationType,
|
|
93
|
+
channel: row.channel,
|
|
94
|
+
recipient: row.recipientAddress ?? "—",
|
|
95
|
+
status: row.error ? `${row.status} (${row.error})` : row.status,
|
|
96
|
+
},
|
|
97
|
+
}))}
|
|
98
|
+
emptyState={<Text variant="small">{t("delivery.log.empty")}</Text>}
|
|
99
|
+
/>
|
|
100
|
+
</Card>
|
|
101
|
+
</FormScreenShell>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
|
|
4
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
5
|
+
de: {
|
|
6
|
+
"delivery.log.title": "Delivery-Log",
|
|
7
|
+
"delivery.log.loading": "Lade Zustellversuche…",
|
|
8
|
+
"delivery.log.empty": "Keine Zustellversuche.",
|
|
9
|
+
"delivery.log.col.type": "Typ",
|
|
10
|
+
"delivery.log.col.channel": "Kanal",
|
|
11
|
+
"delivery.log.col.recipient": "Empfänger",
|
|
12
|
+
"delivery.log.col.status": "Status",
|
|
13
|
+
"delivery:nav.deliveryLog": "Zustellungen",
|
|
14
|
+
},
|
|
15
|
+
en: {
|
|
16
|
+
"delivery.log.title": "Delivery log",
|
|
17
|
+
"delivery.log.loading": "Loading delivery attempts…",
|
|
18
|
+
"delivery.log.empty": "No delivery attempts.",
|
|
19
|
+
"delivery.log.col.type": "Type",
|
|
20
|
+
"delivery.log.col.channel": "Channel",
|
|
21
|
+
"delivery.log.col.recipient": "Recipient",
|
|
22
|
+
"delivery.log.col.status": "Status",
|
|
23
|
+
"delivery:nav.deliveryLog": "Delivery",
|
|
24
|
+
},
|
|
25
|
+
};
|