@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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/documents/aggregate-id.ts +20 -0
  4. package/packages/bundled-features/src/documents/capture.ts +125 -0
  5. package/packages/bundled-features/src/documents/constants.ts +31 -0
  6. package/packages/bundled-features/src/documents/ensure-link.ts +35 -0
  7. package/packages/bundled-features/src/documents/entity.ts +103 -0
  8. package/packages/bundled-features/src/documents/executor.ts +13 -0
  9. package/packages/bundled-features/src/documents/expiry.ts +32 -0
  10. package/packages/bundled-features/src/documents/feature.ts +197 -0
  11. package/packages/bundled-features/src/documents/handlers/import.write.ts +157 -0
  12. package/packages/bundled-features/src/documents/handlers/link.write.ts +103 -0
  13. package/packages/bundled-features/src/documents/i18n.ts +35 -0
  14. package/packages/bundled-features/src/documents/index.ts +22 -0
  15. package/packages/bundled-features/src/documents/pending-file-guard.ts +36 -0
  16. package/packages/bundled-features/src/documents/queries/expiry-overview.query.ts +46 -0
  17. package/packages/bundled-features/src/documents/queries/for-record.query.ts +74 -0
  18. package/packages/bundled-features/src/documents/queries/list-page.ts +51 -0
  19. package/packages/bundled-features/src/documents/queries/overview.query.ts +45 -0
  20. package/packages/bundled-features/src/documents/schemas.ts +66 -0
  21. package/packages/bundled-features/src/documents/screens.ts +86 -0
  22. package/packages/bundled-features/src/documents/tab.ts +46 -0
  23. package/packages/bundled-features/src/documents/target-write-check.ts +41 -0
  24. package/packages/bundled-features/src/documents/write-guard.ts +59 -0
  25. package/packages/bundled-features/src/shared/purge-row-file-refs.ts +5 -4
  26. package/samples/apps/styleguide/src/app/seed.ts +102 -1
  27. package/samples/apps/styleguide/src/app/server.ts +2 -2
  28. package/samples/apps/styleguide/src/features/examples/feature.ts +97 -0
  29. package/samples/apps/styleguide/src/features/examples/i18n.ts +47 -0
  30. package/samples/apps/use-all-bundled/feature-manifest.json +30 -1
  31. package/samples/apps/use-all-bundled/kumiko/migrations/0058_documents.rebuild.json +4 -0
  32. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +11 -2
  33. package/samples/apps/use-all-bundled/src/run-config.ts +7 -0
  34. package/samples/recipes/derived-fields-sql/README.md +27 -0
  35. package/samples/recipes/derived-fields-sql/package.json +11 -0
  36. package/samples/recipes/derived-fields-sql/src/feature.ts +98 -0
  37. package/samples/recipes/documents/package.json +12 -0
  38. package/samples/recipes/documents/src/feature.ts +66 -0
  39. package/samples/recipes/inspection-checklist/README.md +27 -0
  40. package/samples/recipes/inspection-checklist/package.json +12 -0
  41. package/samples/recipes/inspection-checklist/src/entities/inspection-plan.ts +24 -0
  42. package/samples/recipes/inspection-checklist/src/entities/inspection-protocol.ts +72 -0
  43. package/samples/recipes/inspection-checklist/src/feature.ts +33 -0
  44. package/samples/recipes/inspection-checklist/src/handlers/create-from-plan.write.ts +38 -0
  45. package/samples/recipes/inspection-checklist/src/screens/inspection-protocol-edit.screen.ts +27 -0
@@ -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
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ type AccessRule,
3
+ defineQueryHandler,
4
+ type HandlerContext,
5
+ toKebab,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { DocumentsQueries } from "../constants.ts";
8
+ import { type ForRecordPayload, forRecordPayloadSchema } from "../schemas.ts";
9
+ import { linkedDocumentIds, listDocuments } from "./list-page.ts";
10
+
11
+ const EMPTY_PAGE = { rows: [], nextCursor: null, total: 0 };
12
+ const LIVE_ENTITIES = ["filed-document", "filed-document-link"];
13
+
14
+ type Dispatching = Pick<HandlerContext, "dispatch">;
15
+
16
+ async function documentsOfRecord(
17
+ ctx: Dispatching,
18
+ linkTargets: readonly string[],
19
+ payload: ForRecordPayload,
20
+ ) {
21
+ const { entityType, entityId, filter, filters, ...paging } = payload;
22
+ if (!linkTargets.includes(entityType)) return EMPTY_PAGE;
23
+ const ids = await linkedDocumentIds(ctx, { entityType, entityId });
24
+ if (ids.length === 0) return EMPTY_PAGE;
25
+ return listDocuments(ctx, {
26
+ ...paging,
27
+ filters: [
28
+ { field: "id", op: "in", value: ids },
29
+ ...(filter !== undefined ? [filter] : []),
30
+ ...(filters ?? []),
31
+ ],
32
+ });
33
+ }
34
+
35
+ export function createForRecordQuery(access: AccessRule, linkTargets: readonly string[]) {
36
+ return defineQueryHandler({
37
+ name: "for-record",
38
+ schema: forRecordPayloadSchema,
39
+ access,
40
+ liveEntities: LIVE_ENTITIES,
41
+ description:
42
+ "Lists the documents linked to one record, paged and filterable like the document list; a record the caller cannot read, or a type that takes no documents, yields an empty page.",
43
+ handler: async (query, ctx) => documentsOfRecord(ctx, linkTargets, query.payload),
44
+ });
45
+ }
46
+
47
+ export function forTypeQueryName(entityType: string): string {
48
+ return `for-${toKebab(entityType)}`;
49
+ }
50
+
51
+ export function forTypeQueryQn(entityType: string): string {
52
+ return `${DocumentsQueries.forRecord.replace(/for-record$/, "")}${forTypeQueryName(entityType)}`;
53
+ }
54
+
55
+ // A relatedList tab sends only the parent id, so the host type is fixed per query.
56
+ export function createForTypeQuery(
57
+ access: AccessRule,
58
+ linkTargets: readonly string[],
59
+ entityType: string,
60
+ ) {
61
+ return defineQueryHandler({
62
+ name: forTypeQueryName(entityType),
63
+ schema: forRecordPayloadSchema.omit({ entityType: true, entityId: true }).extend({
64
+ id: forRecordPayloadSchema.shape.entityId,
65
+ }),
66
+ access,
67
+ liveEntities: LIVE_ENTITIES,
68
+ description: `Lists the documents linked to one ${entityType} record by its id, paged and filterable like the document list; it feeds the documents tab on the ${entityType} screen.`,
69
+ handler: async (query, ctx) => {
70
+ const { id, ...rest } = query.payload;
71
+ return documentsOfRecord(ctx, linkTargets, { ...rest, entityType, entityId: id });
72
+ },
73
+ });
74
+ }
@@ -0,0 +1,51 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+ import { DocumentsQueries } from "../constants.ts";
4
+
5
+ const LINK_LIST = "documents:query:filed-document-link:list";
6
+ const LINK_PAGE_SIZE = 200;
7
+
8
+ const pageSchema = z.object({
9
+ rows: z.array(z.record(z.string(), z.unknown())),
10
+ nextCursor: z.string().nullable().optional(),
11
+ total: z.number().optional(),
12
+ });
13
+ export type ListPage = z.infer<typeof pageSchema>;
14
+
15
+ export type ListFilter = { readonly field: string; readonly op: string; readonly value: unknown };
16
+
17
+ export async function listDocuments(
18
+ ctx: Pick<HandlerContext, "dispatch">,
19
+ payload: Record<string, unknown>,
20
+ ): Promise<ListPage> {
21
+ return pageSchema.parse(await ctx.dispatch.query(DocumentsQueries.list, payload));
22
+ }
23
+
24
+ // Goes through the link list, so a record the caller cannot read yields no ids (the link's
25
+ // parentRef gate), and a document is then fetched through the document list, which applies the
26
+ // document's own read gate again.
27
+ export async function linkedDocumentIds(
28
+ ctx: Pick<HandlerContext, "dispatch">,
29
+ target: { readonly entityType: string; readonly entityId: string },
30
+ ): Promise<string[]> {
31
+ const ids: string[] = [];
32
+ let cursor: string | undefined;
33
+ do {
34
+ const page = pageSchema.parse(
35
+ await ctx.dispatch.query(LINK_LIST, {
36
+ filters: [
37
+ { field: "entityType", op: "eq", value: target.entityType },
38
+ { field: "entityId", op: "eq", value: target.entityId },
39
+ ],
40
+ limit: LINK_PAGE_SIZE,
41
+ ...(cursor !== undefined && { cursor }),
42
+ }),
43
+ );
44
+ for (const row of page.rows) {
45
+ const documentId = row["documentId"];
46
+ if (typeof documentId === "string") ids.push(documentId);
47
+ }
48
+ cursor = page.nextCursor ?? undefined;
49
+ } while (cursor !== undefined);
50
+ return ids;
51
+ }
@@ -0,0 +1,45 @@
1
+ import { type AccessRule, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { EXPIRING_FACET_VALUE } from "../constants.ts";
3
+ import { expiryStateOf, expiryWindow } from "../expiry.ts";
4
+ import { overviewPayloadSchema } from "../schemas.ts";
5
+ import { type ListFilter, listDocuments } from "./list-page.ts";
6
+
7
+ const EXPIRY_FILTER_FIELD = "expiry";
8
+
9
+ // The "expiring" facet is a moving date window, which a facet on a stored field cannot
10
+ // express; it becomes a validUntil bound here, expired documents included. A document
11
+ // without a date never matches it.
12
+ function toDocumentFilters(filters: readonly ListFilter[], horizon: string): readonly ListFilter[] {
13
+ return filters.flatMap((clause): ListFilter[] => {
14
+ if (clause.field !== EXPIRY_FILTER_FIELD) return [clause];
15
+ const wantsExpiring =
16
+ Array.isArray(clause.value) && clause.value.includes(EXPIRING_FACET_VALUE);
17
+ return wantsExpiring ? [{ field: "validUntil", op: "lte", value: horizon }] : [];
18
+ });
19
+ }
20
+
21
+ export function createOverviewQuery(access: AccessRule, expiringWithinDays: number) {
22
+ return defineQueryHandler({
23
+ name: "overview",
24
+ schema: overviewPayloadSchema,
25
+ access,
26
+ liveEntities: ["filed-document", "filed-document-link"],
27
+ description:
28
+ "Lists the documents the caller can read with an expiry state per row (expired, expiring, valid, none); the expiring filter returns expired and soon-expiring documents. Powers the documents list screen.",
29
+ handler: async (query, ctx) => {
30
+ const { filters, ...paging } = query.payload;
31
+ const window = expiryWindow(ctx, expiringWithinDays);
32
+ const page = await listDocuments(ctx, {
33
+ ...paging,
34
+ filters: toDocumentFilters(filters ?? [], window.horizon),
35
+ });
36
+ return {
37
+ ...page,
38
+ rows: page.rows.map((row) => ({
39
+ ...row,
40
+ expiry: expiryStateOf(row["validUntil"], window),
41
+ })),
42
+ };
43
+ },
44
+ });
45
+ }
@@ -0,0 +1,66 @@
1
+ import * as z from "zod";
2
+
3
+ export const documentLinkPayloadSchema = z.object({
4
+ documentId: z.uuid(),
5
+ entityType: z.string().min(1).max(64),
6
+ entityId: z.string().min(1).max(128),
7
+ });
8
+ export type DocumentLinkPayload = z.infer<typeof documentLinkPayloadSchema>;
9
+
10
+ const isoDate = z.iso.date();
11
+
12
+ export const importDocumentPayloadSchema = z.object({
13
+ id: z.uuid(),
14
+ title: z.string().trim().min(1).max(200),
15
+ category: z.string().min(1),
16
+ issuedAt: isoDate.optional(),
17
+ validUntil: isoDate.optional(),
18
+ fileRefId: z.uuid(),
19
+ links: z.array(
20
+ z.object({ entityType: z.string().min(1).max(64), entityId: z.string().min(1).max(128) }),
21
+ ),
22
+ deleted: z.boolean().optional(),
23
+ });
24
+ export type ImportDocumentPayload = z.infer<typeof importDocumentPayloadSchema>;
25
+
26
+ export const forRecordPayloadSchema = z.object({
27
+ entityType: z.string().min(1).max(64),
28
+ entityId: z.string().min(1).max(128),
29
+ limit: z.number().int().nonnegative().max(200).optional(),
30
+ cursor: z.string().optional(),
31
+ offset: z.number().int().nonnegative().optional(),
32
+ search: z.string().optional(),
33
+ sort: z.string().optional(),
34
+ sortDirection: z.enum(["asc", "desc"]).optional(),
35
+ totalCount: z.boolean().optional(),
36
+ filter: z
37
+ .object({
38
+ field: z.string(),
39
+ op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
40
+ value: z.unknown(),
41
+ })
42
+ .optional(),
43
+ filters: z
44
+ .array(
45
+ z.object({
46
+ field: z.string(),
47
+ op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
48
+ value: z.unknown(),
49
+ }),
50
+ )
51
+ .optional(),
52
+ });
53
+ export type ForRecordPayload = z.infer<typeof forRecordPayloadSchema>;
54
+
55
+ export const overviewPayloadSchema = forRecordPayloadSchema.omit({
56
+ entityType: true,
57
+ entityId: true,
58
+ filter: true,
59
+ });
60
+ export type OverviewPayload = z.infer<typeof overviewPayloadSchema>;
61
+
62
+ export const expiryOverviewPayloadSchema = z.object({
63
+ entityType: z.string().min(1).max(64).optional(),
64
+ entityId: z.string().min(1).max(128).optional(),
65
+ });
66
+ export type ExpiryOverviewPayload = z.infer<typeof expiryOverviewPayloadSchema>;