@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.400.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.400.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
- anyLink: { entity: DOCUMENT_LINK_ENTITY, field: "documentId" },
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 { createDocumentEntity, createDocumentLinkEntity } from "./entity.ts";
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. Each needs an update handler, which linking requires. */
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(categories: readonly string[]) {
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: { ...DOCUMENTS_FEATURE_I18N, ...categoryOptionLabels(opts.categories) },
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",
@@ -12,6 +12,7 @@ export {
12
12
  EXPIRY_STATES,
13
13
  type ExpiryState,
14
14
  } from "./constants.ts";
15
+ export type { DocumentLinkReadMode } from "./entity.ts";
15
16
  export {
16
17
  createDocumentsFeature,
17
18
  type DocumentsAccess,
@@ -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: "documents:document",
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, the entity's update handler and its row-level write rule. A target that
22
- // is not visible answers like a missing one.
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 === undefined || !hasAccess(user, updateAccess)) return false;
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: ["image/png", "image/jpeg", "image/webp"],
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 { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
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: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
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: createTextField({ personal: "ref", required: true }),
34
+ memberId: createReferenceField({ entity: "member", personal: "ref", required: true }),
25
35
  },
26
36
  });
27
37