@cosmicdrift/kumiko-bundled-features 0.398.0 → 0.400.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/auth-mfa/schema/user-mfa.d.ts +4 -4
  2. package/dist/billing-foundation/consumer-protection/consent-text.js +4 -4
  3. package/dist/billing-foundation/entities.d.ts +2 -2
  4. package/dist/billing-foundation/projection.d.ts +2 -2
  5. package/dist/cap-counter/entity.d.ts +3 -3
  6. package/dist/channel-in-app/constants.js +4 -9
  7. package/dist/channel-in-app/feature.js +1 -1
  8. package/dist/channel-in-app/handlers/inbox.query.d.ts +4 -1
  9. package/dist/channel-in-app/handlers/inbox.query.js +23 -8
  10. package/dist/channel-in-app/handlers/unread-count.query.js +2 -9
  11. package/dist/channel-in-app/in-app-channel.js +23 -11
  12. package/dist/channel-in-app/in-app-target.d.ts +5 -0
  13. package/dist/channel-in-app/in-app-target.js +34 -0
  14. package/dist/channel-in-app/index.d.ts +1 -0
  15. package/dist/channel-in-app/index.js +1 -0
  16. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -2
  17. package/dist/custom-fields/entity.d.ts +23 -3
  18. package/dist/delivery/tables.d.ts +11 -1
  19. package/dist/documents/aggregate-id.d.ts +2 -0
  20. package/dist/documents/aggregate-id.js +12 -0
  21. package/dist/documents/capture.d.ts +14 -0
  22. package/dist/documents/capture.js +65 -0
  23. package/dist/documents/constants.d.ts +23 -0
  24. package/dist/documents/constants.js +21 -0
  25. package/dist/documents/ensure-link.d.ts +17 -0
  26. package/dist/documents/ensure-link.js +20 -0
  27. package/dist/documents/entity.d.ts +76 -0
  28. package/dist/documents/entity.js +84 -0
  29. package/dist/documents/executor.d.ts +6 -0
  30. package/dist/documents/executor.js +8 -0
  31. package/dist/documents/expiry.d.ts +9 -0
  32. package/dist/documents/expiry.js +20 -0
  33. package/dist/documents/feature.d.ts +33 -0
  34. package/dist/documents/feature.js +111 -0
  35. package/dist/documents/handlers/import.write.d.ts +23 -0
  36. package/dist/documents/handlers/import.write.js +104 -0
  37. package/dist/documents/handlers/link.write.d.ts +18 -0
  38. package/dist/documents/handlers/link.write.js +61 -0
  39. package/dist/documents/i18n.d.ts +6 -0
  40. package/dist/documents/i18n.js +33 -0
  41. package/dist/documents/index.d.ts +7 -0
  42. package/dist/documents/index.js +6 -0
  43. package/dist/documents/pending-file-guard.d.ts +2 -0
  44. package/dist/documents/pending-file-guard.js +29 -0
  45. package/dist/documents/queries/expiry-overview.query.d.ts +10 -0
  46. package/dist/documents/queries/expiry-overview.query.js +41 -0
  47. package/dist/documents/queries/for-record.query.d.ts +90 -0
  48. package/dist/documents/queries/for-record.query.js +54 -0
  49. package/dist/documents/queries/list-page.d.ts +19 -0
  50. package/dist/documents/queries/list-page.js +36 -0
  51. package/dist/documents/queries/overview.query.d.ts +32 -0
  52. package/dist/documents/queries/overview.query.js +41 -0
  53. package/dist/documents/schemas.d.ts +93 -0
  54. package/dist/documents/schemas.js +51 -0
  55. package/dist/documents/screens.d.ts +3 -0
  56. package/dist/documents/screens.js +76 -0
  57. package/dist/documents/tab.d.ts +4 -0
  58. package/dist/documents/tab.js +42 -0
  59. package/dist/documents/target-write-check.d.ts +3 -0
  60. package/dist/documents/target-write-check.js +25 -0
  61. package/dist/documents/write-guard.d.ts +6 -0
  62. package/dist/documents/write-guard.js +46 -0
  63. package/dist/inbound-mail-foundation/entities.d.ts +17 -7
  64. package/dist/inbound-mail-foundation/ingest-failures.d.ts +3 -3
  65. package/dist/inbound-mail-foundation/projection.d.ts +15 -5
  66. package/dist/inbound-mail-foundation/quarantine-entities.d.ts +3 -3
  67. package/dist/jobs/job-run-table.d.ts +4 -4
  68. package/dist/ledger/entity.d.ts +8 -8
  69. package/dist/managed-pages/table.d.ts +24 -4
  70. package/dist/metrics/compute.d.ts +3 -0
  71. package/dist/metrics/compute.js +10 -3
  72. package/dist/metrics/feature.js +6 -2
  73. package/dist/metrics/types.d.ts +5 -1
  74. package/dist/metrics/validate.js +3 -0
  75. package/dist/personal-access-tokens/schema/api-token.d.ts +3 -3
  76. package/dist/readiness/schema/probe-run.d.ts +60 -20
  77. package/dist/secrets/table.d.ts +1 -1
  78. package/dist/sessions/schema/user-session.d.ts +4 -4
  79. package/dist/shared/purge-row-file-refs.js +5 -4
  80. package/dist/template-resolver/table.d.ts +12 -12
  81. package/dist/template-resolver/user-content-table.d.ts +4 -4
  82. package/dist/tenant/invitation-table.d.ts +6 -6
  83. package/dist/tenant/schema/tenant.d.ts +32 -12
  84. package/dist/tier-engine/entity.d.ts +1 -1
  85. package/dist/tier-engine/tier-resolver.d.ts +1 -1
  86. package/dist/user/schema/user.d.ts +28 -8
  87. package/dist/user-data-rights/schema/download-attempt.d.ts +2 -2
  88. package/dist/user-data-rights/schema/download-token.d.ts +8 -8
  89. package/dist/user-data-rights/schema/export-job.d.ts +12 -12
  90. package/dist/waitlist/entity.d.ts +8 -8
  91. package/package.json +19 -11
  92. package/src/billing-foundation/changes.json +6 -0
  93. package/src/channel-in-app/changes.json +6 -0
  94. package/src/metrics/changes.json +6 -0
@@ -0,0 +1,76 @@
1
+ import { type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ export type DocumentEntityOptions = {
3
+ readonly categories: readonly [string, ...string[]];
4
+ readonly adminRoles: readonly string[];
5
+ readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
6
+ readonly retention?: EntityDefinition["retention"];
7
+ };
8
+ export declare function createDocumentEntity(options: DocumentEntityOptions): EntityDefinition<{
9
+ readonly title: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
10
+ required: true;
11
+ };
12
+ readonly category: {
13
+ readonly type: "select";
14
+ readonly description?: string;
15
+ readonly options: readonly [string, ...string[]];
16
+ readonly required?: boolean;
17
+ readonly sortable?: boolean;
18
+ readonly sortNulls?: import("@cosmicdrift/kumiko-types/fields").SortNulls;
19
+ readonly filterable?: boolean;
20
+ readonly sensitive?: boolean;
21
+ readonly default?: string | undefined;
22
+ readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
23
+ readonly display?: "radio" | "dropdown";
24
+ readonly optionTones?: {
25
+ readonly [x: string]: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
26
+ } | undefined;
27
+ readonly optionsQuery?: string;
28
+ readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
29
+ readonly optionsAvailabilityQuery?: string;
30
+ readonly conditionalOptions?: readonly import("@cosmicdrift/kumiko-types/fields").SelectConditionalOptions<string>[] | undefined;
31
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & import("@cosmicdrift/kumiko-types/fields").FieldStateConditions & {
32
+ required: true;
33
+ };
34
+ readonly issuedAt: {
35
+ readonly type: "date";
36
+ readonly description?: string;
37
+ readonly required?: boolean;
38
+ readonly sortable?: boolean;
39
+ readonly sortNulls?: import("@cosmicdrift/kumiko-types/fields").SortNulls;
40
+ readonly filterable?: boolean;
41
+ readonly sensitive?: boolean;
42
+ readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
43
+ readonly min?: string;
44
+ readonly max?: string;
45
+ readonly locale?: string;
46
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & import("@cosmicdrift/kumiko-types/fields").FieldStateConditions & {
47
+ required: false;
48
+ };
49
+ readonly validUntil: {
50
+ readonly type: "date";
51
+ readonly description?: string;
52
+ readonly required?: boolean;
53
+ readonly sortable?: boolean;
54
+ readonly sortNulls?: import("@cosmicdrift/kumiko-types/fields").SortNulls;
55
+ readonly filterable?: boolean;
56
+ readonly sensitive?: boolean;
57
+ readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
58
+ readonly min?: string;
59
+ readonly max?: string;
60
+ readonly locale?: string;
61
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & import("@cosmicdrift/kumiko-types/fields").FieldStateConditions & {
62
+ required: false;
63
+ };
64
+ readonly file: import("@cosmicdrift/kumiko-types/fields").FileFieldDef;
65
+ }, "tenant">;
66
+ export declare function createDocumentLinkEntity(linkTargets: readonly string[]): EntityDefinition<{
67
+ readonly documentId: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
68
+ required: true;
69
+ };
70
+ readonly entityType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
71
+ required: true;
72
+ };
73
+ readonly entityId: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
74
+ required: true;
75
+ };
76
+ }, "tenant">;
@@ -0,0 +1,84 @@
1
+ import { sql } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createDateField, createEntity, createFileField, createSelectField, createTextField, SYSTEM_ROLE, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { DOCUMENT_LINK_ENTITY } from "./constants.js";
4
+ // Readable only through a link to a host the viewer can read, and only when the file's owner is
5
+ // readable too (see EntityDefinition.readGate); readAccess narrows further.
6
+ export function createDocumentEntity(options) {
7
+ return createEntity({
8
+ table: "read_filed_documents",
9
+ softDelete: true,
10
+ description: "A filed document: title, category, issue and expiry dates and one file. It is visible only through its links to other records and the owner of its file; the file may stay owned by the record it came from.",
11
+ // The system user passes the gate; the capture and import checks run as it and would
12
+ // otherwise miss a document that readAccess hides.
13
+ ...(options.readAccess !== undefined && {
14
+ access: { read: { ...options.readAccess, [SYSTEM_ROLE]: "all" } },
15
+ }),
16
+ ...(options.retention !== undefined && { retention: options.retention }),
17
+ readGate: {
18
+ anyLink: { entity: DOCUMENT_LINK_ENTITY, field: "documentId" },
19
+ fileOwner: { field: "file" },
20
+ linkExemptRoles: options.adminRoles,
21
+ },
22
+ fields: {
23
+ title: createTextField({
24
+ required: true,
25
+ maxLength: 200,
26
+ sortable: true,
27
+ searchable: true,
28
+ personal: false,
29
+ reason: "document_title",
30
+ }),
31
+ category: createSelectField({
32
+ options: options.categories,
33
+ required: true,
34
+ filterable: true,
35
+ sortable: true,
36
+ }),
37
+ issuedAt: createDateField({ sortable: true }),
38
+ validUntil: createDateField({ sortable: true, filterable: true }),
39
+ // Filed business documents belong to the tenant, not to one person.
40
+ file: createFileField({ required: true, personal: false, reason: "is_business_data" }),
41
+ },
42
+ // One live document per file; a deleted one releases the file.
43
+ indexes: [
44
+ {
45
+ columns: ["tenantId", "file"],
46
+ unique: true,
47
+ name: "read_filed_documents_file_unique",
48
+ where: sql `"is_deleted" = false`,
49
+ },
50
+ ],
51
+ });
52
+ }
53
+ export function createDocumentLinkEntity(linkTargets) {
54
+ return createEntity({
55
+ table: "read_filed_document_links",
56
+ softDelete: true,
57
+ description: "The join row recording that one document belongs to one record, addressed by documentId, entityType and entityId, with exactly one row per triple. A document is visible to whoever can read at least one of its linked records.",
58
+ parentRef: {
59
+ entityTypeField: "entityType",
60
+ entityIdField: "entityId",
61
+ allowedTypes: linkTargets,
62
+ },
63
+ fields: {
64
+ documentId: createTextField({
65
+ required: true,
66
+ maxLength: 64,
67
+ personal: false,
68
+ reason: "technical_reference",
69
+ }),
70
+ entityType: createTextField({
71
+ required: true,
72
+ maxLength: 64,
73
+ personal: false,
74
+ reason: "technical_reference",
75
+ }),
76
+ entityId: createTextField({
77
+ required: true,
78
+ maxLength: 128,
79
+ personal: false,
80
+ reason: "technical_reference",
81
+ }),
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,6 @@
1
+ import { type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare function createDocumentExecutors(documentEntity: EntityDefinition, linkEntity: EntityDefinition): {
3
+ document: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor<Record<string, unknown>>;
4
+ link: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor<Record<string, unknown>>;
5
+ };
6
+ export type DocumentExecutors = ReturnType<typeof createDocumentExecutors>;
@@ -0,0 +1,8 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY } from "./constants.js";
3
+ export function createDocumentExecutors(documentEntity, linkEntity) {
4
+ return {
5
+ document: createEntityExecutor(DOCUMENT_ENTITY, documentEntity).executor,
6
+ link: createEntityExecutor(DOCUMENT_LINK_ENTITY, linkEntity).executor,
7
+ };
8
+ }
@@ -0,0 +1,9 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ExpiryState } from "./constants.ts";
3
+ export type ExpiryWindow = {
4
+ readonly today: string;
5
+ /** Last day (inclusive) that still counts as "expiring". */
6
+ readonly horizon: string;
7
+ };
8
+ export declare function expiryWindow(ctx: Pick<HandlerContext, "tz">, expiringWithinDays: number): ExpiryWindow;
9
+ export declare function expiryStateOf(validUntil: unknown, window: ExpiryWindow): ExpiryState;
@@ -0,0 +1,20 @@
1
+ // One definition of the boundaries for the counts and for the per-row state, so the
2
+ // overview column and the expiry-overview buckets cannot drift apart.
3
+ export function expiryWindow(ctx, expiringWithinDays) {
4
+ const today = ctx.tz.today(ctx.tz.tenant);
5
+ return { today: today.toString(), horizon: today.add({ days: expiringWithinDays }).toString() };
6
+ }
7
+ // In-process dispatch hands back the date as a PlainDate, the wire form is an ISO string.
8
+ // ISO dates compare correctly as strings; a missing date is "none", not "valid".
9
+ export function expiryStateOf(validUntil, window) {
10
+ const day = validUntil instanceof Temporal.PlainDate
11
+ ? validUntil.toString()
12
+ : typeof validUntil === "string"
13
+ ? validUntil.slice(0, 10)
14
+ : "";
15
+ if (day === "")
16
+ return "none";
17
+ if (day < window.today)
18
+ return "expired";
19
+ return day <= window.horizon ? "expiring" : "valid";
20
+ }
@@ -0,0 +1,33 @@
1
+ import { type AccessRule, type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type DocumentCaptureRule } from "./capture.ts";
3
+ export type DocumentsFeatureOptions = {
4
+ /** Allowed document categories; they become the options of the category field. */
5
+ readonly categories: readonly [string, ...string[]];
6
+ /** Entities a document can be linked to. Each needs an update handler, which linking requires. */
7
+ readonly linkTargets: readonly [string, ...string[]];
8
+ /** Files saved into these fields become documents automatically. */
9
+ readonly capture?: readonly DocumentCaptureRule[];
10
+ /** Retention of the document rows, in the entity's own retention vocabulary. */
11
+ readonly retention?: EntityDefinition["retention"];
12
+ /** Row-level read rule on the document, AND-ed with the link and file-owner gate. */
13
+ readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
14
+ /**
15
+ * Who may use the document handlers. One rule applies to read, write and delete; the split form
16
+ * sets them apart. Default TenantAdmin and TenantMember; a missing delete follows write.
17
+ */
18
+ readonly access?: AccessRule | DocumentsAccess;
19
+ /** Roles that see every document of their tenant whose file owner they can read, without a link. */
20
+ readonly adminRoles?: readonly string[];
21
+ /** Window of the "expiring" bucket in expiry-overview. Default 30 days. */
22
+ readonly expiringWithinDays?: number;
23
+ };
24
+ export type DocumentsAccess = {
25
+ /** Lists, detail, for-record, the overviews and the list screen. */
26
+ readonly read?: AccessRule;
27
+ /** Create, update, link and unlink. */
28
+ readonly write?: AccessRule;
29
+ readonly delete?: AccessRule;
30
+ };
31
+ export declare function createDocumentsFeature(opts: DocumentsFeatureOptions): import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
32
+ readonly exports: void;
33
+ };
@@ -0,0 +1,111 @@
1
+ // documents — filed documents (title, category, validity, one file) that become visible on other
2
+ // records through link rows. A document is readable only through a record the viewer can read,
3
+ // and only while the owner of its file is readable too (EntityDefinition.readGate). Documents
4
+ // come from capture rules (a file saved into a configured field) or from the system-only import.
5
+ import { defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { registerCapture, validateCaptureRules } from "./capture.js";
7
+ import { DEFAULT_ADMIN_ROLES, DEFAULT_DOCUMENT_ACCESS, DEFAULT_EXPIRING_WITHIN_DAYS, DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY, DOCUMENTS_FEATURE_NAME, } from "./constants.js";
8
+ import { createDocumentEntity, createDocumentLinkEntity } from "./entity.js";
9
+ import { createDocumentExecutors } from "./executor.js";
10
+ import { createImportDocumentHandler } from "./handlers/import.write.js";
11
+ import { createLinkDocumentHandler, createUnlinkDocumentHandler } from "./handlers/link.write.js";
12
+ import { DOCUMENTS_FEATURE_I18N } from "./i18n.js";
13
+ import { rejectForeignPendingFile } from "./pending-file-guard.js";
14
+ import { createExpiryOverviewQuery } from "./queries/expiry-overview.query.js";
15
+ import { createForRecordQuery, createForTypeQuery } from "./queries/for-record.query.js";
16
+ import { createOverviewQuery } from "./queries/overview.query.js";
17
+ import { createDocumentListScreen } from "./screens.js";
18
+ import { createDocumentWriteGuard } from "./write-guard.js";
19
+ function isAccessRule(access) {
20
+ return "roles" in access || "openToAll" in access;
21
+ }
22
+ function resolveAccess(access) {
23
+ if (access === undefined) {
24
+ return {
25
+ read: DEFAULT_DOCUMENT_ACCESS,
26
+ write: DEFAULT_DOCUMENT_ACCESS,
27
+ delete: DEFAULT_DOCUMENT_ACCESS,
28
+ };
29
+ }
30
+ if (isAccessRule(access))
31
+ return { read: access, write: access, delete: access };
32
+ const write = access.write ?? DEFAULT_DOCUMENT_ACCESS;
33
+ return { read: access.read ?? DEFAULT_DOCUMENT_ACCESS, write, delete: access.delete ?? write };
34
+ }
35
+ function categoryOptionLabels(categories) {
36
+ return Object.fromEntries(categories.map((category) => [
37
+ `documents:entity:filed-document:field:category:option:${category}`,
38
+ { en: category, de: category },
39
+ ]));
40
+ }
41
+ function registerDocuments(r, opts) {
42
+ const access = resolveAccess(opts.access);
43
+ const linkTargets = opts.linkTargets;
44
+ const documentEntity = createDocumentEntity({
45
+ categories: opts.categories,
46
+ adminRoles: opts.adminRoles ?? DEFAULT_ADMIN_ROLES,
47
+ ...(opts.readAccess !== undefined && { readAccess: opts.readAccess }),
48
+ ...(opts.retention !== undefined && { retention: opts.retention }),
49
+ });
50
+ const linkEntity = createDocumentLinkEntity(linkTargets);
51
+ const executors = createDocumentExecutors(documentEntity, linkEntity);
52
+ r.requires({ features: ["files"] });
53
+ r.adoptsBundledLists();
54
+ r.describe("Filed documents with title, category, issue and expiry date and one file, shown on the records they are linked to. A document is visible only through a linked record the viewer can read, and only while the record that owns its file is readable too, so linking never widens access to the file. Documents arrive through capture rules (a file saved into a configured field) or through the system-only import for migrations, which keeps the caller's id, refuses an id another record already uses, and stamps createdAt with the import time (issuedAt carries the business date; the event store does not backdate); users link, unlink, edit and delete them. Read a record's documents with for-record, count expired and expiring ones with expiry-overview, list them with an expiry state and an expiring filter with overview (the documents list screen), and add the documents tab to a record screen with documentsTab().");
55
+ r.uiHints({ displayLabel: "Documents", category: "data", recommended: false });
56
+ r.entity({ name: DOCUMENT_ENTITY, ...documentEntity });
57
+ r.entity({ name: DOCUMENT_LINK_ENTITY, ...linkEntity });
58
+ r.writeHandler(defineEntityCreateHandler(DOCUMENT_ENTITY, documentEntity, {
59
+ access: access.write,
60
+ description: "Files a new document from an uploaded file; it stays invisible to everyone but the admin roles until it is linked to a record.",
61
+ }));
62
+ const writeGuard = createDocumentWriteGuard(executors, opts.adminRoles ?? DEFAULT_ADMIN_ROLES);
63
+ r.writeHandler(defineEntityUpdateHandler(DOCUMENT_ENTITY, documentEntity, {
64
+ access: access.write,
65
+ description: "Edits a document's title, category and dates from the entity-edit envelope; it does not change what the document is linked to.",
66
+ }));
67
+ r.writeHandler(defineEntityDeleteHandler(DOCUMENT_ENTITY, documentEntity, {
68
+ access: access.delete,
69
+ description: "Deletes a document so it disappears from every record it was linked to; the file itself stays with its owner.",
70
+ agent: { risk: "high" },
71
+ }));
72
+ r.hook({ type: "preSave", target: "filed-document:create", handler: rejectForeignPendingFile });
73
+ r.hook({ type: "preSave", target: "filed-document:update", handler: rejectForeignPendingFile });
74
+ r.hook({ type: "preSave", target: "filed-document:update", handler: writeGuard.beforeUpdate });
75
+ r.hook({ type: "preDelete", target: "filed-document:delete", handler: writeGuard.beforeDelete });
76
+ r.writeHandler(createLinkDocumentHandler({ access: access.write, executors, linkTargets }));
77
+ r.writeHandler(createUnlinkDocumentHandler({ access: access.write, executors, linkTargets }));
78
+ r.writeHandler(createImportDocumentHandler({ executors, categories: opts.categories, linkTargets }));
79
+ r.queryHandler(defineEntityListHandler(DOCUMENT_ENTITY, documentEntity, {
80
+ access: access.read,
81
+ description: "Lists the documents the caller can read, filterable by category and validity date; use for-record to list one record's documents.",
82
+ }));
83
+ r.queryHandler(defineEntityDetailHandler(DOCUMENT_ENTITY, documentEntity, {
84
+ access: access.read,
85
+ description: "Reads one document the caller can read, with its file reference and dates.",
86
+ }));
87
+ r.queryHandler(defineEntityListHandler(DOCUMENT_LINK_ENTITY, linkEntity, {
88
+ access: access.read,
89
+ description: "Lists the document-to-record link rows whose record the caller can read; filter on entityId for one record's links.",
90
+ }));
91
+ r.queryHandler(createForRecordQuery(access.read, linkTargets));
92
+ for (const entityType of linkTargets) {
93
+ r.queryHandler(createForTypeQuery(access.read, linkTargets, entityType));
94
+ }
95
+ const expiringWithinDays = opts.expiringWithinDays ?? DEFAULT_EXPIRING_WITHIN_DAYS;
96
+ r.queryHandler(createExpiryOverviewQuery(access.read, expiringWithinDays));
97
+ r.queryHandler(createOverviewQuery(access.read, expiringWithinDays));
98
+ const capture = opts.capture ?? [];
99
+ registerCapture(r, executors, capture, linkTargets);
100
+ r.bootCheck((ctx) => validateCaptureRules(ctx, capture, opts.categories, linkTargets));
101
+ r.screen(createDocumentListScreen(access.read, opts.categories));
102
+ r.translations({
103
+ keys: { ...DOCUMENTS_FEATURE_I18N, ...categoryOptionLabels(opts.categories) },
104
+ });
105
+ }
106
+ export function createDocumentsFeature(opts) {
107
+ if (opts.expiringWithinDays !== undefined && opts.expiringWithinDays < 0) {
108
+ throw new Error("createDocumentsFeature({ expiringWithinDays }) must not be negative.");
109
+ }
110
+ return defineFeature(DOCUMENTS_FEATURE_NAME, (r) => registerDocuments(r, opts));
111
+ }
@@ -0,0 +1,23 @@
1
+ import type { DocumentExecutors } from "../executor.ts";
2
+ type ImportArgs = {
3
+ readonly executors: DocumentExecutors;
4
+ readonly categories: readonly string[];
5
+ readonly linkTargets: readonly string[];
6
+ };
7
+ export declare function createImportDocumentHandler(args: ImportArgs): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"import", import("zod").ZodObject<{
8
+ id: import("zod").ZodUUID;
9
+ title: import("zod").ZodString;
10
+ category: import("zod").ZodString;
11
+ issuedAt: import("zod").ZodOptional<import("zod").ZodISODate>;
12
+ validUntil: import("zod").ZodOptional<import("zod").ZodISODate>;
13
+ fileRefId: import("zod").ZodUUID;
14
+ links: import("zod").ZodArray<import("zod").ZodObject<{
15
+ entityType: import("zod").ZodString;
16
+ entityId: import("zod").ZodString;
17
+ }, import("zod/v4/core").$strip>>;
18
+ deleted: import("zod").ZodOptional<import("zod").ZodBoolean>;
19
+ }, import("zod/v4/core").$strip>, {
20
+ id: string;
21
+ entity: string;
22
+ }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined, undefined>;
23
+ export {};
@@ -0,0 +1,104 @@
1
+ import { createEntityExecutor, defineWriteHandler, SYSTEM_ROLE, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { ConflictError, NotFoundError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { FILE_REF_ENTITY_NAME } from "@cosmicdrift/kumiko-framework/files";
4
+ import { DOCUMENT_ENTITY } from "../constants.js";
5
+ import { ensureDocumentLink } from "../ensure-link.js";
6
+ import { importDocumentPayloadSchema } from "../schemas.js";
7
+ async function fileRefExists(registry, fileRefId, user, db) {
8
+ const entity = registry.getEntity(FILE_REF_ENTITY_NAME);
9
+ if (entity === undefined)
10
+ return false;
11
+ const row = await createEntityExecutor(FILE_REF_ENTITY_NAME, entity).executor.detail({ id: fileRefId }, user, db);
12
+ return row !== null;
13
+ }
14
+ // The stream, not the projected row: a row-level readAccess can hide a document from the system
15
+ // user, and a deleted document still has its stream.
16
+ async function streamTypeOf(events, id) {
17
+ const [first] = await events.load(id, { atVersion: 1 });
18
+ return first?.aggregateType;
19
+ }
20
+ // undefined: the document exists now, created here or by an earlier run of the same import.
21
+ async function createUnlessImported(executors, events, payload, user, db) {
22
+ const existingType = await streamTypeOf(events, payload.id);
23
+ if (existingType === undefined) {
24
+ const created = await executors.document.create({
25
+ id: payload.id,
26
+ title: payload.title,
27
+ category: payload.category,
28
+ file: payload.fileRefId,
29
+ ...(payload.issuedAt !== undefined && { issuedAt: payload.issuedAt }),
30
+ ...(payload.validUntil !== undefined && { validUntil: payload.validUntil }),
31
+ }, user, db);
32
+ if (created.isSuccess)
33
+ return undefined;
34
+ if (created.error.code !== "version_conflict")
35
+ return created;
36
+ }
37
+ // Stream ids are unique per tenant across aggregate types: an existing stream is the retry of
38
+ // this import only when it is a document, otherwise linking would point at nothing.
39
+ const streamType = existingType ?? (await streamTypeOf(events, payload.id));
40
+ if (streamType === DOCUMENT_ENTITY)
41
+ return undefined;
42
+ return writeFailure(new ConflictError({
43
+ message: `documents import: id ${payload.id} belongs to another record`,
44
+ i18nKey: "documents.errors.import-id-taken",
45
+ details: { id: payload.id },
46
+ }));
47
+ }
48
+ // For one-off migrations run as a job: the caller picks the document id, so a re-run converges
49
+ // on the same document instead of duplicating it. Never callable by a user.
50
+ export function createImportDocumentHandler(args) {
51
+ const { executors, categories, linkTargets } = args;
52
+ return defineWriteHandler({
53
+ name: "import",
54
+ schema: importDocumentPayloadSchema,
55
+ access: { roles: [SYSTEM_ROLE] },
56
+ agent: { expose: false },
57
+ description: "Creates a document with a caller-chosen id from an existing file, links it to the given records and optionally leaves it deleted; a repeat call with the same id changes nothing, and an id already used by another record fails. The document's createdAt is the time of the import; issuedAt carries the business date. Reserved for the system user, meant for data migrations.",
58
+ handler: async (event, ctx) => {
59
+ const payload = event.payload;
60
+ if (!categories.includes(payload.category)) {
61
+ return writeFailure(new ValidationError({
62
+ fields: [
63
+ {
64
+ path: "category",
65
+ code: "unknown_category",
66
+ i18nKey: "documents.errors.unknown-category",
67
+ params: { category: payload.category },
68
+ },
69
+ ],
70
+ }));
71
+ }
72
+ const badTarget = payload.links.find((link) => !linkTargets.includes(link.entityType));
73
+ if (badTarget !== undefined) {
74
+ return writeFailure(new ValidationError({
75
+ fields: [
76
+ {
77
+ path: "links",
78
+ code: "unknown_link_target",
79
+ i18nKey: "documents.errors.unknown-link-target",
80
+ params: { entityType: badTarget.entityType },
81
+ },
82
+ ],
83
+ }));
84
+ }
85
+ if (!(await fileRefExists(ctx.registry, payload.fileRefId, event.user, ctx.db))) {
86
+ return writeFailure(new NotFoundError(FILE_REF_ENTITY_NAME, payload.fileRefId));
87
+ }
88
+ const notImported = await createUnlessImported(executors, ctx.events, payload, event.user, ctx.db);
89
+ if (notImported !== undefined)
90
+ return notImported;
91
+ for (const link of payload.links) {
92
+ const linked = await ensureDocumentLink(executors, event.user, ctx.db, payload.id, link);
93
+ if (!linked.isSuccess)
94
+ return linked;
95
+ }
96
+ if (payload.deleted === true) {
97
+ const removed = await executors.document.delete({ id: payload.id }, event.user, ctx.db);
98
+ if (!removed.isSuccess && removed.error.code !== "not_found")
99
+ return removed;
100
+ }
101
+ return { isSuccess: true, data: { id: payload.id, entity: DOCUMENT_ENTITY } };
102
+ },
103
+ });
104
+ }
@@ -0,0 +1,18 @@
1
+ import { type AccessRule } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { DocumentExecutors } from "../executor.ts";
3
+ type LinkHandlerArgs = {
4
+ readonly access: AccessRule;
5
+ readonly executors: DocumentExecutors;
6
+ readonly linkTargets: readonly string[];
7
+ };
8
+ export declare function createLinkDocumentHandler(args: LinkHandlerArgs): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"link", import("zod").ZodObject<{
9
+ documentId: import("zod").ZodUUID;
10
+ entityType: import("zod").ZodString;
11
+ entityId: import("zod").ZodString;
12
+ }, import("zod/v4/core").$strip>, unknown, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined, undefined>;
13
+ export declare function createUnlinkDocumentHandler(args: LinkHandlerArgs): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"unlink", import("zod").ZodObject<{
14
+ documentId: import("zod").ZodUUID;
15
+ entityType: import("zod").ZodString;
16
+ entityId: import("zod").ZodString;
17
+ }, import("zod/v4/core").$strip>, unknown, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined, undefined>;
18
+ export {};
@@ -0,0 +1,61 @@
1
+ import { defineWriteHandler, parentVisibilityFor, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { documentLinkAggregateId } from "../aggregate-id.js";
4
+ import { DOCUMENT_ENTITY } from "../constants.js";
5
+ import { ensureDocumentLink } from "../ensure-link.js";
6
+ import { documentLinkPayloadSchema } from "../schemas.js";
7
+ import { canWriteLinkTarget } from "../target-write-check.js";
8
+ // Unreadable document, foreign target type and unwritable target answer alike (not found), so
9
+ // the call is no probe for what exists.
10
+ export function createLinkDocumentHandler(args) {
11
+ const { access, executors, linkTargets } = args;
12
+ return defineWriteHandler({
13
+ name: "link",
14
+ schema: documentLinkPayloadSchema,
15
+ access,
16
+ description: "Makes a document visible on another record by linking it to that record; the caller must be able to read the document and edit the record.",
17
+ handler: async (event, ctx) => {
18
+ const payload = event.payload;
19
+ const notFound = () => writeFailure(new NotFoundError(DOCUMENT_ENTITY, payload.documentId));
20
+ if (!linkTargets.includes(payload.entityType))
21
+ return notFound();
22
+ const document = await executors.document.detail({ id: payload.documentId }, event.user, ctx.db, {
23
+ parentVisibility: parentVisibilityFor(ctx, event.user),
24
+ });
25
+ if (document === null)
26
+ return notFound();
27
+ if (!(await canWriteLinkTarget(ctx.registry, payload.entityType, payload.entityId, event.user, ctx.db))) {
28
+ return notFound();
29
+ }
30
+ return ensureDocumentLink(executors, event.user, ctx.db, payload.documentId, payload);
31
+ },
32
+ });
33
+ }
34
+ export function createUnlinkDocumentHandler(args) {
35
+ const { access, executors, linkTargets } = args;
36
+ return defineWriteHandler({
37
+ name: "unlink",
38
+ schema: documentLinkPayloadSchema,
39
+ access,
40
+ description: "Removes a document from a record it was linked to, reporting success when no link existed; the document and its file stay.",
41
+ handler: async (event, ctx) => {
42
+ const payload = event.payload;
43
+ const notFound = () => writeFailure(new NotFoundError(DOCUMENT_ENTITY, payload.documentId));
44
+ if (!linkTargets.includes(payload.entityType))
45
+ return notFound();
46
+ const document = await executors.document.detail({ id: payload.documentId }, event.user, ctx.db, {
47
+ parentVisibility: parentVisibilityFor(ctx, event.user),
48
+ });
49
+ if (document === null)
50
+ return notFound();
51
+ if (!(await canWriteLinkTarget(ctx.registry, payload.entityType, payload.entityId, event.user, ctx.db))) {
52
+ return notFound();
53
+ }
54
+ const id = documentLinkAggregateId(event.user.tenantId, payload.documentId, payload.entityType, payload.entityId);
55
+ const existing = await executors.link.detail({ id }, event.user, ctx.db);
56
+ if (existing === null)
57
+ return { isSuccess: true, data: { id } };
58
+ return executors.link.delete({ id }, event.user, ctx.db);
59
+ },
60
+ });
61
+ }
@@ -0,0 +1,6 @@
1
+ type LocalizedString = {
2
+ readonly en: string;
3
+ readonly de: string;
4
+ };
5
+ export declare const DOCUMENTS_FEATURE_I18N: Readonly<Record<string, LocalizedString>>;
6
+ export {};
@@ -0,0 +1,33 @@
1
+ export const DOCUMENTS_FEATURE_I18N = {
2
+ "documents:feature.label": { en: "Documents", de: "Dokumente" },
3
+ "screen:list.title": { en: "Documents", de: "Dokumente" },
4
+ "documents:tab.title": { en: "Documents", de: "Dokumente" },
5
+ "documents:overview.expired": { en: "Expired documents", de: "Abgelaufene Dokumente" },
6
+ "documents:overview.expiring": { en: "Documents expiring soon", de: "Bald ablaufende Dokumente" },
7
+ "documents:entity:filed-document:field:title": { en: "Title", de: "Titel" },
8
+ "documents:entity:filed-document:field:category": { en: "Category", de: "Kategorie" },
9
+ "documents:entity:filed-document:field:issuedAt": { en: "Issued", de: "Ausgestellt" },
10
+ "documents:entity:filed-document:field:validUntil": { en: "Valid until", de: "Gültig bis" },
11
+ "documents:entity:filed-document:field:file": { en: "File", de: "Datei" },
12
+ "documents:list.facet.expiry": { en: "Expiry", de: "Ablauf" },
13
+ "documents:list.facet.expiring": {
14
+ en: "Expired or expiring soon",
15
+ de: "Abgelaufen oder bald ablaufend",
16
+ },
17
+ "documents:list.expiry.expired": { en: "Expired", de: "Abgelaufen" },
18
+ "documents:list.expiry.expiring": { en: "Expiring soon", de: "Läuft bald ab" },
19
+ "documents:list.expiry.valid": { en: "Valid", de: "Gültig" },
20
+ "documents:list.expiry.none": { en: "No expiry date", de: "Ohne Ablaufdatum" },
21
+ "documents.errors.unknown-category": {
22
+ en: "Unknown document category",
23
+ de: "Unbekannte Dokumentenkategorie",
24
+ },
25
+ "documents.errors.unknown-link-target": {
26
+ en: "This record type takes no documents",
27
+ de: "Diesem Datensatztyp lassen sich keine Dokumente zuordnen",
28
+ },
29
+ "documents.errors.import-id-taken": {
30
+ en: "This id already belongs to another record",
31
+ de: "Diese ID gehört bereits zu einem anderen Datensatz",
32
+ },
33
+ };
@@ -0,0 +1,7 @@
1
+ export { documentIdForFile, documentLinkAggregateId } from "./aggregate-id.ts";
2
+ export type { DocumentCaptureRule } from "./capture.ts";
3
+ export { DEFAULT_ADMIN_ROLES, DEFAULT_DOCUMENT_ACCESS, DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY, DOCUMENTS_FEATURE_NAME, DOCUMENTS_LIST_SCREEN_ID, DocumentsHandlers, DocumentsQueries, EXPIRY_STATES, type ExpiryState, } from "./constants.ts";
4
+ export { createDocumentsFeature, type DocumentsAccess, type DocumentsFeatureOptions, } from "./feature.ts";
5
+ export { forTypeQueryQn } from "./queries/for-record.query.ts";
6
+ export { DOCUMENT_COLUMNS } from "./screens.ts";
7
+ export { documentExpiryPanels, documentsTab } from "./tab.ts";
@@ -0,0 +1,6 @@
1
+ export { documentIdForFile, documentLinkAggregateId } from "./aggregate-id.js";
2
+ export { DEFAULT_ADMIN_ROLES, DEFAULT_DOCUMENT_ACCESS, DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY, DOCUMENTS_FEATURE_NAME, DOCUMENTS_LIST_SCREEN_ID, DocumentsHandlers, DocumentsQueries, EXPIRY_STATES, } from "./constants.js";
3
+ export { createDocumentsFeature, } from "./feature.js";
4
+ export { forTypeQueryQn } from "./queries/for-record.query.js";
5
+ export { DOCUMENT_COLUMNS } from "./screens.js";
6
+ export { documentExpiryPanels, documentsTab } from "./tab.js";
@@ -0,0 +1,2 @@
1
+ import { type PreSaveHookFn } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare const rejectForeignPendingFile: PreSaveHookFn;