@cosmicdrift/kumiko-samples 0.399.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/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/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 +30 -1
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.400.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.400.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
"./shared/row-bound-grant": "./src/shared/row-bound-grant.ts",
|
|
117
117
|
"./shared/mail-template-body": "./src/shared/mail-template-body.ts",
|
|
118
118
|
"./delivery": "./src/delivery/index.ts",
|
|
119
|
+
"./documents": "./src/documents/index.ts",
|
|
119
120
|
"./delivery/web": "./src/delivery/web/index.ts",
|
|
120
121
|
"./channel-in-app": "./src/channel-in-app/index.ts",
|
|
121
122
|
"./channel-email": "./src/channel-email/index.ts",
|
|
@@ -570,6 +571,10 @@
|
|
|
570
571
|
"types": "./dist/delivery/index.d.ts",
|
|
571
572
|
"default": "./dist/delivery/index.js"
|
|
572
573
|
},
|
|
574
|
+
"./documents": {
|
|
575
|
+
"types": "./dist/documents/index.d.ts",
|
|
576
|
+
"default": "./dist/documents/index.js"
|
|
577
|
+
},
|
|
573
578
|
"./delivery/web": {
|
|
574
579
|
"types": "./dist/delivery/web/index.d.ts",
|
|
575
580
|
"default": "./dist/delivery/web/index.js"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
|
|
3
|
+
// Frozen: changing a namespace re-keys every derived stream and breaks replay.
|
|
4
|
+
const DOCUMENT_FOR_FILE_NAMESPACE = "6d2f7e41-3c58-4b0a-9e17-5a4c8d1b2f93";
|
|
5
|
+
const DOCUMENT_LINK_NAMESPACE = "b83a1c6e-9f20-4d57-a1e4-7c0d52f68b19";
|
|
6
|
+
|
|
7
|
+
// @wrapper-known uuid-domain
|
|
8
|
+
export function documentIdForFile(tenantId: string, fileRefId: string): string {
|
|
9
|
+
return uuidv5(`${tenantId}|${fileRefId}`, DOCUMENT_FOR_FILE_NAMESPACE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// @wrapper-known uuid-domain
|
|
13
|
+
export function documentLinkAggregateId(
|
|
14
|
+
tenantId: string,
|
|
15
|
+
documentId: string,
|
|
16
|
+
entityType: string,
|
|
17
|
+
entityId: string,
|
|
18
|
+
): string {
|
|
19
|
+
return uuidv5(`${tenantId}|${documentId}|${entityType}|${entityId}`, DOCUMENT_LINK_NAMESPACE);
|
|
20
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createSystemUser,
|
|
4
|
+
type FeatureRegistrar,
|
|
5
|
+
type SessionUser,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import type { BootCheckContext } from "@cosmicdrift/kumiko-types/feature";
|
|
8
|
+
import { documentIdForFile } from "./aggregate-id.ts";
|
|
9
|
+
import type { DOCUMENTS_FEATURE_NAME } from "./constants.ts";
|
|
10
|
+
import { ensureDocumentLink, type LinkTarget } from "./ensure-link.ts";
|
|
11
|
+
import type { DocumentExecutors } from "./executor.ts";
|
|
12
|
+
|
|
13
|
+
// Files saved into `entity.field` become documents of `category`, linked to the saved record
|
|
14
|
+
// itself (when it is a link target) and to `links(row)`.
|
|
15
|
+
// `links()` derives targets from the saved row, whose reference values the save handler has
|
|
16
|
+
// already checked, so its output is trusted app config and needs no write check of its own.
|
|
17
|
+
export type DocumentCaptureRule = {
|
|
18
|
+
readonly entity: string;
|
|
19
|
+
readonly field: string;
|
|
20
|
+
readonly category: string;
|
|
21
|
+
readonly title?: (row: Readonly<Record<string, unknown>>) => string;
|
|
22
|
+
readonly links?: (row: Readonly<Record<string, unknown>>) => readonly LinkTarget[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MAX_TITLE_LENGTH = 200;
|
|
26
|
+
|
|
27
|
+
async function captureFile(
|
|
28
|
+
executors: DocumentExecutors,
|
|
29
|
+
rule: DocumentCaptureRule,
|
|
30
|
+
linkTargets: readonly string[],
|
|
31
|
+
saved: { readonly id: string; readonly data: Readonly<Record<string, unknown>> },
|
|
32
|
+
fileRefId: string,
|
|
33
|
+
user: SessionUser,
|
|
34
|
+
db: TenantDb,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const id = documentIdForFile(user.tenantId, fileRefId);
|
|
37
|
+
// The title never defaults to the file name, which can carry personal data.
|
|
38
|
+
const title = (rule.title?.(saved.data) ?? rule.category).slice(0, MAX_TITLE_LENGTH);
|
|
39
|
+
const created = await executors.document.create(
|
|
40
|
+
{ id, title, category: rule.category, file: fileRefId },
|
|
41
|
+
user,
|
|
42
|
+
db,
|
|
43
|
+
);
|
|
44
|
+
// version_conflict: the stream exists already, so the file was captured before (and a document
|
|
45
|
+
// deleted since stays deleted).
|
|
46
|
+
if (!created.isSuccess && created.error.code !== "version_conflict") {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`documents capture failed for ${rule.entity}.${rule.field}: ${created.error.code}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
// The gate hides rows from a caller without the registry; existence is the system user's question.
|
|
52
|
+
const live = await executors.document.detail({ id }, createSystemUser(user.tenantId), db);
|
|
53
|
+
// skip: the document was deleted after an earlier capture and stays deleted
|
|
54
|
+
if (live === null) return;
|
|
55
|
+
|
|
56
|
+
const targets: LinkTarget[] = [
|
|
57
|
+
{ entityType: rule.entity, entityId: saved.id },
|
|
58
|
+
// Trusted: derived from the saved row, see DocumentCaptureRule.links.
|
|
59
|
+
...(rule.links?.(saved.data) ?? []),
|
|
60
|
+
].filter((target) => linkTargets.includes(target.entityType));
|
|
61
|
+
for (const target of targets) {
|
|
62
|
+
const linked = await ensureDocumentLink(executors, user, db, id, target);
|
|
63
|
+
if (!linked.isSuccess) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`documents capture failed to link ${target.entityType}: ${linked.error.code}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerCapture(
|
|
72
|
+
r: FeatureRegistrar<typeof DOCUMENTS_FEATURE_NAME>,
|
|
73
|
+
executors: DocumentExecutors,
|
|
74
|
+
rules: readonly DocumentCaptureRule[],
|
|
75
|
+
linkTargets: readonly string[],
|
|
76
|
+
): void {
|
|
77
|
+
for (const rule of rules) {
|
|
78
|
+
r.hook({
|
|
79
|
+
type: "postSave",
|
|
80
|
+
target: { allOf: rule.entity },
|
|
81
|
+
handler: async (result, ctx) => {
|
|
82
|
+
const value =
|
|
83
|
+
result.changes[rule.field] ?? (result.isNew ? result.data[rule.field] : undefined);
|
|
84
|
+
// skip: the file field was not part of this save
|
|
85
|
+
if (typeof value !== "string") return;
|
|
86
|
+
await captureFile(
|
|
87
|
+
executors,
|
|
88
|
+
rule,
|
|
89
|
+
linkTargets,
|
|
90
|
+
{ id: String(result.id), data: result.data },
|
|
91
|
+
value,
|
|
92
|
+
ctx.user,
|
|
93
|
+
ctx.db,
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateCaptureRules(
|
|
101
|
+
{ features }: BootCheckContext,
|
|
102
|
+
rules: readonly DocumentCaptureRule[],
|
|
103
|
+
categories: readonly string[],
|
|
104
|
+
linkTargets: readonly string[],
|
|
105
|
+
): void {
|
|
106
|
+
for (const rule of rules) {
|
|
107
|
+
const label = `documents capture ${rule.entity}.${rule.field}`;
|
|
108
|
+
if (!categories.includes(rule.category)) {
|
|
109
|
+
throw new Error(`${label}: category "${rule.category}" is not in the configured categories.`);
|
|
110
|
+
}
|
|
111
|
+
const entity = features.map((f) => f.entities?.[rule.entity]).find((e) => e !== undefined);
|
|
112
|
+
if (entity === undefined)
|
|
113
|
+
throw new Error(`${label}: entity "${rule.entity}" is not registered.`);
|
|
114
|
+
const field = entity.fields[rule.field];
|
|
115
|
+
if (field?.type !== "file" && field?.type !== "image") {
|
|
116
|
+
throw new Error(`${label}: the field must be a single file or image field.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const unknownTarget = linkTargets.find(
|
|
120
|
+
(name) => !features.some((f) => f.entities?.[name] !== undefined),
|
|
121
|
+
);
|
|
122
|
+
if (unknownTarget !== undefined) {
|
|
123
|
+
throw new Error(`documents linkTargets: entity "${unknownTarget}" is not registered.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
export const DOCUMENTS_FEATURE_NAME = "documents";
|
|
4
|
+
|
|
5
|
+
export const DOCUMENT_ENTITY = "filed-document";
|
|
6
|
+
export const DOCUMENT_LINK_ENTITY = "filed-document-link";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_DOCUMENT_ACCESS: AccessRule = { roles: ["TenantAdmin", "TenantMember"] };
|
|
9
|
+
export const DEFAULT_ADMIN_ROLES: readonly string[] = ["TenantAdmin", "SystemAdmin"];
|
|
10
|
+
export const DEFAULT_EXPIRING_WITHIN_DAYS = 30;
|
|
11
|
+
|
|
12
|
+
export const DOCUMENTS_LIST_SCREEN_ID = "list";
|
|
13
|
+
|
|
14
|
+
export const DocumentsHandlers = {
|
|
15
|
+
link: "documents:write:link",
|
|
16
|
+
unlink: "documents:write:unlink",
|
|
17
|
+
import: "documents:write:import",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export const DocumentsQueries = {
|
|
21
|
+
list: "documents:query:filed-document:list",
|
|
22
|
+
detail: "documents:query:filed-document:detail",
|
|
23
|
+
forRecord: "documents:query:for-record",
|
|
24
|
+
expiryOverview: "documents:query:expiry-overview",
|
|
25
|
+
overview: "documents:query:overview",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export const EXPIRY_STATES = ["expired", "expiring", "valid", "none"] as const;
|
|
29
|
+
export type ExpiryState = (typeof EXPIRY_STATES)[number];
|
|
30
|
+
|
|
31
|
+
export const EXPIRING_FACET_VALUE = "expiring";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
+
import { documentLinkAggregateId } from "./aggregate-id.ts";
|
|
5
|
+
import type { DocumentExecutors } from "./executor.ts";
|
|
6
|
+
|
|
7
|
+
export type LinkTarget = { readonly entityType: string; readonly entityId: string };
|
|
8
|
+
|
|
9
|
+
// The link id is deterministic, so a removed link is restored rather than recreated and a
|
|
10
|
+
// concurrent first create converges on the row the winner wrote.
|
|
11
|
+
export async function ensureDocumentLink(
|
|
12
|
+
executors: DocumentExecutors,
|
|
13
|
+
user: SessionUser,
|
|
14
|
+
db: TenantDb,
|
|
15
|
+
documentId: string,
|
|
16
|
+
target: LinkTarget,
|
|
17
|
+
) {
|
|
18
|
+
const id = documentLinkAggregateId(user.tenantId, documentId, target.entityType, target.entityId);
|
|
19
|
+
const restored = await executors.link.restore({ id }, user, db);
|
|
20
|
+
if (restored.isSuccess) return restored;
|
|
21
|
+
const alreadyLive =
|
|
22
|
+
isPlainObject(restored.error.details) && restored.error.details["reason"] === "not_deleted";
|
|
23
|
+
if (alreadyLive) return { isSuccess: true as const, data: { id } };
|
|
24
|
+
if (restored.error.code !== "not_found") return restored;
|
|
25
|
+
|
|
26
|
+
const created = await executors.link.create(
|
|
27
|
+
{ id, documentId, entityType: target.entityType, entityId: target.entityId },
|
|
28
|
+
user,
|
|
29
|
+
db,
|
|
30
|
+
);
|
|
31
|
+
if (created.isSuccess || created.error.code === "version_conflict") {
|
|
32
|
+
return { isSuccess: true as const, data: { id } };
|
|
33
|
+
}
|
|
34
|
+
return created;
|
|
35
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { sql } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createDateField,
|
|
4
|
+
createEntity,
|
|
5
|
+
createFileField,
|
|
6
|
+
createSelectField,
|
|
7
|
+
createTextField,
|
|
8
|
+
type EntityDefinition,
|
|
9
|
+
SYSTEM_ROLE,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { DOCUMENT_LINK_ENTITY } from "./constants.ts";
|
|
12
|
+
|
|
13
|
+
export type DocumentEntityOptions = {
|
|
14
|
+
readonly categories: readonly [string, ...string[]];
|
|
15
|
+
readonly adminRoles: readonly string[];
|
|
16
|
+
readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
|
|
17
|
+
readonly retention?: EntityDefinition["retention"];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Readable only through a link to a host the viewer can read, and only when the file's owner is
|
|
21
|
+
// readable too (see EntityDefinition.readGate); readAccess narrows further.
|
|
22
|
+
export function createDocumentEntity(options: DocumentEntityOptions) {
|
|
23
|
+
return createEntity({
|
|
24
|
+
table: "read_filed_documents",
|
|
25
|
+
softDelete: true,
|
|
26
|
+
description:
|
|
27
|
+
"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.",
|
|
28
|
+
// The system user passes the gate; the capture and import checks run as it and would
|
|
29
|
+
// otherwise miss a document that readAccess hides.
|
|
30
|
+
...(options.readAccess !== undefined && {
|
|
31
|
+
access: { read: { ...options.readAccess, [SYSTEM_ROLE]: "all" } },
|
|
32
|
+
}),
|
|
33
|
+
...(options.retention !== undefined && { retention: options.retention }),
|
|
34
|
+
readGate: {
|
|
35
|
+
anyLink: { entity: DOCUMENT_LINK_ENTITY, field: "documentId" },
|
|
36
|
+
fileOwner: { field: "file" },
|
|
37
|
+
linkExemptRoles: options.adminRoles,
|
|
38
|
+
},
|
|
39
|
+
fields: {
|
|
40
|
+
title: createTextField({
|
|
41
|
+
required: true,
|
|
42
|
+
maxLength: 200,
|
|
43
|
+
sortable: true,
|
|
44
|
+
searchable: true,
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "document_title",
|
|
47
|
+
}),
|
|
48
|
+
category: createSelectField({
|
|
49
|
+
options: options.categories,
|
|
50
|
+
required: true,
|
|
51
|
+
filterable: true,
|
|
52
|
+
sortable: true,
|
|
53
|
+
}),
|
|
54
|
+
issuedAt: createDateField({ sortable: true }),
|
|
55
|
+
validUntil: createDateField({ sortable: true, filterable: true }),
|
|
56
|
+
// Filed business documents belong to the tenant, not to one person.
|
|
57
|
+
file: createFileField({ required: true, personal: false, reason: "is_business_data" }),
|
|
58
|
+
},
|
|
59
|
+
// One live document per file; a deleted one releases the file.
|
|
60
|
+
indexes: [
|
|
61
|
+
{
|
|
62
|
+
columns: ["tenantId", "file"],
|
|
63
|
+
unique: true,
|
|
64
|
+
name: "read_filed_documents_file_unique",
|
|
65
|
+
where: sql`"is_deleted" = false`,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createDocumentLinkEntity(linkTargets: readonly string[]) {
|
|
72
|
+
return createEntity({
|
|
73
|
+
table: "read_filed_document_links",
|
|
74
|
+
softDelete: true,
|
|
75
|
+
description:
|
|
76
|
+
"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.",
|
|
77
|
+
parentRef: {
|
|
78
|
+
entityTypeField: "entityType",
|
|
79
|
+
entityIdField: "entityId",
|
|
80
|
+
allowedTypes: linkTargets,
|
|
81
|
+
},
|
|
82
|
+
fields: {
|
|
83
|
+
documentId: createTextField({
|
|
84
|
+
required: true,
|
|
85
|
+
maxLength: 64,
|
|
86
|
+
personal: false,
|
|
87
|
+
reason: "technical_reference",
|
|
88
|
+
}),
|
|
89
|
+
entityType: createTextField({
|
|
90
|
+
required: true,
|
|
91
|
+
maxLength: 64,
|
|
92
|
+
personal: false,
|
|
93
|
+
reason: "technical_reference",
|
|
94
|
+
}),
|
|
95
|
+
entityId: createTextField({
|
|
96
|
+
required: true,
|
|
97
|
+
maxLength: 128,
|
|
98
|
+
personal: false,
|
|
99
|
+
reason: "technical_reference",
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createEntityExecutor, type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY } from "./constants.ts";
|
|
3
|
+
|
|
4
|
+
export function createDocumentExecutors(
|
|
5
|
+
documentEntity: EntityDefinition,
|
|
6
|
+
linkEntity: EntityDefinition,
|
|
7
|
+
) {
|
|
8
|
+
return {
|
|
9
|
+
document: createEntityExecutor(DOCUMENT_ENTITY, documentEntity).executor,
|
|
10
|
+
link: createEntityExecutor(DOCUMENT_LINK_ENTITY, linkEntity).executor,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export type DocumentExecutors = ReturnType<typeof createDocumentExecutors>;
|
|
@@ -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
|
+
}
|