@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
|
@@ -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",
|
|
@@ -472,7 +472,7 @@
|
|
|
472
472
|
},
|
|
473
473
|
{
|
|
474
474
|
"name": "channel-in-app",
|
|
475
|
-
"description": "Persists notifications to an in-app inbox table so users can
|
|
475
|
+
"description": "Persists notifications to an in-app inbox table so users can page through them via `queries.inbox` (cursor, total, parsed `target`) and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. A notification links to a screen when `data.target` is set with `inAppTarget(screenId, entityId?)`. The renderer shell shows a bell with a live unread badge whenever this feature is mounted. Requires `delivery`; no external service needed — messages are stored in the app's own database.",
|
|
476
476
|
"toggleableDefault": null,
|
|
477
477
|
"requires": [
|
|
478
478
|
"delivery"
|
|
@@ -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",
|
|
@@ -43,6 +43,7 @@ import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features
|
|
|
43
43
|
import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
44
44
|
import { createDerivativesSharpFeature } from "@cosmicdrift/kumiko-bundled-features/derivatives-sharp";
|
|
45
45
|
import { createDocumentIngestFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/document-ingest-foundation";
|
|
46
|
+
import { createDocumentsFeature } from "@cosmicdrift/kumiko-bundled-features/documents";
|
|
46
47
|
import {
|
|
47
48
|
createFeatureTogglesFeature,
|
|
48
49
|
FEATURE_TOGGLES_FEATURE,
|
|
@@ -406,6 +407,12 @@ export const APP_FEATURES = [
|
|
|
406
407
|
// user-data-rights — both mounted here.
|
|
407
408
|
createFormDraftUserDataFeature(),
|
|
408
409
|
createFoldersFeature(),
|
|
410
|
+
// documents: filed files visible through the records they link to; folder stands in for a host.
|
|
411
|
+
createDocumentsFeature({
|
|
412
|
+
categories: ["contract", "other"],
|
|
413
|
+
linkTargets: ["folder"],
|
|
414
|
+
adminRoles: ["TenantAdmin", "SystemAdmin"],
|
|
415
|
+
}),
|
|
409
416
|
// folders-user-data: GDPR hooks for folder entities. Depends (optionally)
|
|
410
417
|
// on folders + (hard) on user-data-rights — both mounted above.
|
|
411
418
|
createFoldersUserDataFeature(),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Derived fields (SQL)
|
|
2
|
+
|
|
3
|
+
A derived field is read-time only: no column, never in a write schema. A `sql` derived field is
|
|
4
|
+
computed inside the list SELECT as the viewer, so it can be sorted on the server and never
|
|
5
|
+
enters the entity cache. A viewer who cannot read a field the value depends on gets NULL.
|
|
6
|
+
|
|
7
|
+
## What it shows
|
|
8
|
+
|
|
9
|
+
- `coalesce`: the nickname when set, else the name.
|
|
10
|
+
- `related` + `count` with a `where`: open tickets per building.
|
|
11
|
+
- `related` + `first`: the title of the newest ticket.
|
|
12
|
+
- `related` + `first` on a money field with `valueType: "money"`: amount and currency come
|
|
13
|
+
back in the shape of a stored money field (no key when the value is unset).
|
|
14
|
+
- `sortable: true` on a derived field and an `entityList` `defaultSort` on it.
|
|
15
|
+
|
|
16
|
+
## Source
|
|
17
|
+
|
|
18
|
+
Feature entry point: `src/feature.ts`.
|
|
19
|
+
|
|
20
|
+
## Tests
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd samples/recipes/derived-fields-sql
|
|
24
|
+
bun test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`src/__tests__/feature.integration.test.ts` runs the list query over HTTP.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-sample-derived-fields-sql",
|
|
3
|
+
"description": "SQL derived fields on a list: coalesce, related count with a where, related first (text and money), sortable columns and a defaultSort on a derived value.",
|
|
4
|
+
"private": true,
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@cosmicdrift/kumiko-framework": "workspace:*"
|
|
7
|
+
},
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@cosmicdrift/kumiko-testing": "workspace:*"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDateField,
|
|
3
|
+
createDerivedField,
|
|
4
|
+
createEntity,
|
|
5
|
+
createMoneyField,
|
|
6
|
+
createSelectField,
|
|
7
|
+
createTextField,
|
|
8
|
+
defineFeature,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
|
|
11
|
+
const openAccess = {
|
|
12
|
+
access: {
|
|
13
|
+
openToAll: { reason: "recipe: any signed-in user reads and edits; no per-user ownership" },
|
|
14
|
+
},
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export const ticketEntity = createEntity({
|
|
18
|
+
table: "read_derived_sql_tickets",
|
|
19
|
+
fields: {
|
|
20
|
+
buildingId: { type: "reference", entity: "building" },
|
|
21
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
22
|
+
status: createSelectField({ options: ["open", "inProgress", "done"] as const }),
|
|
23
|
+
openedOn: createDateField({ required: true }),
|
|
24
|
+
cost: createMoneyField(),
|
|
25
|
+
},
|
|
26
|
+
// The sortable derived columns of the building run a subquery per row over this column.
|
|
27
|
+
indexes: [{ columns: ["buildingId"] }],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const buildingEntity = createEntity({
|
|
31
|
+
table: "read_derived_sql_buildings",
|
|
32
|
+
fields: {
|
|
33
|
+
name: createTextField({
|
|
34
|
+
personal: false,
|
|
35
|
+
reason: "is_business_data",
|
|
36
|
+
required: true,
|
|
37
|
+
searchable: true,
|
|
38
|
+
}),
|
|
39
|
+
nickname: createTextField({ personal: false, reason: "is_business_data" }),
|
|
40
|
+
},
|
|
41
|
+
// Computed inside the list SELECT as the viewer; never stored, never cached.
|
|
42
|
+
derivedFields: {
|
|
43
|
+
// First non-null of the columns, so the list shows the nickname when there is one.
|
|
44
|
+
shownName: createDerivedField({
|
|
45
|
+
valueType: "text",
|
|
46
|
+
sortable: true,
|
|
47
|
+
sql: { kind: "coalesce", fields: ["nickname", "name"] },
|
|
48
|
+
}),
|
|
49
|
+
openTickets: createDerivedField({
|
|
50
|
+
valueType: "number",
|
|
51
|
+
sortable: true,
|
|
52
|
+
sql: {
|
|
53
|
+
kind: "related",
|
|
54
|
+
entity: "ticket",
|
|
55
|
+
on: { field: "buildingId" },
|
|
56
|
+
measure: { kind: "count" },
|
|
57
|
+
where: [{ field: "status", op: "in", value: ["open", "inProgress"] }],
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
latestTicket: createDerivedField({
|
|
61
|
+
valueType: "text",
|
|
62
|
+
sql: {
|
|
63
|
+
kind: "related",
|
|
64
|
+
entity: "ticket",
|
|
65
|
+
on: { field: "buildingId" },
|
|
66
|
+
measure: { kind: "first", field: "title", orderBy: "openedOn", direction: "desc" },
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
// Money reads amount and currency together and has the shape of a stored money field.
|
|
70
|
+
latestCost: createDerivedField({
|
|
71
|
+
valueType: "money",
|
|
72
|
+
sortable: true,
|
|
73
|
+
sql: {
|
|
74
|
+
kind: "related",
|
|
75
|
+
entity: "ticket",
|
|
76
|
+
on: { field: "buildingId" },
|
|
77
|
+
measure: { kind: "first", field: "cost", orderBy: "openedOn", direction: "desc" },
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const buildingListSort = { field: "openTickets", dir: "desc" } as const;
|
|
84
|
+
|
|
85
|
+
export const derivedFieldsSqlFeature = defineFeature("derived-fields-sql", (r) => {
|
|
86
|
+
r.crud("ticket", ticketEntity, { write: openAccess, read: openAccess });
|
|
87
|
+
r.crud("building", buildingEntity, { write: openAccess, read: openAccess });
|
|
88
|
+
|
|
89
|
+
r.screen({
|
|
90
|
+
id: "building-list",
|
|
91
|
+
type: "entityList",
|
|
92
|
+
entity: "building",
|
|
93
|
+
columns: ["shownName", "openTickets", "latestTicket", "latestCost"],
|
|
94
|
+
// A derived column sorts on the server only when it is `sortable: true`.
|
|
95
|
+
defaultSort: buildingListSort,
|
|
96
|
+
access: openAccess.access,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-sample-documents",
|
|
3
|
+
"description": "A document register on top of existing records: captured files, expiry dates and per-record visibility.",
|
|
4
|
+
"private": true,
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@cosmicdrift/kumiko-bundled-features": "workspace:*",
|
|
7
|
+
"@cosmicdrift/kumiko-framework": "workspace:*"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@cosmicdrift/kumiko-testing": "workspace:*"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
// Documents Sample
|
|
3
|
+
//
|
|
4
|
+
// A `contract` entity with a file field becomes a document source without knowing about the
|
|
5
|
+
// documents feature: the capture rule below files every saved contract file as a document of
|
|
6
|
+
// category "contract" and links it to the contract and to its property. Who sees the document
|
|
7
|
+
// follows from who may read the linked records and the contract that owns the file.
|
|
8
|
+
|
|
9
|
+
import { createDocumentsFeature } from "@cosmicdrift/kumiko-bundled-features/documents";
|
|
10
|
+
import {
|
|
11
|
+
createEntity,
|
|
12
|
+
createFileField,
|
|
13
|
+
createTextField,
|
|
14
|
+
defineEntityCreateHandler,
|
|
15
|
+
defineEntityDetailHandler,
|
|
16
|
+
defineEntityListHandler,
|
|
17
|
+
defineEntityUpdateHandler,
|
|
18
|
+
defineFeature,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
|
|
21
|
+
const text = () =>
|
|
22
|
+
createTextField({ personal: false, reason: "is_business_data", required: true, maxLength: 120 });
|
|
23
|
+
|
|
24
|
+
export const propertyEntity = createEntity({
|
|
25
|
+
table: "read_sample_documents_properties",
|
|
26
|
+
fields: { name: text() },
|
|
27
|
+
access: { read: { PropertyReader: "all", TenantAdmin: "all" } },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const contractEntity = createEntity({
|
|
31
|
+
table: "read_sample_documents_contracts",
|
|
32
|
+
fields: { name: text(), propertyId: text(), file: createFileField() },
|
|
33
|
+
access: { read: { ContractReader: "all", TenantAdmin: "all" } },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const access = { roles: ["PropertyReader", "ContractReader", "TenantAdmin"] };
|
|
37
|
+
|
|
38
|
+
export const propertyManagement = defineFeature("property-management", (r) => {
|
|
39
|
+
r.requires({ features: ["files", "documents"] });
|
|
40
|
+
r.entity({ name: "property", ...propertyEntity });
|
|
41
|
+
r.entity({ name: "contract", ...contractEntity });
|
|
42
|
+
for (const [name, entity] of [
|
|
43
|
+
["property", propertyEntity],
|
|
44
|
+
["contract", contractEntity],
|
|
45
|
+
] as const) {
|
|
46
|
+
r.writeHandler(defineEntityCreateHandler(name, entity, { access }));
|
|
47
|
+
r.writeHandler(defineEntityUpdateHandler(name, entity, { access: { roles: ["TenantAdmin"] } }));
|
|
48
|
+
r.queryHandler(defineEntityListHandler(name, entity, { access }));
|
|
49
|
+
r.queryHandler(defineEntityDetailHandler(name, entity, { access }));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const documents = createDocumentsFeature({
|
|
54
|
+
categories: ["contract", "insurance"],
|
|
55
|
+
linkTargets: ["property", "contract"],
|
|
56
|
+
access,
|
|
57
|
+
capture: [
|
|
58
|
+
{
|
|
59
|
+
entity: "contract",
|
|
60
|
+
field: "file",
|
|
61
|
+
category: "contract",
|
|
62
|
+
title: (row) => `Contract ${String(row["name"])}`,
|
|
63
|
+
links: (row) => [{ entityType: "property", entityId: String(row["propertyId"]) }],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Inspection checklist
|
|
2
|
+
|
|
3
|
+
An embedded list as a phone inspection checklist: the inspector walks fixed rows,
|
|
4
|
+
enters a measurement or a result, and documents deviations with a finding and photos.
|
|
5
|
+
|
|
6
|
+
## What it shows
|
|
7
|
+
|
|
8
|
+
- **Conditional cells** (`src/entities/inspection-protocol.ts`): `visibleWhen` and
|
|
9
|
+
`requiredWhen` on sub-fields and on top-level fields. The server enforces
|
|
10
|
+
`requiredWhen` on create and update and answers with one `required` path per
|
|
11
|
+
missing cell (`checkpoints.0.finding`, `condition`).
|
|
12
|
+
- **Pass/fail by range**: the derived `inRange` cell turns `measured`, `min` and `max`
|
|
13
|
+
into `ok` or `not_ok` (bounds included). The server recomputes it, so a client-sent
|
|
14
|
+
result never survives a value outside the range. `when` limits it to measurement rows;
|
|
15
|
+
a check row keeps the result the inspector picked.
|
|
16
|
+
- **Photos per row**: an `images` sub-field. Upload with `entityType`, `fieldName` of the
|
|
17
|
+
embedded list and `subField` of the photo cell; files are bound, tenant-checked and
|
|
18
|
+
disposed like a top-level images field, always under the embedded list's field name.
|
|
19
|
+
- **Fixed rows**: `inspection-protocol:create-from-plan` copies label, kind and limits from
|
|
20
|
+
a plan; the edit screen keeps those cells read-only and the row count fixed. It sets
|
|
21
|
+
`layout.width: "full"` because the default form width pushes result, finding and photos
|
|
22
|
+
behind horizontal scroll.
|
|
23
|
+
|
|
24
|
+
## Source
|
|
25
|
+
|
|
26
|
+
`src/feature.ts` wires the two entities, the handler and the screen. The integration test
|
|
27
|
+
covers every behavior above over real HTTP.
|