@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.
Files changed (88) hide show
  1. package/package.json +14 -6
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +199 -0
  3. package/src/admin-shell/__tests__/admin-shell.boot.test.ts +131 -0
  4. package/src/admin-shell/__tests__/overview-allowlist.test.ts +33 -0
  5. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +33 -0
  6. package/src/admin-shell/constants.ts +11 -0
  7. package/src/admin-shell/feature.ts +134 -0
  8. package/src/admin-shell/i18n.ts +35 -0
  9. package/src/admin-shell/index.ts +17 -0
  10. package/src/admin-shell/overview-allowlist.ts +32 -0
  11. package/src/admin-shell/web/client-plugin.tsx +26 -0
  12. package/src/admin-shell/web/i18n.ts +16 -0
  13. package/src/admin-shell/web/index.ts +2 -0
  14. package/src/admin-shell/web/overview-layout.tsx +68 -0
  15. package/src/admin-shell/web/overview-query.ts +15 -0
  16. package/src/admin-shell/web/platform-overview-screen.tsx +75 -0
  17. package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
  18. package/src/audit/__tests__/audit-screens.boot.test.ts +29 -0
  19. package/src/audit/__tests__/audit-security.integration.test.ts +151 -0
  20. package/src/audit/__tests__/audit.integration.test.ts +0 -4
  21. package/src/audit/constants.ts +5 -2
  22. package/src/audit/feature.ts +24 -1
  23. package/src/audit/handlers/list.query.ts +2 -2
  24. package/src/audit/i18n.ts +18 -0
  25. package/src/audit/web/audit-log-screen.tsx +136 -0
  26. package/src/audit/web/client-plugin.tsx +20 -0
  27. package/src/audit/web/i18n.ts +12 -0
  28. package/src/audit/web/index.ts +4 -0
  29. package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
  30. package/src/auth-email-password/web/use-shell-user.ts +5 -2
  31. package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
  32. package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
  33. package/src/cap-counter/constants.ts +2 -0
  34. package/src/cap-counter/feature.ts +20 -1
  35. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
  36. package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
  37. package/src/compliance-profiles/constants.ts +15 -0
  38. package/src/compliance-profiles/feature.ts +19 -1
  39. package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/web/client-plugin.tsx +22 -0
  42. package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
  43. package/src/compliance-profiles/web/i18n.ts +25 -0
  44. package/src/compliance-profiles/web/index.ts +6 -0
  45. package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
  46. package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
  47. package/src/delivery/constants.ts +13 -51
  48. package/src/delivery/feature.ts +19 -2
  49. package/src/delivery/handlers/log.query.ts +14 -6
  50. package/src/delivery/index.ts +1 -0
  51. package/src/delivery/public-names.ts +36 -0
  52. package/src/delivery/web/client-plugin.tsx +20 -0
  53. package/src/delivery/web/delivery-log-screen.tsx +103 -0
  54. package/src/delivery/web/i18n.ts +25 -0
  55. package/src/delivery/web/index.ts +3 -0
  56. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
  57. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
  58. package/src/feature-toggles/constants.ts +14 -0
  59. package/src/feature-toggles/feature.ts +14 -1
  60. package/src/feature-toggles/handlers/list.query.ts +1 -1
  61. package/src/feature-toggles/handlers/registered.query.ts +1 -1
  62. package/src/feature-toggles/index.ts +6 -0
  63. package/src/feature-toggles/web/client-plugin.tsx +22 -0
  64. package/src/feature-toggles/web/i18n.ts +29 -0
  65. package/src/feature-toggles/web/index.ts +6 -0
  66. package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
  67. package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
  68. package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
  69. package/src/jobs/constants.ts +8 -1
  70. package/src/jobs/feature.ts +26 -0
  71. package/src/jobs/i18n.ts +32 -0
  72. package/src/jobs/web/client-plugin.tsx +22 -0
  73. package/src/jobs/web/i18n.ts +12 -0
  74. package/src/jobs/web/index.ts +4 -0
  75. package/src/jobs/web/job-run-detail-screen.tsx +169 -0
  76. package/src/jobs/web/job-runs-screen.tsx +148 -0
  77. package/src/tenant/__tests__/members-screens.boot.test.ts +44 -0
  78. package/src/tenant/__tests__/tenant-security.integration.test.ts +295 -0
  79. package/src/tenant/constants.ts +5 -0
  80. package/src/tenant/feature.ts +16 -0
  81. package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
  82. package/src/tenant/handlers/invitations.query.ts +2 -2
  83. package/src/tenant/handlers/members.query.ts +2 -2
  84. package/src/tenant/web/client-plugin.tsx +20 -0
  85. package/src/tenant/web/i18n.ts +43 -0
  86. package/src/tenant/web/index.ts +4 -0
  87. package/src/tenant/web/members-screen.tsx +265 -0
  88. 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,6 @@
1
+ // @runtime client
2
+ export {
3
+ type ComplianceProfilesClientOptions,
4
+ complianceProfilesClient,
5
+ } from "./client-plugin";
6
+ export { ComplianceProfileScreen } from "./compliance-profile-screen";
@@ -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
- // Feature name
5
- export const DELIVERY_FEATURE = "delivery" as const;
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),
@@ -1,7 +1,11 @@
1
1
  import { upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
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: ["Admin", "SystemAdmin"] },
12
+ access: { roles: access.admin },
13
13
  handler: async (query, ctx) => {
14
- const rows = await selectMany(ctx.db, deliveryAttemptsTable, undefined, {
15
- orderBy: { col: "createdAt", direction: "desc" },
16
- limit: query.payload.limit,
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 {
@@ -1,6 +1,7 @@
1
1
  export type { DeliveryStatusValue } from "./constants";
2
2
  export {
3
3
  DELIVERY_FEATURE,
4
+ DELIVERY_LOG_SCREEN_ID,
4
5
  DeliveryErrors,
5
6
  DeliveryHandlers,
6
7
  DeliveryJobs,
@@ -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
+ };
@@ -0,0 +1,3 @@
1
+ // @runtime client
2
+ export { type DeliveryClientOptions, deliveryClient } from "./client-plugin";
3
+ export { DeliveryLogScreen } from "./delivery-log-screen";