forge-openclaw-plugin 0.2.24 → 0.2.25
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/README.md +13 -0
- package/dist/assets/{board-_C6oMy5w.js → board-VmF4FAfr.js} +3 -3
- package/dist/assets/{board-_C6oMy5w.js.map → board-VmF4FAfr.js.map} +1 -1
- package/dist/assets/index-CFCKDIMH.js +67 -0
- package/dist/assets/index-CFCKDIMH.js.map +1 -0
- package/dist/assets/index-ZPY6U1TU.css +1 -0
- package/dist/assets/{motion-D4sZgCHd.js → motion-DvkU14p-.js} +3 -3
- package/dist/assets/motion-DvkU14p-.js.map +1 -0
- package/dist/assets/{table-BWzTaky1.js → table-DgiPof9E.js} +2 -2
- package/dist/assets/{table-BWzTaky1.js.map → table-DgiPof9E.js.map} +1 -1
- package/dist/assets/{ui-BzK4azQb.js → ui-nYfoC0Gq.js} +2 -2
- package/dist/assets/{ui-BzK4azQb.js.map → ui-nYfoC0Gq.js.map} +1 -1
- package/dist/assets/vendor-D9PTEPSB.js +824 -0
- package/dist/assets/vendor-D9PTEPSB.js.map +1 -0
- package/dist/assets/viz-Cqb6s--o.js +34 -0
- package/dist/assets/viz-Cqb6s--o.js.map +1 -0
- package/dist/index.html +8 -8
- package/dist/openclaw/parity.d.ts +1 -1
- package/dist/openclaw/parity.js +29 -0
- package/dist/openclaw/plugin-entry-shared.d.ts +1 -0
- package/dist/openclaw/plugin-entry-shared.js +7 -4
- package/dist/openclaw/plugin-sdk-types.d.ts +12 -0
- package/dist/openclaw/routes.js +236 -0
- package/dist/openclaw/session-bootstrap.d.ts +78 -0
- package/dist/openclaw/session-bootstrap.js +240 -0
- package/dist/openclaw/tools.js +279 -3
- package/dist/server/app.js +855 -19
- package/dist/server/connectors/box-registry.js +257 -0
- package/dist/server/db.js +2 -0
- package/dist/server/discovery-advertiser.js +114 -0
- package/dist/server/health.js +39 -11
- package/dist/server/index.js +4 -0
- package/dist/server/managers/platform/llm-manager.js +40 -4
- package/dist/server/managers/platform/openai-responses-provider.js +129 -19
- package/dist/server/movement.js +2935 -0
- package/dist/server/openapi.js +628 -5
- package/dist/server/psyche-types.js +15 -1
- package/dist/server/questionnaire-flow.js +552 -0
- package/dist/server/questionnaire-seeds.js +853 -0
- package/dist/server/questionnaire-types.js +340 -0
- package/dist/server/repositories/ai-connectors.js +944 -0
- package/dist/server/repositories/ai-processors.js +547 -0
- package/dist/server/repositories/entity-ownership.js +9 -1
- package/dist/server/repositories/habits.js +69 -5
- package/dist/server/repositories/model-settings.js +216 -0
- package/dist/server/repositories/notes.js +57 -15
- package/dist/server/repositories/preferences.js +124 -0
- package/dist/server/repositories/questionnaires.js +1338 -0
- package/dist/server/repositories/settings.js +108 -12
- package/dist/server/repositories/surface-layouts.js +76 -0
- package/dist/server/repositories/wiki-memory.js +5 -1
- package/dist/server/services/entity-crud.js +81 -2
- package/dist/server/services/openai-codex-oauth.js +153 -0
- package/dist/server/services/psyche-observation-calendar.js +46 -0
- package/dist/server/types.js +492 -3
- package/dist/server/watch-mobile.js +562 -0
- package/dist/server/web.js +9 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -1
- package/server/migrations/024_questionnaires.sql +96 -0
- package/server/migrations/025_ai_model_connections.sql +26 -0
- package/server/migrations/026_custom_theme_settings.sql +2 -0
- package/server/migrations/027_ai_processors.sql +31 -0
- package/server/migrations/028_movement_domain.sql +136 -0
- package/server/migrations/029_watch_micro_capture.sql +23 -0
- package/server/migrations/030_surface_layouts.sql +5 -0
- package/server/migrations/031_ai_processor_runtime_upgrades.sql +10 -0
- package/server/migrations/032_ai_connectors.sql +44 -0
- package/server/migrations/033_movement_trip_point_sync.sql +36 -0
- package/server/migrations/034_movement_segment_sync.sql +49 -0
- package/skills/forge-openclaw/SKILL.md +12 -1
- package/skills/forge-openclaw/entity_conversation_playbooks.md +331 -84
- package/skills/forge-openclaw/psyche_entity_playbooks.md +252 -221
- package/dist/assets/index-DTCwBWAs.js +0 -65
- package/dist/assets/index-DTCwBWAs.js.map +0 -1
- package/dist/assets/index-DttXlAgi.css +0 -1
- package/dist/assets/motion-D4sZgCHd.js.map +0 -1
- package/dist/assets/vendor-De38P6YR.js +0 -729
- package/dist/assets/vendor-De38P6YR.js.map +0 -1
- package/dist/assets/viz-C6hfyqzu.js +0 -34
- package/dist/assets/viz-C6hfyqzu.js.map +0 -1
- package/skills/forge-openclaw/cron_jobs.md +0 -395
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { HttpError } from "../errors.js";
|
|
4
|
+
import { getQuestionnaireSeeds } from "../questionnaire-seeds.js";
|
|
5
|
+
import { getQuestionnaireVisibilityState, validateQuestionnaireFlow } from "../questionnaire-flow.js";
|
|
6
|
+
import { createQuestionnaireInstrumentSchema, questionnaireInstrumentSummarySchema, publishQuestionnaireVersionSchema, questionnaireDefinitionSchema, questionnaireInstrumentDetailSchema, questionnaireRunDetailSchema, questionnaireRunSchema, questionnaireRunScoreSchema, questionnaireScoringSchema, questionnaireVersionSchema, startQuestionnaireRunSchema, updateQuestionnaireRunSchema, updateQuestionnaireVersionSchema } from "../questionnaire-types.js";
|
|
7
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
8
|
+
import { createNote } from "./notes.js";
|
|
9
|
+
const DEFAULT_CUSTOM_USER_ID = "user_operator";
|
|
10
|
+
const SELF_OBSERVATION_TAG = "Self-observation";
|
|
11
|
+
function nowIso() {
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
}
|
|
14
|
+
function parseJson(value) {
|
|
15
|
+
return JSON.parse(value);
|
|
16
|
+
}
|
|
17
|
+
function buildId(prefix) {
|
|
18
|
+
return `${prefix}_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
19
|
+
}
|
|
20
|
+
function createHttpError(options) {
|
|
21
|
+
return new HttpError(options.statusCode, options.code, options.message, options.details);
|
|
22
|
+
}
|
|
23
|
+
function slugify(text) {
|
|
24
|
+
const normalized = text
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
28
|
+
.replace(/^-+|-+$/g, "");
|
|
29
|
+
return normalized || `questionnaire-${randomUUID().slice(0, 8)}`;
|
|
30
|
+
}
|
|
31
|
+
function normalizeCustomOwner(userId) {
|
|
32
|
+
return userId ?? DEFAULT_CUSTOM_USER_ID;
|
|
33
|
+
}
|
|
34
|
+
function isInstrumentVisible(row, userIds) {
|
|
35
|
+
if (row.is_system === 1) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (!userIds || userIds.length === 0) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return row.owner_user_id ? userIds.includes(row.owner_user_id) : true;
|
|
42
|
+
}
|
|
43
|
+
function mapVersion(row) {
|
|
44
|
+
return questionnaireVersionSchema.parse({
|
|
45
|
+
id: row.id,
|
|
46
|
+
instrumentId: row.instrument_id,
|
|
47
|
+
versionNumber: row.version_number,
|
|
48
|
+
status: row.status,
|
|
49
|
+
label: row.label,
|
|
50
|
+
isReadOnly: row.is_read_only === 1,
|
|
51
|
+
definition: questionnaireDefinitionSchema.parse(parseJson(row.definition_json)),
|
|
52
|
+
scoring: questionnaireScoringSchema.parse(parseJson(row.scoring_json)),
|
|
53
|
+
provenance: parseJson(row.provenance_json),
|
|
54
|
+
createdBy: row.created_by,
|
|
55
|
+
createdAt: row.created_at,
|
|
56
|
+
updatedAt: row.updated_at,
|
|
57
|
+
publishedAt: row.published_at
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function selectPrimaryVersion(instrument, versions) {
|
|
61
|
+
if (instrument.current_published_version_id) {
|
|
62
|
+
return (versions.find((version) => version.id === instrument.current_published_version_id) ??
|
|
63
|
+
null);
|
|
64
|
+
}
|
|
65
|
+
if (instrument.current_draft_version_id) {
|
|
66
|
+
return (versions.find((version) => version.id === instrument.current_draft_version_id) ?? null);
|
|
67
|
+
}
|
|
68
|
+
return versions[0] ?? null;
|
|
69
|
+
}
|
|
70
|
+
function getHistoryForInstrument(instrumentId, userIds) {
|
|
71
|
+
const database = getDatabase();
|
|
72
|
+
const rows = database
|
|
73
|
+
.prepare(`
|
|
74
|
+
SELECT
|
|
75
|
+
runs.id AS run_id,
|
|
76
|
+
runs.completed_at,
|
|
77
|
+
scores.label AS score_label,
|
|
78
|
+
scores.value_numeric AS score_value,
|
|
79
|
+
scores.band_label
|
|
80
|
+
FROM questionnaire_runs runs
|
|
81
|
+
LEFT JOIN questionnaire_run_scores scores
|
|
82
|
+
ON scores.run_id = runs.id
|
|
83
|
+
AND scores.sort_order = (
|
|
84
|
+
SELECT MIN(inner_scores.sort_order)
|
|
85
|
+
FROM questionnaire_run_scores inner_scores
|
|
86
|
+
WHERE inner_scores.run_id = runs.id
|
|
87
|
+
AND inner_scores.value_numeric IS NOT NULL
|
|
88
|
+
)
|
|
89
|
+
WHERE runs.instrument_id = ?
|
|
90
|
+
AND runs.status = 'completed'
|
|
91
|
+
${userIds && userIds.length > 0 ? `AND COALESCE(runs.user_id, '') IN (${userIds.map(() => "?").join(",")})` : ""}
|
|
92
|
+
ORDER BY runs.completed_at DESC
|
|
93
|
+
LIMIT 40
|
|
94
|
+
`)
|
|
95
|
+
.all(instrumentId, ...(userIds?.map((entry) => entry ?? "") ?? []));
|
|
96
|
+
return rows.map((row) => ({
|
|
97
|
+
runId: row.run_id,
|
|
98
|
+
completedAt: row.completed_at,
|
|
99
|
+
primaryScore: row.score_value,
|
|
100
|
+
primaryScoreLabel: row.score_label ?? "",
|
|
101
|
+
bandLabel: row.band_label ?? ""
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
function getLatestDraftRunId(instrumentId, versionId, userIds) {
|
|
105
|
+
if (!versionId) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const database = getDatabase();
|
|
109
|
+
const row = database
|
|
110
|
+
.prepare(`
|
|
111
|
+
SELECT id
|
|
112
|
+
FROM questionnaire_runs
|
|
113
|
+
WHERE instrument_id = ?
|
|
114
|
+
AND version_id = ?
|
|
115
|
+
AND status = 'draft'
|
|
116
|
+
${userIds && userIds.length > 0 ? `AND COALESCE(user_id, '') IN (${userIds.map(() => "?").join(",")})` : ""}
|
|
117
|
+
ORDER BY updated_at DESC
|
|
118
|
+
LIMIT 1
|
|
119
|
+
`)
|
|
120
|
+
.get(instrumentId, versionId, ...(userIds?.map((entry) => entry ?? "") ?? []));
|
|
121
|
+
return row?.id ?? null;
|
|
122
|
+
}
|
|
123
|
+
function getSummaryStats(instrumentId, userIds) {
|
|
124
|
+
const database = getDatabase();
|
|
125
|
+
const completedRow = database
|
|
126
|
+
.prepare(`
|
|
127
|
+
SELECT COUNT(*) AS count
|
|
128
|
+
FROM questionnaire_runs
|
|
129
|
+
WHERE instrument_id = ?
|
|
130
|
+
AND status = 'completed'
|
|
131
|
+
${userIds && userIds.length > 0 ? `AND COALESCE(user_id, '') IN (${userIds.map(() => "?").join(",")})` : ""}
|
|
132
|
+
`)
|
|
133
|
+
.get(instrumentId, ...(userIds?.map((entry) => entry ?? "") ?? []));
|
|
134
|
+
const latestRow = database
|
|
135
|
+
.prepare(`
|
|
136
|
+
SELECT id, completed_at
|
|
137
|
+
FROM questionnaire_runs
|
|
138
|
+
WHERE instrument_id = ?
|
|
139
|
+
AND status = 'completed'
|
|
140
|
+
${userIds && userIds.length > 0 ? `AND COALESCE(user_id, '') IN (${userIds.map(() => "?").join(",")})` : ""}
|
|
141
|
+
ORDER BY completed_at DESC
|
|
142
|
+
LIMIT 1
|
|
143
|
+
`)
|
|
144
|
+
.get(instrumentId, ...(userIds?.map((entry) => entry ?? "") ?? []));
|
|
145
|
+
return {
|
|
146
|
+
completedRunCount: completedRow.count,
|
|
147
|
+
latestRunId: latestRow?.id ?? null,
|
|
148
|
+
latestRunAt: latestRow?.completed_at ?? null
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function mapSummary(row, versions, userIds) {
|
|
152
|
+
const currentVersion = selectPrimaryVersion(row, versions);
|
|
153
|
+
const stats = getSummaryStats(row.id, userIds);
|
|
154
|
+
const primarySourceUrl = currentVersion?.provenance.sources[0]?.url ?? "";
|
|
155
|
+
return questionnaireInstrumentSummarySchema.parse({
|
|
156
|
+
id: row.id,
|
|
157
|
+
key: row.key,
|
|
158
|
+
slug: row.slug,
|
|
159
|
+
title: row.title,
|
|
160
|
+
subtitle: row.subtitle,
|
|
161
|
+
description: row.description,
|
|
162
|
+
aliases: parseJson(row.aliases_json),
|
|
163
|
+
symptomDomains: parseJson(row.symptom_domains_json),
|
|
164
|
+
tags: parseJson(row.tags_json),
|
|
165
|
+
sourceClass: row.source_class,
|
|
166
|
+
availability: row.availability,
|
|
167
|
+
responseStyle: currentVersion?.definition.responseStyle ?? "unknown",
|
|
168
|
+
presentationMode: currentVersion?.definition.presentationMode ?? "single_question",
|
|
169
|
+
itemCount: currentVersion?.definition.items.length ?? 0,
|
|
170
|
+
isSelfReport: row.is_self_report === 1,
|
|
171
|
+
isSystem: row.is_system === 1,
|
|
172
|
+
isReadOnly: row.is_system === 1 || currentVersion?.status !== "draft",
|
|
173
|
+
ownerUserId: row.owner_user_id,
|
|
174
|
+
currentVersionId: currentVersion?.id ?? null,
|
|
175
|
+
currentVersionNumber: currentVersion?.versionNumber ?? null,
|
|
176
|
+
latestRunId: stats.latestRunId,
|
|
177
|
+
latestRunAt: stats.latestRunAt,
|
|
178
|
+
completedRunCount: stats.completedRunCount,
|
|
179
|
+
primarySourceUrl,
|
|
180
|
+
createdAt: row.created_at,
|
|
181
|
+
updatedAt: row.updated_at
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function assertValidQuestionnaireDefinition(definition) {
|
|
185
|
+
validateQuestionnaireFlow(definition);
|
|
186
|
+
}
|
|
187
|
+
function getVersionRowsForInstrument(instrumentId) {
|
|
188
|
+
return getDatabase()
|
|
189
|
+
.prepare(`
|
|
190
|
+
SELECT *
|
|
191
|
+
FROM questionnaire_versions
|
|
192
|
+
WHERE instrument_id = ?
|
|
193
|
+
ORDER BY version_number DESC
|
|
194
|
+
`)
|
|
195
|
+
.all(instrumentId);
|
|
196
|
+
}
|
|
197
|
+
function getInstrumentRow(id) {
|
|
198
|
+
return getDatabase()
|
|
199
|
+
.prepare("SELECT * FROM questionnaire_instruments WHERE id = ?")
|
|
200
|
+
.get(id);
|
|
201
|
+
}
|
|
202
|
+
function getVersionRow(id) {
|
|
203
|
+
return getDatabase()
|
|
204
|
+
.prepare("SELECT * FROM questionnaire_versions WHERE id = ?")
|
|
205
|
+
.get(id);
|
|
206
|
+
}
|
|
207
|
+
function getRunRow(id) {
|
|
208
|
+
return getDatabase()
|
|
209
|
+
.prepare("SELECT * FROM questionnaire_runs WHERE id = ?")
|
|
210
|
+
.get(id);
|
|
211
|
+
}
|
|
212
|
+
function getCurrentPublishedOrDraftVersion(instrument) {
|
|
213
|
+
const versionId = instrument.current_published_version_id ?? instrument.current_draft_version_id;
|
|
214
|
+
if (!versionId) {
|
|
215
|
+
throw createHttpError({
|
|
216
|
+
statusCode: 404,
|
|
217
|
+
code: "questionnaire_version_missing",
|
|
218
|
+
message: "No questionnaire version is available for this instrument."
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const row = getVersionRow(versionId);
|
|
222
|
+
if (!row) {
|
|
223
|
+
throw createHttpError({
|
|
224
|
+
statusCode: 404,
|
|
225
|
+
code: "questionnaire_version_missing",
|
|
226
|
+
message: "Questionnaire version not found."
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return mapVersion(row);
|
|
230
|
+
}
|
|
231
|
+
function coerceNumber(value) {
|
|
232
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
if (typeof value === "boolean") {
|
|
236
|
+
return value ? 1 : 0;
|
|
237
|
+
}
|
|
238
|
+
if (typeof value === "string") {
|
|
239
|
+
const parsed = Number(value);
|
|
240
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function compare(left, comparator, right) {
|
|
245
|
+
if (left === null || right === null) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
switch (comparator) {
|
|
249
|
+
case "eq":
|
|
250
|
+
return left === right;
|
|
251
|
+
case "neq":
|
|
252
|
+
return left !== right;
|
|
253
|
+
case "gt":
|
|
254
|
+
return left > right;
|
|
255
|
+
case "gte":
|
|
256
|
+
return left >= right;
|
|
257
|
+
case "lt":
|
|
258
|
+
return left < right;
|
|
259
|
+
case "lte":
|
|
260
|
+
return left <= right;
|
|
261
|
+
default:
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function evaluateExpression(expression, answerMap, scoreMap) {
|
|
266
|
+
switch (expression.kind) {
|
|
267
|
+
case "const":
|
|
268
|
+
return expression.value;
|
|
269
|
+
case "answer":
|
|
270
|
+
return answerMap.get(expression.itemId) ?? expression.defaultValue ?? null;
|
|
271
|
+
case "score":
|
|
272
|
+
return scoreMap.get(expression.scoreKey) ?? null;
|
|
273
|
+
case "add": {
|
|
274
|
+
const numbers = expression.values.map((value) => coerceNumber(evaluateExpression(value, answerMap, scoreMap)));
|
|
275
|
+
if (numbers.some((value) => value === null)) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const present = numbers.filter((value) => value !== null);
|
|
279
|
+
return present.reduce((sum, value) => sum + value, 0);
|
|
280
|
+
}
|
|
281
|
+
case "multiply": {
|
|
282
|
+
const numbers = expression.values.map((value) => coerceNumber(evaluateExpression(value, answerMap, scoreMap)));
|
|
283
|
+
if (numbers.some((value) => value === null)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const present = numbers.filter((value) => value !== null);
|
|
287
|
+
return present.reduce((product, value) => product * value, 1);
|
|
288
|
+
}
|
|
289
|
+
case "min": {
|
|
290
|
+
const numbers = expression.values
|
|
291
|
+
.map((value) => coerceNumber(evaluateExpression(value, answerMap, scoreMap)))
|
|
292
|
+
.filter((value) => value !== null);
|
|
293
|
+
return numbers.length > 0 ? Math.min(...numbers) : null;
|
|
294
|
+
}
|
|
295
|
+
case "max": {
|
|
296
|
+
const numbers = expression.values
|
|
297
|
+
.map((value) => coerceNumber(evaluateExpression(value, answerMap, scoreMap)))
|
|
298
|
+
.filter((value) => value !== null);
|
|
299
|
+
return numbers.length > 0 ? Math.max(...numbers) : null;
|
|
300
|
+
}
|
|
301
|
+
case "subtract": {
|
|
302
|
+
const left = coerceNumber(evaluateExpression(expression.left, answerMap, scoreMap));
|
|
303
|
+
const right = coerceNumber(evaluateExpression(expression.right, answerMap, scoreMap));
|
|
304
|
+
return left === null || right === null ? null : left - right;
|
|
305
|
+
}
|
|
306
|
+
case "divide": {
|
|
307
|
+
const left = coerceNumber(evaluateExpression(expression.left, answerMap, scoreMap));
|
|
308
|
+
const right = coerceNumber(evaluateExpression(expression.right, answerMap, scoreMap));
|
|
309
|
+
if (left === null || right === null) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
if (right === 0) {
|
|
313
|
+
return expression.zeroValue ?? null;
|
|
314
|
+
}
|
|
315
|
+
return left / right;
|
|
316
|
+
}
|
|
317
|
+
case "sum": {
|
|
318
|
+
const values = expression.itemIds
|
|
319
|
+
.map((itemId) => answerMap.get(itemId))
|
|
320
|
+
.filter((value) => value !== null && value !== undefined);
|
|
321
|
+
return values.length > 0 ? values.reduce((sum, value) => sum + value, 0) : null;
|
|
322
|
+
}
|
|
323
|
+
case "average": {
|
|
324
|
+
const values = expression.itemIds
|
|
325
|
+
.map((itemId) => answerMap.get(itemId))
|
|
326
|
+
.filter((value) => value !== null && value !== undefined);
|
|
327
|
+
return values.length > 0
|
|
328
|
+
? values.reduce((sum, value) => sum + value, 0) / values.length
|
|
329
|
+
: null;
|
|
330
|
+
}
|
|
331
|
+
case "weighted_sum": {
|
|
332
|
+
const values = expression.terms.map((term) => {
|
|
333
|
+
const current = answerMap.get(term.itemId);
|
|
334
|
+
return current === null || current === undefined ? null : current * term.weight;
|
|
335
|
+
});
|
|
336
|
+
if (values.some((value) => value === null)) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const present = values.filter((value) => value !== null);
|
|
340
|
+
return present.reduce((sum, value) => sum + value, 0);
|
|
341
|
+
}
|
|
342
|
+
case "count_if": {
|
|
343
|
+
const values = expression.itemIds
|
|
344
|
+
.map((itemId) => answerMap.get(itemId))
|
|
345
|
+
.filter((value) => value !== null && value !== undefined);
|
|
346
|
+
return values.filter((value) => compare(value, expression.comparator, expression.target)).length;
|
|
347
|
+
}
|
|
348
|
+
case "filtered_mean": {
|
|
349
|
+
const values = expression.itemIds
|
|
350
|
+
.map((itemId) => answerMap.get(itemId))
|
|
351
|
+
.filter((value) => value !== null && value !== undefined)
|
|
352
|
+
.filter((value) => compare(value, expression.comparator, expression.target));
|
|
353
|
+
return values.length > 0
|
|
354
|
+
? values.reduce((sum, value) => sum + value, 0) / values.length
|
|
355
|
+
: null;
|
|
356
|
+
}
|
|
357
|
+
case "compare": {
|
|
358
|
+
const left = coerceNumber(evaluateExpression(expression.left, answerMap, scoreMap));
|
|
359
|
+
const right = coerceNumber(evaluateExpression(expression.right, answerMap, scoreMap));
|
|
360
|
+
return compare(left, expression.comparator, right);
|
|
361
|
+
}
|
|
362
|
+
case "if": {
|
|
363
|
+
const condition = evaluateExpression(expression.condition, answerMap, scoreMap);
|
|
364
|
+
return condition ? evaluateExpression(expression.then, answerMap, scoreMap) : evaluateExpression(expression.else, answerMap, scoreMap);
|
|
365
|
+
}
|
|
366
|
+
case "round": {
|
|
367
|
+
const value = coerceNumber(evaluateExpression(expression.value, answerMap, scoreMap));
|
|
368
|
+
if (value === null) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
const factor = 10 ** expression.digits;
|
|
372
|
+
return Math.round(value * factor) / factor;
|
|
373
|
+
}
|
|
374
|
+
default:
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function collectDependentItemIds(expression) {
|
|
379
|
+
switch (expression.kind) {
|
|
380
|
+
case "answer":
|
|
381
|
+
return [expression.itemId];
|
|
382
|
+
case "sum":
|
|
383
|
+
case "average":
|
|
384
|
+
case "count_if":
|
|
385
|
+
case "filtered_mean":
|
|
386
|
+
return [...expression.itemIds];
|
|
387
|
+
case "weighted_sum":
|
|
388
|
+
return expression.terms.map((term) => term.itemId);
|
|
389
|
+
case "add":
|
|
390
|
+
case "multiply":
|
|
391
|
+
case "min":
|
|
392
|
+
case "max":
|
|
393
|
+
return expression.values.flatMap((value) => collectDependentItemIds(value));
|
|
394
|
+
case "subtract":
|
|
395
|
+
case "divide":
|
|
396
|
+
case "compare":
|
|
397
|
+
return [
|
|
398
|
+
...collectDependentItemIds(expression.left),
|
|
399
|
+
...collectDependentItemIds(expression.right)
|
|
400
|
+
];
|
|
401
|
+
case "if":
|
|
402
|
+
return [
|
|
403
|
+
...collectDependentItemIds(expression.condition),
|
|
404
|
+
...collectDependentItemIds(expression.then),
|
|
405
|
+
...collectDependentItemIds(expression.else)
|
|
406
|
+
];
|
|
407
|
+
case "round":
|
|
408
|
+
return collectDependentItemIds(expression.value);
|
|
409
|
+
default:
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function resolveMissingPolicy(definition, answerMap, visibleItemIds) {
|
|
414
|
+
const policy = definition.missingPolicy ?? { mode: "require_all" };
|
|
415
|
+
const itemIds = (definition.dependsOnItemIds.length > 0
|
|
416
|
+
? definition.dependsOnItemIds
|
|
417
|
+
: Array.from(new Set(collectDependentItemIds(definition.expression)))).filter((itemId) => !visibleItemIds || visibleItemIds.has(itemId));
|
|
418
|
+
if (itemIds.length === 0) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
const answered = itemIds.filter((itemId) => {
|
|
422
|
+
const value = answerMap.get(itemId);
|
|
423
|
+
return value !== null && value !== undefined;
|
|
424
|
+
}).length;
|
|
425
|
+
if (policy.mode === "allow_partial") {
|
|
426
|
+
return answered === 0;
|
|
427
|
+
}
|
|
428
|
+
if (policy.mode === "min_answered") {
|
|
429
|
+
return answered < (policy.minAnswered ?? itemIds.length);
|
|
430
|
+
}
|
|
431
|
+
return answered < itemIds.length;
|
|
432
|
+
}
|
|
433
|
+
function resolveBand(definition, value) {
|
|
434
|
+
const numeric = coerceNumber(value);
|
|
435
|
+
if (numeric === null) {
|
|
436
|
+
return { bandLabel: "", severity: "" };
|
|
437
|
+
}
|
|
438
|
+
const band = definition.bands.find((entry) => {
|
|
439
|
+
const minOk = entry.min === null || entry.min === undefined || numeric >= entry.min;
|
|
440
|
+
const maxOk = entry.max === null || entry.max === undefined || numeric <= entry.max;
|
|
441
|
+
return minOk && maxOk;
|
|
442
|
+
});
|
|
443
|
+
return {
|
|
444
|
+
bandLabel: band?.label ?? "",
|
|
445
|
+
severity: band?.severity ?? ""
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function formatScoreForNote(score) {
|
|
449
|
+
const value = score.valueText ??
|
|
450
|
+
(typeof score.valueNumeric === "number" ? String(score.valueNumeric) : "Not scored");
|
|
451
|
+
return score.bandLabel ? `${value} (${score.bandLabel})` : value;
|
|
452
|
+
}
|
|
453
|
+
function buildCompletionNoteContent(options) {
|
|
454
|
+
const answerRowsByItemId = new Map(options.answers.map((answer) => [answer.item_id, answer]));
|
|
455
|
+
const scoreLines = options.scores
|
|
456
|
+
.map((score) => `- ${score.label}: ${formatScoreForNote(score)}`)
|
|
457
|
+
.join("\n");
|
|
458
|
+
const answerLines = options.version.definition.items
|
|
459
|
+
.map((item) => {
|
|
460
|
+
const answer = answerRowsByItemId.get(item.id);
|
|
461
|
+
const label = answer?.value_text ||
|
|
462
|
+
item.options.find((option) => option.key === answer?.option_key)?.label ||
|
|
463
|
+
"No answer";
|
|
464
|
+
const numeric = typeof answer?.numeric_value === "number"
|
|
465
|
+
? ` (${answer.numeric_value})`
|
|
466
|
+
: "";
|
|
467
|
+
return `- ${item.prompt}: ${label}${numeric}`;
|
|
468
|
+
})
|
|
469
|
+
.join("\n");
|
|
470
|
+
return [
|
|
471
|
+
`# ${options.instrument.title}`,
|
|
472
|
+
"",
|
|
473
|
+
`Completed at: ${options.completedAt}`,
|
|
474
|
+
options.version.label ? `Version: ${options.version.label}` : "",
|
|
475
|
+
"",
|
|
476
|
+
"## Scores",
|
|
477
|
+
scoreLines || "- No scores",
|
|
478
|
+
"",
|
|
479
|
+
"## Answers",
|
|
480
|
+
answerLines || "- No answers"
|
|
481
|
+
]
|
|
482
|
+
.filter(Boolean)
|
|
483
|
+
.join("\n");
|
|
484
|
+
}
|
|
485
|
+
function scoreRun(version, answers) {
|
|
486
|
+
const visibility = getQuestionnaireVisibilityState(version.definition, answers);
|
|
487
|
+
const answerMap = new Map();
|
|
488
|
+
for (const item of version.definition.items) {
|
|
489
|
+
answerMap.set(item.id, null);
|
|
490
|
+
}
|
|
491
|
+
for (const answer of answers) {
|
|
492
|
+
answerMap.set(answer.item_id, visibility.visibleItemIds.has(answer.item_id) ? answer.numeric_value : null);
|
|
493
|
+
}
|
|
494
|
+
const scoreValueMap = new Map();
|
|
495
|
+
return version.scoring.scores.map((definition, index) => {
|
|
496
|
+
const blockedByMissing = resolveMissingPolicy(definition, answerMap, visibility.visibleItemIds);
|
|
497
|
+
let value = blockedByMissing
|
|
498
|
+
? null
|
|
499
|
+
: evaluateExpression(definition.expression, answerMap, scoreValueMap);
|
|
500
|
+
if (typeof value === "number" && definition.roundTo !== null && definition.roundTo !== undefined) {
|
|
501
|
+
const factor = 10 ** definition.roundTo;
|
|
502
|
+
value = Math.round(value * factor) / factor;
|
|
503
|
+
}
|
|
504
|
+
scoreValueMap.set(definition.key, value);
|
|
505
|
+
const { bandLabel, severity } = resolveBand(definition, value);
|
|
506
|
+
return {
|
|
507
|
+
sortOrder: index,
|
|
508
|
+
scoreKey: definition.key,
|
|
509
|
+
label: definition.label,
|
|
510
|
+
valueNumeric: typeof value === "number" ? value : coerceNumber(value),
|
|
511
|
+
valueText: typeof value === "string"
|
|
512
|
+
? value
|
|
513
|
+
: typeof value === "boolean"
|
|
514
|
+
? String(value)
|
|
515
|
+
: null,
|
|
516
|
+
bandLabel,
|
|
517
|
+
severity,
|
|
518
|
+
details: {
|
|
519
|
+
description: definition.description,
|
|
520
|
+
valueType: definition.valueType,
|
|
521
|
+
unitLabel: definition.unitLabel,
|
|
522
|
+
missingPolicy: definition.missingPolicy ?? { mode: "require_all" },
|
|
523
|
+
dependsOnItemIds: definition.dependsOnItemIds.length > 0
|
|
524
|
+
? definition.dependsOnItemIds
|
|
525
|
+
: Array.from(new Set(collectDependentItemIds(definition.expression)))
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function hydrateInstrumentDetail(row, userIds) {
|
|
531
|
+
const versions = getVersionRowsForInstrument(row.id).map(mapVersion);
|
|
532
|
+
const currentVersion = selectPrimaryVersion(row, versions);
|
|
533
|
+
const draftVersion = row.current_draft_version_id
|
|
534
|
+
? versions.find((version) => version.id === row.current_draft_version_id) ?? null
|
|
535
|
+
: null;
|
|
536
|
+
const summary = mapSummary(row, versions, userIds);
|
|
537
|
+
return questionnaireInstrumentDetailSchema.parse({
|
|
538
|
+
...summary,
|
|
539
|
+
status: row.status,
|
|
540
|
+
currentVersion,
|
|
541
|
+
draftVersion,
|
|
542
|
+
versions,
|
|
543
|
+
history: getHistoryForInstrument(row.id, userIds),
|
|
544
|
+
latestDraftRunId: getLatestDraftRunId(row.id, currentVersion?.id ?? null, userIds)
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function assertEditableInstrument(row) {
|
|
548
|
+
if (row.is_system === 1) {
|
|
549
|
+
throw createHttpError({
|
|
550
|
+
statusCode: 403,
|
|
551
|
+
code: "questionnaire_read_only",
|
|
552
|
+
message: "System questionnaire definitions cannot be edited directly."
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function insertVersion(options) {
|
|
557
|
+
getDatabase()
|
|
558
|
+
.prepare(`
|
|
559
|
+
INSERT INTO questionnaire_versions (
|
|
560
|
+
id,
|
|
561
|
+
instrument_id,
|
|
562
|
+
version_number,
|
|
563
|
+
status,
|
|
564
|
+
label,
|
|
565
|
+
definition_json,
|
|
566
|
+
scoring_json,
|
|
567
|
+
provenance_json,
|
|
568
|
+
is_read_only,
|
|
569
|
+
created_by,
|
|
570
|
+
created_at,
|
|
571
|
+
updated_at,
|
|
572
|
+
published_at
|
|
573
|
+
)
|
|
574
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
575
|
+
`)
|
|
576
|
+
.run(options.id, options.instrumentId, options.versionNumber, options.status, options.label, JSON.stringify(options.definition), JSON.stringify(options.scoring), JSON.stringify(options.provenance), options.isReadOnly ? 1 : 0, options.createdBy, nowIso(), nowIso(), options.publishedAt ?? null);
|
|
577
|
+
}
|
|
578
|
+
export function ensureQuestionnaireSeeds() {
|
|
579
|
+
const database = getDatabase();
|
|
580
|
+
const hasTables = database
|
|
581
|
+
.prepare(`
|
|
582
|
+
SELECT name
|
|
583
|
+
FROM sqlite_master
|
|
584
|
+
WHERE type = 'table'
|
|
585
|
+
AND name = 'questionnaire_instruments'
|
|
586
|
+
`)
|
|
587
|
+
.get();
|
|
588
|
+
if (!hasTables) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
runInTransaction(() => {
|
|
592
|
+
for (const seed of getQuestionnaireSeeds()) {
|
|
593
|
+
assertValidQuestionnaireDefinition(seed.definition);
|
|
594
|
+
const existing = database
|
|
595
|
+
.prepare("SELECT id FROM questionnaire_instruments WHERE key = ?")
|
|
596
|
+
.get(seed.key);
|
|
597
|
+
if (existing) {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const now = nowIso();
|
|
601
|
+
const instrumentId = `questionnaire_${seed.key}`;
|
|
602
|
+
const versionId = `questionnaire_version_${seed.key}_v1`;
|
|
603
|
+
database
|
|
604
|
+
.prepare(`
|
|
605
|
+
INSERT INTO questionnaire_instruments (
|
|
606
|
+
id,
|
|
607
|
+
key,
|
|
608
|
+
slug,
|
|
609
|
+
title,
|
|
610
|
+
subtitle,
|
|
611
|
+
description,
|
|
612
|
+
aliases_json,
|
|
613
|
+
symptom_domains_json,
|
|
614
|
+
tags_json,
|
|
615
|
+
source_class,
|
|
616
|
+
availability,
|
|
617
|
+
is_self_report,
|
|
618
|
+
is_system,
|
|
619
|
+
status,
|
|
620
|
+
owner_user_id,
|
|
621
|
+
current_draft_version_id,
|
|
622
|
+
current_published_version_id,
|
|
623
|
+
created_at,
|
|
624
|
+
updated_at
|
|
625
|
+
)
|
|
626
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?, ?)
|
|
627
|
+
`)
|
|
628
|
+
.run(instrumentId, seed.key, seed.slug, seed.title, seed.subtitle, seed.description, JSON.stringify(seed.aliases), JSON.stringify(seed.symptomDomains), JSON.stringify(seed.tags), seed.sourceClass, seed.availability, seed.isSelfReport ? 1 : 0, 1, versionId, now, now);
|
|
629
|
+
insertVersion({
|
|
630
|
+
id: versionId,
|
|
631
|
+
instrumentId,
|
|
632
|
+
versionNumber: 1,
|
|
633
|
+
status: "published",
|
|
634
|
+
label: "Seeded v1",
|
|
635
|
+
definition: seed.definition,
|
|
636
|
+
scoring: seed.scoring,
|
|
637
|
+
provenance: seed.provenance,
|
|
638
|
+
isReadOnly: true,
|
|
639
|
+
createdBy: "system",
|
|
640
|
+
publishedAt: now
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
export function listQuestionnaireInstruments(options = {}) {
|
|
646
|
+
const rows = getDatabase()
|
|
647
|
+
.prepare(`
|
|
648
|
+
SELECT *
|
|
649
|
+
FROM questionnaire_instruments
|
|
650
|
+
WHERE status != 'archived'
|
|
651
|
+
ORDER BY is_system DESC, title COLLATE NOCASE ASC
|
|
652
|
+
`)
|
|
653
|
+
.all();
|
|
654
|
+
const instruments = rows
|
|
655
|
+
.filter((row) => isInstrumentVisible(row, options.userIds))
|
|
656
|
+
.map((row) => mapSummary(row, getVersionRowsForInstrument(row.id).map(mapVersion), options.userIds));
|
|
657
|
+
return { instruments };
|
|
658
|
+
}
|
|
659
|
+
export function getQuestionnaireInstrumentDetail(instrumentId, options = {}) {
|
|
660
|
+
const row = getInstrumentRow(instrumentId);
|
|
661
|
+
if (!row || !isInstrumentVisible(row, options.userIds)) {
|
|
662
|
+
throw createHttpError({
|
|
663
|
+
statusCode: 404,
|
|
664
|
+
code: "questionnaire_not_found",
|
|
665
|
+
message: "Questionnaire instrument not found."
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
return { instrument: hydrateInstrumentDetail(row, options.userIds) };
|
|
669
|
+
}
|
|
670
|
+
export function createQuestionnaireInstrument(input, context) {
|
|
671
|
+
const parsed = createQuestionnaireInstrumentSchema.parse(input);
|
|
672
|
+
assertValidQuestionnaireDefinition(parsed.definition);
|
|
673
|
+
return runInTransaction(() => {
|
|
674
|
+
const database = getDatabase();
|
|
675
|
+
const now = nowIso();
|
|
676
|
+
const instrumentId = buildId("questionnaire");
|
|
677
|
+
const versionId = buildId("questionnaire_version");
|
|
678
|
+
const slugBase = slugify(parsed.title);
|
|
679
|
+
const slug = `${slugBase}-${instrumentId.slice(-4)}`;
|
|
680
|
+
database
|
|
681
|
+
.prepare(`
|
|
682
|
+
INSERT INTO questionnaire_instruments (
|
|
683
|
+
id,
|
|
684
|
+
key,
|
|
685
|
+
slug,
|
|
686
|
+
title,
|
|
687
|
+
subtitle,
|
|
688
|
+
description,
|
|
689
|
+
aliases_json,
|
|
690
|
+
symptom_domains_json,
|
|
691
|
+
tags_json,
|
|
692
|
+
source_class,
|
|
693
|
+
availability,
|
|
694
|
+
is_self_report,
|
|
695
|
+
is_system,
|
|
696
|
+
status,
|
|
697
|
+
owner_user_id,
|
|
698
|
+
current_draft_version_id,
|
|
699
|
+
current_published_version_id,
|
|
700
|
+
created_at,
|
|
701
|
+
updated_at
|
|
702
|
+
)
|
|
703
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'active', ?, ?, NULL, ?, ?)
|
|
704
|
+
`)
|
|
705
|
+
.run(instrumentId, slug.replaceAll("-", "_"), slug, parsed.title, parsed.subtitle, parsed.description, JSON.stringify(parsed.aliases), JSON.stringify(parsed.symptomDomains), JSON.stringify(parsed.tags), parsed.sourceClass, parsed.availability, parsed.isSelfReport ? 1 : 0, normalizeCustomOwner(parsed.userId), versionId, now, now);
|
|
706
|
+
insertVersion({
|
|
707
|
+
id: versionId,
|
|
708
|
+
instrumentId,
|
|
709
|
+
versionNumber: 1,
|
|
710
|
+
status: "draft",
|
|
711
|
+
label: parsed.versionLabel,
|
|
712
|
+
definition: parsed.definition,
|
|
713
|
+
scoring: parsed.scoring,
|
|
714
|
+
provenance: parsed.provenance,
|
|
715
|
+
isReadOnly: false,
|
|
716
|
+
createdBy: context.actor ?? null
|
|
717
|
+
});
|
|
718
|
+
recordActivityEvent({
|
|
719
|
+
entityType: "questionnaire_instrument",
|
|
720
|
+
entityId: instrumentId,
|
|
721
|
+
eventType: "questionnaire_instrument_created",
|
|
722
|
+
title: `Questionnaire created: ${parsed.title}`,
|
|
723
|
+
description: "A custom questionnaire draft was created in Psyche.",
|
|
724
|
+
actor: context.actor ?? null,
|
|
725
|
+
source: context.source,
|
|
726
|
+
metadata: {
|
|
727
|
+
versionId,
|
|
728
|
+
availability: parsed.availability
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
return getQuestionnaireInstrumentDetail(instrumentId);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
export const updateQuestionnaireInstrumentSchema = createQuestionnaireInstrumentSchema
|
|
735
|
+
.omit({ versionLabel: true })
|
|
736
|
+
.partial();
|
|
737
|
+
export function listQuestionnaireInstrumentEntities(options = {}) {
|
|
738
|
+
return listQuestionnaireInstruments(options).instruments;
|
|
739
|
+
}
|
|
740
|
+
export function getQuestionnaireInstrumentEntityById(instrumentId, options = {}) {
|
|
741
|
+
return getQuestionnaireInstrumentDetail(instrumentId, options).instrument;
|
|
742
|
+
}
|
|
743
|
+
export function updateQuestionnaireInstrument(instrumentId, patch, context) {
|
|
744
|
+
const parsed = updateQuestionnaireInstrumentSchema.parse(patch);
|
|
745
|
+
const detail = getQuestionnaireInstrumentDetail(instrumentId);
|
|
746
|
+
const currentVersion = detail.instrument.draftVersion ??
|
|
747
|
+
detail.instrument.currentVersion;
|
|
748
|
+
if (!currentVersion) {
|
|
749
|
+
throw createHttpError({
|
|
750
|
+
statusCode: 404,
|
|
751
|
+
code: "questionnaire_version_missing",
|
|
752
|
+
message: "No questionnaire version is available for this instrument."
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
return updateQuestionnaireDraftVersion(instrumentId, {
|
|
756
|
+
title: parsed.title ?? detail.instrument.title,
|
|
757
|
+
subtitle: parsed.subtitle ?? detail.instrument.subtitle,
|
|
758
|
+
description: parsed.description ?? detail.instrument.description,
|
|
759
|
+
aliases: parsed.aliases ?? detail.instrument.aliases,
|
|
760
|
+
symptomDomains: parsed.symptomDomains ?? detail.instrument.symptomDomains,
|
|
761
|
+
tags: parsed.tags ?? detail.instrument.tags,
|
|
762
|
+
sourceClass: parsed.sourceClass ?? detail.instrument.sourceClass,
|
|
763
|
+
availability: parsed.availability ?? detail.instrument.availability,
|
|
764
|
+
isSelfReport: parsed.isSelfReport ?? detail.instrument.isSelfReport,
|
|
765
|
+
label: currentVersion.label,
|
|
766
|
+
definition: parsed.definition ?? currentVersion.definition,
|
|
767
|
+
scoring: parsed.scoring ?? currentVersion.scoring,
|
|
768
|
+
provenance: parsed.provenance ?? currentVersion.provenance
|
|
769
|
+
}, context).instrument;
|
|
770
|
+
}
|
|
771
|
+
export function deleteQuestionnaireInstrument(instrumentId, context) {
|
|
772
|
+
return runInTransaction(() => {
|
|
773
|
+
const row = getInstrumentRow(instrumentId);
|
|
774
|
+
if (!row) {
|
|
775
|
+
throw createHttpError({
|
|
776
|
+
statusCode: 404,
|
|
777
|
+
code: "questionnaire_not_found",
|
|
778
|
+
message: "Questionnaire instrument not found."
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
assertEditableInstrument(row);
|
|
782
|
+
const detail = getQuestionnaireInstrumentDetail(instrumentId);
|
|
783
|
+
getDatabase()
|
|
784
|
+
.prepare(`
|
|
785
|
+
UPDATE questionnaire_instruments
|
|
786
|
+
SET status = 'archived', updated_at = ?
|
|
787
|
+
WHERE id = ?
|
|
788
|
+
`)
|
|
789
|
+
.run(nowIso(), instrumentId);
|
|
790
|
+
recordActivityEvent({
|
|
791
|
+
entityType: "questionnaire_instrument",
|
|
792
|
+
entityId: instrumentId,
|
|
793
|
+
eventType: "questionnaire_archived",
|
|
794
|
+
title: `Questionnaire archived: ${row.title}`,
|
|
795
|
+
description: "A questionnaire instrument was archived.",
|
|
796
|
+
actor: context.actor ?? null,
|
|
797
|
+
source: context.source
|
|
798
|
+
});
|
|
799
|
+
return detail.instrument;
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
export function cloneQuestionnaireInstrument(instrumentId, options, context) {
|
|
803
|
+
const row = getInstrumentRow(instrumentId);
|
|
804
|
+
if (!row) {
|
|
805
|
+
throw createHttpError({
|
|
806
|
+
statusCode: 404,
|
|
807
|
+
code: "questionnaire_not_found",
|
|
808
|
+
message: "Questionnaire instrument not found."
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
const sourceVersion = getCurrentPublishedOrDraftVersion(row);
|
|
812
|
+
return createQuestionnaireInstrument({
|
|
813
|
+
title: `${row.title} copy`,
|
|
814
|
+
subtitle: row.subtitle,
|
|
815
|
+
description: row.description,
|
|
816
|
+
aliases: parseJson(row.aliases_json),
|
|
817
|
+
symptomDomains: parseJson(row.symptom_domains_json),
|
|
818
|
+
tags: Array.from(new Set([...parseJson(row.tags_json), "custom-copy"])),
|
|
819
|
+
sourceClass: row.source_class,
|
|
820
|
+
availability: "custom",
|
|
821
|
+
isSelfReport: row.is_self_report === 1,
|
|
822
|
+
userId: options.userId ?? row.owner_user_id ?? DEFAULT_CUSTOM_USER_ID,
|
|
823
|
+
versionLabel: `Draft from ${row.title}`,
|
|
824
|
+
definition: sourceVersion.definition,
|
|
825
|
+
scoring: sourceVersion.scoring,
|
|
826
|
+
provenance: sourceVersion.provenance
|
|
827
|
+
}, context);
|
|
828
|
+
}
|
|
829
|
+
export function ensureQuestionnaireDraftVersion(instrumentId, context) {
|
|
830
|
+
return runInTransaction(() => {
|
|
831
|
+
const row = getInstrumentRow(instrumentId);
|
|
832
|
+
if (!row) {
|
|
833
|
+
throw createHttpError({
|
|
834
|
+
statusCode: 404,
|
|
835
|
+
code: "questionnaire_not_found",
|
|
836
|
+
message: "Questionnaire instrument not found."
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
assertEditableInstrument(row);
|
|
840
|
+
if (row.current_draft_version_id) {
|
|
841
|
+
return getQuestionnaireInstrumentDetail(instrumentId);
|
|
842
|
+
}
|
|
843
|
+
const sourceVersion = getCurrentPublishedOrDraftVersion(row);
|
|
844
|
+
const nextVersionNumber = Math.max(0, ...getVersionRowsForInstrument(instrumentId).map((entry) => entry.version_number)) + 1;
|
|
845
|
+
const versionId = buildId("questionnaire_version");
|
|
846
|
+
insertVersion({
|
|
847
|
+
id: versionId,
|
|
848
|
+
instrumentId,
|
|
849
|
+
versionNumber: nextVersionNumber,
|
|
850
|
+
status: "draft",
|
|
851
|
+
label: `Draft ${nextVersionNumber}`,
|
|
852
|
+
definition: sourceVersion.definition,
|
|
853
|
+
scoring: sourceVersion.scoring,
|
|
854
|
+
provenance: sourceVersion.provenance,
|
|
855
|
+
isReadOnly: false,
|
|
856
|
+
createdBy: context.actor ?? null
|
|
857
|
+
});
|
|
858
|
+
getDatabase()
|
|
859
|
+
.prepare(`
|
|
860
|
+
UPDATE questionnaire_instruments
|
|
861
|
+
SET current_draft_version_id = ?, updated_at = ?
|
|
862
|
+
WHERE id = ?
|
|
863
|
+
`)
|
|
864
|
+
.run(versionId, nowIso(), instrumentId);
|
|
865
|
+
return getQuestionnaireInstrumentDetail(instrumentId);
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
export function updateQuestionnaireDraftVersion(instrumentId, input, context) {
|
|
869
|
+
const parsed = updateQuestionnaireVersionSchema.parse(input);
|
|
870
|
+
assertValidQuestionnaireDefinition(parsed.definition);
|
|
871
|
+
return runInTransaction(() => {
|
|
872
|
+
const row = getInstrumentRow(instrumentId);
|
|
873
|
+
if (!row) {
|
|
874
|
+
throw createHttpError({
|
|
875
|
+
statusCode: 404,
|
|
876
|
+
code: "questionnaire_not_found",
|
|
877
|
+
message: "Questionnaire instrument not found."
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
assertEditableInstrument(row);
|
|
881
|
+
const detail = ensureQuestionnaireDraftVersion(instrumentId, context);
|
|
882
|
+
const draftVersionId = detail.instrument.draftVersion?.id;
|
|
883
|
+
if (!draftVersionId) {
|
|
884
|
+
throw createHttpError({
|
|
885
|
+
statusCode: 400,
|
|
886
|
+
code: "questionnaire_draft_missing",
|
|
887
|
+
message: "No editable draft version is available."
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
getDatabase()
|
|
891
|
+
.prepare(`
|
|
892
|
+
UPDATE questionnaire_instruments
|
|
893
|
+
SET
|
|
894
|
+
title = ?,
|
|
895
|
+
subtitle = ?,
|
|
896
|
+
description = ?,
|
|
897
|
+
aliases_json = ?,
|
|
898
|
+
symptom_domains_json = ?,
|
|
899
|
+
tags_json = ?,
|
|
900
|
+
source_class = ?,
|
|
901
|
+
availability = ?,
|
|
902
|
+
is_self_report = ?,
|
|
903
|
+
updated_at = ?
|
|
904
|
+
WHERE id = ?
|
|
905
|
+
`)
|
|
906
|
+
.run(parsed.title, parsed.subtitle, parsed.description, JSON.stringify(parsed.aliases), JSON.stringify(parsed.symptomDomains), JSON.stringify(parsed.tags), parsed.sourceClass, parsed.availability, parsed.isSelfReport ? 1 : 0, nowIso(), instrumentId);
|
|
907
|
+
getDatabase()
|
|
908
|
+
.prepare(`
|
|
909
|
+
UPDATE questionnaire_versions
|
|
910
|
+
SET
|
|
911
|
+
label = ?,
|
|
912
|
+
definition_json = ?,
|
|
913
|
+
scoring_json = ?,
|
|
914
|
+
provenance_json = ?,
|
|
915
|
+
updated_at = ?
|
|
916
|
+
WHERE id = ?
|
|
917
|
+
AND status = 'draft'
|
|
918
|
+
`)
|
|
919
|
+
.run(parsed.label, JSON.stringify(parsed.definition), JSON.stringify(parsed.scoring), JSON.stringify(parsed.provenance), nowIso(), draftVersionId);
|
|
920
|
+
recordActivityEvent({
|
|
921
|
+
entityType: "questionnaire_instrument",
|
|
922
|
+
entityId: instrumentId,
|
|
923
|
+
eventType: "questionnaire_draft_updated",
|
|
924
|
+
title: `Questionnaire draft updated: ${parsed.title}`,
|
|
925
|
+
description: "A questionnaire draft definition was updated.",
|
|
926
|
+
actor: context.actor ?? null,
|
|
927
|
+
source: context.source,
|
|
928
|
+
metadata: {
|
|
929
|
+
versionId: draftVersionId
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
return getQuestionnaireInstrumentDetail(instrumentId);
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
export function publishQuestionnaireDraftVersion(instrumentId, input, context) {
|
|
936
|
+
const parsed = publishQuestionnaireVersionSchema.parse(input ?? {});
|
|
937
|
+
return runInTransaction(() => {
|
|
938
|
+
const row = getInstrumentRow(instrumentId);
|
|
939
|
+
if (!row) {
|
|
940
|
+
throw createHttpError({
|
|
941
|
+
statusCode: 404,
|
|
942
|
+
code: "questionnaire_not_found",
|
|
943
|
+
message: "Questionnaire instrument not found."
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
assertEditableInstrument(row);
|
|
947
|
+
const draftVersionId = row.current_draft_version_id;
|
|
948
|
+
if (!draftVersionId) {
|
|
949
|
+
throw createHttpError({
|
|
950
|
+
statusCode: 400,
|
|
951
|
+
code: "questionnaire_draft_missing",
|
|
952
|
+
message: "No draft version is available to publish."
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
const publishedAt = nowIso();
|
|
956
|
+
getDatabase()
|
|
957
|
+
.prepare(`
|
|
958
|
+
UPDATE questionnaire_versions
|
|
959
|
+
SET status = 'published', label = ?, published_at = ?, updated_at = ?
|
|
960
|
+
WHERE id = ?
|
|
961
|
+
`)
|
|
962
|
+
.run(parsed.label || "Published", publishedAt, publishedAt, draftVersionId);
|
|
963
|
+
getDatabase()
|
|
964
|
+
.prepare(`
|
|
965
|
+
UPDATE questionnaire_instruments
|
|
966
|
+
SET
|
|
967
|
+
current_published_version_id = ?,
|
|
968
|
+
current_draft_version_id = NULL,
|
|
969
|
+
updated_at = ?
|
|
970
|
+
WHERE id = ?
|
|
971
|
+
`)
|
|
972
|
+
.run(draftVersionId, publishedAt, instrumentId);
|
|
973
|
+
recordActivityEvent({
|
|
974
|
+
entityType: "questionnaire_instrument",
|
|
975
|
+
entityId: instrumentId,
|
|
976
|
+
eventType: "questionnaire_version_published",
|
|
977
|
+
title: `Questionnaire published: ${row.title}`,
|
|
978
|
+
description: "A questionnaire draft version was published.",
|
|
979
|
+
actor: context.actor ?? null,
|
|
980
|
+
source: context.source,
|
|
981
|
+
metadata: {
|
|
982
|
+
versionId: draftVersionId
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
return getQuestionnaireInstrumentDetail(instrumentId);
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
function upsertRunAnswers(runId, answers) {
|
|
989
|
+
const database = getDatabase();
|
|
990
|
+
const now = nowIso();
|
|
991
|
+
const statement = database.prepare(`
|
|
992
|
+
INSERT INTO questionnaire_answers (
|
|
993
|
+
id,
|
|
994
|
+
run_id,
|
|
995
|
+
item_id,
|
|
996
|
+
option_key,
|
|
997
|
+
value_text,
|
|
998
|
+
numeric_value,
|
|
999
|
+
answer_json,
|
|
1000
|
+
created_at,
|
|
1001
|
+
updated_at
|
|
1002
|
+
)
|
|
1003
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1004
|
+
ON CONFLICT(run_id, item_id) DO UPDATE SET
|
|
1005
|
+
option_key = excluded.option_key,
|
|
1006
|
+
value_text = excluded.value_text,
|
|
1007
|
+
numeric_value = excluded.numeric_value,
|
|
1008
|
+
answer_json = excluded.answer_json,
|
|
1009
|
+
updated_at = excluded.updated_at
|
|
1010
|
+
`);
|
|
1011
|
+
for (const answer of answers) {
|
|
1012
|
+
statement.run(buildId("questionnaire_answer"), runId, answer.itemId, answer.optionKey ?? null, answer.valueText, answer.numericValue ?? null, JSON.stringify(answer.answer), now, now);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
function listAnswerRows(runId) {
|
|
1016
|
+
return getDatabase()
|
|
1017
|
+
.prepare(`
|
|
1018
|
+
SELECT item_id, option_key, value_text, numeric_value, answer_json, created_at, updated_at
|
|
1019
|
+
FROM questionnaire_answers
|
|
1020
|
+
WHERE run_id = ?
|
|
1021
|
+
ORDER BY item_id
|
|
1022
|
+
`)
|
|
1023
|
+
.all(runId);
|
|
1024
|
+
}
|
|
1025
|
+
function listRunScoreRows(runId) {
|
|
1026
|
+
return getDatabase()
|
|
1027
|
+
.prepare(`
|
|
1028
|
+
SELECT score_key, label, value_numeric, value_text, band_label, severity, sort_order, details_json, created_at
|
|
1029
|
+
FROM questionnaire_run_scores
|
|
1030
|
+
WHERE run_id = ?
|
|
1031
|
+
ORDER BY sort_order ASC, score_key ASC
|
|
1032
|
+
`)
|
|
1033
|
+
.all(runId);
|
|
1034
|
+
}
|
|
1035
|
+
function mapRun(row) {
|
|
1036
|
+
return questionnaireRunSchema.parse({
|
|
1037
|
+
id: row.id,
|
|
1038
|
+
instrumentId: row.instrument_id,
|
|
1039
|
+
versionId: row.version_id,
|
|
1040
|
+
userId: row.user_id,
|
|
1041
|
+
status: row.status,
|
|
1042
|
+
startedAt: row.started_at,
|
|
1043
|
+
updatedAt: row.updated_at,
|
|
1044
|
+
completedAt: row.completed_at,
|
|
1045
|
+
progressIndex: row.progress_index
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
export function startQuestionnaireRun(instrumentId, input, context) {
|
|
1049
|
+
const parsed = startQuestionnaireRunSchema.parse(input ?? {});
|
|
1050
|
+
return runInTransaction(() => {
|
|
1051
|
+
const row = getInstrumentRow(instrumentId);
|
|
1052
|
+
if (!row) {
|
|
1053
|
+
throw createHttpError({
|
|
1054
|
+
statusCode: 404,
|
|
1055
|
+
code: "questionnaire_not_found",
|
|
1056
|
+
message: "Questionnaire instrument not found."
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
const versionId = parsed.versionId ??
|
|
1060
|
+
row.current_published_version_id ??
|
|
1061
|
+
row.current_draft_version_id;
|
|
1062
|
+
if (!versionId) {
|
|
1063
|
+
throw createHttpError({
|
|
1064
|
+
statusCode: 404,
|
|
1065
|
+
code: "questionnaire_version_missing",
|
|
1066
|
+
message: "No questionnaire version is available for this instrument."
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
const version = getVersionRow(versionId);
|
|
1070
|
+
if (!version) {
|
|
1071
|
+
throw createHttpError({
|
|
1072
|
+
statusCode: 404,
|
|
1073
|
+
code: "questionnaire_version_missing",
|
|
1074
|
+
message: "Questionnaire version not found."
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
const userId = normalizeCustomOwner(parsed.userId);
|
|
1078
|
+
const existing = getDatabase()
|
|
1079
|
+
.prepare(`
|
|
1080
|
+
SELECT *
|
|
1081
|
+
FROM questionnaire_runs
|
|
1082
|
+
WHERE instrument_id = ?
|
|
1083
|
+
AND version_id = ?
|
|
1084
|
+
AND COALESCE(user_id, '') = ?
|
|
1085
|
+
AND status = 'draft'
|
|
1086
|
+
ORDER BY updated_at DESC
|
|
1087
|
+
LIMIT 1
|
|
1088
|
+
`)
|
|
1089
|
+
.get(instrumentId, versionId, userId);
|
|
1090
|
+
if (existing) {
|
|
1091
|
+
return getQuestionnaireRunDetail(existing.id);
|
|
1092
|
+
}
|
|
1093
|
+
const now = nowIso();
|
|
1094
|
+
const runId = buildId("questionnaire_run");
|
|
1095
|
+
getDatabase()
|
|
1096
|
+
.prepare(`
|
|
1097
|
+
INSERT INTO questionnaire_runs (
|
|
1098
|
+
id,
|
|
1099
|
+
instrument_id,
|
|
1100
|
+
version_id,
|
|
1101
|
+
user_id,
|
|
1102
|
+
status,
|
|
1103
|
+
progress_index,
|
|
1104
|
+
started_at,
|
|
1105
|
+
updated_at,
|
|
1106
|
+
completed_at
|
|
1107
|
+
)
|
|
1108
|
+
VALUES (?, ?, ?, ?, 'draft', 0, ?, ?, NULL)
|
|
1109
|
+
`)
|
|
1110
|
+
.run(runId, instrumentId, versionId, userId, now, now);
|
|
1111
|
+
recordActivityEvent({
|
|
1112
|
+
entityType: "questionnaire_run",
|
|
1113
|
+
entityId: runId,
|
|
1114
|
+
eventType: "questionnaire_run_started",
|
|
1115
|
+
title: `Questionnaire started: ${row.title}`,
|
|
1116
|
+
description: "A questionnaire run was started or resumed.",
|
|
1117
|
+
actor: context.actor ?? null,
|
|
1118
|
+
source: context.source,
|
|
1119
|
+
metadata: {
|
|
1120
|
+
instrumentId,
|
|
1121
|
+
versionId
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
return getQuestionnaireRunDetail(runId);
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
export function updateQuestionnaireRun(runId, input, context) {
|
|
1128
|
+
const parsed = updateQuestionnaireRunSchema.parse(input ?? {});
|
|
1129
|
+
return runInTransaction(() => {
|
|
1130
|
+
const run = getRunRow(runId);
|
|
1131
|
+
if (!run) {
|
|
1132
|
+
throw createHttpError({
|
|
1133
|
+
statusCode: 404,
|
|
1134
|
+
code: "questionnaire_run_not_found",
|
|
1135
|
+
message: "Questionnaire run not found."
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
if (run.status !== "draft") {
|
|
1139
|
+
throw createHttpError({
|
|
1140
|
+
statusCode: 409,
|
|
1141
|
+
code: "questionnaire_run_locked",
|
|
1142
|
+
message: "Only draft questionnaire runs can be updated."
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
upsertRunAnswers(runId, parsed.answers);
|
|
1146
|
+
getDatabase()
|
|
1147
|
+
.prepare(`
|
|
1148
|
+
UPDATE questionnaire_runs
|
|
1149
|
+
SET progress_index = ?, updated_at = ?
|
|
1150
|
+
WHERE id = ?
|
|
1151
|
+
`)
|
|
1152
|
+
.run(parsed.progressIndex ?? run.progress_index, nowIso(), runId);
|
|
1153
|
+
return getQuestionnaireRunDetail(runId);
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
export function completeQuestionnaireRun(runId, context) {
|
|
1157
|
+
return runInTransaction(() => {
|
|
1158
|
+
const run = getRunRow(runId);
|
|
1159
|
+
if (!run) {
|
|
1160
|
+
throw createHttpError({
|
|
1161
|
+
statusCode: 404,
|
|
1162
|
+
code: "questionnaire_run_not_found",
|
|
1163
|
+
message: "Questionnaire run not found."
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
if (run.status !== "draft") {
|
|
1167
|
+
return getQuestionnaireRunDetail(runId);
|
|
1168
|
+
}
|
|
1169
|
+
const versionRow = getVersionRow(run.version_id);
|
|
1170
|
+
if (!versionRow) {
|
|
1171
|
+
throw createHttpError({
|
|
1172
|
+
statusCode: 404,
|
|
1173
|
+
code: "questionnaire_version_missing",
|
|
1174
|
+
message: "Questionnaire version not found."
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
const version = mapVersion(versionRow);
|
|
1178
|
+
const answers = listAnswerRows(runId);
|
|
1179
|
+
const visibility = getQuestionnaireVisibilityState(version.definition, answers);
|
|
1180
|
+
const answerIds = new Set(answers
|
|
1181
|
+
.filter((entry) => visibility.visibleItemIds.has(entry.item_id))
|
|
1182
|
+
.map((entry) => entry.item_id));
|
|
1183
|
+
const missingRequired = version.definition.items
|
|
1184
|
+
.filter((entry) => visibility.visibleItemIds.has(entry.id))
|
|
1185
|
+
.filter((entry) => entry.required)
|
|
1186
|
+
.filter((entry) => !answerIds.has(entry.id))
|
|
1187
|
+
.map((entry) => entry.prompt);
|
|
1188
|
+
if (missingRequired.length > 0) {
|
|
1189
|
+
throw createHttpError({
|
|
1190
|
+
statusCode: 400,
|
|
1191
|
+
code: "questionnaire_missing_answers",
|
|
1192
|
+
message: "Complete all required questionnaire items before finishing the run.",
|
|
1193
|
+
details: {
|
|
1194
|
+
missingItems: missingRequired
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
const scored = scoreRun(version, answers);
|
|
1199
|
+
getDatabase()
|
|
1200
|
+
.prepare("DELETE FROM questionnaire_run_scores WHERE run_id = ?")
|
|
1201
|
+
.run(runId);
|
|
1202
|
+
const now = nowIso();
|
|
1203
|
+
const insertScore = getDatabase().prepare(`
|
|
1204
|
+
INSERT INTO questionnaire_run_scores (
|
|
1205
|
+
id,
|
|
1206
|
+
run_id,
|
|
1207
|
+
score_key,
|
|
1208
|
+
label,
|
|
1209
|
+
value_numeric,
|
|
1210
|
+
value_text,
|
|
1211
|
+
band_label,
|
|
1212
|
+
severity,
|
|
1213
|
+
sort_order,
|
|
1214
|
+
details_json,
|
|
1215
|
+
created_at
|
|
1216
|
+
)
|
|
1217
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1218
|
+
`);
|
|
1219
|
+
for (const score of scored) {
|
|
1220
|
+
insertScore.run(buildId("questionnaire_score"), runId, score.scoreKey, score.label, score.valueNumeric, score.valueText, score.bandLabel, score.severity, score.sortOrder, JSON.stringify(score.details), now);
|
|
1221
|
+
}
|
|
1222
|
+
getDatabase()
|
|
1223
|
+
.prepare(`
|
|
1224
|
+
UPDATE questionnaire_runs
|
|
1225
|
+
SET status = 'completed', completed_at = ?, updated_at = ?
|
|
1226
|
+
WHERE id = ?
|
|
1227
|
+
`)
|
|
1228
|
+
.run(now, now, runId);
|
|
1229
|
+
const instrument = getInstrumentRow(run.instrument_id);
|
|
1230
|
+
if (instrument) {
|
|
1231
|
+
const contentMarkdown = buildCompletionNoteContent({
|
|
1232
|
+
instrument,
|
|
1233
|
+
version,
|
|
1234
|
+
answers,
|
|
1235
|
+
scores: scored,
|
|
1236
|
+
completedAt: now
|
|
1237
|
+
});
|
|
1238
|
+
const primaryScore = scored.find((entry) => entry.valueNumeric !== null || entry.valueText !== null);
|
|
1239
|
+
createNote({
|
|
1240
|
+
kind: "evidence",
|
|
1241
|
+
title: `${instrument.title} self observation`,
|
|
1242
|
+
aliases: [],
|
|
1243
|
+
indexOrder: 0,
|
|
1244
|
+
summary: primaryScore !== undefined
|
|
1245
|
+
? `${primaryScore.label}: ${formatScoreForNote(primaryScore)}`
|
|
1246
|
+
: `${instrument.title} completed`,
|
|
1247
|
+
contentMarkdown,
|
|
1248
|
+
author: context.actor ?? "Questionnaire",
|
|
1249
|
+
links: [],
|
|
1250
|
+
tags: [SELF_OBSERVATION_TAG],
|
|
1251
|
+
destroyAt: null,
|
|
1252
|
+
sourcePath: "",
|
|
1253
|
+
frontmatter: {
|
|
1254
|
+
observedAt: now,
|
|
1255
|
+
questionnaireInstrumentId: instrument.id,
|
|
1256
|
+
questionnaireRunId: runId,
|
|
1257
|
+
questionnaireVersionId: run.version_id
|
|
1258
|
+
},
|
|
1259
|
+
revisionHash: "",
|
|
1260
|
+
userId: run.user_id
|
|
1261
|
+
}, context);
|
|
1262
|
+
}
|
|
1263
|
+
recordActivityEvent({
|
|
1264
|
+
entityType: "questionnaire_run",
|
|
1265
|
+
entityId: runId,
|
|
1266
|
+
eventType: "questionnaire_run_completed",
|
|
1267
|
+
title: `Questionnaire completed: ${instrument?.title ?? run.instrument_id}`,
|
|
1268
|
+
description: "A questionnaire run was completed and scored.",
|
|
1269
|
+
actor: context.actor ?? null,
|
|
1270
|
+
source: context.source,
|
|
1271
|
+
metadata: {
|
|
1272
|
+
instrumentId: run.instrument_id,
|
|
1273
|
+
versionId: run.version_id,
|
|
1274
|
+
scoreCount: scored.length
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
return getQuestionnaireRunDetail(runId);
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
export function getQuestionnaireRunDetail(runId, options = {}) {
|
|
1281
|
+
const run = getRunRow(runId);
|
|
1282
|
+
if (!run) {
|
|
1283
|
+
throw createHttpError({
|
|
1284
|
+
statusCode: 404,
|
|
1285
|
+
code: "questionnaire_run_not_found",
|
|
1286
|
+
message: "Questionnaire run not found."
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
if (options.userIds &&
|
|
1290
|
+
options.userIds.length > 0 &&
|
|
1291
|
+
run.user_id &&
|
|
1292
|
+
!options.userIds.includes(run.user_id)) {
|
|
1293
|
+
throw createHttpError({
|
|
1294
|
+
statusCode: 404,
|
|
1295
|
+
code: "questionnaire_run_not_found",
|
|
1296
|
+
message: "Questionnaire run not found."
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
const instrumentRow = getInstrumentRow(run.instrument_id);
|
|
1300
|
+
const versionRow = getVersionRow(run.version_id);
|
|
1301
|
+
if (!instrumentRow || !versionRow) {
|
|
1302
|
+
throw createHttpError({
|
|
1303
|
+
statusCode: 404,
|
|
1304
|
+
code: "questionnaire_context_missing",
|
|
1305
|
+
message: "The questionnaire context for this run is no longer available."
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
const versions = getVersionRowsForInstrument(instrumentRow.id).map(mapVersion);
|
|
1309
|
+
const instrument = mapSummary(instrumentRow, versions, options.userIds);
|
|
1310
|
+
const version = mapVersion(versionRow);
|
|
1311
|
+
const answers = listAnswerRows(runId).map((row) => ({
|
|
1312
|
+
itemId: row.item_id,
|
|
1313
|
+
optionKey: row.option_key,
|
|
1314
|
+
valueText: row.value_text,
|
|
1315
|
+
numericValue: row.numeric_value,
|
|
1316
|
+
answer: parseJson(row.answer_json),
|
|
1317
|
+
createdAt: row.created_at,
|
|
1318
|
+
updatedAt: row.updated_at
|
|
1319
|
+
}));
|
|
1320
|
+
const scores = listRunScoreRows(runId).map((row) => questionnaireRunScoreSchema.parse({
|
|
1321
|
+
scoreKey: row.score_key,
|
|
1322
|
+
label: row.label,
|
|
1323
|
+
valueNumeric: row.value_numeric,
|
|
1324
|
+
valueText: row.value_text,
|
|
1325
|
+
bandLabel: row.band_label,
|
|
1326
|
+
severity: row.severity,
|
|
1327
|
+
details: parseJson(row.details_json),
|
|
1328
|
+
createdAt: row.created_at
|
|
1329
|
+
}));
|
|
1330
|
+
return questionnaireRunDetailSchema.parse({
|
|
1331
|
+
run: mapRun(run),
|
|
1332
|
+
instrument,
|
|
1333
|
+
version,
|
|
1334
|
+
answers,
|
|
1335
|
+
scores,
|
|
1336
|
+
history: getHistoryForInstrument(run.instrument_id, run.user_id ? [run.user_id] : undefined)
|
|
1337
|
+
});
|
|
1338
|
+
}
|