@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.108.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 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +26 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
- package/src/folders/web/folder-manager.tsx +18 -13
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +22 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +45 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +8 -1
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
|
@@ -12,15 +12,13 @@ import { DELIVERY_ATTEMPT_EVENT } from "./constants";
|
|
|
12
12
|
import { deliveryAttemptSchema } from "./events";
|
|
13
13
|
import type { DeliveryLogEntry } from "./types";
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
// returns 0 for a fresh stream, so the same call serves both the first event
|
|
19
|
-
// (queued, or a single-shot terminal) and follow-ups (queued → sent/failed).
|
|
20
|
-
export async function appendAttemptEvent(
|
|
15
|
+
// Shared append + inline-projection write (low-level append() does not
|
|
16
|
+
// auto-fire projections — only the dispatcher/executor paths do).
|
|
17
|
+
async function writeAttemptEvent(
|
|
21
18
|
db: DbConnection,
|
|
22
19
|
registry: Registry,
|
|
23
20
|
attemptId: string,
|
|
21
|
+
expectedVersion: number,
|
|
24
22
|
entry: DeliveryLogEntry,
|
|
25
23
|
): Promise<void> {
|
|
26
24
|
const { tenantId, ...rest } = entry;
|
|
@@ -28,7 +26,6 @@ export async function appendAttemptEvent(
|
|
|
28
26
|
// service/job + feature-registration fails loudly here instead of landing on
|
|
29
27
|
// the events-table and crashing a consumer later.
|
|
30
28
|
const payload = deliveryAttemptSchema.parse(rest);
|
|
31
|
-
const expectedVersion = await getStreamVersion(db, attemptId, tenantId);
|
|
32
29
|
const stored = await append(db, {
|
|
33
30
|
aggregateId: attemptId,
|
|
34
31
|
aggregateType: "deliveryAttempt",
|
|
@@ -41,14 +38,29 @@ export async function appendAttemptEvent(
|
|
|
41
38
|
await runProjectionsForEvent(stored, registry, db);
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
41
|
+
// Append one delivery-attempt event to `attemptId`'s stream — for the
|
|
42
|
+
// MULTI-EVENT path (queued → sent/failed follow-ups), where the stream may
|
|
43
|
+
// already carry the "queued" event and the real current version must be
|
|
44
|
+
// looked up.
|
|
45
|
+
export async function appendAttemptEvent(
|
|
46
|
+
db: DbConnection,
|
|
47
|
+
registry: Registry,
|
|
48
|
+
attemptId: string,
|
|
49
|
+
entry: DeliveryLogEntry,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const expectedVersion = await getStreamVersion(db, attemptId, entry.tenantId);
|
|
52
|
+
await writeAttemptEvent(db, registry, attemptId, expectedVersion, entry);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Single-shot terminal log (inline channels, skips, idempotency dups): a
|
|
56
|
+
// FRESH aggregate id is guaranteed version 0 — skip the getStreamVersion
|
|
57
|
+
// round-trip that appendAttemptEvent needs for its follow-up-event case.
|
|
46
58
|
export async function logAttempt(
|
|
47
59
|
db: DbConnection,
|
|
48
60
|
registry: Registry,
|
|
49
61
|
entry: DeliveryLogEntry,
|
|
50
62
|
): Promise<string> {
|
|
51
63
|
const attemptId = generateId();
|
|
52
|
-
await
|
|
64
|
+
await writeAttemptEvent(db, registry, attemptId, 0, entry);
|
|
53
65
|
return attemptId;
|
|
54
66
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { asRawClient,
|
|
2
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
3
|
import {
|
|
4
4
|
buildEntityTable,
|
|
5
5
|
createEventStoreExecutor,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
unsafeCreateEntityTable,
|
|
26
26
|
unsafePushTables,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
-
import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
28
|
+
import { createLateBoundHolder, seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
29
29
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
30
30
|
import { Temporal } from "temporal-polyfill";
|
|
31
31
|
import { z } from "zod";
|
|
@@ -93,7 +93,7 @@ function widgetAuditFeature(): FeatureDefinition {
|
|
|
93
93
|
if (!ctx.db) return;
|
|
94
94
|
const name = result.changes!["name"] as string | undefined;
|
|
95
95
|
if (!name) return;
|
|
96
|
-
await
|
|
96
|
+
await seedRow(ctx.db, widgetAuditTable, {
|
|
97
97
|
id: generateId(),
|
|
98
98
|
widgetName: name,
|
|
99
99
|
version: 1,
|
|
@@ -253,7 +253,7 @@ describe("feature-toggles runtime cache", () => {
|
|
|
253
253
|
});
|
|
254
254
|
|
|
255
255
|
test("refresh() re-reads the DB snapshot", async () => {
|
|
256
|
-
await
|
|
256
|
+
await seedRow(stack.db, globalFeatureStateTable, {
|
|
257
257
|
featureName: "widget",
|
|
258
258
|
enabled: false,
|
|
259
259
|
version: 1,
|
|
@@ -192,6 +192,32 @@ describe("folders integration — single-membership set / move / clear", () => {
|
|
|
192
192
|
});
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
describe("folders integration — delete blocks on live assignments (658/1)", () => {
|
|
196
|
+
test("deleting a folder that still holds an assignment is rejected, row survives", async () => {
|
|
197
|
+
const f = await createFolder("occupied");
|
|
198
|
+
await setFolder(f, "credit-occupied");
|
|
199
|
+
|
|
200
|
+
const err = await stack.http.writeErr(FoldersHandlers.deleteFolder, { id: f }, admin);
|
|
201
|
+
expect(err.httpStatus).toBe(422);
|
|
202
|
+
expect((await listFolders()).some((row) => row["id"] === f)).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("deleting an empty folder succeeds", async () => {
|
|
206
|
+
const f = await createFolder("empty");
|
|
207
|
+
await stack.http.writeOk(FoldersHandlers.deleteFolder, { id: f }, admin);
|
|
208
|
+
expect((await listFolders()).some((row) => row["id"] === f)).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("clearing the assignment first unblocks the delete", async () => {
|
|
212
|
+
const f = await createFolder("occupied-then-cleared");
|
|
213
|
+
await setFolder(f, "credit-occupied-2");
|
|
214
|
+
await clearFolder("credit-occupied-2");
|
|
215
|
+
|
|
216
|
+
await stack.http.writeOk(FoldersHandlers.deleteFolder, { id: f }, admin);
|
|
217
|
+
expect((await listFolders()).some((row) => row["id"] === f)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
195
221
|
describe("folders integration — rename via folder:update", () => {
|
|
196
222
|
test("update renames, optimistic-locked, bumps version", async () => {
|
|
197
223
|
const id = await createFolder("Alt");
|
package/src/folders/feature.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
import {
|
|
19
19
|
type AccessRule,
|
|
20
20
|
defineEntityCreateHandler,
|
|
21
|
-
defineEntityDeleteHandler,
|
|
22
21
|
defineEntityDetailHandler,
|
|
23
22
|
defineEntityListHandler,
|
|
24
23
|
defineEntityUpdateHandler,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants";
|
|
29
28
|
import { folderAssignmentEntity, folderEntity } from "./entity";
|
|
30
29
|
import { createClearFolderHandler } from "./handlers/clear-folder.write";
|
|
30
|
+
import { createDeleteFolderHandler } from "./handlers/delete-folder.write";
|
|
31
31
|
import { createSetFolderHandler } from "./handlers/set-folder.write";
|
|
32
32
|
|
|
33
33
|
// Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
|
|
@@ -60,7 +60,9 @@ function registerFolders(
|
|
|
60
60
|
// later stage, reparent: it accepts changes.parentId, optimistic-locked).
|
|
61
61
|
r.writeHandler(defineEntityCreateHandler("folder", folderEntity, { access }));
|
|
62
62
|
r.writeHandler(defineEntityUpdateHandler("folder", folderEntity, { access }));
|
|
63
|
-
|
|
63
|
+
// Custom, not defineEntityDeleteHandler: blocks the delete when folder-
|
|
64
|
+
// assignments still point at this folder (658/1) — see delete-folder.write.ts.
|
|
65
|
+
r.writeHandler(createDeleteFolderHandler(access));
|
|
64
66
|
r.queryHandler(defineEntityListHandler("folder", folderEntity, { access }));
|
|
65
67
|
r.queryHandler(defineEntityDetailHandler("folder", folderEntity, { access }));
|
|
66
68
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { DEFAULT_FOLDER_ACCESS } from "../constants";
|
|
5
|
+
import { folderAssignmentExecutor, folderExecutor } from "../executor";
|
|
6
|
+
|
|
7
|
+
const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
|
|
8
|
+
|
|
9
|
+
// delete-folder — the UI's folder-manager only blocks deleting a folder that
|
|
10
|
+
// still has CHILD folders; it never checks folder-assignments. A leaf folder
|
|
11
|
+
// holding entities could be deleted, leaving its folder-assignment rows
|
|
12
|
+
// pointing at a folderId that no longer exists (no cascade, no cleanup path —
|
|
13
|
+
// see 658/1). Block server-side instead: this is the same "referential
|
|
14
|
+
// integrity has no FK here" reasoning set-folder.write.ts already applies to
|
|
15
|
+
// folderId on assignment-creation.
|
|
16
|
+
export function createDeleteFolderHandler(
|
|
17
|
+
access: AccessRule = DEFAULT_FOLDER_ACCESS,
|
|
18
|
+
): WriteHandlerDef {
|
|
19
|
+
return {
|
|
20
|
+
name: "folder:delete",
|
|
21
|
+
schema: deleteFolderPayloadSchema,
|
|
22
|
+
access,
|
|
23
|
+
handler: async (event, ctx) => {
|
|
24
|
+
const payload = event.payload as { id: string }; // @cast-boundary engine-payload
|
|
25
|
+
const assigned = await folderAssignmentExecutor.list(
|
|
26
|
+
{ filter: { field: "folderId", op: "eq", value: payload.id }, limit: 1 },
|
|
27
|
+
event.user,
|
|
28
|
+
ctx.db,
|
|
29
|
+
);
|
|
30
|
+
if (assigned.rows.length > 0) {
|
|
31
|
+
return writeFailure(
|
|
32
|
+
new UnprocessableError("folder_has_assignments", { details: { folderId: payload.id } }),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return folderExecutor.delete({ id: payload.id }, event.user, ctx.db);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const deleteFolderHandler: WriteHandlerDef = createDeleteFolderHandler();
|
|
@@ -248,6 +248,30 @@ describe("FolderManager filing mode", () => {
|
|
|
248
248
|
expect(row.className).not.toContain("ring-primary/40");
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
+
test("a second drop while a reassign is still in flight is ignored (busy guard)", async () => {
|
|
252
|
+
folderRows = [
|
|
253
|
+
{ id: "f1", name: "A", parentId: null, version: 1 },
|
|
254
|
+
{ id: "f2", name: "B", parentId: null, version: 1 },
|
|
255
|
+
];
|
|
256
|
+
let resolveWrite: (v: { isSuccess: true; data: undefined }) => void = () => {};
|
|
257
|
+
dispatchSpy.mockImplementationOnce(
|
|
258
|
+
() =>
|
|
259
|
+
new Promise((resolve) => {
|
|
260
|
+
resolveWrite = resolve;
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
render(
|
|
264
|
+
<Wrapper>
|
|
265
|
+
<FolderManager filing={filingWith(() => {})} />
|
|
266
|
+
</Wrapper>,
|
|
267
|
+
);
|
|
268
|
+
dropLeaf("folder-node-f2", "c-1");
|
|
269
|
+
// Second drop lands WHILE the first write is still pending.
|
|
270
|
+
dropLeaf("folder-node-f2", "c-1");
|
|
271
|
+
resolveWrite({ isSuccess: true, data: undefined });
|
|
272
|
+
await waitFor(() => expect(dispatchSpy).toHaveBeenCalledTimes(1));
|
|
273
|
+
});
|
|
274
|
+
|
|
251
275
|
test("the unfiled bucket stays a drop target even when nothing is currently unfiled", () => {
|
|
252
276
|
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
253
277
|
const allFiled: FolderFiling = {
|
|
@@ -238,21 +238,26 @@ export function FolderManager({
|
|
|
238
238
|
// No-op if it already lives there (both handlers are idempotent, but a re-set
|
|
239
239
|
// would burn a redundant event).
|
|
240
240
|
const reassign = async (entityId: string, folderId: string | null): Promise<void> => {
|
|
241
|
-
if (filing === undefined) return;
|
|
241
|
+
if (filing === undefined || busy) return;
|
|
242
242
|
if ((currentFolderByEntity.get(entityId) ?? null) === folderId) return;
|
|
243
|
+
setBusy(true);
|
|
243
244
|
setErrorKey(null);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
245
|
+
try {
|
|
246
|
+
const entityType = typeByEntity.get(entityId) ?? filing.entityType;
|
|
247
|
+
const ok =
|
|
248
|
+
folderId === null
|
|
249
|
+
? await writeOk(FoldersHandlers.clearFolder, { entityType, entityId })
|
|
250
|
+
: await writeOk(FoldersHandlers.setFolder, {
|
|
251
|
+
folderId,
|
|
252
|
+
entityType,
|
|
253
|
+
entityId,
|
|
254
|
+
});
|
|
255
|
+
if (ok) {
|
|
256
|
+
await catalog.refetch();
|
|
257
|
+
await filing.onReassigned();
|
|
258
|
+
}
|
|
259
|
+
} finally {
|
|
260
|
+
setBusy(false);
|
|
256
261
|
}
|
|
257
262
|
};
|
|
258
263
|
|
package/src/folders/web/i18n.ts
CHANGED
|
@@ -18,7 +18,6 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
18
18
|
"folders.section.working": "Speichert…",
|
|
19
19
|
|
|
20
20
|
"folders.manager.loading": "Lädt…",
|
|
21
|
-
"folders.manager.empty": "Noch keine Ordner.",
|
|
22
21
|
"folders.manager.newRoot": "Neuer Ordner",
|
|
23
22
|
"folders.manager.add": "Anlegen",
|
|
24
23
|
"folders.manager.addChild": "Unterordner",
|
|
@@ -44,7 +43,6 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
44
43
|
"folders.section.working": "Saving…",
|
|
45
44
|
|
|
46
45
|
"folders.manager.loading": "Loading…",
|
|
47
|
-
"folders.manager.empty": "No folders yet.",
|
|
48
46
|
"folders.manager.newRoot": "New folder",
|
|
49
47
|
"folders.manager.add": "Create",
|
|
50
48
|
"folders.manager.addChild": "Subfolder",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// folderAssignmentExportHook — GDPR export must not surface cleared
|
|
2
|
+
// (soft-deleted) folder-assignment rows. Regression (658/3): the hook read
|
|
3
|
+
// via selectMany without an isDeleted filter — folderAssignmentEntity is
|
|
4
|
+
// softDelete: true, so a cleared assignment (clear-folder) still had a row
|
|
5
|
+
// in read_folder_assignments and rode along in the export.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
|
+
import {
|
|
10
|
+
createTestUser,
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
unsafeCreateEntityTable,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { createFoldersFeature, FoldersHandlers } from "../../folders";
|
|
16
|
+
import { folderAssignmentEntity, folderEntity } from "../../folders/entity";
|
|
17
|
+
import { folderAssignmentExportHook } from "../hooks";
|
|
18
|
+
|
|
19
|
+
let stack: TestStack;
|
|
20
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
stack = await setupTestStack({ features: [createFoldersFeature()] });
|
|
24
|
+
await unsafeCreateEntityTable(stack.db, folderEntity);
|
|
25
|
+
await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
|
|
26
|
+
await createEventsTable(stack.db);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await stack.cleanup();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("folderAssignmentExportHook", () => {
|
|
34
|
+
test("excludes a cleared (soft-deleted) assignment from the export", async () => {
|
|
35
|
+
const folder = await stack.http.writeOk<{ id: string }>(
|
|
36
|
+
FoldersHandlers.createFolder,
|
|
37
|
+
{ name: "Active loans" },
|
|
38
|
+
admin,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await stack.http.writeOk(
|
|
42
|
+
FoldersHandlers.setFolder,
|
|
43
|
+
{ folderId: folder.id, entityType: "credit", entityId: "credit-kept" },
|
|
44
|
+
admin,
|
|
45
|
+
);
|
|
46
|
+
await stack.http.writeOk(
|
|
47
|
+
FoldersHandlers.setFolder,
|
|
48
|
+
{ folderId: folder.id, entityType: "credit", entityId: "credit-cleared" },
|
|
49
|
+
admin,
|
|
50
|
+
);
|
|
51
|
+
// clear-folder soft-deletes the assignment row (isDeleted: true).
|
|
52
|
+
await stack.http.writeOk(
|
|
53
|
+
FoldersHandlers.clearFolder,
|
|
54
|
+
{ entityType: "credit", entityId: "credit-cleared" },
|
|
55
|
+
admin,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const snippet = await folderAssignmentExportHook({
|
|
59
|
+
db: stack.db,
|
|
60
|
+
tenantId: admin.tenantId,
|
|
61
|
+
userId: admin.id,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(snippet).not.toBeNull();
|
|
65
|
+
const entityIds = (snippet?.rows ?? []).map((r) => r["entityId"]);
|
|
66
|
+
expect(entityIds).toContain("credit-kept");
|
|
67
|
+
expect(entityIds).not.toContain("credit-cleared");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -4,16 +4,21 @@
|
|
|
4
4
|
// Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
|
|
5
5
|
// (a folder name is tenant data, not per-user PII).
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createTenantDb, type EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
8
9
|
import {
|
|
9
10
|
createEntityExecutor,
|
|
11
|
+
createSystemUser,
|
|
10
12
|
type UserDataDeleteHook,
|
|
11
13
|
type UserDataExportHook,
|
|
12
14
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
15
|
import { folderAssignmentEntity, folderEntity } from "../folders";
|
|
14
16
|
|
|
15
|
-
const { table: folderTable } = createEntityExecutor(
|
|
16
|
-
|
|
17
|
+
const { table: folderTable, executor: folderExecutor } = createEntityExecutor(
|
|
18
|
+
"folder",
|
|
19
|
+
folderEntity,
|
|
20
|
+
);
|
|
21
|
+
const { table: folderAssignmentTable, executor: folderAssignmentExecutor } = createEntityExecutor(
|
|
17
22
|
"folder-assignment",
|
|
18
23
|
folderAssignmentEntity,
|
|
19
24
|
);
|
|
@@ -30,8 +35,12 @@ export const folderExportHook: UserDataExportHook = async (ctx) => {
|
|
|
30
35
|
};
|
|
31
36
|
|
|
32
37
|
export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
|
|
38
|
+
// folderAssignmentEntity is softDelete: true — a cleared assignment
|
|
39
|
+
// (isDeleted: true) is a removed folder membership, not something the GDPR
|
|
40
|
+
// export should still surface as current data.
|
|
33
41
|
const rows = await selectMany<Record<string, unknown>>(ctx.db, folderAssignmentTable, {
|
|
34
42
|
tenantId: ctx.tenantId,
|
|
43
|
+
isDeleted: false,
|
|
35
44
|
});
|
|
36
45
|
if (rows.length === 0) return null;
|
|
37
46
|
return { entity: "folder-assignment", rows };
|
|
@@ -43,16 +52,30 @@ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
|
|
|
43
52
|
// tenant would destroy co-members' folders. anonymize is also a no-op — folder
|
|
44
53
|
// rows carry no person-link to strip (name is tenant data, not PII), so a
|
|
45
54
|
// retention hold simply keeps them.
|
|
46
|
-
function tenantScopedDelete(
|
|
55
|
+
function tenantScopedDelete(
|
|
56
|
+
table: typeof folderTable,
|
|
57
|
+
executor: EventStoreExecutor,
|
|
58
|
+
): UserDataDeleteHook {
|
|
47
59
|
return async (ctx, strategy) => {
|
|
48
60
|
// skip: multi-user tenant — a tenant-wide delete would destroy co-members' folders
|
|
49
61
|
if (ctx.tenantModel !== "single-user") return;
|
|
50
62
|
// skip: anonymize is a no-op — folder rows carry no per-user PII to strip
|
|
51
63
|
if (strategy === "anonymize") return;
|
|
52
|
-
|
|
64
|
+
// Per-row via the executor (event -> rebuild-safe): a bulk deleteMany is
|
|
65
|
+
// eventless, so a projection rebuild resurrects the rows. Bounded — forget
|
|
66
|
+
// only fires for single-user tenants.
|
|
67
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
68
|
+
// The executor needs a TenantDb (loadById → db.fetchOne), not the raw ctx.db.
|
|
69
|
+
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
70
|
+
const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
await executor.delete({ id: row.id }, systemUser, tdb);
|
|
73
|
+
}
|
|
53
74
|
};
|
|
54
75
|
}
|
|
55
76
|
|
|
56
|
-
export const folderDeleteHook: UserDataDeleteHook = tenantScopedDelete(folderTable);
|
|
57
|
-
export const folderAssignmentDeleteHook: UserDataDeleteHook =
|
|
58
|
-
|
|
77
|
+
export const folderDeleteHook: UserDataDeleteHook = tenantScopedDelete(folderTable, folderExecutor);
|
|
78
|
+
export const folderAssignmentDeleteHook: UserDataDeleteHook = tenantScopedDelete(
|
|
79
|
+
folderAssignmentTable,
|
|
80
|
+
folderAssignmentExecutor,
|
|
81
|
+
);
|
|
@@ -16,10 +16,10 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
|
|
|
16
16
|
r.describe(
|
|
17
17
|
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `folders` feature's `folder` + `folder-assignment` entities. Mounts the EXT_USER_DATA export + delete hooks so a tenant's folder tree and its entity-to-folder assignments are included in the user-data export bundle and erased on a tenant-scoped forget (single-user tenants only; multi-user + anonymize are no-ops since folder rows carry no per-user PII). Kept separate from `folders` so folder consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `folders`.",
|
|
18
18
|
);
|
|
19
|
-
// user-data-rights
|
|
20
|
-
// OPTIONAL:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// user-data-rights is the hard dependency (EXT_USER_DATA host). `folders` is
|
|
20
|
+
// OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
|
|
21
|
+
// tier), a hard r.requires would throw an "effectively disabled" boot
|
|
22
|
+
// warning even though the folder entities exist and the hooks work fine.
|
|
23
23
|
r.requires("user-data-rights");
|
|
24
24
|
r.optionalRequires("folders");
|
|
25
25
|
r.useExtension(EXT_USER_DATA, "folder", {
|
package/src/jobs/feature.ts
CHANGED
|
@@ -57,9 +57,9 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
57
57
|
table: jobRunsTable,
|
|
58
58
|
apply: {
|
|
59
59
|
[JOB_RUN_STARTED_EVENT]: defineApply<z.infer<typeof runStartedSchema>>(
|
|
60
|
-
async (event, tx) => {
|
|
60
|
+
async (event, tx, table) => {
|
|
61
61
|
const p = event.payload;
|
|
62
|
-
await insertOne(tx,
|
|
62
|
+
await insertOne(tx, table, {
|
|
63
63
|
id: event.aggregateId,
|
|
64
64
|
tenantId: event.tenantId,
|
|
65
65
|
version: event.version,
|
|
@@ -76,11 +76,11 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
76
76
|
},
|
|
77
77
|
),
|
|
78
78
|
[JOB_RUN_COMPLETED_EVENT]: defineApply<z.infer<typeof runCompletedSchema>>(
|
|
79
|
-
async (event, tx) => {
|
|
79
|
+
async (event, tx, table) => {
|
|
80
80
|
const p = event.payload;
|
|
81
81
|
await updateMany(
|
|
82
82
|
tx,
|
|
83
|
-
|
|
83
|
+
table,
|
|
84
84
|
{
|
|
85
85
|
status: "completed",
|
|
86
86
|
duration: p.duration,
|
|
@@ -93,23 +93,25 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
93
93
|
);
|
|
94
94
|
},
|
|
95
95
|
),
|
|
96
|
-
[JOB_RUN_FAILED_EVENT]: defineApply<z.infer<typeof runFailedSchema>>(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
96
|
+
[JOB_RUN_FAILED_EVENT]: defineApply<z.infer<typeof runFailedSchema>>(
|
|
97
|
+
async (event, tx, table) => {
|
|
98
|
+
const p = event.payload;
|
|
99
|
+
await updateMany(
|
|
100
|
+
tx,
|
|
101
|
+
table,
|
|
102
|
+
{
|
|
103
|
+
status: "failed",
|
|
104
|
+
error: p.error,
|
|
105
|
+
duration: p.duration,
|
|
106
|
+
finishedAt: Temporal.Instant.from(p.finishedAt),
|
|
107
|
+
version: event.version,
|
|
108
|
+
modifiedAt: event.createdAt,
|
|
109
|
+
modifiedById: event.metadata?.userId ?? "system",
|
|
110
|
+
},
|
|
111
|
+
{ id: event.aggregateId },
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
),
|
|
113
115
|
},
|
|
114
116
|
});
|
|
115
117
|
|
|
@@ -6,10 +6,15 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
|
6
6
|
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
|
|
9
|
-
export const projectionRebuildPayloadSchema = z.object({
|
|
9
|
+
export const projectionRebuildPayloadSchema = z.object({
|
|
10
|
+
projection: z.string().min(1),
|
|
11
|
+
// Quarantine mode (#760): skip + dead-letter poison events instead of
|
|
12
|
+
// failing the whole rebuild. Operator opt-in per run.
|
|
13
|
+
skipApplyErrors: z.boolean().optional(),
|
|
14
|
+
});
|
|
10
15
|
|
|
11
16
|
export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
12
|
-
const { projection } = projectionRebuildPayloadSchema.parse(rawPayload);
|
|
17
|
+
const { projection, skipApplyErrors } = projectionRebuildPayloadSchema.parse(rawPayload);
|
|
13
18
|
if (!ctx.db) {
|
|
14
19
|
throw new InternalError({
|
|
15
20
|
message:
|
|
@@ -23,8 +28,13 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
|
|
|
23
28
|
});
|
|
24
29
|
}
|
|
25
30
|
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
26
|
-
const result = await rebuildProjection(projection, {
|
|
31
|
+
const result = await rebuildProjection(projection, {
|
|
32
|
+
db,
|
|
33
|
+
registry: ctx.registry,
|
|
34
|
+
...(skipApplyErrors === true && { errorPolicy: { skipApplyErrors: true } }),
|
|
35
|
+
});
|
|
27
36
|
ctx.log?.info?.(
|
|
28
|
-
`[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events
|
|
37
|
+
`[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events` +
|
|
38
|
+
`${result.eventsSkipped > 0 ? ` (${result.eventsSkipped} quarantined)` : ""} in ${result.durationMs}ms`,
|
|
29
39
|
);
|
|
30
40
|
};
|
|
@@ -188,6 +188,50 @@ describe("ledger integration — reverse-transaction (Storno)", () => {
|
|
|
188
188
|
// Original + Storno cancel → books net to zero.
|
|
189
189
|
expect(trialBalance(rows)).toBe(0);
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
test("reversing a draft transaction is rejected (only posted entries count)", async () => {
|
|
193
|
+
const bank = await createAccount("Bank Draft", "asset");
|
|
194
|
+
const rent = await createAccount("Draft Income", "income");
|
|
195
|
+
const draft = await stack.http.writeOk<{ id: string }>(
|
|
196
|
+
LedgerHandlers.createTransaction,
|
|
197
|
+
{
|
|
198
|
+
date: "2026-01-15",
|
|
199
|
+
description: "Draft entry",
|
|
200
|
+
status: "draft",
|
|
201
|
+
lines: [
|
|
202
|
+
{ accountId: bank, amount: 5000 },
|
|
203
|
+
{ accountId: rent, amount: -5000 },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
admin,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const err = await stack.http.writeErr(
|
|
210
|
+
LedgerHandlers.reverseTransaction,
|
|
211
|
+
{ id: draft.id },
|
|
212
|
+
admin,
|
|
213
|
+
);
|
|
214
|
+
expect(err.code).toBe("unprocessable");
|
|
215
|
+
|
|
216
|
+
// No Storno booked — the draft is the only row.
|
|
217
|
+
expect(await listTransactions()).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("reversing the same transaction twice is rejected on the second call", async () => {
|
|
221
|
+
const bank = await createAccount("Bank Dup", "asset");
|
|
222
|
+
const rent = await createAccount("Dup Income", "income");
|
|
223
|
+
const tx = await createTransaction([
|
|
224
|
+
{ accountId: bank, amount: 20000 },
|
|
225
|
+
{ accountId: rent, amount: -20000 },
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
await stack.http.writeOk(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
|
|
229
|
+
const err = await stack.http.writeErr(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
|
|
230
|
+
expect(err.code).toBe("conflict");
|
|
231
|
+
|
|
232
|
+
// Original + exactly ONE Storno — a second reverse must not book again.
|
|
233
|
+
expect(await listTransactions()).toHaveLength(2);
|
|
234
|
+
});
|
|
191
235
|
});
|
|
192
236
|
|
|
193
237
|
describe("ledger integration — trial balance (golden invariant)", () => {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
incomeStatement,
|
|
6
6
|
type LedgerAccount,
|
|
7
7
|
type LedgerEntry,
|
|
8
|
+
normalizeLines,
|
|
8
9
|
} from "../reports";
|
|
9
10
|
|
|
10
11
|
// Pure report math — no DB. A small but complete set of books:
|
|
@@ -50,6 +51,25 @@ function balanceOf(report: ReturnType<typeof accountBalances>, id: string): numb
|
|
|
50
51
|
return report.accounts.find((a) => a.id === id)?.balance ?? Number.NaN;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
describe("normalizeLines — jsonb array-or-string driver surfacing", () => {
|
|
55
|
+
test("passes an already-parsed array through unchanged", () => {
|
|
56
|
+
const lines = [{ accountId: "bank", amount: 100 }];
|
|
57
|
+
expect(normalizeLines(lines)).toEqual(lines);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("parses a JSON-string-encoded array (the other driver path)", () => {
|
|
61
|
+
const lines = [{ accountId: "bank", amount: 100 }];
|
|
62
|
+
expect(normalizeLines(JSON.stringify(lines))).toEqual(lines);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("falls back to [] for malformed JSON or a non-array value", () => {
|
|
66
|
+
expect(normalizeLines("not json")).toEqual([]);
|
|
67
|
+
expect(normalizeLines(null)).toEqual([]);
|
|
68
|
+
expect(normalizeLines(undefined)).toEqual([]);
|
|
69
|
+
expect(normalizeLines(42)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
53
73
|
describe("accountBalances — natural balances + trial balance", () => {
|
|
54
74
|
test("natural balances are sign-corrected by account type", () => {
|
|
55
75
|
const r = accountBalances(accounts, posted);
|
package/src/ledger/entity.ts
CHANGED
|
@@ -73,7 +73,7 @@ export const scheduleEntity = createEntity({
|
|
|
73
73
|
// Absent → open-ended (projects to the window's end).
|
|
74
74
|
endDate: createDateField(),
|
|
75
75
|
interval: createSelectField({ options: SCHEDULE_INTERVALS, required: true }),
|
|
76
|
-
amount: createNumberField({ required: true }),
|
|
76
|
+
amount: createNumberField({ required: true, min: 1, integer: true }),
|
|
77
77
|
debitAccountId: createTextField({ required: true, maxLength: 64 }),
|
|
78
78
|
creditAccountId: createTextField({ required: true, maxLength: 64 }),
|
|
79
79
|
},
|