forge-openclaw-plugin 0.2.23 → 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.
Files changed (84) hide show
  1. package/README.md +13 -0
  2. package/dist/assets/{board-_C6oMy5w.js → board-VmF4FAfr.js} +3 -3
  3. package/dist/assets/{board-_C6oMy5w.js.map → board-VmF4FAfr.js.map} +1 -1
  4. package/dist/assets/index-CFCKDIMH.js +67 -0
  5. package/dist/assets/index-CFCKDIMH.js.map +1 -0
  6. package/dist/assets/index-ZPY6U1TU.css +1 -0
  7. package/dist/assets/{motion-D4sZgCHd.js → motion-DvkU14p-.js} +3 -3
  8. package/dist/assets/motion-DvkU14p-.js.map +1 -0
  9. package/dist/assets/{table-BWzTaky1.js → table-DgiPof9E.js} +2 -2
  10. package/dist/assets/{table-BWzTaky1.js.map → table-DgiPof9E.js.map} +1 -1
  11. package/dist/assets/{ui-BzK4azQb.js → ui-nYfoC0Gq.js} +2 -2
  12. package/dist/assets/{ui-BzK4azQb.js.map → ui-nYfoC0Gq.js.map} +1 -1
  13. package/dist/assets/vendor-D9PTEPSB.js +824 -0
  14. package/dist/assets/vendor-D9PTEPSB.js.map +1 -0
  15. package/dist/assets/viz-Cqb6s--o.js +34 -0
  16. package/dist/assets/viz-Cqb6s--o.js.map +1 -0
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/parity.d.ts +1 -1
  19. package/dist/openclaw/parity.js +29 -0
  20. package/dist/openclaw/plugin-entry-shared.d.ts +1 -0
  21. package/dist/openclaw/plugin-entry-shared.js +7 -4
  22. package/dist/openclaw/plugin-sdk-types.d.ts +12 -0
  23. package/dist/openclaw/routes.js +236 -0
  24. package/dist/openclaw/session-bootstrap.d.ts +78 -0
  25. package/dist/openclaw/session-bootstrap.js +240 -0
  26. package/dist/openclaw/tools.js +279 -3
  27. package/dist/server/app.js +855 -19
  28. package/dist/server/connectors/box-registry.js +257 -0
  29. package/dist/server/db.js +2 -0
  30. package/dist/server/discovery-advertiser.js +114 -0
  31. package/dist/server/health.js +39 -11
  32. package/dist/server/index.js +4 -0
  33. package/dist/server/managers/platform/llm-manager.js +40 -4
  34. package/dist/server/managers/platform/openai-responses-provider.js +129 -19
  35. package/dist/server/movement.js +2935 -0
  36. package/dist/server/openapi.js +628 -5
  37. package/dist/server/psyche-types.js +15 -1
  38. package/dist/server/questionnaire-flow.js +552 -0
  39. package/dist/server/questionnaire-seeds.js +853 -0
  40. package/dist/server/questionnaire-types.js +340 -0
  41. package/dist/server/repositories/ai-connectors.js +944 -0
  42. package/dist/server/repositories/ai-processors.js +547 -0
  43. package/dist/server/repositories/diagnostic-logs.js +57 -4
  44. package/dist/server/repositories/entity-ownership.js +9 -1
  45. package/dist/server/repositories/habits.js +77 -9
  46. package/dist/server/repositories/model-settings.js +216 -0
  47. package/dist/server/repositories/notes.js +57 -15
  48. package/dist/server/repositories/preferences.js +124 -0
  49. package/dist/server/repositories/questionnaires.js +1338 -0
  50. package/dist/server/repositories/rewards.js +2 -2
  51. package/dist/server/repositories/settings.js +108 -12
  52. package/dist/server/repositories/surface-layouts.js +76 -0
  53. package/dist/server/repositories/wiki-memory.js +5 -1
  54. package/dist/server/services/entity-crud.js +81 -2
  55. package/dist/server/services/openai-codex-oauth.js +153 -0
  56. package/dist/server/services/psyche-observation-calendar.js +46 -0
  57. package/dist/server/types.js +492 -3
  58. package/dist/server/watch-mobile.js +562 -0
  59. package/dist/server/web.js +9 -2
  60. package/openclaw.plugin.json +1 -1
  61. package/package.json +6 -1
  62. package/server/migrations/024_questionnaires.sql +96 -0
  63. package/server/migrations/025_ai_model_connections.sql +26 -0
  64. package/server/migrations/026_custom_theme_settings.sql +2 -0
  65. package/server/migrations/027_ai_processors.sql +31 -0
  66. package/server/migrations/028_movement_domain.sql +136 -0
  67. package/server/migrations/029_watch_micro_capture.sql +23 -0
  68. package/server/migrations/030_surface_layouts.sql +5 -0
  69. package/server/migrations/031_ai_processor_runtime_upgrades.sql +10 -0
  70. package/server/migrations/032_ai_connectors.sql +44 -0
  71. package/server/migrations/033_movement_trip_point_sync.sql +36 -0
  72. package/server/migrations/034_movement_segment_sync.sql +49 -0
  73. package/skills/forge-openclaw/SKILL.md +12 -1
  74. package/skills/forge-openclaw/entity_conversation_playbooks.md +331 -84
  75. package/skills/forge-openclaw/psyche_entity_playbooks.md +252 -221
  76. package/dist/assets/index-Ch_xeZ2u.js +0 -63
  77. package/dist/assets/index-Ch_xeZ2u.js.map +0 -1
  78. package/dist/assets/index-DvVM7K6j.css +0 -1
  79. package/dist/assets/motion-D4sZgCHd.js.map +0 -1
  80. package/dist/assets/vendor-De38P6YR.js +0 -729
  81. package/dist/assets/vendor-De38P6YR.js.map +0 -1
  82. package/dist/assets/viz-C6hfyqzu.js +0 -34
  83. package/dist/assets/viz-C6hfyqzu.js.map +0 -1
  84. 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
+ }