@cosmicdrift/kumiko-samples 0.398.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/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +4 -6
- package/packages/bundled-features/src/channel-in-app/changes.json +6 -0
- package/packages/bundled-features/src/channel-in-app/constants.ts +9 -9
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/handlers/inbox.query.ts +31 -7
- package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +6 -9
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +25 -11
- package/packages/bundled-features/src/channel-in-app/in-app-target.ts +37 -0
- package/packages/bundled-features/src/channel-in-app/index.ts +1 -0
- 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/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/compute.ts +19 -2
- package/packages/bundled-features/src/metrics/feature.ts +14 -1
- package/packages/bundled-features/src/metrics/types.ts +3 -1
- package/packages/bundled-features/src/metrics/validate.ts +3 -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 +31 -2
- 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
|
@@ -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>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AccessRule,
|
|
3
|
+
i18nKey,
|
|
4
|
+
type ProjectionListScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { DOCUMENTS_LIST_SCREEN_ID, DocumentsQueries, EXPIRING_FACET_VALUE } from "./constants.ts";
|
|
7
|
+
|
|
8
|
+
// The columns every document list shows; the tab and the standalone screen stay alike.
|
|
9
|
+
export const DOCUMENT_COLUMNS = ["title", "category", "validUntil", "file"] as const;
|
|
10
|
+
|
|
11
|
+
const FIELD_PREFIX = "documents:entity:filed-document:field:";
|
|
12
|
+
const CATEGORY_OPTION_PREFIX = `${FIELD_PREFIX}category:option:`;
|
|
13
|
+
|
|
14
|
+
// A projectionList, not an entityList: the expiry state and its "expiring" filter are computed
|
|
15
|
+
// per request from a moving date window, which an entityList's facets on stored fields cannot
|
|
16
|
+
// express. The overview query still reads through the document list, so the read gate holds.
|
|
17
|
+
export function createDocumentListScreen(
|
|
18
|
+
access: AccessRule,
|
|
19
|
+
categories: readonly string[],
|
|
20
|
+
): ProjectionListScreenDefinition {
|
|
21
|
+
return {
|
|
22
|
+
id: DOCUMENTS_LIST_SCREEN_ID,
|
|
23
|
+
type: "projectionList",
|
|
24
|
+
query: DocumentsQueries.overview,
|
|
25
|
+
columns: [
|
|
26
|
+
{ field: "title", label: `${FIELD_PREFIX}title` },
|
|
27
|
+
{
|
|
28
|
+
field: "category",
|
|
29
|
+
label: `${FIELD_PREFIX}category`,
|
|
30
|
+
renderer: { format: "enumOption", keyPrefix: CATEGORY_OPTION_PREFIX },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
field: "validUntil",
|
|
34
|
+
label: `${FIELD_PREFIX}validUntil`,
|
|
35
|
+
renderer: { format: "date", dateStyle: "medium" },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
field: "expiry",
|
|
39
|
+
label: "documents:list.facet.expiry",
|
|
40
|
+
sortable: false,
|
|
41
|
+
hideOnNarrow: true,
|
|
42
|
+
renderer: { format: "enumOption", keyPrefix: "documents:list.expiry." },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
field: "file",
|
|
46
|
+
label: `${FIELD_PREFIX}file`,
|
|
47
|
+
sortable: false,
|
|
48
|
+
renderer: { format: "file" },
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
pagination: "pages",
|
|
52
|
+
defaultSort: { field: "validUntil", dir: "asc" },
|
|
53
|
+
searchable: true,
|
|
54
|
+
facets: [
|
|
55
|
+
{
|
|
56
|
+
field: "category",
|
|
57
|
+
type: "select",
|
|
58
|
+
label: `${FIELD_PREFIX}category`,
|
|
59
|
+
options: categories.map((value) => ({
|
|
60
|
+
value,
|
|
61
|
+
label: `${CATEGORY_OPTION_PREFIX}${value}`,
|
|
62
|
+
})),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
field: "expiry",
|
|
66
|
+
type: "select",
|
|
67
|
+
label: "documents:list.facet.expiry",
|
|
68
|
+
options: [{ value: EXPIRING_FACET_VALUE, label: "documents:list.facet.expiring" }],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
rowActions: [
|
|
72
|
+
{
|
|
73
|
+
kind: "writeHandler",
|
|
74
|
+
id: "delete",
|
|
75
|
+
label: i18nKey("kumiko.actions.delete"),
|
|
76
|
+
handler: "documents:write:filed-document:delete",
|
|
77
|
+
payload: { pick: ["id"] },
|
|
78
|
+
confirm: i18nKey("kumiko.actions.delete-confirm"),
|
|
79
|
+
style: "danger",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
access,
|
|
83
|
+
// The app places it with r.nav("documents:screen:list").
|
|
84
|
+
dormant: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { EditRelatedListSection } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { DashboardStatPanel } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { DocumentsQueries } from "./constants.ts";
|
|
4
|
+
import { forTypeQueryQn } from "./queries/for-record.query.ts";
|
|
5
|
+
|
|
6
|
+
// "Documents" tab for the edit screen of a link target. The tab id is the same on every screen,
|
|
7
|
+
// so deep links like ?tab=documents work across hosts.
|
|
8
|
+
export function documentsTab(entityType: string): EditRelatedListSection {
|
|
9
|
+
return {
|
|
10
|
+
id: "documents",
|
|
11
|
+
kind: "relatedList",
|
|
12
|
+
title: "documents:tab.title",
|
|
13
|
+
query: forTypeQueryQn(entityType),
|
|
14
|
+
entity: "documents:document",
|
|
15
|
+
columns: [
|
|
16
|
+
{ field: "title", sortable: true },
|
|
17
|
+
{ field: "category", sortable: true },
|
|
18
|
+
{ field: "validUntil", sortable: true },
|
|
19
|
+
{ field: "file" },
|
|
20
|
+
],
|
|
21
|
+
searchable: true,
|
|
22
|
+
count: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Stat tiles for a dashboard: how many readable documents are expired or about to expire.
|
|
27
|
+
export function documentExpiryPanels(): readonly DashboardStatPanel[] {
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
kind: "stat",
|
|
31
|
+
id: "documents-expired",
|
|
32
|
+
label: "documents:overview.expired",
|
|
33
|
+
query: DocumentsQueries.expiryOverview,
|
|
34
|
+
valueField: "expired",
|
|
35
|
+
tone: "negative",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
kind: "stat",
|
|
39
|
+
id: "documents-expiring",
|
|
40
|
+
label: "documents:overview.expiring",
|
|
41
|
+
query: DocumentsQueries.expiryOverview,
|
|
42
|
+
valueField: "expiring",
|
|
43
|
+
tone: "warn",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntityExecutor,
|
|
4
|
+
hasAccess,
|
|
5
|
+
type Registry,
|
|
6
|
+
type SessionUser,
|
|
7
|
+
toKebab,
|
|
8
|
+
userCanWriteFieldRow,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { parentRowIsVisible } from "../shared/index.ts";
|
|
11
|
+
|
|
12
|
+
function updateHandlerAccessOf(registry: Registry, entityType: string) {
|
|
13
|
+
const suffix = `:write:${toKebab(entityType)}:update`;
|
|
14
|
+
for (const [qn, handler] of registry.getAllWriteHandlers()) {
|
|
15
|
+
if (qn.endsWith(suffix) && registry.getHandlerEntity(qn) === entityType) return handler.access;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
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.
|
|
23
|
+
export async function canWriteLinkTarget(
|
|
24
|
+
registry: Registry,
|
|
25
|
+
entityType: string,
|
|
26
|
+
entityId: string,
|
|
27
|
+
user: SessionUser,
|
|
28
|
+
db: TenantDb,
|
|
29
|
+
): Promise<boolean> {
|
|
30
|
+
if (!(await parentRowIsVisible(registry, entityType, entityId, user, db))) return false;
|
|
31
|
+
const entity = registry.getEntity(entityType);
|
|
32
|
+
if (entity === undefined) return false;
|
|
33
|
+
const updateAccess = updateHandlerAccessOf(registry, entityType);
|
|
34
|
+
if (updateAccess === undefined || !hasAccess(user, updateAccess)) return false;
|
|
35
|
+
const row = await createEntityExecutor(entityType, entity).executor.detail(
|
|
36
|
+
{ id: entityId },
|
|
37
|
+
user,
|
|
38
|
+
db,
|
|
39
|
+
);
|
|
40
|
+
return row !== null && userCanWriteFieldRow(user, entity.access?.write, row, row);
|
|
41
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DeleteContext,
|
|
3
|
+
type HandlerContext,
|
|
4
|
+
type PreSaveHookFn,
|
|
5
|
+
SYSTEM_ROLE,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { DOCUMENT_ENTITY } from "./constants.ts";
|
|
9
|
+
import type { DocumentExecutors } from "./executor.ts";
|
|
10
|
+
import { canWriteLinkTarget } from "./target-write-check.ts";
|
|
11
|
+
|
|
12
|
+
const LINK_PAGE_SIZE = 200;
|
|
13
|
+
|
|
14
|
+
// Editing or deleting a document changes what every reader of its hosts sees, so it needs what
|
|
15
|
+
// unlink needs: write access to at least one host it is linked to. Admin roles see unlinked
|
|
16
|
+
// documents too and must be able to clean them up, and SYSTEM jobs (import, capture) write on
|
|
17
|
+
// their own authority. A refused caller gets "not found", like a document they cannot read.
|
|
18
|
+
export function createDocumentWriteGuard(
|
|
19
|
+
executors: DocumentExecutors,
|
|
20
|
+
adminRoles: readonly string[],
|
|
21
|
+
) {
|
|
22
|
+
async function assertMayWrite(documentId: string, ctx: HandlerContext): Promise<void> {
|
|
23
|
+
const { user } = ctx;
|
|
24
|
+
if (user.roles.includes(SYSTEM_ROLE) || user.roles.some((role) => adminRoles.includes(role))) {
|
|
25
|
+
// skip: exempt roles write on their own authority
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let offset = 0;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const page = await executors.link.list(
|
|
31
|
+
{
|
|
32
|
+
filters: [{ field: "documentId", op: "eq", value: documentId }],
|
|
33
|
+
limit: LINK_PAGE_SIZE,
|
|
34
|
+
offset,
|
|
35
|
+
},
|
|
36
|
+
user,
|
|
37
|
+
ctx.db,
|
|
38
|
+
);
|
|
39
|
+
for (const row of page.rows) {
|
|
40
|
+
const entityType = String(row["entityType"]);
|
|
41
|
+
const entityId = String(row["entityId"]);
|
|
42
|
+
// skip: one writable host is enough
|
|
43
|
+
if (await canWriteLinkTarget(ctx.registry, entityType, entityId, user, ctx.db)) return;
|
|
44
|
+
}
|
|
45
|
+
if (page.rows.length < LINK_PAGE_SIZE) break;
|
|
46
|
+
offset += LINK_PAGE_SIZE;
|
|
47
|
+
}
|
|
48
|
+
throw new NotFoundError(DOCUMENT_ENTITY, documentId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const beforeUpdate: PreSaveHookFn = async (changes, ctx) => {
|
|
52
|
+
if (!ctx.isNew) await assertMayWrite(String(ctx.previous["id"]), ctx);
|
|
53
|
+
return changes;
|
|
54
|
+
};
|
|
55
|
+
const beforeDelete = async (payload: DeleteContext, ctx: HandlerContext): Promise<void> => {
|
|
56
|
+
await assertMayWrite(String(payload.id), ctx);
|
|
57
|
+
};
|
|
58
|
+
return { beforeUpdate, beforeDelete };
|
|
59
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.399.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Metric access roles and group names from a source table",
|
|
6
|
+
"detail": "A metric declares `access: { roles }` to open its tenant query to roles other than the admin roles; an empty list fails boot validation and no `access` keeps the admin-only default. A `groupBy` metric on an entity-table source whose column is a single reference names the rows after the referenced row's label field; the lookup runs through the caller's read access on the target entity, so unreadable or foreign-tenant rows keep their key, and static `groupLabels` still win."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.365.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -26,6 +26,10 @@ export type MetricRunParams = {
|
|
|
26
26
|
readonly filterWhere: WhereObject;
|
|
27
27
|
/** Extra filter that narrows the source to the caller's scope. */
|
|
28
28
|
readonly scopeWhere: WhereObject;
|
|
29
|
+
/** Names for referenced-row group keys; absent where the caller cannot resolve them (system scope). */
|
|
30
|
+
readonly referenceLabels?: (
|
|
31
|
+
keys: readonly AggregateKey[],
|
|
32
|
+
) => Promise<ReadonlyMap<string, string>>;
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
const NULL_KEY_TEXT = "—";
|
|
@@ -153,6 +157,7 @@ function buildRows(
|
|
|
153
157
|
aggregates: MetricAggregates,
|
|
154
158
|
total: number | null,
|
|
155
159
|
labelOf: (key: string) => string,
|
|
160
|
+
groupLabelOf: (key: string) => string,
|
|
156
161
|
): readonly MetricRow[] {
|
|
157
162
|
const segmentsByGroup = groupSegments(aggregates.stacks, labelOf);
|
|
158
163
|
return aggregates.groups.map((row) => {
|
|
@@ -161,7 +166,7 @@ function buildRows(
|
|
|
161
166
|
return {
|
|
162
167
|
id: key,
|
|
163
168
|
key,
|
|
164
|
-
label:
|
|
169
|
+
label: groupLabelOf(key),
|
|
165
170
|
value: rowValue,
|
|
166
171
|
fraction: fractionOf(rowValue, total),
|
|
167
172
|
...(metric.stackBy !== undefined && { segments: segmentsByGroup.get(key) ?? [] }),
|
|
@@ -188,6 +193,15 @@ function buildSeries(
|
|
|
188
193
|
}));
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
async function resolveReferencedGroupLabels(
|
|
197
|
+
metric: MetricDefinition,
|
|
198
|
+
params: MetricRunParams,
|
|
199
|
+
groups: readonly AggregateRow[],
|
|
200
|
+
): Promise<ReadonlyMap<string, string>> {
|
|
201
|
+
if (params.referenceLabels === undefined || metric.groupBy === undefined) return new Map();
|
|
202
|
+
return params.referenceLabels(groups.map((row) => row.keys[0] ?? null));
|
|
203
|
+
}
|
|
204
|
+
|
|
191
205
|
export async function runMetric(
|
|
192
206
|
db: TenantDb,
|
|
193
207
|
metric: MetricDefinition,
|
|
@@ -230,7 +244,10 @@ export async function runMetric(
|
|
|
230
244
|
),
|
|
231
245
|
)
|
|
232
246
|
: [];
|
|
233
|
-
const
|
|
247
|
+
const referencedLabels = await resolveReferencedGroupLabels(metric, params, aggregates.groups);
|
|
248
|
+
const groupLabelOf = (key: string): string =>
|
|
249
|
+
metric.groupLabels?.[key] ?? referencedLabels.get(key) ?? key;
|
|
250
|
+
const rows = buildRows(metric, aggregates, value, labelOf, groupLabelOf);
|
|
234
251
|
|
|
235
252
|
return {
|
|
236
253
|
value,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { extractTableInfo } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import {
|
|
2
3
|
access,
|
|
3
4
|
defineFeature,
|
|
4
5
|
defineQueryHandler,
|
|
5
6
|
type FeatureDefinition,
|
|
6
7
|
type QueryHandlerDefinition,
|
|
8
|
+
referenceGroupLabels,
|
|
7
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
10
|
import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
11
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
@@ -78,7 +80,7 @@ function createTenantMetricQuery(
|
|
|
78
80
|
name: metric.id,
|
|
79
81
|
description: metric.description,
|
|
80
82
|
schema: tenantPayloadSchema(metric),
|
|
81
|
-
access: { roles: access.admin },
|
|
83
|
+
access: { roles: metric.access?.roles ?? access.admin },
|
|
82
84
|
handler: async (query, ctx) =>
|
|
83
85
|
runMetric(ctx.db, metric, {
|
|
84
86
|
range: query.payload.range,
|
|
@@ -87,6 +89,17 @@ function createTenantMetricQuery(
|
|
|
87
89
|
filterWhere: await resolveMetricFilterWhere(metric, query.payload, ctx),
|
|
88
90
|
// ctx.db already reads own-tenant + SYSTEM reference rows; this narrows to own-tenant only.
|
|
89
91
|
scopeWhere: { tenantId: query.user.tenantId },
|
|
92
|
+
referenceLabels: (keys) =>
|
|
93
|
+
metric.groupBy === undefined
|
|
94
|
+
? Promise.resolve(new Map())
|
|
95
|
+
: referenceGroupLabels(
|
|
96
|
+
ctx,
|
|
97
|
+
query.user,
|
|
98
|
+
extractTableInfo(metric.source).name,
|
|
99
|
+
metric.groupBy,
|
|
100
|
+
keys,
|
|
101
|
+
ctx.db,
|
|
102
|
+
),
|
|
90
103
|
}),
|
|
91
104
|
});
|
|
92
105
|
}
|
|
@@ -41,8 +41,10 @@ export type MetricDefinition = {
|
|
|
41
41
|
readonly groupBy?: string;
|
|
42
42
|
/** Second dimension, becomes `rows[].segments`; only without bucket. */
|
|
43
43
|
readonly stackBy?: string;
|
|
44
|
-
/** Key value → i18n key, applied to groupBy and stackBy keys. */
|
|
44
|
+
/** Key value → i18n key, applied to groupBy and stackBy keys. Without an entry, a groupBy that is a single reference of an entity-table source is named after the referenced row (tenant scope, through the caller's read access); otherwise the key stays. */
|
|
45
45
|
readonly groupLabels?: Readonly<Record<string, string>>;
|
|
46
|
+
/** Roles that may read the tenant query; default is the admin roles. Ignored for scope "system", which stays SystemAdmin only. */
|
|
47
|
+
readonly access?: { readonly roles: readonly string[] };
|
|
46
48
|
readonly scopes: readonly MetricScope[];
|
|
47
49
|
};
|
|
48
50
|
|
|
@@ -31,6 +31,9 @@ function validateMetric(metric: MetricDefinition): void {
|
|
|
31
31
|
};
|
|
32
32
|
if (!KEBAB_ID.test(metric.id)) fail("has an id that is not kebab-case");
|
|
33
33
|
if (metric.scopes.length === 0) fail("declares no scopes");
|
|
34
|
+
if (metric.access !== undefined && metric.access.roles.length === 0) {
|
|
35
|
+
fail("declares access with an empty roles list");
|
|
36
|
+
}
|
|
34
37
|
|
|
35
38
|
const info = extractTableInfo(metric.source);
|
|
36
39
|
validateColumns(metric, info, fail);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type DbRunner, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { EntityDefinition, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
3
|
+
import { fileRefsTable, holdsFileRefs } from "@cosmicdrift/kumiko-framework/files";
|
|
4
4
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
5
|
|
|
6
6
|
type EntityFields = EntityDefinition["fields"];
|
|
@@ -23,8 +23,9 @@ export type FileRefPurgeOutcome = "purged" | "missing_file_storage" | "file_dele
|
|
|
23
23
|
|
|
24
24
|
const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
|
|
25
25
|
|
|
26
|
-
// Same set as boot-validator/entity-handler.ts FILE_FIELD_TYPES
|
|
27
|
-
// matters here because only plural fields resolve through
|
|
26
|
+
// Same set as boot-validator/entity-handler.ts FILE_FIELD_TYPES plus embedded fields with file
|
|
27
|
+
// sub-fields; the single/multi split matters here because only plural fields resolve through
|
|
28
|
+
// file_refs, bound under the top-level field name.
|
|
28
29
|
export function planFileFields(
|
|
29
30
|
fields: EntityFields,
|
|
30
31
|
fieldNames?: readonly string[],
|
|
@@ -34,7 +35,7 @@ export function planFileFields(
|
|
|
34
35
|
for (const [name, field] of Object.entries(fields)) {
|
|
35
36
|
if (fieldNames !== undefined && !fieldNames.includes(name)) continue;
|
|
36
37
|
if (field.type === "file" || field.type === "image") singleFields.push(name);
|
|
37
|
-
else if (field
|
|
38
|
+
else if (holdsFileRefs(field)) multiFields.push(name);
|
|
38
39
|
}
|
|
39
40
|
return singleFields.length > 0 || multiFields.length > 0
|
|
40
41
|
? { singleFields, multiFields }
|