@cosmicdrift/kumiko-bundled-features 0.215.2 → 0.215.3
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/config/i18n.ts +1 -1
- package/src/config/web/i18n.ts +1 -1
- package/src/custom-fields/web/client-plugin.tsx +1 -1
- package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +89 -0
- package/src/notes-history/__tests__/notes-history.integration.test.ts +14 -0
- package/src/notes-history/__tests__/notes-section.test.tsx +171 -0
- package/src/notes-history/changes.json +9 -1
- package/src/notes-history/entity.ts +7 -0
- package/src/notes-history/handlers/add-note.write.ts +25 -1
- package/src/notes-history/web/i18n.ts +1 -0
- package/src/notes-history/web/notes-section.tsx +10 -4
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +3 -0
- package/src/notes-history-user-data/changes.json +8 -1
- package/src/notes-history-user-data/hooks.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.215.
|
|
3
|
+
"version": "0.215.3",
|
|
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.215.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.215.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.215.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.215.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.215.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.215.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.215.3",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.215.3",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.215.3",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.215.3",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.215.3",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.215.3",
|
|
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.215.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.215.3"
|
|
164
164
|
}
|
|
165
165
|
}
|
package/src/config/i18n.ts
CHANGED
|
@@ -4,6 +4,6 @@ type LocalizedString = { readonly en: string };
|
|
|
4
4
|
export const CONFIG_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
5
5
|
"config.settings.title": { en: "Settings" },
|
|
6
6
|
"config.settings.system": { en: "Platform" },
|
|
7
|
-
"config.settings.tenant": { en: "
|
|
7
|
+
"config.settings.tenant": { en: "Tenant" },
|
|
8
8
|
"config.settings.user": { en: "Personal" },
|
|
9
9
|
};
|
package/src/config/web/i18n.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
13
13
|
en: {
|
|
14
14
|
"config.settings.title": "Settings",
|
|
15
15
|
"config.settings.system": "Platform",
|
|
16
|
-
"config.settings.tenant": "
|
|
16
|
+
"config.settings.tenant": "Tenant",
|
|
17
17
|
"config.settings.user": "Personal",
|
|
18
18
|
"config.errors.systemOnly": "This value can only be set by the system.",
|
|
19
19
|
"config.errors.invalidScope": "This scope is not allowed for this key.",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// referenzieren den Namen im Screen-Schema:
|
|
7
7
|
//
|
|
8
8
|
// layout: { sections: [
|
|
9
|
-
// { kind: "extension", title: "...", component: {
|
|
9
|
+
// { kind: "extension", title: "...", contributesToFormSubmit: true, component: {
|
|
10
10
|
// react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME }
|
|
11
11
|
// } },
|
|
12
12
|
// ]}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// add-note's authorName stamping (see add-note.write.ts): the handler reads
|
|
2
|
+
// the caller's own read_users row (ctx.db.raw, tenant-agnostic self-lookup —
|
|
3
|
+
// same pattern as user-data-rights/handlers/cancel-deletion.write.ts) and
|
|
4
|
+
// decrypts displayName before writing it as authorName. Pinned with a real
|
|
5
|
+
// KMS adapter so the round-trip is proven end-to-end, not assumed from a
|
|
6
|
+
// no-op-encrypt test env — a later refactor that silently reintroduces the
|
|
7
|
+
// raw UUID or gets stuck on the placeholder would fail this.
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
+
import {
|
|
12
|
+
configurePiiSubjectKms,
|
|
13
|
+
InMemoryKmsAdapter,
|
|
14
|
+
isPiiCiphertext,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
testTenantId,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
24
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
25
|
+
import { seedUser } from "../../user/seeding";
|
|
26
|
+
import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
|
|
27
|
+
import { noteEntryEntity } from "../entity";
|
|
28
|
+
import { createNotesHistoryFeature } from "../feature";
|
|
29
|
+
|
|
30
|
+
const notesHistoryFeature = createNotesHistoryFeature();
|
|
31
|
+
const tenantId = testTenantId(1);
|
|
32
|
+
|
|
33
|
+
let stack: TestStack;
|
|
34
|
+
|
|
35
|
+
function memberUser(userId: string): SessionUser {
|
|
36
|
+
return { id: userId, tenantId, roles: ["TenantMember"] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
stack = await setupTestStack({ features: [notesHistoryFeature] });
|
|
41
|
+
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
42
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await stack.cleanup();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
resetPiiSubjectKmsForTests();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("add-note — authorName stamped from the writer's own user row", () => {
|
|
54
|
+
test("with an active KMS, the written note carries the writer's decrypted displayName — not the UUID, not the placeholder", async () => {
|
|
55
|
+
// KMS active BEFORE the seed — seedUser runs through the executor, so
|
|
56
|
+
// the row reflects the encrypted prod state.
|
|
57
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
58
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
59
|
+
email: "notes-author@example.com",
|
|
60
|
+
displayName: "Priya Natarajan",
|
|
61
|
+
emailVerified: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Prove the row is actually ciphertext at rest — otherwise a no-op
|
|
65
|
+
// encrypt path would make this test pass while proving nothing.
|
|
66
|
+
const rawUserRows = await selectMany<{ displayName: unknown }>(stack.db, userTable, {
|
|
67
|
+
id: userId,
|
|
68
|
+
});
|
|
69
|
+
expect(isPiiCiphertext(rawUserRows[0]?.displayName)).toBe(true);
|
|
70
|
+
|
|
71
|
+
const { id: noteId } = await stack.http.writeOk<{ id: string }>(
|
|
72
|
+
NotesHistoryHandlers.addNote,
|
|
73
|
+
{ entityType: "contact", entityId: "contact-kms-author", body: "Called back." },
|
|
74
|
+
memberUser(userId),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const { rows } = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
78
|
+
NotesHistoryQueries.noteList,
|
|
79
|
+
{ filter: { field: "entityId", op: "eq", value: "contact-kms-author" } },
|
|
80
|
+
memberUser(userId),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(rows).toHaveLength(1);
|
|
84
|
+
expect(rows[0]?.["id"]).toBe(noteId);
|
|
85
|
+
expect(rows[0]?.["authorName"]).toBe("Priya Natarajan");
|
|
86
|
+
expect(rows[0]?.["authorName"]).not.toBe(userId);
|
|
87
|
+
expect(rows[0]?.["authorName"]).not.toBe("Unknown author");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -87,6 +87,11 @@ describe("notes-history integration — add + list", () => {
|
|
|
87
87
|
expect(rows[0]?.["body"]).toBe("Called about renewal");
|
|
88
88
|
expect(rows[0]?.["authorId"]).toBe(member.id);
|
|
89
89
|
expect(rows[0]?.["insertedAt"]).toBeTruthy();
|
|
90
|
+
// member is a synthetic SessionUser (createTestUser) with no backing
|
|
91
|
+
// read_users row, so add-note's self-lookup finds nothing to stamp — see
|
|
92
|
+
// add-note-author-name-kms.integration.test.ts for the seeded-user path
|
|
93
|
+
// where a real displayName does land in authorName.
|
|
94
|
+
expect(rows[0]?.["authorName"]).toBeNull();
|
|
90
95
|
});
|
|
91
96
|
|
|
92
97
|
test("a client-supplied authorId in the payload is ignored", async () => {
|
|
@@ -98,6 +103,15 @@ describe("notes-history integration — add + list", () => {
|
|
|
98
103
|
expect(rows[0]?.["authorId"]).toBe(member.id);
|
|
99
104
|
});
|
|
100
105
|
|
|
106
|
+
test("a client-supplied authorName in the payload is ignored", async () => {
|
|
107
|
+
await addNote("contact", "contact-2b", "note", member, { authorName: "Fake Display Name" });
|
|
108
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-2b" });
|
|
109
|
+
// Same guard as authorId above: addNotePayloadSchema doesn't accept an
|
|
110
|
+
// authorName field, so a smuggled value never survives to the write —
|
|
111
|
+
// the name (once stamped) can only come from the caller's own session.
|
|
112
|
+
expect(rows[0]?.["authorName"]).not.toBe("Fake Display Name");
|
|
113
|
+
});
|
|
114
|
+
|
|
101
115
|
test("multiple notes on the same entity accumulate — nothing overwrites", async () => {
|
|
102
116
|
await addNote("contact", "contact-3", "first");
|
|
103
117
|
await addNote("contact", "contact-3", "second");
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// DOM test for the notes-history entry display: body/meta must render as two
|
|
3
|
+
// visually separate lines, the author must never be the raw authorId, and the
|
|
4
|
+
// timestamp must be human-formatted — not the concatenated single-line dump
|
|
5
|
+
// this test guards against (see notes-section.tsx for the fix).
|
|
6
|
+
// Provider wrapper is local — renderer-web → bundled-features forbids importing
|
|
7
|
+
// renderer-web test-utils from here.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
11
|
+
import {
|
|
12
|
+
createStaticLocaleResolver,
|
|
13
|
+
DispatcherProvider,
|
|
14
|
+
formatWhen,
|
|
15
|
+
kumikoDefaultTranslations,
|
|
16
|
+
type LiveEventSubscriber,
|
|
17
|
+
LiveEventsProvider,
|
|
18
|
+
LocaleProvider,
|
|
19
|
+
PrimitivesProvider,
|
|
20
|
+
TokensProvider,
|
|
21
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
22
|
+
import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
|
|
23
|
+
import { render, waitFor } from "@testing-library/react";
|
|
24
|
+
import type { ReactNode } from "react";
|
|
25
|
+
import { NotesHistoryQueries } from "../constants";
|
|
26
|
+
import { defaultTranslations } from "../web/i18n";
|
|
27
|
+
import { NotesSection } from "../web/notes-section";
|
|
28
|
+
|
|
29
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
30
|
+
globalThis.ResizeObserver = class {
|
|
31
|
+
observe(): void {}
|
|
32
|
+
unobserve(): void {}
|
|
33
|
+
disconnect(): void {}
|
|
34
|
+
} as unknown as typeof ResizeObserver;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const stubLiveEvents: LiveEventSubscriber = () => () => {};
|
|
38
|
+
const stubTokens = {
|
|
39
|
+
tokens: defaultTokens,
|
|
40
|
+
mode: "light" as const,
|
|
41
|
+
setMode: () => {},
|
|
42
|
+
toggleMode: () => {},
|
|
43
|
+
};
|
|
44
|
+
const stubResolver = createStaticLocaleResolver();
|
|
45
|
+
|
|
46
|
+
type NoteRowFixture = {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly entityType: string;
|
|
49
|
+
readonly entityId: string;
|
|
50
|
+
readonly authorId: string;
|
|
51
|
+
readonly authorName: string | null;
|
|
52
|
+
readonly body: string;
|
|
53
|
+
readonly insertedAt: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const AUTHOR_ID = "01a0244b-71f2-76e4-854f-e360584f01e9";
|
|
57
|
+
const AUTHOR_NAME = "Jamie Vance";
|
|
58
|
+
const INSERTED_AT = "2026-08-21T12:28:41.580Z";
|
|
59
|
+
const NOTE_BODY = "Vertragsunterlagen per Post verschickt.";
|
|
60
|
+
|
|
61
|
+
const NOTE_ROWS: readonly NoteRowFixture[] = [
|
|
62
|
+
{
|
|
63
|
+
id: "note-1",
|
|
64
|
+
entityType: "contact",
|
|
65
|
+
entityId: "contact-1",
|
|
66
|
+
authorId: AUTHOR_ID,
|
|
67
|
+
authorName: AUTHOR_NAME,
|
|
68
|
+
body: NOTE_BODY,
|
|
69
|
+
insertedAt: INSERTED_AT,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "note-2",
|
|
73
|
+
entityType: "contact",
|
|
74
|
+
entityId: "contact-1",
|
|
75
|
+
authorId: "02b1355c-8203-87f5-9650-f471695002f0",
|
|
76
|
+
authorName: null,
|
|
77
|
+
body: "Legacy entry predating authorName.",
|
|
78
|
+
insertedAt: "2026-07-01T09:00:00.000Z",
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
function makeDispatcher(): Dispatcher {
|
|
83
|
+
const statusStore = createStore<DispatcherStatus>("online");
|
|
84
|
+
const query = (async (type: string) => {
|
|
85
|
+
if (type === NotesHistoryQueries.noteList) {
|
|
86
|
+
return { isSuccess: true, data: { rows: NOTE_ROWS } };
|
|
87
|
+
}
|
|
88
|
+
return { isSuccess: true, data: null };
|
|
89
|
+
}) as unknown as Dispatcher["query"];
|
|
90
|
+
return {
|
|
91
|
+
write: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["write"],
|
|
92
|
+
query,
|
|
93
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
94
|
+
statusStore,
|
|
95
|
+
async *stream() {},
|
|
96
|
+
pendingWrites: () => [],
|
|
97
|
+
pendingFiles: () => [],
|
|
98
|
+
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderSection(): ReturnType<typeof render> {
|
|
102
|
+
const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
103
|
+
<TokensProvider value={stubTokens}>
|
|
104
|
+
<LocaleProvider
|
|
105
|
+
resolver={stubResolver}
|
|
106
|
+
fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
|
|
107
|
+
>
|
|
108
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
109
|
+
<LiveEventsProvider value={stubLiveEvents}>
|
|
110
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>{children}</DispatcherProvider>
|
|
111
|
+
</LiveEventsProvider>
|
|
112
|
+
</PrimitivesProvider>
|
|
113
|
+
</LocaleProvider>
|
|
114
|
+
</TokensProvider>
|
|
115
|
+
);
|
|
116
|
+
return render(<NotesSection entityName="contact" entityId="contact-1" />, { wrapper });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("NotesSection — entry display", () => {
|
|
120
|
+
test("note body and meta line render as two distinct nodes, not one run-on line", async () => {
|
|
121
|
+
const view = renderSection();
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
if (view.queryByTestId("notes-section-row-note-1-body") === null) {
|
|
124
|
+
throw new Error("row not rendered yet");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
const bodyEl = view.getByTestId("notes-section-row-note-1-body");
|
|
128
|
+
const metaEl = view.getByTestId("notes-section-row-note-1-meta");
|
|
129
|
+
expect(bodyEl).not.toBe(metaEl);
|
|
130
|
+
expect(bodyEl.textContent).toBe(NOTE_BODY);
|
|
131
|
+
expect(metaEl.textContent).not.toContain(NOTE_BODY);
|
|
132
|
+
|
|
133
|
+
const row = view.getByTestId("notes-section-row-note-1");
|
|
134
|
+
expect(row.className).toContain("flex-col");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a stamped authorName renders as the author, never the raw authorId", async () => {
|
|
138
|
+
const view = renderSection();
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
if (view.queryByTestId("notes-section-row-note-1-meta") === null) {
|
|
141
|
+
throw new Error("row not rendered yet");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
const metaEl = view.getByTestId("notes-section-row-note-1-meta");
|
|
145
|
+
expect(metaEl.textContent).not.toContain(AUTHOR_ID);
|
|
146
|
+
expect(metaEl.textContent).toContain(AUTHOR_NAME);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a null authorName (legacy row, or shredded author) renders the placeholder", async () => {
|
|
150
|
+
const view = renderSection();
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
if (view.queryByTestId("notes-section-row-note-2-meta") === null) {
|
|
153
|
+
throw new Error("row not rendered yet");
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
const metaEl = view.getByTestId("notes-section-row-note-2-meta");
|
|
157
|
+
expect(metaEl.textContent).toContain("Unknown author");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("date renders formatted, never the raw ISO timestamp", async () => {
|
|
161
|
+
const view = renderSection();
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
if (view.queryByTestId("notes-section-row-note-1-meta") === null) {
|
|
164
|
+
throw new Error("row not rendered yet");
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
const metaEl = view.getByTestId("notes-section-row-note-1-meta");
|
|
168
|
+
expect(metaEl.textContent).not.toContain(INSERTED_AT);
|
|
169
|
+
expect(metaEl.textContent).toContain(formatWhen(INSERTED_AT));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.215.3",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "note-entry gains authorName, stamped at write time",
|
|
6
|
+
"detail": "The entry display previously had no readable author — only the internal authorId (UUID). note-entry now carries an `authorName` field (personal: { of: \"authorId\" }, same crypto-shredding subject as `body`), stamped once at add-note write time from the writer's own read_users row (self-lookup by id, decrypted displayName) so the append-only history keeps the name as it was when the note was written, not whatever the account is called later. A failed lookup, a missing displayName, and pre-field/shredded rows all read `authorName: null` — the UI falls back to a translated placeholder, never the raw id; the write itself never fails over a missing name.",
|
|
7
|
+
"migration": "Run `kumiko-schema generate` and apply — the migration adds `read_note_entries.authorName`. No backfill: existing rows read null and show the placeholder, same as before this change from the user's point of view."
|
|
8
|
+
}
|
|
9
|
+
]
|
|
@@ -31,6 +31,13 @@ export const noteEntryEntity = createEntity({
|
|
|
31
31
|
authorId: createTextField({
|
|
32
32
|
personal: "ref",
|
|
33
33
|
}),
|
|
34
|
+
// Stamped once at write time, never re-resolved later — the history
|
|
35
|
+
// needs the name as it was then, not whatever the account is called now.
|
|
36
|
+
authorName: createTextField({
|
|
37
|
+
maxLength: 200,
|
|
38
|
+
personal: { of: "authorId" },
|
|
39
|
+
find: "none",
|
|
40
|
+
}),
|
|
34
41
|
body: createLongTextField({
|
|
35
42
|
required: true,
|
|
36
43
|
maxLength: 20_000,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { fetchOne, runInSavepoint } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
2
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { decryptStoredPii } from "../../shared";
|
|
4
|
+
import { userTable } from "../../user";
|
|
2
5
|
import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
|
|
3
6
|
import { noteEntryExecutor } from "../executor";
|
|
4
7
|
import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
@@ -17,7 +20,28 @@ export function createAddNoteHandler(
|
|
|
17
20
|
access,
|
|
18
21
|
handler: async (event, ctx) => {
|
|
19
22
|
const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
|
|
20
|
-
|
|
23
|
+
|
|
24
|
+
let authorName: string | null = null;
|
|
25
|
+
try {
|
|
26
|
+
// read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
|
|
27
|
+
// Bun.SQL poisons the whole tx after any error inside it, even one that's
|
|
28
|
+
// caught — a bare try/catch here would take the note write down with it.
|
|
29
|
+
authorName = await runInSavepoint(ctx.db.raw, async (sp) => {
|
|
30
|
+
const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
|
|
31
|
+
id: event.user.id,
|
|
32
|
+
});
|
|
33
|
+
if (!userRow?.displayName) return null;
|
|
34
|
+
return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
|
|
35
|
+
});
|
|
36
|
+
} catch {
|
|
37
|
+
authorName = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return noteEntryExecutor.create(
|
|
41
|
+
{ ...payload, authorId: event.user.id, authorName },
|
|
42
|
+
event.user,
|
|
43
|
+
ctx.db,
|
|
44
|
+
);
|
|
21
45
|
},
|
|
22
46
|
};
|
|
23
47
|
}
|
|
@@ -14,5 +14,6 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
14
14
|
"notesHistory.section.working": "Saving…",
|
|
15
15
|
"notesHistory.section.add": "Add note",
|
|
16
16
|
"notesHistory.section.meta": "{author} · {date}",
|
|
17
|
+
"notesHistory.section.authorUnknown": "Unknown author",
|
|
17
18
|
},
|
|
18
19
|
};
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// (RenderEdit passes { entityName, entityId }).
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
+
formatWhen,
|
|
15
16
|
useDispatcher,
|
|
16
17
|
usePrimitives,
|
|
17
18
|
useQuery,
|
|
@@ -25,6 +26,8 @@ type NoteRow = {
|
|
|
25
26
|
readonly entityType: string;
|
|
26
27
|
readonly entityId: string;
|
|
27
28
|
readonly authorId: string;
|
|
29
|
+
// Null for pre-authorName history and for shredded authors.
|
|
30
|
+
readonly authorName: string | null;
|
|
28
31
|
readonly body: string;
|
|
29
32
|
readonly insertedAt: string;
|
|
30
33
|
};
|
|
@@ -136,11 +139,14 @@ export function NotesSection({
|
|
|
136
139
|
<div
|
|
137
140
|
key={n.id}
|
|
138
141
|
data-testid={`notes-section-row-${n.id}`}
|
|
139
|
-
className="rounded-md border p-2"
|
|
142
|
+
className="flex flex-col gap-1 rounded-md border p-2"
|
|
140
143
|
>
|
|
141
|
-
<Text>{n.body}</Text>
|
|
142
|
-
<Text variant="small">
|
|
143
|
-
{t("notesHistory.section.meta", {
|
|
144
|
+
<Text testId={`notes-section-row-${n.id}-body`}>{n.body}</Text>
|
|
145
|
+
<Text variant="small" testId={`notes-section-row-${n.id}-meta`}>
|
|
146
|
+
{t("notesHistory.section.meta", {
|
|
147
|
+
author: n.authorName ?? t("notesHistory.section.authorUnknown"),
|
|
148
|
+
date: formatWhen(n.insertedAt),
|
|
149
|
+
})}
|
|
144
150
|
</Text>
|
|
145
151
|
</div>
|
|
146
152
|
))}
|
|
@@ -54,6 +54,9 @@ describe("noteEntryExportHook", () => {
|
|
|
54
54
|
expect(snippet).not.toBeNull();
|
|
55
55
|
const bodies = (snippet?.rows ?? []).map((r) => r["body"]);
|
|
56
56
|
expect(bodies).toEqual(["by author"]);
|
|
57
|
+
// No read_users row is seeded in this stack, so add-note's self-lookup
|
|
58
|
+
// stamps authorName null — export must still carry the key.
|
|
59
|
+
expect(Object.keys(snippet?.rows?.[0] ?? {})).toContain("authorName");
|
|
57
60
|
});
|
|
58
61
|
|
|
59
62
|
test("returns null when the user authored no notes", async () => {
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.215.3",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "note-entry GDPR export now includes authorName",
|
|
6
|
+
"detail": "The Art. 20 export for `note-entry` (noteEntryExportHook) enumerates fields explicitly and did not know about the new `authorName` field added alongside `body`. The export row now also carries the stamped `authorName` (null on legacy/shredded rows, same as the read path)."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -21,12 +21,13 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
|
21
21
|
entityType: r["entityType"],
|
|
22
22
|
entityId: r["entityId"],
|
|
23
23
|
body: r["body"],
|
|
24
|
+
authorName: r["authorName"],
|
|
24
25
|
insertedAt: r["insertedAt"],
|
|
25
26
|
})),
|
|
26
27
|
};
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
// Deliberate no-op: `body`
|
|
30
|
+
// Deliberate no-op: `body` and `authorName` are annotated `personal: { of: "authorId" }`
|
|
30
31
|
// (entity.ts), which relies on crypto-shredding (mounted KMS) for erasure —
|
|
31
32
|
// destroying the author's subject key makes every note-entry event AND
|
|
32
33
|
// projected row unreadable at once, without needing a physical delete
|