@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +6 -1
- 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/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 +30 -1
- 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,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,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 }
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
|
|
2
2
|
import { buildEntityTable, createTenantDb, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createEntityExecutor, createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import {
|
|
5
|
+
createFileRefFromBytes,
|
|
6
|
+
createTenantFileContext,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
4
8
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
|
|
5
9
|
|
|
6
10
|
import { demoEntity } from "../features/demo/feature";
|
|
11
|
+
import { inspectionEntity } from "../features/examples/feature";
|
|
7
12
|
|
|
8
13
|
// Gives the list view real rows with status/flag variety. price (money)
|
|
9
14
|
// stays empty — the money input shows in the edit screen, in the seed we leave
|
|
@@ -16,6 +21,8 @@ const STATUSES = ["draft", "review", "published", "archived"] as const;
|
|
|
16
21
|
// otherwise change every run.
|
|
17
22
|
export const FIRST_STYLEGUIDE_ITEM_ID = "00000000-0000-4000-8000-0000000000f1";
|
|
18
23
|
|
|
24
|
+
export const FIRST_STYLEGUIDE_INSPECTION_ID = "00000000-0000-4000-8000-0000000000f2";
|
|
25
|
+
|
|
19
26
|
const demoItemTable = buildEntityTable("item", demoEntity);
|
|
20
27
|
|
|
21
28
|
export const seedStyleguideItems: SeedFn = async (stack) => {
|
|
@@ -58,3 +65,97 @@ export const seedStyleguideItems: SeedFn = async (stack) => {
|
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
};
|
|
68
|
+
|
|
69
|
+
const inspectionTable = buildEntityTable("inspection", inspectionEntity);
|
|
70
|
+
|
|
71
|
+
// 1x1 PNG, enough for the thumbnail chain; the bytes only need to be a valid image.
|
|
72
|
+
const SEED_PHOTO_PNG = Uint8Array.from(
|
|
73
|
+
Buffer.from(
|
|
74
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
75
|
+
"base64",
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Fixed id for the inspection-checklist screenshot scenarios. Created through the executor like
|
|
80
|
+
// the first demo item. Photos are real file refs attached to checkpoints.photos, created
|
|
81
|
+
// server-side the way POST /files would (a not_ok checkpoint requires a photo).
|
|
82
|
+
export const seedStyleguideInspection: SeedFn = async (stack) => {
|
|
83
|
+
if (await fetchOne(stack.db, inspectionTable, { id: FIRST_STYLEGUIDE_INSPECTION_ID })) return;
|
|
84
|
+
const tenantId = TestUsers.admin.tenantId;
|
|
85
|
+
const tenantDb = createTenantDb({ db: stack.db, tenantId });
|
|
86
|
+
const resolver = stack.context._fileProviderResolver;
|
|
87
|
+
const files = resolver ? createTenantFileContext(resolver, tenantId) : stack.context.files;
|
|
88
|
+
|
|
89
|
+
const photoIds: string[] = [];
|
|
90
|
+
for (const fileName of ["hose.png", "bearing.png"]) {
|
|
91
|
+
const photo = await createFileRefFromBytes(
|
|
92
|
+
{
|
|
93
|
+
db: tenantDb,
|
|
94
|
+
services: { files },
|
|
95
|
+
registry: stack.registry,
|
|
96
|
+
user: createSystemUser(tenantId),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
fileName,
|
|
100
|
+
mimeType: "image/png",
|
|
101
|
+
data: SEED_PHOTO_PNG,
|
|
102
|
+
attachTo: {
|
|
103
|
+
entityType: "inspection",
|
|
104
|
+
fieldName: "checkpoints",
|
|
105
|
+
subField: "photos",
|
|
106
|
+
entityId: FIRST_STYLEGUIDE_INSPECTION_ID,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
if (!photo.isSuccess) {
|
|
111
|
+
// biome-ignore lint/suspicious/noConsole: dev-seed surfaces failures in the runner log
|
|
112
|
+
console.error("[seed] styleguide inspection photo failed:", photo.error);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
photoIds.push(photo.data.id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { executor } = createEntityExecutor("inspection", inspectionEntity);
|
|
119
|
+
const res = await executor.create(
|
|
120
|
+
{
|
|
121
|
+
id: FIRST_STYLEGUIDE_INSPECTION_ID,
|
|
122
|
+
measuringDevice: "Torque wrench TW-200",
|
|
123
|
+
checkpoints: [
|
|
124
|
+
{ label: "Guard fitted", kind: "check", result: "ok" },
|
|
125
|
+
{
|
|
126
|
+
label: "Hydraulic hose intact",
|
|
127
|
+
kind: "check",
|
|
128
|
+
result: "not_ok",
|
|
129
|
+
finding: "Hose chafed at the clamp, replace before next shift.",
|
|
130
|
+
photos: [photoIds[0]],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
label: "Bolt torque (Nm)",
|
|
134
|
+
kind: "measurement",
|
|
135
|
+
min: 180,
|
|
136
|
+
max: 200,
|
|
137
|
+
measured: 190,
|
|
138
|
+
result: "ok",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
label: "Bearing play (mm)",
|
|
142
|
+
kind: "measurement",
|
|
143
|
+
min: 0.05,
|
|
144
|
+
max: 0.2,
|
|
145
|
+
measured: 0.31,
|
|
146
|
+
result: "not_ok",
|
|
147
|
+
finding: "Play above tolerance, re-measure after re-seating.",
|
|
148
|
+
photos: [photoIds[1]],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
continuedOperation: "with_condition",
|
|
152
|
+
condition: "Operate at reduced load until the hose is replaced.",
|
|
153
|
+
},
|
|
154
|
+
TestUsers.admin,
|
|
155
|
+
tenantDb,
|
|
156
|
+
);
|
|
157
|
+
if (!res.isSuccess) {
|
|
158
|
+
// biome-ignore lint/suspicious/noConsole: dev-seed surfaces failures in the runner log
|
|
159
|
+
console.error("[seed] styleguide inspection failed:", res.error);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
@@ -22,7 +22,7 @@ import { aiTextDemoFeature } from "../features/widgets/ai-text-demo";
|
|
|
22
22
|
import { widgetsFeature } from "../features/widgets/feature";
|
|
23
23
|
import { workboardFeature } from "../features/workboard/feature";
|
|
24
24
|
import { seedWorkboardTasks } from "../features/workboard/seed";
|
|
25
|
-
import { seedStyleguideItems } from "./seed";
|
|
25
|
+
import { seedStyleguideInspection, seedStyleguideItems } from "./seed";
|
|
26
26
|
|
|
27
27
|
const configResolver = createConfigResolver();
|
|
28
28
|
|
|
@@ -46,7 +46,7 @@ await runDevApp({
|
|
|
46
46
|
clientEntry: "./src/app/client.tsx",
|
|
47
47
|
htmlPath: "./public/index.html",
|
|
48
48
|
watchDirs: ["./src", "../../../packages/*/src"],
|
|
49
|
-
seeds: [seedStyleguideItems, seedAiConnections, seedWorkboardTasks],
|
|
49
|
+
seeds: [seedStyleguideItems, seedStyleguideInspection, seedAiConnections, seedWorkboardTasks],
|
|
50
50
|
extraContext: ({ registry }) => ({
|
|
51
51
|
configResolver,
|
|
52
52
|
configAccessorFactory: createConfigAccessorFactory(registry, configResolver),
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
createBooleanField,
|
|
8
8
|
createDateField,
|
|
9
|
+
createEmbeddedListField,
|
|
9
10
|
createEntity,
|
|
10
11
|
createImageField,
|
|
11
12
|
createLocatedTimestampField,
|
|
@@ -70,6 +71,65 @@ export const deliveryEntity = createEntity({
|
|
|
70
71
|
},
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
// Inspection — the inspection-checklist recipe's protocol (sub-fields, derived result,
|
|
75
|
+
// conditions), rendered as the phone checklist in the auto-form.
|
|
76
|
+
const isMeasurement = { field: "kind", eq: "measurement" } as const;
|
|
77
|
+
const isNotOk = { field: "result", eq: "not_ok" } as const;
|
|
78
|
+
|
|
79
|
+
export const inspectionEntity = createEntity({
|
|
80
|
+
table: "read_examples_inspection",
|
|
81
|
+
fields: {
|
|
82
|
+
measuringDevice: createTextField({ personal: false, reason: "is_business_data" }),
|
|
83
|
+
checkpoints: createEmbeddedListField(
|
|
84
|
+
{
|
|
85
|
+
label: { type: "text", required: true },
|
|
86
|
+
kind: { type: "select", options: ["check", "measurement"], required: true },
|
|
87
|
+
min: { type: "number", visibleWhen: isMeasurement },
|
|
88
|
+
max: { type: "number", visibleWhen: isMeasurement },
|
|
89
|
+
measured: { type: "number", visibleWhen: isMeasurement },
|
|
90
|
+
result: {
|
|
91
|
+
type: "select",
|
|
92
|
+
options: ["ok", "not_ok"],
|
|
93
|
+
display: "radio",
|
|
94
|
+
optionTones: { ok: "ok", not_ok: "bad" },
|
|
95
|
+
},
|
|
96
|
+
finding: { type: "text", visibleWhen: isNotOk, requiredWhen: isNotOk },
|
|
97
|
+
photos: {
|
|
98
|
+
type: "images",
|
|
99
|
+
capture: "environment",
|
|
100
|
+
maxCount: 5,
|
|
101
|
+
visibleWhen: isNotOk,
|
|
102
|
+
requiredWhen: isNotOk,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
personal: false,
|
|
107
|
+
reason: "is_business_data",
|
|
108
|
+
derived: {
|
|
109
|
+
result: {
|
|
110
|
+
op: "inRange",
|
|
111
|
+
from: ["measured", "min", "max"],
|
|
112
|
+
inside: "ok",
|
|
113
|
+
outside: "not_ok",
|
|
114
|
+
when: isMeasurement,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
),
|
|
119
|
+
continuedOperation: createSelectField({
|
|
120
|
+
options: ["yes", "with_condition", "no"],
|
|
121
|
+
display: "radio",
|
|
122
|
+
optionTones: { yes: "ok", with_condition: "warn", no: "bad" },
|
|
123
|
+
}),
|
|
124
|
+
condition: createTextField({
|
|
125
|
+
personal: false,
|
|
126
|
+
reason: "is_business_data",
|
|
127
|
+
visibleWhen: { field: "continuedOperation", eq: "with_condition" },
|
|
128
|
+
requiredWhen: { field: "continuedOperation", eq: "with_condition" },
|
|
129
|
+
}),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
73
133
|
const openReason =
|
|
74
134
|
"demo app: any signed-in user edits the shipping, profile and delivery example " +
|
|
75
135
|
"forms; there is no per-user ownership in this sample";
|
|
@@ -194,6 +254,35 @@ export const examplesFeature = defineFeature("examples", (r) => {
|
|
|
194
254
|
},
|
|
195
255
|
});
|
|
196
256
|
|
|
257
|
+
r.crud("inspection", inspectionEntity, editFormOnly);
|
|
258
|
+
|
|
259
|
+
r.screen({
|
|
260
|
+
id: "inspection-edit",
|
|
261
|
+
type: "entityEdit",
|
|
262
|
+
entity: "inspection",
|
|
263
|
+
submitLabel: "examples:inspection:submit",
|
|
264
|
+
layout: {
|
|
265
|
+
width: "full",
|
|
266
|
+
sections: [
|
|
267
|
+
{
|
|
268
|
+
columns: 1,
|
|
269
|
+
fields: [
|
|
270
|
+
"measuringDevice",
|
|
271
|
+
{
|
|
272
|
+
field: "checkpoints",
|
|
273
|
+
embeddedList: {
|
|
274
|
+
readOnlyCells: ["label", "kind", "min", "max"],
|
|
275
|
+
cardHiddenCells: ["kind"],
|
|
276
|
+
fixedRows: true,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
{ columns: 1, fields: ["continuedOperation", "condition"] },
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
197
286
|
r.nav({ id: "examples", label: "examples:nav.examples", icon: "folder", order: 30 });
|
|
198
287
|
r.nav({
|
|
199
288
|
id: "shipping",
|
|
@@ -219,4 +308,12 @@ export const examplesFeature = defineFeature("examples", (r) => {
|
|
|
219
308
|
icon: "calendar",
|
|
220
309
|
order: 30,
|
|
221
310
|
});
|
|
311
|
+
r.nav({
|
|
312
|
+
id: "inspection",
|
|
313
|
+
label: "examples:nav.inspection",
|
|
314
|
+
parent: "examples:nav:examples",
|
|
315
|
+
screen: "examples:screen:inspection-edit",
|
|
316
|
+
icon: "file",
|
|
317
|
+
order: 40,
|
|
318
|
+
});
|
|
222
319
|
});
|
|
@@ -69,4 +69,51 @@ export const EXAMPLES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
69
69
|
en: "Booked at (UTC instant)",
|
|
70
70
|
},
|
|
71
71
|
"examples:entity:delivery:field:homeZone": { de: "Heimatzone Kurier", en: "Courier home zone" },
|
|
72
|
+
"examples:nav.inspection": { de: "Prüfprotokoll", en: "Inspection checklist" },
|
|
73
|
+
"screen:inspection-edit.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
|
|
74
|
+
"screen:inspection-edit.create.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
|
|
75
|
+
"screen:inspection-edit.create.subtitle": {
|
|
76
|
+
de: "Prüfpunkte abhaken oder messen, bei Abweichung Befund und Fotos erfassen.",
|
|
77
|
+
en: "Check or measure each point, record a finding and photos on deviation.",
|
|
78
|
+
},
|
|
79
|
+
"screen:inspection-edit.edit.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
|
|
80
|
+
"screen:inspection-edit.edit.subtitle": {
|
|
81
|
+
de: "Prüfpunkte abhaken oder messen, bei Abweichung Befund und Fotos erfassen.",
|
|
82
|
+
en: "Check or measure each point, record a finding and photos on deviation.",
|
|
83
|
+
},
|
|
84
|
+
"examples:inspection:submit": { de: "Protokoll speichern", en: "Save protocol" },
|
|
85
|
+
"examples:entity:inspection:field:measuringDevice": { de: "Messgerät", en: "Measuring device" },
|
|
86
|
+
"examples:entity:inspection:field:checkpoints": { de: "Prüfpunkte", en: "Checkpoints" },
|
|
87
|
+
"examples:entity:inspection:field:checkpoints:cell:label": { de: "Prüfpunkt", en: "Checkpoint" },
|
|
88
|
+
"examples:entity:inspection:field:checkpoints:cell:kind": { de: "Art", en: "Kind" },
|
|
89
|
+
"examples:entity:inspection:field:checkpoints:cell:min": { de: "Min", en: "Min" },
|
|
90
|
+
"examples:entity:inspection:field:checkpoints:cell:max": { de: "Max", en: "Max" },
|
|
91
|
+
"examples:entity:inspection:field:checkpoints:cell:measured": { de: "Messwert", en: "Measured" },
|
|
92
|
+
"examples:entity:inspection:field:checkpoints:cell:result": { de: "Ergebnis", en: "Result" },
|
|
93
|
+
"examples:entity:inspection:field:checkpoints:cell:finding": { de: "Befund", en: "Finding" },
|
|
94
|
+
"examples:entity:inspection:field:checkpoints:cell:photos": { de: "Fotos", en: "Photos" },
|
|
95
|
+
"examples:entity:inspection:field:continuedOperation": {
|
|
96
|
+
de: "Weiterbetrieb",
|
|
97
|
+
en: "Continued operation",
|
|
98
|
+
},
|
|
99
|
+
"examples:entity:inspection:field:condition": { de: "Auflage", en: "Condition" },
|
|
100
|
+
"examples:entity:inspection:field:checkpoints:cell:kind:option:check": {
|
|
101
|
+
de: "Sichtprüfung",
|
|
102
|
+
en: "Check",
|
|
103
|
+
},
|
|
104
|
+
"examples:entity:inspection:field:checkpoints:cell:kind:option:measurement": {
|
|
105
|
+
de: "Messung",
|
|
106
|
+
en: "Measurement",
|
|
107
|
+
},
|
|
108
|
+
"examples:entity:inspection:field:checkpoints:cell:result:option:ok": { de: "i.O.", en: "OK" },
|
|
109
|
+
"examples:entity:inspection:field:checkpoints:cell:result:option:not_ok": {
|
|
110
|
+
de: "n.i.O.",
|
|
111
|
+
en: "Not OK",
|
|
112
|
+
},
|
|
113
|
+
"examples:entity:inspection:field:continuedOperation:option:yes": { de: "Ja", en: "Yes" },
|
|
114
|
+
"examples:entity:inspection:field:continuedOperation:option:with_condition": {
|
|
115
|
+
de: "Mit Auflage",
|
|
116
|
+
en: "With condition",
|
|
117
|
+
},
|
|
118
|
+
"examples:entity:inspection:field:continuedOperation:option:no": { de: "Nein", en: "No" },
|
|
72
119
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
|
|
3
|
-
"featureCount":
|
|
3
|
+
"featureCount": 90,
|
|
4
4
|
"features": [
|
|
5
5
|
{
|
|
6
6
|
"name": "account-security",
|
|
@@ -914,6 +914,35 @@
|
|
|
914
914
|
"recommended": false
|
|
915
915
|
}
|
|
916
916
|
},
|
|
917
|
+
{
|
|
918
|
+
"name": "documents",
|
|
919
|
+
"description": "Filed documents with title, category, issue and expiry date and one file, shown on the records they are linked to. A document is visible only through a linked record the viewer can read, and only while the record that owns its file is readable too, so linking never widens access to the file. Documents arrive through capture rules (a file saved into a configured field) or through the system-only import for migrations, which keeps the caller's id, refuses an id another record already uses, and stamps createdAt with the import time (issuedAt carries the business date; the event store does not backdate); users link, unlink, edit and delete them. Read a record's documents with for-record, count expired and expiring ones with expiry-overview, list them with an expiry state and an expiring filter with overview (the documents list screen), and add the documents tab to a record screen with documentsTab().",
|
|
920
|
+
"toggleableDefault": null,
|
|
921
|
+
"adoptsBundledLists": true,
|
|
922
|
+
"requires": [
|
|
923
|
+
"files"
|
|
924
|
+
],
|
|
925
|
+
"optionalRequires": [],
|
|
926
|
+
"configReads": [],
|
|
927
|
+
"exposesApis": [],
|
|
928
|
+
"usesApis": [],
|
|
929
|
+
"extensionsUsed": [],
|
|
930
|
+
"configKeys": [],
|
|
931
|
+
"secrets": [],
|
|
932
|
+
"writeHandlers": [
|
|
933
|
+
"documents:write:filed-document:create",
|
|
934
|
+
"documents:write:filed-document:delete",
|
|
935
|
+
"documents:write:filed-document:update",
|
|
936
|
+
"documents:write:import",
|
|
937
|
+
"documents:write:link",
|
|
938
|
+
"documents:write:unlink"
|
|
939
|
+
],
|
|
940
|
+
"uiHints": {
|
|
941
|
+
"displayLabel": "Documents",
|
|
942
|
+
"category": "data",
|
|
943
|
+
"recommended": false
|
|
944
|
+
}
|
|
945
|
+
},
|
|
917
946
|
{
|
|
918
947
|
"name": "feature-toggles",
|
|
919
948
|
"description": "Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: \"shared\"` syncs the snapshot across instances whenever a `toggle-set` event is appended.",
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
14
14
|
import type { RunDevAppOptions } from "@cosmicdrift/kumiko-dev-server";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
FILE_STORAGE_PROVIDER_ENV,
|
|
17
|
+
SYSTEM_TENANT_ID,
|
|
18
|
+
type TenantId,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
20
|
import { BUNDLED_LISTS } from "../bundled-lists";
|
|
17
21
|
import { AUTH_COMPOSE_OPTIONS, BOOT_FEATURES, VALIDATE_BOOT_OPTIONS } from "../run-config";
|
|
18
22
|
import { ADMIN_EMAIL, ADMIN_PASSWORD, BETA_TENANT_ID, DEV_TENANT_ID } from "./auth-constants";
|
|
@@ -31,7 +35,12 @@ export const devAppOptions = {
|
|
|
31
35
|
// it stays out of APP_FEATURES. appScreensFeature places the user-profile screen.
|
|
32
36
|
features: [...BOOT_FEATURES, appScreensFeature, notesFeature, invoiceReportsFeature],
|
|
33
37
|
featureToggles: true,
|
|
34
|
-
|
|
38
|
+
// documents has a file field, so the boot gate wants a storage provider; the seeded
|
|
39
|
+
// file-foundation config selects the real one, so only the validation env names it.
|
|
40
|
+
validateBootOptions: {
|
|
41
|
+
...VALIDATE_BOOT_OPTIONS,
|
|
42
|
+
env: { [FILE_STORAGE_PROVIDER_ENV]: "inmemory", ...process.env },
|
|
43
|
+
},
|
|
35
44
|
bundledLists: BUNDLED_LISTS,
|
|
36
45
|
port: Number.parseInt(process.env["PORT"] ?? "4186", 10),
|
|
37
46
|
clientEntry: "./src/app/client.tsx",
|