@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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  4. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +4 -6
  5. package/packages/bundled-features/src/channel-in-app/changes.json +6 -0
  6. package/packages/bundled-features/src/channel-in-app/constants.ts +9 -9
  7. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
  8. package/packages/bundled-features/src/channel-in-app/handlers/inbox.query.ts +31 -7
  9. package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +6 -9
  10. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +25 -11
  11. package/packages/bundled-features/src/channel-in-app/in-app-target.ts +37 -0
  12. package/packages/bundled-features/src/channel-in-app/index.ts +1 -0
  13. package/packages/bundled-features/src/documents/aggregate-id.ts +20 -0
  14. package/packages/bundled-features/src/documents/capture.ts +125 -0
  15. package/packages/bundled-features/src/documents/constants.ts +31 -0
  16. package/packages/bundled-features/src/documents/ensure-link.ts +35 -0
  17. package/packages/bundled-features/src/documents/entity.ts +103 -0
  18. package/packages/bundled-features/src/documents/executor.ts +13 -0
  19. package/packages/bundled-features/src/documents/expiry.ts +32 -0
  20. package/packages/bundled-features/src/documents/feature.ts +197 -0
  21. package/packages/bundled-features/src/documents/handlers/import.write.ts +157 -0
  22. package/packages/bundled-features/src/documents/handlers/link.write.ts +103 -0
  23. package/packages/bundled-features/src/documents/i18n.ts +35 -0
  24. package/packages/bundled-features/src/documents/index.ts +22 -0
  25. package/packages/bundled-features/src/documents/pending-file-guard.ts +36 -0
  26. package/packages/bundled-features/src/documents/queries/expiry-overview.query.ts +46 -0
  27. package/packages/bundled-features/src/documents/queries/for-record.query.ts +74 -0
  28. package/packages/bundled-features/src/documents/queries/list-page.ts +51 -0
  29. package/packages/bundled-features/src/documents/queries/overview.query.ts +45 -0
  30. package/packages/bundled-features/src/documents/schemas.ts +66 -0
  31. package/packages/bundled-features/src/documents/screens.ts +86 -0
  32. package/packages/bundled-features/src/documents/tab.ts +46 -0
  33. package/packages/bundled-features/src/documents/target-write-check.ts +41 -0
  34. package/packages/bundled-features/src/documents/write-guard.ts +59 -0
  35. package/packages/bundled-features/src/metrics/changes.json +6 -0
  36. package/packages/bundled-features/src/metrics/compute.ts +19 -2
  37. package/packages/bundled-features/src/metrics/feature.ts +14 -1
  38. package/packages/bundled-features/src/metrics/types.ts +3 -1
  39. package/packages/bundled-features/src/metrics/validate.ts +3 -0
  40. package/packages/bundled-features/src/shared/purge-row-file-refs.ts +5 -4
  41. package/samples/apps/styleguide/src/app/seed.ts +102 -1
  42. package/samples/apps/styleguide/src/app/server.ts +2 -2
  43. package/samples/apps/styleguide/src/features/examples/feature.ts +97 -0
  44. package/samples/apps/styleguide/src/features/examples/i18n.ts +47 -0
  45. package/samples/apps/use-all-bundled/feature-manifest.json +31 -2
  46. package/samples/apps/use-all-bundled/kumiko/migrations/0058_documents.rebuild.json +4 -0
  47. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +11 -2
  48. package/samples/apps/use-all-bundled/src/run-config.ts +7 -0
  49. package/samples/recipes/derived-fields-sql/README.md +27 -0
  50. package/samples/recipes/derived-fields-sql/package.json +11 -0
  51. package/samples/recipes/derived-fields-sql/src/feature.ts +98 -0
  52. package/samples/recipes/documents/package.json +12 -0
  53. package/samples/recipes/documents/src/feature.ts +66 -0
  54. package/samples/recipes/inspection-checklist/README.md +27 -0
  55. package/samples/recipes/inspection-checklist/package.json +12 -0
  56. package/samples/recipes/inspection-checklist/src/entities/inspection-plan.ts +24 -0
  57. package/samples/recipes/inspection-checklist/src/entities/inspection-protocol.ts +72 -0
  58. package/samples/recipes/inspection-checklist/src/feature.ts +33 -0
  59. package/samples/recipes/inspection-checklist/src/handlers/create-from-plan.write.ts +38 -0
  60. 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: labelOf(key),
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 rows = buildRows(metric, aggregates, value, labelOf);
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; the single/multi split
27
- // matters here because only plural fields resolve through file_refs.
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.type === "files" || field.type === "images") multiFields.push(name);
38
+ else if (holdsFileRefs(field)) multiFields.push(name);
38
39
  }
39
40
  return singleFields.length > 0 || multiFields.length > 0
40
41
  ? { singleFields, multiFields }