@cosmicdrift/kumiko-samples 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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +4 -6
- package/packages/bundled-features/src/channel-in-app/changes.json +6 -0
- package/packages/bundled-features/src/channel-in-app/constants.ts +9 -9
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/handlers/inbox.query.ts +31 -7
- package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +6 -9
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +25 -11
- package/packages/bundled-features/src/channel-in-app/in-app-target.ts +37 -0
- package/packages/bundled-features/src/channel-in-app/index.ts +1 -0
- package/packages/bundled-features/src/documents/aggregate-id.ts +20 -0
- package/packages/bundled-features/src/documents/capture.ts +125 -0
- package/packages/bundled-features/src/documents/constants.ts +31 -0
- package/packages/bundled-features/src/documents/ensure-link.ts +35 -0
- package/packages/bundled-features/src/documents/entity.ts +103 -0
- package/packages/bundled-features/src/documents/executor.ts +13 -0
- package/packages/bundled-features/src/documents/expiry.ts +32 -0
- package/packages/bundled-features/src/documents/feature.ts +197 -0
- package/packages/bundled-features/src/documents/handlers/import.write.ts +157 -0
- package/packages/bundled-features/src/documents/handlers/link.write.ts +103 -0
- package/packages/bundled-features/src/documents/i18n.ts +35 -0
- package/packages/bundled-features/src/documents/index.ts +22 -0
- package/packages/bundled-features/src/documents/pending-file-guard.ts +36 -0
- package/packages/bundled-features/src/documents/queries/expiry-overview.query.ts +46 -0
- package/packages/bundled-features/src/documents/queries/for-record.query.ts +74 -0
- package/packages/bundled-features/src/documents/queries/list-page.ts +51 -0
- package/packages/bundled-features/src/documents/queries/overview.query.ts +45 -0
- package/packages/bundled-features/src/documents/schemas.ts +66 -0
- package/packages/bundled-features/src/documents/screens.ts +86 -0
- package/packages/bundled-features/src/documents/tab.ts +46 -0
- package/packages/bundled-features/src/documents/target-write-check.ts +41 -0
- package/packages/bundled-features/src/documents/write-guard.ts +59 -0
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/compute.ts +19 -2
- package/packages/bundled-features/src/metrics/feature.ts +14 -1
- package/packages/bundled-features/src/metrics/types.ts +3 -1
- package/packages/bundled-features/src/metrics/validate.ts +3 -0
- package/packages/bundled-features/src/shared/purge-row-file-refs.ts +5 -4
- package/samples/apps/styleguide/src/app/seed.ts +102 -1
- package/samples/apps/styleguide/src/app/server.ts +2 -2
- package/samples/apps/styleguide/src/features/examples/feature.ts +97 -0
- package/samples/apps/styleguide/src/features/examples/i18n.ts +47 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +31 -2
- package/samples/apps/use-all-bundled/kumiko/migrations/0058_documents.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +11 -2
- package/samples/apps/use-all-bundled/src/run-config.ts +7 -0
- package/samples/recipes/derived-fields-sql/README.md +27 -0
- package/samples/recipes/derived-fields-sql/package.json +11 -0
- package/samples/recipes/derived-fields-sql/src/feature.ts +98 -0
- package/samples/recipes/documents/package.json +12 -0
- package/samples/recipes/documents/src/feature.ts +66 -0
- package/samples/recipes/inspection-checklist/README.md +27 -0
- package/samples/recipes/inspection-checklist/package.json +12 -0
- package/samples/recipes/inspection-checklist/src/entities/inspection-plan.ts +24 -0
- package/samples/recipes/inspection-checklist/src/entities/inspection-protocol.ts +72 -0
- package/samples/recipes/inspection-checklist/src/feature.ts +33 -0
- package/samples/recipes/inspection-checklist/src/handlers/create-from-plan.write.ts +38 -0
- package/samples/recipes/inspection-checklist/src/screens/inspection-protocol-edit.screen.ts +27 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { ExpiryState } from "./constants.ts";
|
|
3
|
+
|
|
4
|
+
export type ExpiryWindow = {
|
|
5
|
+
readonly today: string;
|
|
6
|
+
/** Last day (inclusive) that still counts as "expiring". */
|
|
7
|
+
readonly horizon: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// One definition of the boundaries for the counts and for the per-row state, so the
|
|
11
|
+
// overview column and the expiry-overview buckets cannot drift apart.
|
|
12
|
+
export function expiryWindow(
|
|
13
|
+
ctx: Pick<HandlerContext, "tz">,
|
|
14
|
+
expiringWithinDays: number,
|
|
15
|
+
): ExpiryWindow {
|
|
16
|
+
const today = ctx.tz.today(ctx.tz.tenant);
|
|
17
|
+
return { today: today.toString(), horizon: today.add({ days: expiringWithinDays }).toString() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// In-process dispatch hands back the date as a PlainDate, the wire form is an ISO string.
|
|
21
|
+
// ISO dates compare correctly as strings; a missing date is "none", not "valid".
|
|
22
|
+
export function expiryStateOf(validUntil: unknown, window: ExpiryWindow): ExpiryState {
|
|
23
|
+
const day =
|
|
24
|
+
validUntil instanceof Temporal.PlainDate
|
|
25
|
+
? validUntil.toString()
|
|
26
|
+
: typeof validUntil === "string"
|
|
27
|
+
? validUntil.slice(0, 10)
|
|
28
|
+
: "";
|
|
29
|
+
if (day === "") return "none";
|
|
30
|
+
if (day < window.today) return "expired";
|
|
31
|
+
return day <= window.horizon ? "expiring" : "valid";
|
|
32
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
|
|
6
|
+
import {
|
|
7
|
+
type AccessRule,
|
|
8
|
+
defineEntityCreateHandler,
|
|
9
|
+
defineEntityDeleteHandler,
|
|
10
|
+
defineEntityDetailHandler,
|
|
11
|
+
defineEntityListHandler,
|
|
12
|
+
defineEntityUpdateHandler,
|
|
13
|
+
defineFeature,
|
|
14
|
+
type EntityDefinition,
|
|
15
|
+
type FeatureRegistrar,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { type DocumentCaptureRule, registerCapture, validateCaptureRules } from "./capture.ts";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_ADMIN_ROLES,
|
|
20
|
+
DEFAULT_DOCUMENT_ACCESS,
|
|
21
|
+
DEFAULT_EXPIRING_WITHIN_DAYS,
|
|
22
|
+
DOCUMENT_ENTITY,
|
|
23
|
+
DOCUMENT_LINK_ENTITY,
|
|
24
|
+
DOCUMENTS_FEATURE_NAME,
|
|
25
|
+
} from "./constants.ts";
|
|
26
|
+
import { createDocumentEntity, createDocumentLinkEntity } from "./entity.ts";
|
|
27
|
+
import { createDocumentExecutors } from "./executor.ts";
|
|
28
|
+
import { createImportDocumentHandler } from "./handlers/import.write.ts";
|
|
29
|
+
import { createLinkDocumentHandler, createUnlinkDocumentHandler } from "./handlers/link.write.ts";
|
|
30
|
+
import { DOCUMENTS_FEATURE_I18N } from "./i18n.ts";
|
|
31
|
+
import { rejectForeignPendingFile } from "./pending-file-guard.ts";
|
|
32
|
+
import { createExpiryOverviewQuery } from "./queries/expiry-overview.query.ts";
|
|
33
|
+
import { createForRecordQuery, createForTypeQuery } from "./queries/for-record.query.ts";
|
|
34
|
+
import { createOverviewQuery } from "./queries/overview.query.ts";
|
|
35
|
+
import { createDocumentListScreen } from "./screens.ts";
|
|
36
|
+
import { createDocumentWriteGuard } from "./write-guard.ts";
|
|
37
|
+
|
|
38
|
+
export type DocumentsFeatureOptions = {
|
|
39
|
+
/** Allowed document categories; they become the options of the category field. */
|
|
40
|
+
readonly categories: readonly [string, ...string[]];
|
|
41
|
+
/** Entities a document can be linked to. Each needs an update handler, which linking requires. */
|
|
42
|
+
readonly linkTargets: readonly [string, ...string[]];
|
|
43
|
+
/** Files saved into these fields become documents automatically. */
|
|
44
|
+
readonly capture?: readonly DocumentCaptureRule[];
|
|
45
|
+
/** Retention of the document rows, in the entity's own retention vocabulary. */
|
|
46
|
+
readonly retention?: EntityDefinition["retention"];
|
|
47
|
+
/** Row-level read rule on the document, AND-ed with the link and file-owner gate. */
|
|
48
|
+
readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
|
|
49
|
+
/**
|
|
50
|
+
* Who may use the document handlers. One rule applies to read, write and delete; the split form
|
|
51
|
+
* sets them apart. Default TenantAdmin and TenantMember; a missing delete follows write.
|
|
52
|
+
*/
|
|
53
|
+
readonly access?: AccessRule | DocumentsAccess;
|
|
54
|
+
/** Roles that see every document of their tenant whose file owner they can read, without a link. */
|
|
55
|
+
readonly adminRoles?: readonly string[];
|
|
56
|
+
/** Window of the "expiring" bucket in expiry-overview. Default 30 days. */
|
|
57
|
+
readonly expiringWithinDays?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type DocumentsAccess = {
|
|
61
|
+
/** Lists, detail, for-record, the overviews and the list screen. */
|
|
62
|
+
readonly read?: AccessRule;
|
|
63
|
+
/** Create, update, link and unlink. */
|
|
64
|
+
readonly write?: AccessRule;
|
|
65
|
+
readonly delete?: AccessRule;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type ResolvedDocumentsAccess = Required<DocumentsAccess>;
|
|
69
|
+
|
|
70
|
+
function isAccessRule(access: AccessRule | DocumentsAccess): access is AccessRule {
|
|
71
|
+
return "roles" in access || "openToAll" in access;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveAccess(access: AccessRule | DocumentsAccess | undefined): ResolvedDocumentsAccess {
|
|
75
|
+
if (access === undefined) {
|
|
76
|
+
return {
|
|
77
|
+
read: DEFAULT_DOCUMENT_ACCESS,
|
|
78
|
+
write: DEFAULT_DOCUMENT_ACCESS,
|
|
79
|
+
delete: DEFAULT_DOCUMENT_ACCESS,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (isAccessRule(access)) return { read: access, write: access, delete: access };
|
|
83
|
+
const write = access.write ?? DEFAULT_DOCUMENT_ACCESS;
|
|
84
|
+
return { read: access.read ?? DEFAULT_DOCUMENT_ACCESS, write, delete: access.delete ?? write };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function categoryOptionLabels(categories: readonly string[]) {
|
|
88
|
+
return Object.fromEntries(
|
|
89
|
+
categories.map((category) => [
|
|
90
|
+
`documents:entity:filed-document:field:category:option:${category}`,
|
|
91
|
+
{ en: category, de: category },
|
|
92
|
+
]),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function registerDocuments(
|
|
97
|
+
r: FeatureRegistrar<typeof DOCUMENTS_FEATURE_NAME>,
|
|
98
|
+
opts: DocumentsFeatureOptions,
|
|
99
|
+
): void {
|
|
100
|
+
const access = resolveAccess(opts.access);
|
|
101
|
+
const linkTargets = opts.linkTargets;
|
|
102
|
+
const documentEntity = createDocumentEntity({
|
|
103
|
+
categories: opts.categories,
|
|
104
|
+
adminRoles: opts.adminRoles ?? DEFAULT_ADMIN_ROLES,
|
|
105
|
+
...(opts.readAccess !== undefined && { readAccess: opts.readAccess }),
|
|
106
|
+
...(opts.retention !== undefined && { retention: opts.retention }),
|
|
107
|
+
});
|
|
108
|
+
const linkEntity = createDocumentLinkEntity(linkTargets);
|
|
109
|
+
const executors = createDocumentExecutors(documentEntity, linkEntity);
|
|
110
|
+
|
|
111
|
+
r.requires({ features: ["files"] });
|
|
112
|
+
r.adoptsBundledLists();
|
|
113
|
+
r.describe(
|
|
114
|
+
"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().",
|
|
115
|
+
);
|
|
116
|
+
r.uiHints({ displayLabel: "Documents", category: "data", recommended: false });
|
|
117
|
+
|
|
118
|
+
r.entity({ name: DOCUMENT_ENTITY, ...documentEntity });
|
|
119
|
+
r.entity({ name: DOCUMENT_LINK_ENTITY, ...linkEntity });
|
|
120
|
+
|
|
121
|
+
r.writeHandler(
|
|
122
|
+
defineEntityCreateHandler(DOCUMENT_ENTITY, documentEntity, {
|
|
123
|
+
access: access.write,
|
|
124
|
+
description:
|
|
125
|
+
"Files a new document from an uploaded file; it stays invisible to everyone but the admin roles until it is linked to a record.",
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
const writeGuard = createDocumentWriteGuard(executors, opts.adminRoles ?? DEFAULT_ADMIN_ROLES);
|
|
129
|
+
r.writeHandler(
|
|
130
|
+
defineEntityUpdateHandler(DOCUMENT_ENTITY, documentEntity, {
|
|
131
|
+
access: access.write,
|
|
132
|
+
description:
|
|
133
|
+
"Edits a document's title, category and dates from the entity-edit envelope; it does not change what the document is linked to.",
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
r.writeHandler(
|
|
137
|
+
defineEntityDeleteHandler(DOCUMENT_ENTITY, documentEntity, {
|
|
138
|
+
access: access.delete,
|
|
139
|
+
description:
|
|
140
|
+
"Deletes a document so it disappears from every record it was linked to; the file itself stays with its owner.",
|
|
141
|
+
agent: { risk: "high" },
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
r.hook({ type: "preSave", target: "filed-document:create", handler: rejectForeignPendingFile });
|
|
145
|
+
r.hook({ type: "preSave", target: "filed-document:update", handler: rejectForeignPendingFile });
|
|
146
|
+
r.hook({ type: "preSave", target: "filed-document:update", handler: writeGuard.beforeUpdate });
|
|
147
|
+
r.hook({ type: "preDelete", target: "filed-document:delete", handler: writeGuard.beforeDelete });
|
|
148
|
+
r.writeHandler(createLinkDocumentHandler({ access: access.write, executors, linkTargets }));
|
|
149
|
+
r.writeHandler(createUnlinkDocumentHandler({ access: access.write, executors, linkTargets }));
|
|
150
|
+
r.writeHandler(
|
|
151
|
+
createImportDocumentHandler({ executors, categories: opts.categories, linkTargets }),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
r.queryHandler(
|
|
155
|
+
defineEntityListHandler(DOCUMENT_ENTITY, documentEntity, {
|
|
156
|
+
access: access.read,
|
|
157
|
+
description:
|
|
158
|
+
"Lists the documents the caller can read, filterable by category and validity date; use for-record to list one record's documents.",
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
r.queryHandler(
|
|
162
|
+
defineEntityDetailHandler(DOCUMENT_ENTITY, documentEntity, {
|
|
163
|
+
access: access.read,
|
|
164
|
+
description: "Reads one document the caller can read, with its file reference and dates.",
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
r.queryHandler(
|
|
168
|
+
defineEntityListHandler(DOCUMENT_LINK_ENTITY, linkEntity, {
|
|
169
|
+
access: access.read,
|
|
170
|
+
description:
|
|
171
|
+
"Lists the document-to-record link rows whose record the caller can read; filter on entityId for one record's links.",
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
r.queryHandler(createForRecordQuery(access.read, linkTargets));
|
|
175
|
+
for (const entityType of linkTargets) {
|
|
176
|
+
r.queryHandler(createForTypeQuery(access.read, linkTargets, entityType));
|
|
177
|
+
}
|
|
178
|
+
const expiringWithinDays = opts.expiringWithinDays ?? DEFAULT_EXPIRING_WITHIN_DAYS;
|
|
179
|
+
r.queryHandler(createExpiryOverviewQuery(access.read, expiringWithinDays));
|
|
180
|
+
r.queryHandler(createOverviewQuery(access.read, expiringWithinDays));
|
|
181
|
+
|
|
182
|
+
const capture = opts.capture ?? [];
|
|
183
|
+
registerCapture(r, executors, capture, linkTargets);
|
|
184
|
+
r.bootCheck((ctx) => validateCaptureRules(ctx, capture, opts.categories, linkTargets));
|
|
185
|
+
|
|
186
|
+
r.screen(createDocumentListScreen(access.read, opts.categories));
|
|
187
|
+
r.translations({
|
|
188
|
+
keys: { ...DOCUMENTS_FEATURE_I18N, ...categoryOptionLabels(opts.categories) },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function createDocumentsFeature(opts: DocumentsFeatureOptions) {
|
|
193
|
+
if (opts.expiringWithinDays !== undefined && opts.expiringWithinDays < 0) {
|
|
194
|
+
throw new Error("createDocumentsFeature({ expiringWithinDays }) must not be negative.");
|
|
195
|
+
}
|
|
196
|
+
return defineFeature(DOCUMENTS_FEATURE_NAME, (r) => registerDocuments(r, opts));
|
|
197
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntityExecutor,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
type HandlerEvents,
|
|
6
|
+
type SessionUser,
|
|
7
|
+
SYSTEM_ROLE,
|
|
8
|
+
type WriteHandlerDef,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import {
|
|
11
|
+
ConflictError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
type WriteFailure,
|
|
15
|
+
writeFailure,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
17
|
+
import { FILE_REF_ENTITY_NAME } from "@cosmicdrift/kumiko-framework/files";
|
|
18
|
+
import { DOCUMENT_ENTITY } from "../constants.ts";
|
|
19
|
+
import { ensureDocumentLink } from "../ensure-link.ts";
|
|
20
|
+
import type { DocumentExecutors } from "../executor.ts";
|
|
21
|
+
import { type ImportDocumentPayload, importDocumentPayloadSchema } from "../schemas.ts";
|
|
22
|
+
|
|
23
|
+
type ImportArgs = {
|
|
24
|
+
readonly executors: DocumentExecutors;
|
|
25
|
+
readonly categories: readonly string[];
|
|
26
|
+
readonly linkTargets: readonly string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function fileRefExists(
|
|
30
|
+
registry: Parameters<WriteHandlerDef["handler"]>[1]["registry"],
|
|
31
|
+
fileRefId: string,
|
|
32
|
+
user: SessionUser,
|
|
33
|
+
db: TenantDb,
|
|
34
|
+
): Promise<boolean> {
|
|
35
|
+
const entity = registry.getEntity(FILE_REF_ENTITY_NAME);
|
|
36
|
+
if (entity === undefined) return false;
|
|
37
|
+
const row = await createEntityExecutor(FILE_REF_ENTITY_NAME, entity).executor.detail(
|
|
38
|
+
{ id: fileRefId },
|
|
39
|
+
user,
|
|
40
|
+
db,
|
|
41
|
+
);
|
|
42
|
+
return row !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type StreamLoader = Pick<HandlerEvents, "load">;
|
|
46
|
+
|
|
47
|
+
// The stream, not the projected row: a row-level readAccess can hide a document from the system
|
|
48
|
+
// user, and a deleted document still has its stream.
|
|
49
|
+
async function streamTypeOf(events: StreamLoader, id: string): Promise<string | undefined> {
|
|
50
|
+
const [first] = await events.load(id, { atVersion: 1 });
|
|
51
|
+
return first?.aggregateType;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// undefined: the document exists now, created here or by an earlier run of the same import.
|
|
55
|
+
async function createUnlessImported(
|
|
56
|
+
executors: DocumentExecutors,
|
|
57
|
+
events: StreamLoader,
|
|
58
|
+
payload: ImportDocumentPayload,
|
|
59
|
+
user: SessionUser,
|
|
60
|
+
db: TenantDb,
|
|
61
|
+
): Promise<WriteFailure | undefined> {
|
|
62
|
+
const existingType = await streamTypeOf(events, payload.id);
|
|
63
|
+
if (existingType === undefined) {
|
|
64
|
+
const created = await executors.document.create(
|
|
65
|
+
{
|
|
66
|
+
id: payload.id,
|
|
67
|
+
title: payload.title,
|
|
68
|
+
category: payload.category,
|
|
69
|
+
file: payload.fileRefId,
|
|
70
|
+
...(payload.issuedAt !== undefined && { issuedAt: payload.issuedAt }),
|
|
71
|
+
...(payload.validUntil !== undefined && { validUntil: payload.validUntil }),
|
|
72
|
+
},
|
|
73
|
+
user,
|
|
74
|
+
db,
|
|
75
|
+
);
|
|
76
|
+
if (created.isSuccess) return undefined;
|
|
77
|
+
if (created.error.code !== "version_conflict") return created;
|
|
78
|
+
}
|
|
79
|
+
// Stream ids are unique per tenant across aggregate types: an existing stream is the retry of
|
|
80
|
+
// this import only when it is a document, otherwise linking would point at nothing.
|
|
81
|
+
const streamType = existingType ?? (await streamTypeOf(events, payload.id));
|
|
82
|
+
if (streamType === DOCUMENT_ENTITY) return undefined;
|
|
83
|
+
return writeFailure(
|
|
84
|
+
new ConflictError({
|
|
85
|
+
message: `documents import: id ${payload.id} belongs to another record`,
|
|
86
|
+
i18nKey: "documents.errors.import-id-taken",
|
|
87
|
+
details: { id: payload.id },
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// For one-off migrations run as a job: the caller picks the document id, so a re-run converges
|
|
93
|
+
// on the same document instead of duplicating it. Never callable by a user.
|
|
94
|
+
export function createImportDocumentHandler(args: ImportArgs) {
|
|
95
|
+
const { executors, categories, linkTargets } = args;
|
|
96
|
+
return defineWriteHandler({
|
|
97
|
+
name: "import",
|
|
98
|
+
schema: importDocumentPayloadSchema,
|
|
99
|
+
access: { roles: [SYSTEM_ROLE] },
|
|
100
|
+
agent: { expose: false },
|
|
101
|
+
description:
|
|
102
|
+
"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.",
|
|
103
|
+
handler: async (event, ctx) => {
|
|
104
|
+
const payload = event.payload;
|
|
105
|
+
if (!categories.includes(payload.category)) {
|
|
106
|
+
return writeFailure(
|
|
107
|
+
new ValidationError({
|
|
108
|
+
fields: [
|
|
109
|
+
{
|
|
110
|
+
path: "category",
|
|
111
|
+
code: "unknown_category",
|
|
112
|
+
i18nKey: "documents.errors.unknown-category",
|
|
113
|
+
params: { category: payload.category },
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const badTarget = payload.links.find((link) => !linkTargets.includes(link.entityType));
|
|
120
|
+
if (badTarget !== undefined) {
|
|
121
|
+
return writeFailure(
|
|
122
|
+
new ValidationError({
|
|
123
|
+
fields: [
|
|
124
|
+
{
|
|
125
|
+
path: "links",
|
|
126
|
+
code: "unknown_link_target",
|
|
127
|
+
i18nKey: "documents.errors.unknown-link-target",
|
|
128
|
+
params: { entityType: badTarget.entityType },
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (!(await fileRefExists(ctx.registry, payload.fileRefId, event.user, ctx.db))) {
|
|
135
|
+
return writeFailure(new NotFoundError(FILE_REF_ENTITY_NAME, payload.fileRefId));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const notImported = await createUnlessImported(
|
|
139
|
+
executors,
|
|
140
|
+
ctx.events,
|
|
141
|
+
payload,
|
|
142
|
+
event.user,
|
|
143
|
+
ctx.db,
|
|
144
|
+
);
|
|
145
|
+
if (notImported !== undefined) return notImported;
|
|
146
|
+
for (const link of payload.links) {
|
|
147
|
+
const linked = await ensureDocumentLink(executors, event.user, ctx.db, payload.id, link);
|
|
148
|
+
if (!linked.isSuccess) return linked;
|
|
149
|
+
}
|
|
150
|
+
if (payload.deleted === true) {
|
|
151
|
+
const removed = await executors.document.delete({ id: payload.id }, event.user, ctx.db);
|
|
152
|
+
if (!removed.isSuccess && removed.error.code !== "not_found") return removed;
|
|
153
|
+
}
|
|
154
|
+
return { isSuccess: true as const, data: { id: payload.id, entity: DOCUMENT_ENTITY } };
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AccessRule,
|
|
3
|
+
defineWriteHandler,
|
|
4
|
+
parentVisibilityFor,
|
|
5
|
+
type WriteResult,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { documentLinkAggregateId } from "../aggregate-id.ts";
|
|
9
|
+
import { DOCUMENT_ENTITY } from "../constants.ts";
|
|
10
|
+
import { ensureDocumentLink } from "../ensure-link.ts";
|
|
11
|
+
import type { DocumentExecutors } from "../executor.ts";
|
|
12
|
+
import { documentLinkPayloadSchema } from "../schemas.ts";
|
|
13
|
+
import { canWriteLinkTarget } from "../target-write-check.ts";
|
|
14
|
+
|
|
15
|
+
type LinkHandlerArgs = {
|
|
16
|
+
readonly access: AccessRule;
|
|
17
|
+
readonly executors: DocumentExecutors;
|
|
18
|
+
readonly linkTargets: readonly string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Unreadable document, foreign target type and unwritable target answer alike (not found), so
|
|
22
|
+
// the call is no probe for what exists.
|
|
23
|
+
export function createLinkDocumentHandler(args: LinkHandlerArgs) {
|
|
24
|
+
const { access, executors, linkTargets } = args;
|
|
25
|
+
return defineWriteHandler({
|
|
26
|
+
name: "link",
|
|
27
|
+
schema: documentLinkPayloadSchema,
|
|
28
|
+
access,
|
|
29
|
+
description:
|
|
30
|
+
"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.",
|
|
31
|
+
handler: async (event, ctx): Promise<WriteResult<unknown>> => {
|
|
32
|
+
const payload = event.payload;
|
|
33
|
+
const notFound = () => writeFailure(new NotFoundError(DOCUMENT_ENTITY, payload.documentId));
|
|
34
|
+
if (!linkTargets.includes(payload.entityType)) return notFound();
|
|
35
|
+
const document = await executors.document.detail(
|
|
36
|
+
{ id: payload.documentId },
|
|
37
|
+
event.user,
|
|
38
|
+
ctx.db,
|
|
39
|
+
{
|
|
40
|
+
parentVisibility: parentVisibilityFor(ctx, event.user),
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
if (document === null) return notFound();
|
|
44
|
+
if (
|
|
45
|
+
!(await canWriteLinkTarget(
|
|
46
|
+
ctx.registry,
|
|
47
|
+
payload.entityType,
|
|
48
|
+
payload.entityId,
|
|
49
|
+
event.user,
|
|
50
|
+
ctx.db,
|
|
51
|
+
))
|
|
52
|
+
) {
|
|
53
|
+
return notFound();
|
|
54
|
+
}
|
|
55
|
+
return ensureDocumentLink(executors, event.user, ctx.db, payload.documentId, payload);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createUnlinkDocumentHandler(args: LinkHandlerArgs) {
|
|
61
|
+
const { access, executors, linkTargets } = args;
|
|
62
|
+
return defineWriteHandler({
|
|
63
|
+
name: "unlink",
|
|
64
|
+
schema: documentLinkPayloadSchema,
|
|
65
|
+
access,
|
|
66
|
+
description:
|
|
67
|
+
"Removes a document from a record it was linked to, reporting success when no link existed; the document and its file stay.",
|
|
68
|
+
handler: async (event, ctx): Promise<WriteResult<unknown>> => {
|
|
69
|
+
const payload = event.payload;
|
|
70
|
+
const notFound = () => writeFailure(new NotFoundError(DOCUMENT_ENTITY, payload.documentId));
|
|
71
|
+
if (!linkTargets.includes(payload.entityType)) return notFound();
|
|
72
|
+
const document = await executors.document.detail(
|
|
73
|
+
{ id: payload.documentId },
|
|
74
|
+
event.user,
|
|
75
|
+
ctx.db,
|
|
76
|
+
{
|
|
77
|
+
parentVisibility: parentVisibilityFor(ctx, event.user),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
if (document === null) return notFound();
|
|
81
|
+
if (
|
|
82
|
+
!(await canWriteLinkTarget(
|
|
83
|
+
ctx.registry,
|
|
84
|
+
payload.entityType,
|
|
85
|
+
payload.entityId,
|
|
86
|
+
event.user,
|
|
87
|
+
ctx.db,
|
|
88
|
+
))
|
|
89
|
+
) {
|
|
90
|
+
return notFound();
|
|
91
|
+
}
|
|
92
|
+
const id = documentLinkAggregateId(
|
|
93
|
+
event.user.tenantId,
|
|
94
|
+
payload.documentId,
|
|
95
|
+
payload.entityType,
|
|
96
|
+
payload.entityId,
|
|
97
|
+
);
|
|
98
|
+
const existing = await executors.link.detail({ id }, event.user, ctx.db);
|
|
99
|
+
if (existing === null) return { isSuccess: true as const, data: { id } };
|
|
100
|
+
return executors.link.delete({ id }, event.user, ctx.db);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
type LocalizedString = { readonly en: string; readonly de: string };
|
|
2
|
+
|
|
3
|
+
export const DOCUMENTS_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"documents:feature.label": { en: "Documents", de: "Dokumente" },
|
|
5
|
+
"screen:list.title": { en: "Documents", de: "Dokumente" },
|
|
6
|
+
"documents:tab.title": { en: "Documents", de: "Dokumente" },
|
|
7
|
+
"documents:overview.expired": { en: "Expired documents", de: "Abgelaufene Dokumente" },
|
|
8
|
+
"documents:overview.expiring": { en: "Documents expiring soon", de: "Bald ablaufende Dokumente" },
|
|
9
|
+
"documents:entity:filed-document:field:title": { en: "Title", de: "Titel" },
|
|
10
|
+
"documents:entity:filed-document:field:category": { en: "Category", de: "Kategorie" },
|
|
11
|
+
"documents:entity:filed-document:field:issuedAt": { en: "Issued", de: "Ausgestellt" },
|
|
12
|
+
"documents:entity:filed-document:field:validUntil": { en: "Valid until", de: "Gültig bis" },
|
|
13
|
+
"documents:entity:filed-document:field:file": { en: "File", de: "Datei" },
|
|
14
|
+
"documents:list.facet.expiry": { en: "Expiry", de: "Ablauf" },
|
|
15
|
+
"documents:list.facet.expiring": {
|
|
16
|
+
en: "Expired or expiring soon",
|
|
17
|
+
de: "Abgelaufen oder bald ablaufend",
|
|
18
|
+
},
|
|
19
|
+
"documents:list.expiry.expired": { en: "Expired", de: "Abgelaufen" },
|
|
20
|
+
"documents:list.expiry.expiring": { en: "Expiring soon", de: "Läuft bald ab" },
|
|
21
|
+
"documents:list.expiry.valid": { en: "Valid", de: "Gültig" },
|
|
22
|
+
"documents:list.expiry.none": { en: "No expiry date", de: "Ohne Ablaufdatum" },
|
|
23
|
+
"documents.errors.unknown-category": {
|
|
24
|
+
en: "Unknown document category",
|
|
25
|
+
de: "Unbekannte Dokumentenkategorie",
|
|
26
|
+
},
|
|
27
|
+
"documents.errors.unknown-link-target": {
|
|
28
|
+
en: "This record type takes no documents",
|
|
29
|
+
de: "Diesem Datensatztyp lassen sich keine Dokumente zuordnen",
|
|
30
|
+
},
|
|
31
|
+
"documents.errors.import-id-taken": {
|
|
32
|
+
en: "This id already belongs to another record",
|
|
33
|
+
de: "Diese ID gehört bereits zu einem anderen Datensatz",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { documentIdForFile, documentLinkAggregateId } from "./aggregate-id.ts";
|
|
2
|
+
export type { DocumentCaptureRule } from "./capture.ts";
|
|
3
|
+
export {
|
|
4
|
+
DEFAULT_ADMIN_ROLES,
|
|
5
|
+
DEFAULT_DOCUMENT_ACCESS,
|
|
6
|
+
DOCUMENT_ENTITY,
|
|
7
|
+
DOCUMENT_LINK_ENTITY,
|
|
8
|
+
DOCUMENTS_FEATURE_NAME,
|
|
9
|
+
DOCUMENTS_LIST_SCREEN_ID,
|
|
10
|
+
DocumentsHandlers,
|
|
11
|
+
DocumentsQueries,
|
|
12
|
+
EXPIRY_STATES,
|
|
13
|
+
type ExpiryState,
|
|
14
|
+
} from "./constants.ts";
|
|
15
|
+
export {
|
|
16
|
+
createDocumentsFeature,
|
|
17
|
+
type DocumentsAccess,
|
|
18
|
+
type DocumentsFeatureOptions,
|
|
19
|
+
} from "./feature.ts";
|
|
20
|
+
export { forTypeQueryQn } from "./queries/for-record.query.ts";
|
|
21
|
+
export { DOCUMENT_COLUMNS } from "./screens.ts";
|
|
22
|
+
export { documentExpiryPanels, documentsTab } from "./tab.ts";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEntityExecutor,
|
|
3
|
+
createSystemUser,
|
|
4
|
+
type PreSaveHookFn,
|
|
5
|
+
SYSTEM_ROLE,
|
|
6
|
+
SYSTEM_USER_ID,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "@cosmicdrift/kumiko-framework/files";
|
|
10
|
+
import { DOCUMENT_ENTITY } from "./constants.ts";
|
|
11
|
+
|
|
12
|
+
const fileRefs = createEntityExecutor(FILE_REF_ENTITY_NAME, fileRefEntity).executor;
|
|
13
|
+
|
|
14
|
+
// One live document per file (unique on tenant + file): a member who names a colleague's pending
|
|
15
|
+
// upload would block the colleague's own later save of that file, because capture could no longer
|
|
16
|
+
// create its document. Files a job made (system uploader) and files already bound to a record stay
|
|
17
|
+
// allowed; for the latter the readGate's file-owner check covers it.
|
|
18
|
+
export const rejectForeignPendingFile: PreSaveHookFn = async (changes, ctx) => {
|
|
19
|
+
const file = changes["file"];
|
|
20
|
+
if (typeof file !== "string" || file === ctx.previous["file"]) return changes;
|
|
21
|
+
if (ctx.user.roles.includes(SYSTEM_ROLE)) return changes;
|
|
22
|
+
const ref = await fileRefs.detail({ id: file }, createSystemUser(ctx.user.tenantId), ctx.db);
|
|
23
|
+
// skip: a missing file is rejected by the framework's own file-reference check
|
|
24
|
+
if (ref === null) return changes;
|
|
25
|
+
const uploader = ref["insertedById"];
|
|
26
|
+
const unbound = ref["entityId"] === null || ref["entityId"] === undefined;
|
|
27
|
+
if (
|
|
28
|
+
unbound &&
|
|
29
|
+
typeof uploader === "string" &&
|
|
30
|
+
uploader !== ctx.user.id &&
|
|
31
|
+
uploader !== SYSTEM_USER_ID
|
|
32
|
+
) {
|
|
33
|
+
throw new NotFoundError(DOCUMENT_ENTITY, file);
|
|
34
|
+
}
|
|
35
|
+
return changes;
|
|
36
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type AccessRule, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { expiryWindow } from "../expiry.ts";
|
|
3
|
+
import { expiryOverviewPayloadSchema } from "../schemas.ts";
|
|
4
|
+
import { linkedDocumentIds, listDocuments } from "./list-page.ts";
|
|
5
|
+
|
|
6
|
+
type Filter = { readonly field: string; readonly op: string; readonly value: unknown };
|
|
7
|
+
|
|
8
|
+
// Counts come from the document list itself (totalCount), so they follow the same read gate
|
|
9
|
+
// as the rows a user can open.
|
|
10
|
+
export function createExpiryOverviewQuery(access: AccessRule, expiringWithinDays: number) {
|
|
11
|
+
return defineQueryHandler({
|
|
12
|
+
name: "expiry-overview",
|
|
13
|
+
schema: expiryOverviewPayloadSchema,
|
|
14
|
+
access,
|
|
15
|
+
liveEntities: ["filed-document", "filed-document-link"],
|
|
16
|
+
description:
|
|
17
|
+
"Counts the documents the caller can read by validity: expired, expiring within the configured window, valid beyond it and without an expiry date; optionally for one record only.",
|
|
18
|
+
handler: async (query, ctx) => {
|
|
19
|
+
const payload = query.payload;
|
|
20
|
+
const { today, horizon } = expiryWindow(ctx, expiringWithinDays);
|
|
21
|
+
const scope: Filter[] = [];
|
|
22
|
+
if (payload.entityType !== undefined && payload.entityId !== undefined) {
|
|
23
|
+
const ids = await linkedDocumentIds(ctx, {
|
|
24
|
+
entityType: payload.entityType,
|
|
25
|
+
entityId: payload.entityId,
|
|
26
|
+
});
|
|
27
|
+
if (ids.length === 0) return { expired: 0, expiring: 0, valid: 0, none: 0 };
|
|
28
|
+
scope.push({ field: "id", op: "in", value: ids });
|
|
29
|
+
}
|
|
30
|
+
const count = async (extra: Filter[]) =>
|
|
31
|
+
(await listDocuments(ctx, { limit: 1, totalCount: true, filters: [...scope, ...extra] }))
|
|
32
|
+
.total ?? 0;
|
|
33
|
+
|
|
34
|
+
const [all, expired, expiring, valid] = await Promise.all([
|
|
35
|
+
count([]),
|
|
36
|
+
count([{ field: "validUntil", op: "lt", value: today }]),
|
|
37
|
+
count([
|
|
38
|
+
{ field: "validUntil", op: "gte", value: today },
|
|
39
|
+
{ field: "validUntil", op: "lte", value: horizon },
|
|
40
|
+
]),
|
|
41
|
+
count([{ field: "validUntil", op: "gt", value: horizon }]),
|
|
42
|
+
]);
|
|
43
|
+
return { expired, expiring, valid, none: all - expired - expiring - valid };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|