@cosmicdrift/kumiko-samples 0.400.0 → 0.401.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 +1 -1
- package/packages/bundled-features/src/documents/entity.ts +7 -1
- package/packages/bundled-features/src/documents/feature.ts +34 -5
- package/packages/bundled-features/src/documents/handlers/import.write.ts +1 -1
- package/packages/bundled-features/src/documents/handlers/link.write.ts +2 -0
- package/packages/bundled-features/src/documents/i18n.ts +14 -0
- package/packages/bundled-features/src/documents/index.ts +1 -0
- package/packages/bundled-features/src/documents/tab.ts +2 -2
- package/packages/bundled-features/src/documents/target-write-check.ts +9 -3
- package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -0
- package/samples/recipes/relations/src/feature.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.401.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.401.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>",
|
|
@@ -10,16 +10,20 @@ import {
|
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
11
|
import { DOCUMENT_LINK_ENTITY } from "./constants.ts";
|
|
12
12
|
|
|
13
|
+
export type DocumentLinkReadMode = "anyLink" | "allLinks";
|
|
14
|
+
|
|
13
15
|
export type DocumentEntityOptions = {
|
|
14
16
|
readonly categories: readonly [string, ...string[]];
|
|
15
17
|
readonly adminRoles: readonly string[];
|
|
16
18
|
readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
|
|
19
|
+
readonly linkReadMode?: DocumentLinkReadMode;
|
|
17
20
|
readonly retention?: EntityDefinition["retention"];
|
|
18
21
|
};
|
|
19
22
|
|
|
20
23
|
// Readable only through a link to a host the viewer can read, and only when the file's owner is
|
|
21
24
|
// readable too (see EntityDefinition.readGate); readAccess narrows further.
|
|
22
25
|
export function createDocumentEntity(options: DocumentEntityOptions) {
|
|
26
|
+
const documentLink = { entity: DOCUMENT_LINK_ENTITY, field: "documentId" };
|
|
23
27
|
return createEntity({
|
|
24
28
|
table: "read_filed_documents",
|
|
25
29
|
softDelete: true,
|
|
@@ -32,7 +36,9 @@ export function createDocumentEntity(options: DocumentEntityOptions) {
|
|
|
32
36
|
}),
|
|
33
37
|
...(options.retention !== undefined && { retention: options.retention }),
|
|
34
38
|
readGate: {
|
|
35
|
-
|
|
39
|
+
...(options.linkReadMode === "allLinks"
|
|
40
|
+
? { allLinks: documentLink }
|
|
41
|
+
: { anyLink: documentLink }),
|
|
36
42
|
fileOwner: { field: "file" },
|
|
37
43
|
linkExemptRoles: options.adminRoles,
|
|
38
44
|
},
|
|
@@ -23,7 +23,11 @@ import {
|
|
|
23
23
|
DOCUMENT_LINK_ENTITY,
|
|
24
24
|
DOCUMENTS_FEATURE_NAME,
|
|
25
25
|
} from "./constants.ts";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createDocumentEntity,
|
|
28
|
+
createDocumentLinkEntity,
|
|
29
|
+
type DocumentLinkReadMode,
|
|
30
|
+
} from "./entity.ts";
|
|
27
31
|
import { createDocumentExecutors } from "./executor.ts";
|
|
28
32
|
import { createImportDocumentHandler } from "./handlers/import.write.ts";
|
|
29
33
|
import { createLinkDocumentHandler, createUnlinkDocumentHandler } from "./handlers/link.write.ts";
|
|
@@ -38,14 +42,23 @@ import { createDocumentWriteGuard } from "./write-guard.ts";
|
|
|
38
42
|
export type DocumentsFeatureOptions = {
|
|
39
43
|
/** Allowed document categories; they become the options of the category field. */
|
|
40
44
|
readonly categories: readonly [string, ...string[]];
|
|
41
|
-
/** Entities a document can be linked to.
|
|
45
|
+
/** Entities a document can be linked to. Linking needs write access to the target: its update handler, or its entity write roles when it has none. */
|
|
42
46
|
readonly linkTargets: readonly [string, ...string[]];
|
|
47
|
+
/** Display label per category (de, en); a category without one shows its raw value. */
|
|
48
|
+
readonly categoryLabels?: Readonly<Record<string, { readonly de: string; readonly en: string }>>;
|
|
43
49
|
/** Files saved into these fields become documents automatically. */
|
|
44
50
|
readonly capture?: readonly DocumentCaptureRule[];
|
|
45
51
|
/** Retention of the document rows, in the entity's own retention vocabulary. */
|
|
46
52
|
readonly retention?: EntityDefinition["retention"];
|
|
47
53
|
/** Row-level read rule on the document, AND-ed with the link and file-owner gate. */
|
|
48
54
|
readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
|
|
55
|
+
/**
|
|
56
|
+
* "anyLink" (default): one readable linked record shows the document. "allLinks": the viewer
|
|
57
|
+
* must read every record it is linked to, so a link to a shared record (a contact) does not
|
|
58
|
+
* open a document that also hangs off a record of another team. The file-owner check applies
|
|
59
|
+
* either way.
|
|
60
|
+
*/
|
|
61
|
+
readonly linkReadMode?: DocumentLinkReadMode;
|
|
49
62
|
/**
|
|
50
63
|
* Who may use the document handlers. One rule applies to read, write and delete; the split form
|
|
51
64
|
* sets them apart. Default TenantAdmin and TenantMember; a missing delete follows write.
|
|
@@ -84,11 +97,14 @@ function resolveAccess(access: AccessRule | DocumentsAccess | undefined): Resolv
|
|
|
84
97
|
return { read: access.read ?? DEFAULT_DOCUMENT_ACCESS, write, delete: access.delete ?? write };
|
|
85
98
|
}
|
|
86
99
|
|
|
87
|
-
function categoryOptionLabels(
|
|
100
|
+
function categoryOptionLabels(
|
|
101
|
+
categories: readonly string[],
|
|
102
|
+
labels: DocumentsFeatureOptions["categoryLabels"],
|
|
103
|
+
) {
|
|
88
104
|
return Object.fromEntries(
|
|
89
105
|
categories.map((category) => [
|
|
90
106
|
`documents:entity:filed-document:field:category:option:${category}`,
|
|
91
|
-
{ en: category, de: category },
|
|
107
|
+
labels?.[category] ?? { en: category, de: category },
|
|
92
108
|
]),
|
|
93
109
|
);
|
|
94
110
|
}
|
|
@@ -103,6 +119,7 @@ function registerDocuments(
|
|
|
103
119
|
categories: opts.categories,
|
|
104
120
|
adminRoles: opts.adminRoles ?? DEFAULT_ADMIN_ROLES,
|
|
105
121
|
...(opts.readAccess !== undefined && { readAccess: opts.readAccess }),
|
|
122
|
+
...(opts.linkReadMode !== undefined && { linkReadMode: opts.linkReadMode }),
|
|
106
123
|
...(opts.retention !== undefined && { retention: opts.retention }),
|
|
107
124
|
});
|
|
108
125
|
const linkEntity = createDocumentLinkEntity(linkTargets);
|
|
@@ -123,6 +140,7 @@ function registerDocuments(
|
|
|
123
140
|
access: access.write,
|
|
124
141
|
description:
|
|
125
142
|
"Files a new document from an uploaded file; it stays invisible to everyone but the admin roles until it is linked to a record.",
|
|
143
|
+
agent: { risk: "mid" },
|
|
126
144
|
}),
|
|
127
145
|
);
|
|
128
146
|
const writeGuard = createDocumentWriteGuard(executors, opts.adminRoles ?? DEFAULT_ADMIN_ROLES);
|
|
@@ -131,6 +149,7 @@ function registerDocuments(
|
|
|
131
149
|
access: access.write,
|
|
132
150
|
description:
|
|
133
151
|
"Edits a document's title, category and dates from the entity-edit envelope; it does not change what the document is linked to.",
|
|
152
|
+
agent: { risk: "mid" },
|
|
134
153
|
}),
|
|
135
154
|
);
|
|
136
155
|
r.writeHandler(
|
|
@@ -185,7 +204,10 @@ function registerDocuments(
|
|
|
185
204
|
|
|
186
205
|
r.screen(createDocumentListScreen(access.read, opts.categories));
|
|
187
206
|
r.translations({
|
|
188
|
-
keys: {
|
|
207
|
+
keys: {
|
|
208
|
+
...DOCUMENTS_FEATURE_I18N,
|
|
209
|
+
...categoryOptionLabels(opts.categories, opts.categoryLabels),
|
|
210
|
+
},
|
|
189
211
|
});
|
|
190
212
|
}
|
|
191
213
|
|
|
@@ -193,5 +215,12 @@ export function createDocumentsFeature(opts: DocumentsFeatureOptions) {
|
|
|
193
215
|
if (opts.expiringWithinDays !== undefined && opts.expiringWithinDays < 0) {
|
|
194
216
|
throw new Error("createDocumentsFeature({ expiringWithinDays }) must not be negative.");
|
|
195
217
|
}
|
|
218
|
+
for (const category of Object.keys(opts.categoryLabels ?? {})) {
|
|
219
|
+
if (!opts.categories.includes(category)) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`createDocumentsFeature({ categoryLabels }) names "${category}", which is not in categories.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
196
225
|
return defineFeature(DOCUMENTS_FEATURE_NAME, (r) => registerDocuments(r, opts));
|
|
197
226
|
}
|
|
@@ -97,7 +97,7 @@ export function createImportDocumentHandler(args: ImportArgs) {
|
|
|
97
97
|
name: "import",
|
|
98
98
|
schema: importDocumentPayloadSchema,
|
|
99
99
|
access: { roles: [SYSTEM_ROLE] },
|
|
100
|
-
agent: { expose: false },
|
|
100
|
+
agent: { expose: false, risk: "high" },
|
|
101
101
|
description:
|
|
102
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
103
|
handler: async (event, ctx) => {
|
|
@@ -26,6 +26,7 @@ export function createLinkDocumentHandler(args: LinkHandlerArgs) {
|
|
|
26
26
|
name: "link",
|
|
27
27
|
schema: documentLinkPayloadSchema,
|
|
28
28
|
access,
|
|
29
|
+
agent: { risk: "mid" },
|
|
29
30
|
description:
|
|
30
31
|
"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
32
|
handler: async (event, ctx): Promise<WriteResult<unknown>> => {
|
|
@@ -63,6 +64,7 @@ export function createUnlinkDocumentHandler(args: LinkHandlerArgs) {
|
|
|
63
64
|
name: "unlink",
|
|
64
65
|
schema: documentLinkPayloadSchema,
|
|
65
66
|
access,
|
|
67
|
+
agent: { risk: "mid" },
|
|
66
68
|
description:
|
|
67
69
|
"Removes a document from a record it was linked to, reporting success when no link existed; the document and its file stay.",
|
|
68
70
|
handler: async (event, ctx): Promise<WriteResult<unknown>> => {
|
|
@@ -20,6 +20,20 @@ export const DOCUMENTS_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
|
|
|
20
20
|
"documents:list.expiry.expiring": { en: "Expiring soon", de: "Läuft bald ab" },
|
|
21
21
|
"documents:list.expiry.valid": { en: "Valid", de: "Gültig" },
|
|
22
22
|
"documents:list.expiry.none": { en: "No expiry date", de: "Ohne Ablaufdatum" },
|
|
23
|
+
"documents:write:link:title": {
|
|
24
|
+
en: "Link document to record",
|
|
25
|
+
de: "Dokument mit Datensatz verknüpfen",
|
|
26
|
+
},
|
|
27
|
+
"documents:write:link:field:documentId": { en: "Document", de: "Dokument" },
|
|
28
|
+
"documents:write:link:field:entityType": { en: "Record type", de: "Datensatztyp" },
|
|
29
|
+
"documents:write:link:field:entityId": { en: "Record", de: "Datensatz" },
|
|
30
|
+
"documents:write:unlink:title": {
|
|
31
|
+
en: "Remove document from record",
|
|
32
|
+
de: "Dokument von Datensatz lösen",
|
|
33
|
+
},
|
|
34
|
+
"documents:write:unlink:field:documentId": { en: "Document", de: "Dokument" },
|
|
35
|
+
"documents:write:unlink:field:entityType": { en: "Record type", de: "Datensatztyp" },
|
|
36
|
+
"documents:write:unlink:field:entityId": { en: "Record", de: "Datensatz" },
|
|
23
37
|
"documents.errors.unknown-category": {
|
|
24
38
|
en: "Unknown document category",
|
|
25
39
|
de: "Unbekannte Dokumentenkategorie",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EditRelatedListSection } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import type { DashboardStatPanel } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import { DocumentsQueries } from "./constants.ts";
|
|
3
|
+
import { DOCUMENT_ENTITY, DOCUMENTS_FEATURE_NAME, DocumentsQueries } from "./constants.ts";
|
|
4
4
|
import { forTypeQueryQn } from "./queries/for-record.query.ts";
|
|
5
5
|
|
|
6
6
|
// "Documents" tab for the edit screen of a link target. The tab id is the same on every screen,
|
|
@@ -11,7 +11,7 @@ export function documentsTab(entityType: string): EditRelatedListSection {
|
|
|
11
11
|
kind: "relatedList",
|
|
12
12
|
title: "documents:tab.title",
|
|
13
13
|
query: forTypeQueryQn(entityType),
|
|
14
|
-
entity:
|
|
14
|
+
entity: `${DOCUMENTS_FEATURE_NAME}:${DOCUMENT_ENTITY}`,
|
|
15
15
|
columns: [
|
|
16
16
|
{ field: "title", sortable: true },
|
|
17
17
|
{ field: "category", sortable: true },
|
|
@@ -18,8 +18,10 @@ function updateHandlerAccessOf(registry: Registry, entityType: string) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// Linking a document changes what readers of the target see, so it needs what saving the target
|
|
21
|
-
// needs: read visibility,
|
|
22
|
-
//
|
|
21
|
+
// needs: read visibility, a write gate and its row-level write rule. The gate is the entity's
|
|
22
|
+
// update handler; an entity saved only through custom handlers has none, so its declared write
|
|
23
|
+
// roles stand in, and without either the target stays unlinkable. A target that is not visible
|
|
24
|
+
// answers like a missing one.
|
|
23
25
|
export async function canWriteLinkTarget(
|
|
24
26
|
registry: Registry,
|
|
25
27
|
entityType: string,
|
|
@@ -31,7 +33,11 @@ export async function canWriteLinkTarget(
|
|
|
31
33
|
const entity = registry.getEntity(entityType);
|
|
32
34
|
if (entity === undefined) return false;
|
|
33
35
|
const updateAccess = updateHandlerAccessOf(registry, entityType);
|
|
34
|
-
if (updateAccess
|
|
36
|
+
if (updateAccess !== undefined) {
|
|
37
|
+
if (!hasAccess(user, updateAccess)) return false;
|
|
38
|
+
} else if (!hasAccess(user, { roles: Object.keys(entity.access?.write ?? {}) })) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
35
41
|
const row = await createEntityExecutor(entityType, entity).executor.detail(
|
|
36
42
|
{ id: entityId },
|
|
37
43
|
user,
|
|
@@ -65,7 +65,7 @@ export function createBrandingSettingsScreen(opts: {
|
|
|
65
65
|
reason: "is_business_data",
|
|
66
66
|
}),
|
|
67
67
|
logoFile: createImageField({
|
|
68
|
-
accept: ["
|
|
68
|
+
accept: ["png", "jpg", "jpeg", "webp"],
|
|
69
69
|
personal: false,
|
|
70
70
|
reason: "is_business_data",
|
|
71
71
|
}),
|
|
@@ -21,6 +21,10 @@ export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> =
|
|
|
21
21
|
},
|
|
22
22
|
"user-data-rights:entity:export-job:field:userId": { en: "User" },
|
|
23
23
|
"user-data-rights:entity:export-job:field:status": { en: "Status" },
|
|
24
|
+
"user-data-rights:entity:export-job:field:status:option:pending": { en: "Pending" },
|
|
25
|
+
"user-data-rights:entity:export-job:field:status:option:running": { en: "Running" },
|
|
26
|
+
"user-data-rights:entity:export-job:field:status:option:done": { en: "Done" },
|
|
27
|
+
"user-data-rights:entity:export-job:field:status:option:failed": { en: "Failed" },
|
|
24
28
|
"user-data-rights:entity:export-job:field:requestedAt": { en: "Requested" },
|
|
25
29
|
"user-data-rights:entity:export-job:field:completedAt": { en: "Completed" },
|
|
26
30
|
"user-data-rights:entity:export-job:field:expiresAt": { en: "Expires" },
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createReferenceField,
|
|
5
|
+
createTextField,
|
|
6
|
+
defineFeature,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
8
|
|
|
4
9
|
export const teamEntity = createEntity({
|
|
5
10
|
table: "read_sample_teams",
|
|
@@ -12,7 +17,12 @@ export const memberEntity = createEntity({
|
|
|
12
17
|
table: "read_sample_members",
|
|
13
18
|
fields: {
|
|
14
19
|
name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
15
|
-
teamId:
|
|
20
|
+
teamId: createReferenceField({
|
|
21
|
+
entity: "team",
|
|
22
|
+
personal: false,
|
|
23
|
+
reason: "is_system_identifier",
|
|
24
|
+
required: true,
|
|
25
|
+
}),
|
|
16
26
|
role: createTextField({ personal: false, reason: "is_business_data" }),
|
|
17
27
|
},
|
|
18
28
|
});
|
|
@@ -21,7 +31,7 @@ export const taskEntity = createEntity({
|
|
|
21
31
|
table: "read_sample_member_tasks",
|
|
22
32
|
fields: {
|
|
23
33
|
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
24
|
-
memberId:
|
|
34
|
+
memberId: createReferenceField({ entity: "member", personal: "ref", required: true }),
|
|
25
35
|
},
|
|
26
36
|
});
|
|
27
37
|
|