@cosmicdrift/kumiko-bundled-features 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.
@@ -1,8 +1,10 @@
1
1
  import { type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ export type DocumentLinkReadMode = "anyLink" | "allLinks";
2
3
  export type DocumentEntityOptions = {
3
4
  readonly categories: readonly [string, ...string[]];
4
5
  readonly adminRoles: readonly string[];
5
6
  readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
7
+ readonly linkReadMode?: DocumentLinkReadMode;
6
8
  readonly retention?: EntityDefinition["retention"];
7
9
  };
8
10
  export declare function createDocumentEntity(options: DocumentEntityOptions): EntityDefinition<{
@@ -4,6 +4,7 @@ import { DOCUMENT_LINK_ENTITY } from "./constants.js";
4
4
  // Readable only through a link to a host the viewer can read, and only when the file's owner is
5
5
  // readable too (see EntityDefinition.readGate); readAccess narrows further.
6
6
  export function createDocumentEntity(options) {
7
+ const documentLink = { entity: DOCUMENT_LINK_ENTITY, field: "documentId" };
7
8
  return createEntity({
8
9
  table: "read_filed_documents",
9
10
  softDelete: true,
@@ -15,7 +16,9 @@ export function createDocumentEntity(options) {
15
16
  }),
16
17
  ...(options.retention !== undefined && { retention: options.retention }),
17
18
  readGate: {
18
- anyLink: { entity: DOCUMENT_LINK_ENTITY, field: "documentId" },
19
+ ...(options.linkReadMode === "allLinks"
20
+ ? { allLinks: documentLink }
21
+ : { anyLink: documentLink }),
19
22
  fileOwner: { field: "file" },
20
23
  linkExemptRoles: options.adminRoles,
21
24
  },
@@ -1,16 +1,29 @@
1
1
  import { type AccessRule, type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { type DocumentCaptureRule } from "./capture.ts";
3
+ import { type DocumentLinkReadMode } from "./entity.ts";
3
4
  export type DocumentsFeatureOptions = {
4
5
  /** Allowed document categories; they become the options of the category field. */
5
6
  readonly categories: readonly [string, ...string[]];
6
- /** Entities a document can be linked to. Each needs an update handler, which linking requires. */
7
+ /** 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. */
7
8
  readonly linkTargets: readonly [string, ...string[]];
9
+ /** Display label per category (de, en); a category without one shows its raw value. */
10
+ readonly categoryLabels?: Readonly<Record<string, {
11
+ readonly de: string;
12
+ readonly en: string;
13
+ }>>;
8
14
  /** Files saved into these fields become documents automatically. */
9
15
  readonly capture?: readonly DocumentCaptureRule[];
10
16
  /** Retention of the document rows, in the entity's own retention vocabulary. */
11
17
  readonly retention?: EntityDefinition["retention"];
12
18
  /** Row-level read rule on the document, AND-ed with the link and file-owner gate. */
13
19
  readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
20
+ /**
21
+ * "anyLink" (default): one readable linked record shows the document. "allLinks": the viewer
22
+ * must read every record it is linked to, so a link to a shared record (a contact) does not
23
+ * open a document that also hangs off a record of another team. The file-owner check applies
24
+ * either way.
25
+ */
26
+ readonly linkReadMode?: DocumentLinkReadMode;
14
27
  /**
15
28
  * Who may use the document handlers. One rule applies to read, write and delete; the split form
16
29
  * sets them apart. Default TenantAdmin and TenantMember; a missing delete follows write.
@@ -5,7 +5,7 @@
5
5
  import { defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { registerCapture, validateCaptureRules } from "./capture.js";
7
7
  import { DEFAULT_ADMIN_ROLES, DEFAULT_DOCUMENT_ACCESS, DEFAULT_EXPIRING_WITHIN_DAYS, DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY, DOCUMENTS_FEATURE_NAME, } from "./constants.js";
8
- import { createDocumentEntity, createDocumentLinkEntity } from "./entity.js";
8
+ import { createDocumentEntity, createDocumentLinkEntity, } from "./entity.js";
9
9
  import { createDocumentExecutors } from "./executor.js";
10
10
  import { createImportDocumentHandler } from "./handlers/import.write.js";
11
11
  import { createLinkDocumentHandler, createUnlinkDocumentHandler } from "./handlers/link.write.js";
@@ -32,10 +32,10 @@ function resolveAccess(access) {
32
32
  const write = access.write ?? DEFAULT_DOCUMENT_ACCESS;
33
33
  return { read: access.read ?? DEFAULT_DOCUMENT_ACCESS, write, delete: access.delete ?? write };
34
34
  }
35
- function categoryOptionLabels(categories) {
35
+ function categoryOptionLabels(categories, labels) {
36
36
  return Object.fromEntries(categories.map((category) => [
37
37
  `documents:entity:filed-document:field:category:option:${category}`,
38
- { en: category, de: category },
38
+ labels?.[category] ?? { en: category, de: category },
39
39
  ]));
40
40
  }
41
41
  function registerDocuments(r, opts) {
@@ -45,6 +45,7 @@ function registerDocuments(r, opts) {
45
45
  categories: opts.categories,
46
46
  adminRoles: opts.adminRoles ?? DEFAULT_ADMIN_ROLES,
47
47
  ...(opts.readAccess !== undefined && { readAccess: opts.readAccess }),
48
+ ...(opts.linkReadMode !== undefined && { linkReadMode: opts.linkReadMode }),
48
49
  ...(opts.retention !== undefined && { retention: opts.retention }),
49
50
  });
50
51
  const linkEntity = createDocumentLinkEntity(linkTargets);
@@ -58,11 +59,13 @@ function registerDocuments(r, opts) {
58
59
  r.writeHandler(defineEntityCreateHandler(DOCUMENT_ENTITY, documentEntity, {
59
60
  access: access.write,
60
61
  description: "Files a new document from an uploaded file; it stays invisible to everyone but the admin roles until it is linked to a record.",
62
+ agent: { risk: "mid" },
61
63
  }));
62
64
  const writeGuard = createDocumentWriteGuard(executors, opts.adminRoles ?? DEFAULT_ADMIN_ROLES);
63
65
  r.writeHandler(defineEntityUpdateHandler(DOCUMENT_ENTITY, documentEntity, {
64
66
  access: access.write,
65
67
  description: "Edits a document's title, category and dates from the entity-edit envelope; it does not change what the document is linked to.",
68
+ agent: { risk: "mid" },
66
69
  }));
67
70
  r.writeHandler(defineEntityDeleteHandler(DOCUMENT_ENTITY, documentEntity, {
68
71
  access: access.delete,
@@ -100,12 +103,20 @@ function registerDocuments(r, opts) {
100
103
  r.bootCheck((ctx) => validateCaptureRules(ctx, capture, opts.categories, linkTargets));
101
104
  r.screen(createDocumentListScreen(access.read, opts.categories));
102
105
  r.translations({
103
- keys: { ...DOCUMENTS_FEATURE_I18N, ...categoryOptionLabels(opts.categories) },
106
+ keys: {
107
+ ...DOCUMENTS_FEATURE_I18N,
108
+ ...categoryOptionLabels(opts.categories, opts.categoryLabels),
109
+ },
104
110
  });
105
111
  }
106
112
  export function createDocumentsFeature(opts) {
107
113
  if (opts.expiringWithinDays !== undefined && opts.expiringWithinDays < 0) {
108
114
  throw new Error("createDocumentsFeature({ expiringWithinDays }) must not be negative.");
109
115
  }
116
+ for (const category of Object.keys(opts.categoryLabels ?? {})) {
117
+ if (!opts.categories.includes(category)) {
118
+ throw new Error(`createDocumentsFeature({ categoryLabels }) names "${category}", which is not in categories.`);
119
+ }
120
+ }
110
121
  return defineFeature(DOCUMENTS_FEATURE_NAME, (r) => registerDocuments(r, opts));
111
122
  }
@@ -53,7 +53,7 @@ export function createImportDocumentHandler(args) {
53
53
  name: "import",
54
54
  schema: importDocumentPayloadSchema,
55
55
  access: { roles: [SYSTEM_ROLE] },
56
- agent: { expose: false },
56
+ agent: { expose: false, risk: "high" },
57
57
  description: "Creates a document with a caller-chosen id from an existing file, links it to the given records and optionally leaves it deleted; a repeat call with the same id changes nothing, and an id already used by another record fails. The document's createdAt is the time of the import; issuedAt carries the business date. Reserved for the system user, meant for data migrations.",
58
58
  handler: async (event, ctx) => {
59
59
  const payload = event.payload;
@@ -13,6 +13,7 @@ export function createLinkDocumentHandler(args) {
13
13
  name: "link",
14
14
  schema: documentLinkPayloadSchema,
15
15
  access,
16
+ agent: { risk: "mid" },
16
17
  description: "Makes a document visible on another record by linking it to that record; the caller must be able to read the document and edit the record.",
17
18
  handler: async (event, ctx) => {
18
19
  const payload = event.payload;
@@ -37,6 +38,7 @@ export function createUnlinkDocumentHandler(args) {
37
38
  name: "unlink",
38
39
  schema: documentLinkPayloadSchema,
39
40
  access,
41
+ agent: { risk: "mid" },
40
42
  description: "Removes a document from a record it was linked to, reporting success when no link existed; the document and its file stay.",
41
43
  handler: async (event, ctx) => {
42
44
  const payload = event.payload;
@@ -18,6 +18,20 @@ export const DOCUMENTS_FEATURE_I18N = {
18
18
  "documents:list.expiry.expiring": { en: "Expiring soon", de: "Läuft bald ab" },
19
19
  "documents:list.expiry.valid": { en: "Valid", de: "Gültig" },
20
20
  "documents:list.expiry.none": { en: "No expiry date", de: "Ohne Ablaufdatum" },
21
+ "documents:write:link:title": {
22
+ en: "Link document to record",
23
+ de: "Dokument mit Datensatz verknüpfen",
24
+ },
25
+ "documents:write:link:field:documentId": { en: "Document", de: "Dokument" },
26
+ "documents:write:link:field:entityType": { en: "Record type", de: "Datensatztyp" },
27
+ "documents:write:link:field:entityId": { en: "Record", de: "Datensatz" },
28
+ "documents:write:unlink:title": {
29
+ en: "Remove document from record",
30
+ de: "Dokument von Datensatz lösen",
31
+ },
32
+ "documents:write:unlink:field:documentId": { en: "Document", de: "Dokument" },
33
+ "documents:write:unlink:field:entityType": { en: "Record type", de: "Datensatztyp" },
34
+ "documents:write:unlink:field:entityId": { en: "Record", de: "Datensatz" },
21
35
  "documents.errors.unknown-category": {
22
36
  en: "Unknown document category",
23
37
  de: "Unbekannte Dokumentenkategorie",
@@ -1,6 +1,7 @@
1
1
  export { documentIdForFile, documentLinkAggregateId } from "./aggregate-id.ts";
2
2
  export type { DocumentCaptureRule } from "./capture.ts";
3
3
  export { DEFAULT_ADMIN_ROLES, DEFAULT_DOCUMENT_ACCESS, DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY, DOCUMENTS_FEATURE_NAME, DOCUMENTS_LIST_SCREEN_ID, DocumentsHandlers, DocumentsQueries, EXPIRY_STATES, type ExpiryState, } from "./constants.ts";
4
+ export type { DocumentLinkReadMode } from "./entity.ts";
4
5
  export { createDocumentsFeature, type DocumentsAccess, type DocumentsFeatureOptions, } from "./feature.ts";
5
6
  export { forTypeQueryQn } from "./queries/for-record.query.ts";
6
7
  export { DOCUMENT_COLUMNS } from "./screens.ts";
@@ -1,4 +1,4 @@
1
- import { DocumentsQueries } from "./constants.js";
1
+ import { DOCUMENT_ENTITY, DOCUMENTS_FEATURE_NAME, DocumentsQueries } from "./constants.js";
2
2
  import { forTypeQueryQn } from "./queries/for-record.query.js";
3
3
  // "Documents" tab for the edit screen of a link target. The tab id is the same on every screen,
4
4
  // so deep links like ?tab=documents work across hosts.
@@ -8,7 +8,7 @@ export function documentsTab(entityType) {
8
8
  kind: "relatedList",
9
9
  title: "documents:tab.title",
10
10
  query: forTypeQueryQn(entityType),
11
- entity: "documents:document",
11
+ entity: `${DOCUMENTS_FEATURE_NAME}:${DOCUMENT_ENTITY}`,
12
12
  columns: [
13
13
  { field: "title", sortable: true },
14
14
  { field: "category", sortable: true },
@@ -9,8 +9,10 @@ function updateHandlerAccessOf(registry, entityType) {
9
9
  return undefined;
10
10
  }
11
11
  // Linking a document changes what readers of the target see, so it needs what saving the target
12
- // needs: read visibility, the entity's update handler and its row-level write rule. A target that
13
- // is not visible answers like a missing one.
12
+ // needs: read visibility, a write gate and its row-level write rule. The gate is the entity's
13
+ // update handler; an entity saved only through custom handlers has none, so its declared write
14
+ // roles stand in, and without either the target stays unlinkable. A target that is not visible
15
+ // answers like a missing one.
14
16
  export async function canWriteLinkTarget(registry, entityType, entityId, user, db) {
15
17
  if (!(await parentRowIsVisible(registry, entityType, entityId, user, db)))
16
18
  return false;
@@ -18,8 +20,13 @@ export async function canWriteLinkTarget(registry, entityType, entityId, user, d
18
20
  if (entity === undefined)
19
21
  return false;
20
22
  const updateAccess = updateHandlerAccessOf(registry, entityType);
21
- if (updateAccess === undefined || !hasAccess(user, updateAccess))
23
+ if (updateAccess !== undefined) {
24
+ if (!hasAccess(user, updateAccess))
25
+ return false;
26
+ }
27
+ else if (!hasAccess(user, { roles: Object.keys(entity.access?.write ?? {}) })) {
22
28
  return false;
29
+ }
23
30
  const row = await createEntityExecutor(entityType, entity).executor.detail({ id: entityId }, user, db);
24
31
  return row !== null && userCanWriteFieldRow(user, entity.access?.write, row, row);
25
32
  }
@@ -56,7 +56,7 @@ export function createBrandingSettingsScreen(opts) {
56
56
  reason: "is_business_data",
57
57
  }),
58
58
  logoFile: createImageField({
59
- accept: ["image/png", "image/jpeg", "image/webp"],
59
+ accept: ["png", "jpg", "jpeg", "webp"],
60
60
  personal: false,
61
61
  reason: "is_business_data",
62
62
  }),
@@ -17,6 +17,10 @@ export const USER_DATA_RIGHTS_I18N = {
17
17
  },
18
18
  "user-data-rights:entity:export-job:field:userId": { en: "User" },
19
19
  "user-data-rights:entity:export-job:field:status": { en: "Status" },
20
+ "user-data-rights:entity:export-job:field:status:option:pending": { en: "Pending" },
21
+ "user-data-rights:entity:export-job:field:status:option:running": { en: "Running" },
22
+ "user-data-rights:entity:export-job:field:status:option:done": { en: "Done" },
23
+ "user-data-rights:entity:export-job:field:status:option:failed": { en: "Failed" },
20
24
  "user-data-rights:entity:export-job:field:requestedAt": { en: "Requested" },
21
25
  "user-data-rights:entity:export-job:field:completedAt": { en: "Completed" },
22
26
  "user-data-rights:entity:export-job:field:expiresAt": { en: "Expires" },
package/package.json CHANGED
@@ -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>",
@@ -570,9 +570,9 @@
570
570
  }
571
571
  },
572
572
  "dependencies": {
573
- "@cosmicdrift/kumiko-framework": "0.400.0",
574
- "@cosmicdrift/kumiko-renderer": "0.400.0",
575
- "@cosmicdrift/kumiko-types": "0.400.0",
573
+ "@cosmicdrift/kumiko-framework": "0.401.0",
574
+ "@cosmicdrift/kumiko-renderer": "0.401.0",
575
+ "@cosmicdrift/kumiko-types": "0.401.0",
576
576
  "@mollie/api-client": "^4.5.0",
577
577
  "@node-rs/argon2": "^2.0.2",
578
578
  "@tiptap/core": "^3.31.3",
@@ -1166,17 +1166,17 @@
1166
1166
  ],
1167
1167
  "devDependencies": {
1168
1168
  "@testing-library/user-event": "^14.6.8",
1169
- "@cosmicdrift/kumiko-locale-de": "0.400.0",
1170
- "@cosmicdrift/kumiko-locale-es": "0.400.0",
1171
- "@cosmicdrift/kumiko-locale-fr": "0.400.0",
1172
- "@cosmicdrift/kumiko-locale-it": "0.400.0",
1173
- "@cosmicdrift/kumiko-renderer-mail-html": "0.400.0",
1169
+ "@cosmicdrift/kumiko-locale-de": "0.401.0",
1170
+ "@cosmicdrift/kumiko-locale-es": "0.401.0",
1171
+ "@cosmicdrift/kumiko-locale-fr": "0.401.0",
1172
+ "@cosmicdrift/kumiko-locale-it": "0.401.0",
1173
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.401.0",
1174
1174
  "jsqr": "^1.4.0",
1175
1175
  "unpdf": "^1.8.1",
1176
- "@cosmicdrift/kumiko-testing": "0.400.0"
1176
+ "@cosmicdrift/kumiko-testing": "0.401.0"
1177
1177
  },
1178
1178
  "peerDependencies": {
1179
- "@cosmicdrift/kumiko-testing": "0.400.0"
1179
+ "@cosmicdrift/kumiko-testing": "0.401.0"
1180
1180
  },
1181
1181
  "peerDependenciesMeta": {
1182
1182
  "@cosmicdrift/kumiko-testing": {