@cosmicdrift/kumiko-bundled-features 0.208.2 → 0.209.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 +8 -8
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +10 -2
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +50 -0
- package/src/compliance-profiles/changes.json +8 -1
- package/src/compliance-profiles/constants.ts +5 -0
- package/src/compliance-profiles/feature.ts +28 -3
- package/src/compliance-profiles/i18n.ts +25 -0
- package/src/compliance-profiles/web/client-plugin.tsx +7 -4
- package/src/compliance-profiles/web/compliance-profile-catalog.tsx +55 -0
- package/src/compliance-profiles/web/i18n.ts +1 -7
- package/src/compliance-profiles/web/index.ts +1 -1
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +132 -0
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +5 -5
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +1 -1
- package/src/delivery/changes.json +6 -0
- package/src/delivery/constants.ts +1 -0
- package/src/delivery/feature.ts +28 -3
- package/src/delivery/handlers/log.query.ts +115 -18
- package/src/delivery/i18n.ts +4 -0
- package/src/delivery/public-names.ts +5 -0
- package/src/delivery/web/client-plugin.tsx +6 -7
- package/src/delivery/web/delivery-status-cell.tsx +26 -0
- package/src/delivery/web/index.ts +1 -1
- package/src/sessions/__tests__/session-last-seen.integration.test.ts +165 -0
- package/src/sessions/changes.json +6 -0
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +17 -1
- package/src/sessions/schema/user-session.ts +5 -0
- package/src/sessions/session-callbacks.ts +20 -1
- package/src/sessions/session-checker-fail-open.test.ts +38 -0
- package/src/tenant-settings/__tests__/config.test.ts +19 -0
- package/src/tenant-settings/config.ts +5 -6
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +0 -152
- package/src/delivery/web/delivery-log-screen.tsx +0 -103
- package/src/delivery/web/i18n.ts +0 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.209.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.209.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.209.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.209.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.209.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.209.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.209.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,6 +160,6 @@
|
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@testing-library/user-event": "^14.6.1",
|
|
162
162
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.209.0"
|
|
164
164
|
}
|
|
165
165
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SELECTABLE_PROFILE_KEYS } from "@cosmicdrift/kumiko-framework/compliance";
|
|
2
3
|
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
5
|
import { COMPLIANCE_PROFILE_SCREEN_ID, ComplianceProfileHandlers } from "../constants";
|
|
@@ -11,11 +12,18 @@ describe("compliance profile screen + handler access alignment", () => {
|
|
|
11
12
|
expect(() => validateBoot(features)).not.toThrow();
|
|
12
13
|
});
|
|
13
14
|
|
|
14
|
-
test("profile-picker screen is
|
|
15
|
+
test("profile-picker screen is an actionForm wired to set-profile, access.admin-gated", () => {
|
|
15
16
|
const feature = createComplianceProfilesFeature();
|
|
16
17
|
const screen = feature.screens[COMPLIANCE_PROFILE_SCREEN_ID];
|
|
17
|
-
expect(screen?.type).toBe("
|
|
18
|
+
expect(screen?.type).toBe("actionForm");
|
|
18
19
|
expect(rolesOf(screen?.access)).toEqual([...access.admin]);
|
|
20
|
+
if (screen?.type !== "actionForm") throw new Error("expected actionForm screen");
|
|
21
|
+
expect(screen.handler).toBe(ComplianceProfileHandlers.setProfile);
|
|
22
|
+
// Same choosable set the write-handler's zod schema enforces — a screen
|
|
23
|
+
// option outside SELECTABLE_PROFILE_KEYS would let a user pick a value
|
|
24
|
+
// the server always rejects.
|
|
25
|
+
const profileField = screen.fields["profileKey"] as { readonly options?: readonly string[] };
|
|
26
|
+
expect(profileField.options).toEqual([...SELECTABLE_PROFILE_KEYS]);
|
|
19
27
|
});
|
|
20
28
|
|
|
21
29
|
test("set-profile handler shares access.admin", () => {
|
|
@@ -89,3 +89,53 @@ describe("compliance profile HTTP access", () => {
|
|
|
89
89
|
).toBe(403);
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
describe("profile-picker actionForm screen: submit-path wiring", () => {
|
|
94
|
+
function pickerScreen() {
|
|
95
|
+
const feature = createComplianceProfilesFeature();
|
|
96
|
+
const screen = feature.screens[COMPLIANCE_PROFILE_SCREEN_ID];
|
|
97
|
+
if (screen?.type !== "actionForm") throw new Error("expected actionForm screen");
|
|
98
|
+
return screen;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("submitting the screen's own handler with a screen-listed profile actually sets it", async () => {
|
|
102
|
+
const screen = pickerScreen();
|
|
103
|
+
const profileField = screen.fields["profileKey"] as { readonly options?: readonly string[] };
|
|
104
|
+
const chosen = profileField.options?.[1];
|
|
105
|
+
if (chosen === undefined) throw new Error("expected at least 2 selectable profiles");
|
|
106
|
+
const admin = createTestUser({ id: 34, roles: ["TenantAdmin"] });
|
|
107
|
+
|
|
108
|
+
const res = await stack.http.writeOk<{ profileKey: string }>(
|
|
109
|
+
screen.handler,
|
|
110
|
+
{ profileKey: chosen },
|
|
111
|
+
admin,
|
|
112
|
+
);
|
|
113
|
+
expect(res.profileKey).toBe(chosen);
|
|
114
|
+
|
|
115
|
+
const current = await stack.http.queryOk<{ profile: { key: string } }>(
|
|
116
|
+
ComplianceProfileQueries.forTenant,
|
|
117
|
+
{},
|
|
118
|
+
admin,
|
|
119
|
+
);
|
|
120
|
+
expect(current.profile.key).toBe(chosen);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a profileKey outside the screen's declared options is rejected", async () => {
|
|
124
|
+
const screen = pickerScreen();
|
|
125
|
+
const admin = createTestUser({ id: 35, roles: ["TenantAdmin"] });
|
|
126
|
+
const res = await stack.http.write(
|
|
127
|
+
screen.handler,
|
|
128
|
+
{ profileKey: "not-a-declared-profile" },
|
|
129
|
+
admin,
|
|
130
|
+
);
|
|
131
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a role outside the screen's access can't reach the handler it submits to", async () => {
|
|
135
|
+
const screen = pickerScreen();
|
|
136
|
+
const outsider = createTestUser({ id: 36, roles: ["User"] });
|
|
137
|
+
expect(rolesOf(screen.access)).not.toContain("User");
|
|
138
|
+
const res = await stack.http.write(screen.handler, { profileKey: "eu-dsgvo" }, outsider);
|
|
139
|
+
expect(res.status).toBe(403);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.208.2",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "profile-picker screen switched from custom TSX to a declarative actionForm (fw#2222).",
|
|
6
|
+
"detail": "The 152-line custom compliance-profile-screen.tsx is gone. The picker is now an actionForm (select field submitting to the existing `compliance-profiles:write:set-profile` handler) plus an extension section for the read-only profile catalog (region + supervisory authority), now ~40 lines. Nav gained a `shield-check` icon."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -13,3 +13,8 @@ export const ComplianceProfileQueries = {
|
|
|
13
13
|
} as const;
|
|
14
14
|
|
|
15
15
|
export const COMPLIANCE_PROFILE_SCREEN_ID = "profile-picker" as const;
|
|
16
|
+
|
|
17
|
+
// Extension-section component name for the profile-catalog block under the
|
|
18
|
+
// picker's select field — registered client-side via
|
|
19
|
+
// complianceProfilesClient()'s extensionSectionComponents.
|
|
20
|
+
export const COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME = "ComplianceProfileCatalog" as const;
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { SELECTABLE_PROFILE_KEYS } from "@cosmicdrift/kumiko-framework/compliance";
|
|
1
2
|
import {
|
|
2
3
|
access,
|
|
3
4
|
defineFeature,
|
|
4
5
|
type FeatureDefinition,
|
|
5
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME,
|
|
9
|
+
COMPLIANCE_PROFILE_SCREEN_ID,
|
|
10
|
+
ComplianceProfileHandlers,
|
|
11
|
+
} from "./constants";
|
|
7
12
|
import { forTenantQuery } from "./handlers/for-tenant.query";
|
|
8
13
|
import { listProfilesQuery } from "./handlers/list-profiles.query";
|
|
9
14
|
import { createNeedsProfileQuery } from "./handlers/needs-profile.query";
|
|
@@ -75,14 +80,34 @@ export function createComplianceProfilesFeature(
|
|
|
75
80
|
|
|
76
81
|
r.screen({
|
|
77
82
|
id: COMPLIANCE_PROFILE_SCREEN_ID,
|
|
78
|
-
type: "
|
|
79
|
-
|
|
83
|
+
type: "actionForm",
|
|
84
|
+
handler: ComplianceProfileHandlers.setProfile,
|
|
85
|
+
fields: {
|
|
86
|
+
profileKey: { type: "select", options: SELECTABLE_PROFILE_KEYS, required: true },
|
|
87
|
+
},
|
|
88
|
+
layout: {
|
|
89
|
+
width: "full",
|
|
90
|
+
sections: [
|
|
91
|
+
{ fields: ["profileKey"] },
|
|
92
|
+
{
|
|
93
|
+
kind: "extension",
|
|
94
|
+
title: "compliance-profiles:profile.catalog.title",
|
|
95
|
+
component: { react: { __component: COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME } },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
submitLabel: "compliance-profiles:profile.actions.save",
|
|
80
100
|
access: { roles: resolvedAccess },
|
|
81
101
|
});
|
|
82
102
|
r.nav({
|
|
83
103
|
id: "profile-picker",
|
|
84
104
|
label: "compliance-profiles:nav.profilePicker",
|
|
85
105
|
screen: "compliance-profiles:screen:profile-picker",
|
|
106
|
+
// "shield-check" (spec suggestion, fw#2222) isn't in NavIconKey's closed
|
|
107
|
+
// vocabulary (packages/types/src/nav-icon.ts) — adding it needs a
|
|
108
|
+
// matching lucide-react entry in renderer-web's NAV_ICONS map too, both
|
|
109
|
+
// outside this feature. "shield" is the closest already-registered key.
|
|
110
|
+
icon: "shield",
|
|
86
111
|
order: 50,
|
|
87
112
|
});
|
|
88
113
|
|
|
@@ -1,6 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMPLIANCE_PROFILES,
|
|
3
|
+
SELECTABLE_PROFILE_KEYS,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/compliance";
|
|
5
|
+
|
|
1
6
|
type LocalizedString = { readonly en: string };
|
|
2
7
|
|
|
8
|
+
// Option labels for the actionForm's `profileKey` select — derived from
|
|
9
|
+
// SELECTABLE_PROFILE_KEYS so a new selectable profile picks up its i18n
|
|
10
|
+
// label automatically instead of needing a second hardcoded list here.
|
|
11
|
+
const PROFILE_OPTION_KEYS: Readonly<Record<string, LocalizedString>> = Object.fromEntries(
|
|
12
|
+
SELECTABLE_PROFILE_KEYS.map((key) => [
|
|
13
|
+
`compliance-profiles:entity:__action-form__:field:profileKey:option:${key}`,
|
|
14
|
+
{ en: COMPLIANCE_PROFILES[key].label },
|
|
15
|
+
]),
|
|
16
|
+
);
|
|
17
|
+
|
|
3
18
|
export const COMPLIANCE_PROFILES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
19
|
"screen:profile-picker.title": { en: "Compliance profile" },
|
|
5
20
|
"compliance-profiles:nav.profilePicker": { en: "Compliance" },
|
|
21
|
+
// Field label + option labels for the actionForm's synthesized pseudo-
|
|
22
|
+
// entity (`__action-form__`, see action-form-shim.ts) — required by the
|
|
23
|
+
// i18n boot-validator (requiredKeysFromScreen) and must live in the
|
|
24
|
+
// server-registered translations, not the client bundle's own i18n: the
|
|
25
|
+
// declarative actionForm renderer resolves these through the schema
|
|
26
|
+
// payload, not through a custom screen component's client-side t().
|
|
27
|
+
"compliance-profiles:entity:__action-form__:field:profileKey": { en: "Compliance profile" },
|
|
28
|
+
"compliance-profiles:profile.actions.save": { en: "Save profile" },
|
|
29
|
+
"compliance-profiles:profile.catalog.title": { en: "Available profiles" },
|
|
30
|
+
...PROFILE_OPTION_KEYS,
|
|
6
31
|
};
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME,
|
|
6
|
+
COMPLIANCE_PROFILES_FEATURE,
|
|
7
|
+
} from "../constants";
|
|
8
|
+
import { ComplianceProfileCatalog } from "./compliance-profile-catalog";
|
|
6
9
|
import { defaultTranslations } from "./i18n";
|
|
7
10
|
|
|
8
11
|
export type ComplianceProfilesClientOptions = {
|
|
@@ -15,8 +18,8 @@ export function complianceProfilesClient(
|
|
|
15
18
|
return {
|
|
16
19
|
name: COMPLIANCE_PROFILES_FEATURE,
|
|
17
20
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
18
|
-
|
|
19
|
-
[
|
|
21
|
+
extensionSectionComponents: {
|
|
22
|
+
[COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME]: ComplianceProfileCatalog,
|
|
20
23
|
},
|
|
21
24
|
};
|
|
22
25
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Extension-section component for the profile-picker actionForm's catalog
|
|
3
|
+
// block — read-only list of the selectable compliance profiles (region +
|
|
4
|
+
// supervisory authority). The select field above owns the actual choice;
|
|
5
|
+
// this is reference material for the tenant admin.
|
|
6
|
+
import {
|
|
7
|
+
type ExtensionSectionProps,
|
|
8
|
+
usePrimitives,
|
|
9
|
+
useQuery,
|
|
10
|
+
useTranslation,
|
|
11
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
import { ComplianceProfileQueries } from "../constants";
|
|
14
|
+
|
|
15
|
+
type ProfileSummary = {
|
|
16
|
+
readonly key: string;
|
|
17
|
+
readonly region: string;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly authorityContact: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ListProfilesResponse = {
|
|
23
|
+
readonly profiles: readonly ProfileSummary[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function ComplianceProfileCatalog(_props: ExtensionSectionProps): ReactNode {
|
|
27
|
+
const { Banner, Text } = usePrimitives();
|
|
28
|
+
const t = useTranslation();
|
|
29
|
+
const query = useQuery<ListProfilesResponse>(ComplianceProfileQueries.listProfiles, {});
|
|
30
|
+
|
|
31
|
+
if (query.loading && query.data === null) {
|
|
32
|
+
return (
|
|
33
|
+
<Banner variant="loading" testId="compliance-profile-catalog-loading">
|
|
34
|
+
<Text>{t("compliance.profile.catalog.loading")}</Text>
|
|
35
|
+
</Banner>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (query.error) {
|
|
39
|
+
return (
|
|
40
|
+
<Banner variant="error" testId="compliance-profile-catalog-error">
|
|
41
|
+
<Text>{t(query.error.i18nKey, query.error.i18nParams)}</Text>
|
|
42
|
+
</Banner>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<ul className="flex flex-col gap-2 text-sm" data-testid="compliance-profile-catalog">
|
|
48
|
+
{(query.data?.profiles ?? []).map((profile) => (
|
|
49
|
+
<li key={profile.key} data-profile-key={profile.key}>
|
|
50
|
+
<strong>{profile.label}</strong> — {profile.region} — {profile.authorityContact}
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -3,13 +3,7 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
|
3
3
|
|
|
4
4
|
export const defaultTranslations: TranslationsByLocale = {
|
|
5
5
|
en: {
|
|
6
|
-
"compliance.profile.
|
|
7
|
-
"compliance.profile.loading": "Loading compliance profiles…",
|
|
8
|
-
"compliance.profile.current": "Current",
|
|
9
|
-
"compliance.profile.select": "Profile",
|
|
10
|
-
"compliance.profile.save": "Save profile",
|
|
11
|
-
"compliance.profile.saving": "Saving…",
|
|
12
|
-
"compliance.profile.catalog": "Available profiles",
|
|
6
|
+
"compliance.profile.catalog.loading": "Loading compliance profiles…",
|
|
13
7
|
"compliance-profiles:nav.profilePicker": "Compliance",
|
|
14
8
|
},
|
|
15
9
|
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// delivery:query:log — PagedRows contract, real sort, sort-whitelist
|
|
2
|
+
// fallback, and the notificationType/recipientAddress → type/recipient
|
|
3
|
+
// field-mapping the query handler now owns (see log.query.ts).
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { insertMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
|
+
import {
|
|
8
|
+
createTestUser,
|
|
9
|
+
setupTestStack,
|
|
10
|
+
type TestStack,
|
|
11
|
+
testTenantId,
|
|
12
|
+
unsafePushTables,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { DeliveryQueries } from "../constants";
|
|
15
|
+
import { createDeliveryFeature } from "../feature";
|
|
16
|
+
import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
17
|
+
|
|
18
|
+
type LogRow = {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly type: string;
|
|
21
|
+
readonly channel: string;
|
|
22
|
+
readonly recipient: string | null;
|
|
23
|
+
readonly status: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let stack: TestStack;
|
|
27
|
+
const tenantId = testTenantId(701);
|
|
28
|
+
const admin = createTestUser({ id: 701, roles: ["TenantAdmin"], tenantId });
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
stack = await setupTestStack({ features: [createDeliveryFeature()] });
|
|
32
|
+
await unsafePushTables(stack.db, { deliveryAttemptsTable, notificationPreferencesTable });
|
|
33
|
+
await insertMany(stack.db, deliveryAttemptsTable, [
|
|
34
|
+
{
|
|
35
|
+
id: crypto.randomUUID(),
|
|
36
|
+
tenantId,
|
|
37
|
+
notificationType: "logtest:alpha",
|
|
38
|
+
channel: "email",
|
|
39
|
+
recipientAddress: "alpha@example.com",
|
|
40
|
+
status: "sent",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: crypto.randomUUID(),
|
|
44
|
+
tenantId,
|
|
45
|
+
notificationType: "logtest:beta",
|
|
46
|
+
channel: "push",
|
|
47
|
+
recipientAddress: "beta@example.com",
|
|
48
|
+
status: "failed",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
tenantId,
|
|
53
|
+
notificationType: "logtest:gamma",
|
|
54
|
+
channel: "inApp",
|
|
55
|
+
recipientAddress: null,
|
|
56
|
+
status: "queued",
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await stack.cleanup();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("delivery:query:log — PagedRows contract", () => {
|
|
66
|
+
test("a full page carries a nextCursor; the following (partial) page does not", async () => {
|
|
67
|
+
const first = await stack.http.queryOk<{
|
|
68
|
+
rows: readonly LogRow[];
|
|
69
|
+
nextCursor: string | null;
|
|
70
|
+
}>(DeliveryQueries.log, { limit: 2, sort: "type", sortDirection: "asc" }, admin);
|
|
71
|
+
expect(first.rows.map((r) => r.type)).toEqual(["logtest:alpha", "logtest:beta"]);
|
|
72
|
+
expect(first.nextCursor).not.toBeNull();
|
|
73
|
+
|
|
74
|
+
const second = await stack.http.queryOk<{
|
|
75
|
+
rows: readonly LogRow[];
|
|
76
|
+
nextCursor: string | null;
|
|
77
|
+
}>(
|
|
78
|
+
DeliveryQueries.log,
|
|
79
|
+
{ limit: 2, sort: "type", sortDirection: "asc", cursor: first.nextCursor ?? undefined },
|
|
80
|
+
admin,
|
|
81
|
+
);
|
|
82
|
+
expect(second.rows.map((r) => r.type)).toEqual(["logtest:gamma"]);
|
|
83
|
+
expect(second.nextCursor).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("delivery:query:log — sort", () => {
|
|
88
|
+
test("asc vs desc on the same whitelisted field actually reverses row order", async () => {
|
|
89
|
+
const asc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
|
|
90
|
+
DeliveryQueries.log,
|
|
91
|
+
{ sort: "type", sortDirection: "asc" },
|
|
92
|
+
admin,
|
|
93
|
+
);
|
|
94
|
+
const desc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
|
|
95
|
+
DeliveryQueries.log,
|
|
96
|
+
{ sort: "type", sortDirection: "desc" },
|
|
97
|
+
admin,
|
|
98
|
+
);
|
|
99
|
+
const ascTypes = asc.rows.map((r) => r.type);
|
|
100
|
+
const descTypes = desc.rows.map((r) => r.type);
|
|
101
|
+
expect(ascTypes).toEqual(["logtest:alpha", "logtest:beta", "logtest:gamma"]);
|
|
102
|
+
expect(descTypes).toEqual([...ascTypes].reverse());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a sort field outside the whitelist falls back to the default order, not an ad-hoc SQL column", async () => {
|
|
106
|
+
const withInvalidSort = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
|
|
107
|
+
DeliveryQueries.log,
|
|
108
|
+
{ sort: "recipient" },
|
|
109
|
+
admin,
|
|
110
|
+
);
|
|
111
|
+
const withNoSort = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
|
|
112
|
+
DeliveryQueries.log,
|
|
113
|
+
{},
|
|
114
|
+
admin,
|
|
115
|
+
);
|
|
116
|
+
expect(withInvalidSort.rows.map((r) => r.id)).toEqual(withNoSort.rows.map((r) => r.id));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("delivery:query:log — field mapping", () => {
|
|
121
|
+
test("rows carry type/recipient (display names), not the raw notificationType/recipientAddress column names", async () => {
|
|
122
|
+
const result = await stack.http.queryOk<{ rows: readonly Record<string, unknown>[] }>(
|
|
123
|
+
DeliveryQueries.log,
|
|
124
|
+
{ sort: "type", sortDirection: "asc" },
|
|
125
|
+
admin,
|
|
126
|
+
);
|
|
127
|
+
const alpha = result.rows.find((r) => r["type"] === "logtest:alpha");
|
|
128
|
+
expect(alpha?.["recipient"]).toBe("alpha@example.com");
|
|
129
|
+
expect(alpha).not.toHaveProperty("notificationType");
|
|
130
|
+
expect(alpha).not.toHaveProperty("recipientAddress");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -171,7 +171,7 @@ describe("delivery attempt log under KMS", () => {
|
|
|
171
171
|
const entry = before.rows.find(
|
|
172
172
|
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
173
173
|
);
|
|
174
|
-
expect(entry?.["
|
|
174
|
+
expect(entry?.["recipient"]).toBe(testEmail(recipient.id));
|
|
175
175
|
|
|
176
176
|
await kms.eraseKey({ kind: "user", userId: recipient.id });
|
|
177
177
|
|
|
@@ -183,7 +183,7 @@ describe("delivery attempt log under KMS", () => {
|
|
|
183
183
|
const erased = after.rows.find(
|
|
184
184
|
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
185
185
|
);
|
|
186
|
-
expect(erased?.["
|
|
186
|
+
expect(erased?.["recipient"]).toBe(PII_ERASED_SENTINEL);
|
|
187
187
|
});
|
|
188
188
|
|
|
189
189
|
// money-horse#330: route:{email} (no user account, e.g. a share-token
|
|
@@ -224,7 +224,7 @@ describe("delivery attempt log under KMS", () => {
|
|
|
224
224
|
);
|
|
225
225
|
const otherSubjectBefore = beforeErase.rows.find(
|
|
226
226
|
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
227
|
-
)?.["
|
|
227
|
+
)?.["recipient"];
|
|
228
228
|
// Guard against a vacuous pass: if the row fell outside the `limit 100`
|
|
229
229
|
// window, both sides below would be `undefined` and the "unaffected"
|
|
230
230
|
// assertion would prove nothing.
|
|
@@ -241,13 +241,13 @@ describe("delivery attempt log under KMS", () => {
|
|
|
241
241
|
const shredded = after.rows.find(
|
|
242
242
|
(r) => r["recipientId"] === subjectId && r["channel"] === "email",
|
|
243
243
|
);
|
|
244
|
-
expect(shredded?.["
|
|
244
|
+
expect(shredded?.["recipient"]).toBe(PII_ERASED_SENTINEL);
|
|
245
245
|
|
|
246
246
|
// Other subjects (e.g. the plain `to:` recipient) are unaffected by the
|
|
247
247
|
// share-token subject's erasure.
|
|
248
248
|
const otherSubjectAfter = after.rows.find(
|
|
249
249
|
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
250
|
-
)?.["
|
|
250
|
+
)?.["recipient"];
|
|
251
251
|
expect(otherSubjectAfter).toBe(otherSubjectBefore);
|
|
252
252
|
});
|
|
253
253
|
});
|
|
@@ -11,10 +11,10 @@ describe("delivery screens + handler access alignment", () => {
|
|
|
11
11
|
expect(() => validateBoot(features)).not.toThrow();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
test("delivery-log screen is
|
|
14
|
+
test("delivery-log screen is projectionList, access.admin-gated", () => {
|
|
15
15
|
const delivery = createDeliveryFeature();
|
|
16
16
|
const screen = delivery.screens[DELIVERY_LOG_SCREEN_ID];
|
|
17
|
-
expect(screen?.type).toBe("
|
|
17
|
+
expect(screen?.type).toBe("projectionList");
|
|
18
18
|
expect(rolesOf(screen?.access)).toEqual([...access.admin]);
|
|
19
19
|
});
|
|
20
20
|
|
|
@@ -515,7 +515,7 @@ describe("flow 3: broadcast to multiple users + markAllRead", () => {
|
|
|
515
515
|
|
|
516
516
|
// 1 orderAssigned × 3 channels + 2 announcement × 3 channels = 9 total
|
|
517
517
|
expect(result.rows.length).toBe(9);
|
|
518
|
-
expect(result.rows[0]?.["
|
|
518
|
+
expect(result.rows[0]?.["type"]).toBe("app:notify:announcement");
|
|
519
519
|
});
|
|
520
520
|
});
|
|
521
521
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.208.2",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "`/delivery-log` is a declarative projectionList screen (#2221).",
|
|
6
|
+
"detail": "The 103-line hand-rolled `custom` screen (`delivery-log-screen.tsx`) is gone. `r.screen({ id: DELIVERY_LOG_SCREEN_ID, type: \"projectionList\", ... })` drives the list off `delivery:query:log`, now built on `definePagedQueryHandler` with `cursor`/`limit`/`sort`/`sortDirection` and a server-side sort whitelist (type-guarded, not an `as`-cast) covering `createdAt`/`type`/`channel`/`status` — `recipient` isn't sortable since it's PII decrypted per-row, not a column SQL can ORDER BY. Pagination is cursor-based (`pagination: \"infinite\"`): `deliveryAttemptsTable.id` is an event-stream aggregate id, not a sequential PK, so there's no cheap offset/COUNT(*) path. The notificationType/recipientAddress → type/recipient display-name mapping moved from the old client component into the query handler, matching the read-projection convention for declarative list screens; column i18n keys moved from the client-only bundle into the server-registered `DELIVERY_I18N` so the generic renderer can resolve them. The status column keeps one small TSX cell (`DeliveryStatusCell`, a `StatusBadge`), registered as a `columnRenderer` — that's the only surviving hand-written UI for this screen."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.120.0",
|
|
4
10
|
"type": "improvement",
|
package/src/delivery/feature.ts
CHANGED
|
@@ -5,7 +5,13 @@ import {
|
|
|
5
5
|
type FeatureDefinition,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import type { z } from "zod";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DELIVERY_ATTEMPT_EVENT,
|
|
10
|
+
DELIVERY_LOG_SCREEN_ID,
|
|
11
|
+
DELIVERY_STATUS_CELL_COMPONENT,
|
|
12
|
+
DeliveryJobNames,
|
|
13
|
+
DeliveryQueries,
|
|
14
|
+
} from "./constants";
|
|
9
15
|
import { deliveryAttemptSchema } from "./events";
|
|
10
16
|
import { logQuery } from "./handlers/log.query";
|
|
11
17
|
import { preferencesQuery } from "./handlers/preferences.query";
|
|
@@ -134,13 +140,32 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
134
140
|
|
|
135
141
|
r.screen({
|
|
136
142
|
id: DELIVERY_LOG_SCREEN_ID,
|
|
137
|
-
type: "
|
|
138
|
-
|
|
143
|
+
type: "projectionList",
|
|
144
|
+
query: DeliveryQueries.log,
|
|
145
|
+
columns: [
|
|
146
|
+
{ field: "type", label: "delivery.log.col.type" },
|
|
147
|
+
{ field: "channel", label: "delivery.log.col.channel" },
|
|
148
|
+
{ field: "recipient", label: "delivery.log.col.recipient" },
|
|
149
|
+
{
|
|
150
|
+
field: "status",
|
|
151
|
+
label: "delivery.log.col.status",
|
|
152
|
+
renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
// Cursor-based paging: deliveryAttemptsTable's id is an event-stream
|
|
156
|
+
// aggregate id (not a sequential PK), so the "pages" default (offset +
|
|
157
|
+
// COUNT(*)) has no cheap query-side implementation here — "infinite"
|
|
158
|
+
// matches what log.query.ts's cursor/limit/sort schema actually supports.
|
|
159
|
+
pagination: "infinite",
|
|
160
|
+
defaultSort: { field: "createdAt", dir: "desc" },
|
|
139
161
|
access: { roles: resolvedAccess },
|
|
140
162
|
});
|
|
141
163
|
r.nav({
|
|
142
164
|
id: "delivery-log",
|
|
143
165
|
label: "delivery:nav.deliveryLog",
|
|
166
|
+
// "send" isn't in the closed NavIconKey vocabulary (packages/types/src/
|
|
167
|
+
// nav-icon.ts) — "bell" matches the feature's own notifications framing.
|
|
168
|
+
icon: "bell",
|
|
144
169
|
screen: "delivery:screen:delivery-log",
|
|
145
170
|
order: 40,
|
|
146
171
|
});
|