@peopl-health/nexus 6.0.0-dev.666 → 6.0.0-dev.691
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/lib/config/airtableConfig.js +3 -0
- package/lib/core/workflowRunner.js +35 -0
- package/lib/fhir/config/fhirConfig.js +34 -3
- package/lib/fhir/config/fhirTablesConfig.js +9 -0
- package/lib/fhir/helpers/compositeHelper.js +88 -0
- package/lib/fhir/helpers/elementHelper.js +9 -0
- package/lib/fhir/helpers/fhirHelper.js +5 -2
- package/lib/fhir/helpers/terminologyHelper.js +260 -0
- package/lib/fhir/index.js +4 -0
- package/lib/fhir/projections/proctcaeProjection.js +179 -0
- package/lib/fhir/resources/Condition.js +24 -2
- package/lib/fhir/resources/Device.js +24 -0
- package/lib/fhir/resources/Observation.js +27 -1
- package/lib/fhir/resources/Patient.js +18 -0
- package/lib/fhir/resources/Questionnaire.js +23 -0
- package/lib/fhir/resources/QuestionnaireResponse.js +29 -0
- package/lib/fhir/services/fhirService.js +2 -2
- package/lib/fhir/services/snapshotService.js +28 -0
- package/lib/fhir/services/terminologyService.js +58 -0
- package/lib/fhir/services/triageService.js +57 -0
- package/lib/fhir/utils/normalizeUtils.js +33 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +2 -0
- package/lib/services/airtableService.js +2 -1
- package/lib/shared/dtos/GradedSymptom.js +23 -0
- package/lib/shared/dtos/TriageResponse.js +35 -0
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ const Logging_ID = runtimeConfig.get('AIRTABLE_LOGGING_ID') || 'appQ7YhzfebRDbSP
|
|
|
13
13
|
const Monitoreo_ID = runtimeConfig.get('AIRTABLE_MONITOREO_ID') || 'appdvraKSdp0XVn5n';
|
|
14
14
|
const Programa_Juntas_ID = runtimeConfig.get('AIRTABLE_PROGRAMA_JUNTAS_ID') || 'appKFWzkcDEWlrXBE';
|
|
15
15
|
const Symptoms_ID = runtimeConfig.get('AIRTABLE_SYMPTOMS_ID') || 'appQRhZlQ9tMfYZWJ';
|
|
16
|
+
const Ctcae_ID = runtimeConfig.get('AIRTABLE_CTCAE_ID') || 'appEr4ATOs0Xc8ymP';
|
|
16
17
|
const Follow_Up_ID = runtimeConfig.get('AIRTABLE_FOLLOW_UP_ID') || 'appBjKw1Ub0KkbZf0';
|
|
17
18
|
const Webinars_Leads_ID = runtimeConfig.get('AIRTABLE_WEBINARS_LEADS_ID') || 'appzjpVXTI0TgqGPq';
|
|
18
19
|
const Product_ID = runtimeConfig.get('AIRTABLE_PRODUCT_ID') || 'appu2YDW2pKDYLL5H';
|
|
@@ -32,6 +33,7 @@ const BASE_MAP = {
|
|
|
32
33
|
monitoreo: Monitoreo_ID,
|
|
33
34
|
programa: Programa_Juntas_ID,
|
|
34
35
|
symptoms: Symptoms_ID,
|
|
36
|
+
ctcae: Ctcae_ID,
|
|
35
37
|
followup: Follow_Up_ID,
|
|
36
38
|
webinars: Webinars_Leads_ID,
|
|
37
39
|
product: Product_ID,
|
|
@@ -49,6 +51,7 @@ module.exports = {
|
|
|
49
51
|
Monitoreo_ID,
|
|
50
52
|
Programa_Juntas_ID,
|
|
51
53
|
Symptoms_ID,
|
|
54
|
+
Ctcae_ID,
|
|
52
55
|
Follow_Up_ID,
|
|
53
56
|
Webinars_Leads_ID,
|
|
54
57
|
Product_ID,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
function createWorkflowRunner({ queueAdapter } = {}) {
|
|
2
|
+
if (!queueAdapter) throw new Error('createWorkflowRunner requires a queueAdapter');
|
|
3
|
+
const workflows = new Map();
|
|
4
|
+
|
|
5
|
+
async function register(workflow = {}) {
|
|
6
|
+
if (!workflow.kind || typeof workflow.kind !== 'string') throw new Error('workflow requires a kind');
|
|
7
|
+
if (workflow.kind.includes('__')) throw new Error(`workflow '${workflow.kind}' must not contain '__' (reserved as the jobId delimiter)`);
|
|
8
|
+
if (typeof workflow.dedupeKey !== 'function') throw new Error(`workflow '${workflow.kind}' requires a dedupeKey function`);
|
|
9
|
+
if (typeof workflow.prepare !== 'function') throw new Error(`workflow '${workflow.kind}' requires a prepare function`);
|
|
10
|
+
if (workflows.has(workflow.kind)) throw new Error(`workflow '${workflow.kind}' is already registered`);
|
|
11
|
+
workflows.set(workflow.kind, workflow);
|
|
12
|
+
try {
|
|
13
|
+
await queueAdapter.process(workflow.kind, (trigger) => workflow.prepare(trigger));
|
|
14
|
+
} catch (error) {
|
|
15
|
+
workflows.delete(workflow.kind);
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
return workflow;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function enqueue(kind, trigger, options = {}) {
|
|
22
|
+
const workflow = workflows.get(kind);
|
|
23
|
+
if (!workflow) throw new Error(`no workflow registered for '${kind}'`);
|
|
24
|
+
const dedupeKey = workflow.dedupeKey(trigger);
|
|
25
|
+
if (dedupeKey == null || (typeof dedupeKey === 'string' && !dedupeKey.trim())) {
|
|
26
|
+
throw new Error(`workflow '${kind}' produced an empty dedupe key`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof dedupeKey !== 'string') throw new Error(`workflow '${kind}' produced a non-string dedupe key`);
|
|
29
|
+
return queueAdapter.enqueue(kind, trigger, { ...options, jobId: `${kind}__${dedupeKey}` });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { register, enqueue };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { createWorkflowRunner };
|
|
@@ -2,12 +2,32 @@ const { Config_ID } = require('../../config/airtableConfig');
|
|
|
2
2
|
|
|
3
3
|
const { getRecordByFilter } = require('../../services/airtableService');
|
|
4
4
|
|
|
5
|
-
const { safeParse } = require('../../utils/jsonUtils');
|
|
5
|
+
const { safeParse, safeParseArray } = require('../../utils/jsonUtils');
|
|
6
6
|
|
|
7
7
|
const FHIR_CONFIG_TABLE = 'fhir';
|
|
8
|
-
const REQUIRED_KEYS = [
|
|
8
|
+
const REQUIRED_KEYS = [
|
|
9
|
+
'IDENT_BASE',
|
|
10
|
+
'EXT_BASE',
|
|
11
|
+
'CODESYSTEM_BASE',
|
|
12
|
+
'SYSTEMS',
|
|
13
|
+
'SUSPECTED_GRADE_DISPLAYS',
|
|
14
|
+
'QUESTIONNAIRE_CANONICAL_URL',
|
|
15
|
+
'PROCTCAE_VERSION',
|
|
16
|
+
'DEVICE_ID',
|
|
17
|
+
'ORGANIZATION_ID',
|
|
18
|
+
];
|
|
9
19
|
|
|
10
|
-
const state = {
|
|
20
|
+
const state = {
|
|
21
|
+
identBase: null,
|
|
22
|
+
extBase: null,
|
|
23
|
+
codesystemBase: null,
|
|
24
|
+
systems: null,
|
|
25
|
+
suspectedGradeDisplays: null,
|
|
26
|
+
questionnaireCanonicalUrl: null,
|
|
27
|
+
proctcaeVersion: null,
|
|
28
|
+
deviceId: null,
|
|
29
|
+
organizationId: null,
|
|
30
|
+
};
|
|
11
31
|
|
|
12
32
|
async function initialize() {
|
|
13
33
|
const records = await getRecordByFilter(Config_ID, FHIR_CONFIG_TABLE, 'TRUE()');
|
|
@@ -20,9 +40,16 @@ async function initialize() {
|
|
|
20
40
|
const systems = safeParse(byKey.SYSTEMS, null);
|
|
21
41
|
if (!systems) throw new Error('fhir config SYSTEMS is not valid JSON');
|
|
22
42
|
|
|
43
|
+
const suspectedGradeDisplays = safeParseArray(byKey.SUSPECTED_GRADE_DISPLAYS, null);
|
|
44
|
+
if (!suspectedGradeDisplays) throw new Error('fhir config SUSPECTED_GRADE_DISPLAYS is not a JSON array');
|
|
45
|
+
|
|
23
46
|
state.identBase = byKey.IDENT_BASE;
|
|
24
47
|
state.extBase = byKey.EXT_BASE;
|
|
48
|
+
state.codesystemBase = byKey.CODESYSTEM_BASE;
|
|
25
49
|
state.systems = systems;
|
|
50
|
+
state.suspectedGradeDisplays = suspectedGradeDisplays;
|
|
51
|
+
state.questionnaireCanonicalUrl = byKey.QUESTIONNAIRE_CANONICAL_URL;
|
|
52
|
+
state.proctcaeVersion = byKey.PROCTCAE_VERSION;
|
|
26
53
|
state.deviceId = byKey.DEVICE_ID;
|
|
27
54
|
state.organizationId = byKey.ORGANIZATION_ID;
|
|
28
55
|
}
|
|
@@ -35,7 +62,11 @@ module.exports = {
|
|
|
35
62
|
initialize,
|
|
36
63
|
getIdentBase: () => { assertLoaded(); return state.identBase; },
|
|
37
64
|
getExtBase: () => { assertLoaded(); return state.extBase; },
|
|
65
|
+
getCodesystemBase: () => { assertLoaded(); return state.codesystemBase; },
|
|
38
66
|
getSystems: () => { assertLoaded(); return state.systems; },
|
|
67
|
+
getSuspectedGradeDisplays: () => { assertLoaded(); return state.suspectedGradeDisplays; },
|
|
68
|
+
getQuestionnaireCanonicalUrl: () => { assertLoaded(); return state.questionnaireCanonicalUrl; },
|
|
69
|
+
getProctcaeVersion: () => { assertLoaded(); return state.proctcaeVersion; },
|
|
39
70
|
getDeviceId: () => { assertLoaded(); return state.deviceId; },
|
|
40
71
|
getOrganizationId: () => { assertLoaded(); return state.organizationId; },
|
|
41
72
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const runtimeConfig = require('../../config/runtimeConfig');
|
|
2
|
+
|
|
3
|
+
const PROCTCAE_ITEMS_TABLE = runtimeConfig.get('AIRTABLE_PROCTCAE_ITEMS_TABLE') || 'all_items_proctcae';
|
|
4
|
+
const PROCTCAE_COMPOSITE_TABLE = runtimeConfig.get('AIRTABLE_PROCTCAE_COMPOSITE_TABLE') || 'composite_calculations';
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
PROCTCAE_ITEMS_TABLE,
|
|
8
|
+
PROCTCAE_COMPOSITE_TABLE,
|
|
9
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const { logger } = require('../../utils/logger');
|
|
2
|
+
const {
|
|
3
|
+
itemForQuestionId,
|
|
4
|
+
canonicalAnswer,
|
|
5
|
+
affirmative,
|
|
6
|
+
standardOrdinal,
|
|
7
|
+
compositeGrade,
|
|
8
|
+
dimensionsFor,
|
|
9
|
+
} = require('./terminologyHelper');
|
|
10
|
+
|
|
11
|
+
const PAIN_TERM = 'general_pain';
|
|
12
|
+
|
|
13
|
+
function gradeComposites(catalog, input) {
|
|
14
|
+
const answersByTerm = new Map();
|
|
15
|
+
for (const [questionId, label] of Object.entries(input.answers || {})) {
|
|
16
|
+
if (label == null || String(label).trim() === '') continue;
|
|
17
|
+
const item = itemForQuestionId(catalog, questionId);
|
|
18
|
+
if (!item) continue;
|
|
19
|
+
const [term, dimension] = item;
|
|
20
|
+
if (!answersByTerm.has(term)) answersByTerm.set(term, {});
|
|
21
|
+
answersByTerm.get(term)[dimension] = String(canonicalAnswer(catalog, questionId, label));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const pain = painDimensionAnswers(catalog, input.pain, input.triageId);
|
|
25
|
+
if (pain) answersByTerm.set(PAIN_TERM, pain);
|
|
26
|
+
|
|
27
|
+
const graded = [];
|
|
28
|
+
for (const [term, dimensionAnswers] of answersByTerm) {
|
|
29
|
+
const ordinals = {};
|
|
30
|
+
for (const [dimension, label] of Object.entries(dimensionAnswers)) {
|
|
31
|
+
const ordinal = dimension === 'presence' ? presenceOrdinal(catalog, label) : standardOrdinal(catalog, dimension, label);
|
|
32
|
+
if (ordinal !== null) ordinals[dimension] = ordinal;
|
|
33
|
+
}
|
|
34
|
+
const grade = resolveGrade(catalog, ordinals);
|
|
35
|
+
if (grade === null) {
|
|
36
|
+
logger.warn('[gradeComposites] no composite mapping for combination', {
|
|
37
|
+
term,
|
|
38
|
+
ordinals,
|
|
39
|
+
dimensionAnswers,
|
|
40
|
+
triageId: input.triageId,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
graded.push({
|
|
45
|
+
symptomId: `${input.triageId}-${term}`,
|
|
46
|
+
patientId: input.patientId,
|
|
47
|
+
term,
|
|
48
|
+
grade,
|
|
49
|
+
effective: input.authored || null,
|
|
50
|
+
triageId: input.triageId,
|
|
51
|
+
dimensionAnswers: dimensionAnswers,
|
|
52
|
+
source: 'triage',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return graded;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function presenceOrdinal(catalog, label) {
|
|
59
|
+
const ordinal = standardOrdinal(catalog, 'presence', label);
|
|
60
|
+
if (ordinal !== null) return ordinal;
|
|
61
|
+
return affirmative(label) ? 1 : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function painDimensionAnswers(catalog, pain, triageId) {
|
|
65
|
+
if (!pain || pain.hasPain == null || String(pain.hasPain).trim() === '') return null;
|
|
66
|
+
const dimensions = dimensionsFor(catalog, PAIN_TERM);
|
|
67
|
+
if (!dimensions.length) {
|
|
68
|
+
logger.warn('[gradeComposites] pain term missing from catalog', { term: PAIN_TERM, triageId });
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!affirmative(pain.hasPain)) return { presence: String(pain.hasPain) };
|
|
72
|
+
const answers = {};
|
|
73
|
+
for (const dimension of dimensions) {
|
|
74
|
+
const label = pain[dimension];
|
|
75
|
+
if (label != null && String(label).trim() !== '') answers[dimension] = String(label);
|
|
76
|
+
}
|
|
77
|
+
return Object.keys(answers).length ? answers : { presence: String(pain.hasPain) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveGrade(catalog, ordinals) {
|
|
81
|
+
const grade = compositeGrade(catalog, ordinals);
|
|
82
|
+
if (grade !== null) return grade;
|
|
83
|
+
const dimensions = Object.keys(ordinals);
|
|
84
|
+
if (dimensions.length !== 1 || dimensions[0] !== 'presence') return null;
|
|
85
|
+
return ordinals.presence > 0 ? 1 : 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { gradeComposites, PAIN_TERM };
|
|
@@ -24,10 +24,19 @@ function extension(name, value) {
|
|
|
24
24
|
return { url: extensionUrl(name), ...value };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function toIso(value) {
|
|
28
|
+
if (value instanceof Date) return value.toISOString().replace(/\.000Z$/, 'Z');
|
|
29
|
+
const s = String(value);
|
|
30
|
+
if (/\+00:00$/.test(s)) return s.replace(/\+00:00$/, 'Z');
|
|
31
|
+
if (/(Z|[+-]\d{2}:\d{2})$/.test(s)) return s;
|
|
32
|
+
return `${s}Z`;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
module.exports = {
|
|
28
36
|
identifier,
|
|
29
37
|
reference,
|
|
30
38
|
patientReference,
|
|
31
39
|
codeableConcept,
|
|
32
40
|
extension,
|
|
41
|
+
toIso,
|
|
33
42
|
};
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
const { getIdentBase, getExtBase } = require('../config/fhirConfig');
|
|
1
|
+
const { getIdentBase, getExtBase, getCodesystemBase } = require('../config/fhirConfig');
|
|
2
2
|
|
|
3
3
|
function fhirId(businessId) {
|
|
4
|
-
|
|
4
|
+
const id = String(businessId).replace(/[^A-Za-z0-9.-]/g, '-').slice(0, 64);
|
|
5
|
+
return id || 'x';
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
const identifierSystem = (slug) => `${getIdentBase()}/${slug}`;
|
|
8
9
|
const extensionUrl = (name) => `${getExtBase()}/${name}`;
|
|
10
|
+
const codeSystemUrl = (name) => `${getCodesystemBase()}/${name}`;
|
|
9
11
|
|
|
10
12
|
module.exports = {
|
|
11
13
|
fhirId,
|
|
12
14
|
identifierSystem,
|
|
13
15
|
extensionUrl,
|
|
16
|
+
codeSystemUrl,
|
|
14
17
|
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
|
|
3
|
+
const { getSuspectedGradeDisplays, getSystems } = require('../config/fhirConfig');
|
|
4
|
+
const { lookupValue, integerId, parseList, termKey } = require('../utils/normalizeUtils');
|
|
5
|
+
const { codeSystemUrl } = require('./fhirHelper');
|
|
6
|
+
|
|
7
|
+
const { safeParse } = require('../../utils/jsonUtils');
|
|
8
|
+
|
|
9
|
+
const DIMENSION_SCALES = {
|
|
10
|
+
frequency: ['Nunca', 'Rara vez', 'A veces', 'A menudo', 'Casi siempre'],
|
|
11
|
+
intensity: ['Ninguna', 'Leve', 'Moderada', 'Intensa', 'Muy intensa'],
|
|
12
|
+
interference: ['Nada', 'Un poco', 'Algo', 'Mucho', 'Muchísimo'],
|
|
13
|
+
amount: ['Ninguna', 'Leve', 'Moderada', 'Intensa', 'Muy intensa'],
|
|
14
|
+
presence: ['No', 'Sí'],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const DIMENSION_ORDER = ['presence', 'frequency', 'intensity', 'interference', 'amount'];
|
|
18
|
+
|
|
19
|
+
const CANONICAL_SOURCE = 'original';
|
|
20
|
+
|
|
21
|
+
function buildCatalog(items, composites = [], source = null) {
|
|
22
|
+
const symptoms = {};
|
|
23
|
+
const questionIndex = {};
|
|
24
|
+
const answerMap = {};
|
|
25
|
+
|
|
26
|
+
for (const row of items) {
|
|
27
|
+
const questionId = integerId(row.question_id);
|
|
28
|
+
const map = answerMapOf(row);
|
|
29
|
+
if (questionId != null && map) answerMap[String(questionId)] = map;
|
|
30
|
+
|
|
31
|
+
const en = row.symptom_en;
|
|
32
|
+
if (!en) continue;
|
|
33
|
+
const term = termKey(en);
|
|
34
|
+
const dimension = row.dimension ?? null;
|
|
35
|
+
|
|
36
|
+
const record = (symptoms[term] = symptoms[term] || {
|
|
37
|
+
term,
|
|
38
|
+
en,
|
|
39
|
+
es: row.symptom_es ?? null,
|
|
40
|
+
ctcae_v5: lookupValue(row.ctcae_v5),
|
|
41
|
+
meddra: lookupValue(row.meddra_code),
|
|
42
|
+
ae_category: lookupValue(row.AEcategory),
|
|
43
|
+
dimensions: [],
|
|
44
|
+
});
|
|
45
|
+
record.dimensions.push({
|
|
46
|
+
dimension,
|
|
47
|
+
options: map ? [...(DIMENSION_SCALES[dimension] || [])] : parseList(row.options),
|
|
48
|
+
question_id: questionId != null ? String(questionId) : null,
|
|
49
|
+
source: row.source ?? null,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (questionId != null && dimension) questionIndex[String(questionId)] = [term, dimension];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const record of Object.values(symptoms)) {
|
|
56
|
+
record.dimensions = pickCanonicalDimensions(record.dimensions);
|
|
57
|
+
}
|
|
58
|
+
const compositeGrades = buildCompositeGrades(composites);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
_meta: {
|
|
62
|
+
source,
|
|
63
|
+
n_symptoms: Object.keys(symptoms).length,
|
|
64
|
+
n_items: Object.keys(questionIndex).length,
|
|
65
|
+
n_mapped_items: Object.keys(answerMap).length,
|
|
66
|
+
n_composites: Object.keys(compositeGrades).length,
|
|
67
|
+
composite_table_hash: Object.keys(compositeGrades).length ? hashCompositeGrades(compositeGrades) : null,
|
|
68
|
+
dimension_scales: DIMENSION_SCALES,
|
|
69
|
+
},
|
|
70
|
+
symptoms,
|
|
71
|
+
question_index: questionIndex,
|
|
72
|
+
answer_map: answerMap,
|
|
73
|
+
composite_grades: compositeGrades,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function answerMapOf(row) {
|
|
78
|
+
const map = safeParse(row.proctcae_map, null);
|
|
79
|
+
return map && typeof map === 'object' && Object.keys(map).length ? map : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function canonicalAnswer(catalog, questionId, answer) {
|
|
83
|
+
const map = catalog.answer_map[String(questionId).trim()];
|
|
84
|
+
if (!map) return answer;
|
|
85
|
+
return map[String(answer).trim()] ?? answer;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pickCanonicalDimensions(dimensions) {
|
|
89
|
+
const best = {};
|
|
90
|
+
for (const d of dimensions) {
|
|
91
|
+
if (d.dimension == null) continue;
|
|
92
|
+
const prev = best[d.dimension];
|
|
93
|
+
if (!prev || outranks(d, prev)) best[d.dimension] = d;
|
|
94
|
+
}
|
|
95
|
+
return Object.values(best).sort((a, b) => {
|
|
96
|
+
const rank = (d) => (DIMENSION_ORDER.includes(d.dimension) ? DIMENSION_ORDER.indexOf(d.dimension) : 99);
|
|
97
|
+
return rank(a) - rank(b);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function outranks(candidate, current) {
|
|
102
|
+
const canonical = (d) => d.source === CANONICAL_SOURCE;
|
|
103
|
+
if (canonical(candidate) !== canonical(current)) return canonical(candidate);
|
|
104
|
+
return (integerId(candidate.question_id) || 0) > (integerId(current.question_id) || 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildCompositeGrades(composites) {
|
|
108
|
+
const compositeGrades = {};
|
|
109
|
+
for (const row of composites) {
|
|
110
|
+
const code = row.combination_code;
|
|
111
|
+
const raw = String(row.composite_ctcae ?? '').trim();
|
|
112
|
+
const grade = raw === '' ? NaN : Number(raw);
|
|
113
|
+
if (code && Number.isInteger(grade)) compositeGrades[code] = grade;
|
|
114
|
+
}
|
|
115
|
+
return compositeGrades;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hashCompositeGrades(compositeGrades) {
|
|
119
|
+
const lines = Object.entries(compositeGrades)
|
|
120
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
121
|
+
.map(([code, grade]) => `${code}=${grade}`)
|
|
122
|
+
.join('\n');
|
|
123
|
+
return crypto.createHash('sha256').update(lines, 'utf8').digest('hex').slice(0, 12);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function allTerms(catalog) {
|
|
127
|
+
return Object.keys(catalog.symptoms).sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function compositeTableHash(catalog) {
|
|
131
|
+
return catalog._meta.composite_table_hash;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getSymptom(catalog, term) {
|
|
135
|
+
return catalog.symptoms[term] || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function itemForQuestionId(catalog, questionId) {
|
|
139
|
+
const entry = catalog.question_index[String(questionId).trim()];
|
|
140
|
+
return Array.isArray(entry) && entry.length >= 2 ? [entry[0], entry[1]] : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function standardScale(catalog, dimension) {
|
|
144
|
+
return [...(catalog._meta.dimension_scales[dimension] || [])];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function standardOrdinal(catalog, dimension, answer) {
|
|
148
|
+
return ordinalIn(standardScale(catalog, dimension), answer);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function compositeGrade(catalog, ordinals = {}) {
|
|
152
|
+
const code = COMPOSITE_DIMENSIONS.map((d) => (ordinals[d] == null ? 'x' : String(ordinals[d]))).join('');
|
|
153
|
+
const grade = catalog.composite_grades[code];
|
|
154
|
+
return grade === undefined ? null : grade;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function codeText(catalog, term) {
|
|
158
|
+
const rec = getSymptom(catalog, term);
|
|
159
|
+
return rec ? rec.es || rec.en : term;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function dimensionsFor(catalog, term) {
|
|
163
|
+
const rec = getSymptom(catalog, term);
|
|
164
|
+
return (rec ? rec.dimensions : []).map((d) => d.dimension).filter(Boolean);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function answerOptions(catalog, term, dimension) {
|
|
168
|
+
return optionsForRec(getSymptom(catalog, term), dimension);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function ordinalValue(catalog, term, dimension, answer) {
|
|
172
|
+
return ordinalIn(answerOptions(catalog, term, dimension), answer);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function codingsFor(catalog, term) {
|
|
176
|
+
const rec = getSymptom(catalog, term);
|
|
177
|
+
const codings = [
|
|
178
|
+
{ system: codeSystemUrl('pro-ctcae'), code: term, ...(rec && rec.en ? { display: rec.en } : {}) },
|
|
179
|
+
];
|
|
180
|
+
if (rec && rec.ctcae_v5) {
|
|
181
|
+
codings.push({
|
|
182
|
+
system: codeSystemUrl('ctcae-v5-term'),
|
|
183
|
+
code: ctcaeSlug(rec.ctcae_v5),
|
|
184
|
+
display: rec.ctcae_v5,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (rec && rec.meddra) {
|
|
188
|
+
codings.push({
|
|
189
|
+
system: getSystems().meddra,
|
|
190
|
+
code: String(rec.meddra),
|
|
191
|
+
...(rec.ctcae_v5 ? { display: rec.ctcae_v5 } : {}),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return codings;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function aeCategory(catalog, term) {
|
|
198
|
+
const rec = getSymptom(catalog, term);
|
|
199
|
+
if (!rec || !rec.ae_category) return null;
|
|
200
|
+
return { code: ctcaeSlug(rec.ae_category), display: rec.ae_category };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function gradeConcept(grade) {
|
|
204
|
+
const g = Number(grade);
|
|
205
|
+
if (!Number.isInteger(g)) throw new Error(`grade is not an integer: '${grade}'`);
|
|
206
|
+
const display = getSuspectedGradeDisplays()[g] || `Grado CTCAE ${g} sospechado`;
|
|
207
|
+
return {
|
|
208
|
+
coding: [
|
|
209
|
+
{
|
|
210
|
+
system: codeSystemUrl('suspected-ctcae-grade'),
|
|
211
|
+
code: String(g),
|
|
212
|
+
display,
|
|
213
|
+
extension: [{ url: getSystems().itemWeight, valueDecimal: g }],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
text: display,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function ordinalIn(options, answer) {
|
|
221
|
+
const norm = String(answer ?? '').trim().toLowerCase();
|
|
222
|
+
const i = options.findIndex((o) => o.trim().toLowerCase() === norm);
|
|
223
|
+
return i === -1 ? null : i;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const AFFIRMATIVE = ['sí', 'si', 'yes', 'true', '1'];
|
|
227
|
+
function affirmative(value) {
|
|
228
|
+
return AFFIRMATIVE.includes(String(value).trim().toLowerCase());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function optionsForRec(rec, dimension) {
|
|
232
|
+
for (const d of rec ? rec.dimensions : []) {
|
|
233
|
+
if (d.dimension === dimension) return [...d.options];
|
|
234
|
+
}
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const ctcaeSlug = (value) =>
|
|
239
|
+
String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
240
|
+
|
|
241
|
+
const COMPOSITE_DIMENSIONS = ['frequency', 'intensity', 'interference', 'amount'];
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
buildCatalog,
|
|
245
|
+
allTerms,
|
|
246
|
+
compositeTableHash,
|
|
247
|
+
itemForQuestionId,
|
|
248
|
+
canonicalAnswer,
|
|
249
|
+
affirmative,
|
|
250
|
+
standardScale,
|
|
251
|
+
standardOrdinal,
|
|
252
|
+
compositeGrade,
|
|
253
|
+
codeText,
|
|
254
|
+
aeCategory,
|
|
255
|
+
dimensionsFor,
|
|
256
|
+
answerOptions,
|
|
257
|
+
ordinalValue,
|
|
258
|
+
codingsFor,
|
|
259
|
+
gradeConcept,
|
|
260
|
+
};
|
package/lib/fhir/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const { registerProjector, project, storeResources } = require('./services/fhirService');
|
|
2
2
|
const { storeClinicalMentions } = require('./services/clinicalMentionService');
|
|
3
|
+
const { storeTriage } = require('./services/triageService');
|
|
4
|
+
const { patientSnapshot } = require('./services/snapshotService');
|
|
3
5
|
const { registerAllProjectors } = require('./projections/registerProjectors');
|
|
4
6
|
const { getFhirStore, ensureFhirIndexes } = require('./stores/fhirStore');
|
|
5
7
|
const fhirConfig = require('./config/fhirConfig');
|
|
@@ -16,6 +18,8 @@ module.exports = {
|
|
|
16
18
|
project,
|
|
17
19
|
storeResources,
|
|
18
20
|
storeClinicalMentions,
|
|
21
|
+
storeTriage,
|
|
22
|
+
patientSnapshot,
|
|
19
23
|
getFhirStore,
|
|
20
24
|
ensureFhirIndexes,
|
|
21
25
|
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const { logger } = require('../../utils/logger');
|
|
2
|
+
const { getSystems } = require('../config/fhirConfig');
|
|
3
|
+
const { codeSystemUrl } = require('../helpers/fhirHelper');
|
|
4
|
+
const { codeableConcept } = require('../helpers/elementHelper');
|
|
5
|
+
const terminology = require('../helpers/terminologyHelper');
|
|
6
|
+
const { PAIN_TERM } = require('../helpers/compositeHelper');
|
|
7
|
+
const { Condition } = require('../resources/Condition');
|
|
8
|
+
const { Device } = require('../resources/Device');
|
|
9
|
+
const { Observation } = require('../resources/Observation');
|
|
10
|
+
const { Patient } = require('../resources/Patient');
|
|
11
|
+
const { Questionnaire } = require('../resources/Questionnaire');
|
|
12
|
+
const { QuestionnaireResponse } = require('../resources/QuestionnaireResponse');
|
|
13
|
+
|
|
14
|
+
const GRADE_METHOD = 'PRO-CTCAE composite → CTCAE (unofficial approximation)';
|
|
15
|
+
const INGEST_DEVICE_ID = 'lookup-table-grade-estimator';
|
|
16
|
+
|
|
17
|
+
const PAIN_PRESENCE_LINK = 'pain.presence';
|
|
18
|
+
const PAIN_LOCATION_LINK = 'pain.location';
|
|
19
|
+
|
|
20
|
+
function painLocationText(pain) {
|
|
21
|
+
const locations = (pain.locations || []).filter(Boolean).map(String);
|
|
22
|
+
return locations.length ? locations.join(', ') : String(pain.mostImportant || '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const linkId = (term, dimension) => `${term}.${dimension}`;
|
|
26
|
+
|
|
27
|
+
const answerOption = (catalog, dimension, options) =>
|
|
28
|
+
options.map((label, i) => {
|
|
29
|
+
const weight = terminology.standardOrdinal(catalog, dimension, label);
|
|
30
|
+
return {
|
|
31
|
+
valueCoding: {
|
|
32
|
+
system: codeSystemUrl('pro-ctcae-answer'),
|
|
33
|
+
code: String(i),
|
|
34
|
+
display: label,
|
|
35
|
+
...(weight === null
|
|
36
|
+
? {}
|
|
37
|
+
: { extension: [{ url: getSystems().itemWeight, valueDecimal: weight }] }),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function painCanonicalItems(catalog) {
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
linkId: PAIN_PRESENCE_LINK,
|
|
46
|
+
text: 'Dolor — presencia',
|
|
47
|
+
type: 'coding',
|
|
48
|
+
answerOption: answerOption(catalog, 'presence', terminology.standardScale(catalog, 'presence')),
|
|
49
|
+
},
|
|
50
|
+
{ linkId: PAIN_LOCATION_LINK, text: 'Dolor más importante — ubicación', type: 'string' },
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function projectProctcaeCanonical(catalog) {
|
|
55
|
+
const items = painCanonicalItems(catalog);
|
|
56
|
+
for (const term of terminology.allTerms(catalog)) {
|
|
57
|
+
const textEs = terminology.codeText(catalog, term);
|
|
58
|
+
for (const dim of terminology.dimensionsFor(catalog, term)) {
|
|
59
|
+
if (!terminology.standardScale(catalog, dim).length) {
|
|
60
|
+
logger.warn('[projectProctcaeCanonical] unknown dimension, item skipped', { term, dimension: dim });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
items.push({
|
|
64
|
+
linkId: linkId(term, dim),
|
|
65
|
+
text: `${textEs} — ${dim}`,
|
|
66
|
+
type: 'coding',
|
|
67
|
+
code: terminology.codingsFor(catalog, term),
|
|
68
|
+
answerOption: answerOption(catalog, dim, terminology.answerOptions(catalog, term, dim)),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return Questionnaire.fromProctcaeCatalog({ items });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function projectTriagePatient(patientId) {
|
|
76
|
+
return Patient.fromTriageProjection({ patientId });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function projectTriageIngestDevice(catalog) {
|
|
80
|
+
return Device.fromTriageProjection({
|
|
81
|
+
deviceId: INGEST_DEVICE_ID,
|
|
82
|
+
compositeTableHash: terminology.compositeTableHash(catalog),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function projectProctcaeSymptomObservation(catalog, sym) {
|
|
87
|
+
const term = sym.term;
|
|
88
|
+
const components = [];
|
|
89
|
+
for (const dim of terminology.dimensionsFor(catalog, term)) {
|
|
90
|
+
const answer = (sym.dimensionAnswers || {})[dim];
|
|
91
|
+
if (answer == null) continue;
|
|
92
|
+
const code = codeableConcept(dim, { code: dim, system: codeSystemUrl('pro-ctcae-dimension') });
|
|
93
|
+
if (dim === 'presence') {
|
|
94
|
+
components.push({ code, valueBoolean: terminology.affirmative(answer) });
|
|
95
|
+
} else {
|
|
96
|
+
const ordinal = terminology.standardOrdinal(catalog, dim, answer);
|
|
97
|
+
components.push({
|
|
98
|
+
code,
|
|
99
|
+
...(ordinal !== null ? { valueInteger: ordinal } : { valueString: String(answer) }),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const aeCategory = terminology.aeCategory(catalog, term);
|
|
104
|
+
return Observation.fromTriageProjection({
|
|
105
|
+
sym,
|
|
106
|
+
code: { coding: terminology.codingsFor(catalog, term), text: terminology.codeText(catalog, term) },
|
|
107
|
+
aeCategoryConcept: aeCategory
|
|
108
|
+
? codeableConcept(aeCategory.display, { code: aeCategory.code, system: codeSystemUrl('pro-ctcae-ae-category') })
|
|
109
|
+
: null,
|
|
110
|
+
components,
|
|
111
|
+
gradeConcept: terminology.gradeConcept(sym.grade),
|
|
112
|
+
method: codeableConcept(GRADE_METHOD),
|
|
113
|
+
deviceId: INGEST_DEVICE_ID,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function projectTriageQuestionnaireResponse(catalog, qr) {
|
|
118
|
+
const byLink = new Map();
|
|
119
|
+
const bestQid = new Map();
|
|
120
|
+
for (const [rawQid, label] of Object.entries(qr.answers || {})) {
|
|
121
|
+
if (label == null || String(label).trim() === '') continue;
|
|
122
|
+
const mapped = terminology.itemForQuestionId(catalog, rawQid);
|
|
123
|
+
if (!mapped) continue;
|
|
124
|
+
const [term, dim] = mapped;
|
|
125
|
+
const link = linkId(term, dim);
|
|
126
|
+
const qidInt = /^-?\d+$/.test(String(rawQid).trim()) ? Number(rawQid) : -1;
|
|
127
|
+
if (byLink.has(link) && qidInt <= (bestQid.get(link) ?? -1)) continue;
|
|
128
|
+
const canonical = terminology.canonicalAnswer(catalog, rawQid, label);
|
|
129
|
+
const ordinal = terminology.ordinalValue(catalog, term, dim, String(canonical));
|
|
130
|
+
const answer =
|
|
131
|
+
ordinal !== null
|
|
132
|
+
? { valueCoding: { system: codeSystemUrl('pro-ctcae-answer'), code: String(ordinal), display: String(label) } }
|
|
133
|
+
: { valueString: String(label) };
|
|
134
|
+
byLink.set(link, { linkId: link, answer: [answer] });
|
|
135
|
+
bestQid.set(link, qidInt);
|
|
136
|
+
}
|
|
137
|
+
const pain = qr.pain || {};
|
|
138
|
+
if (pain.hasPain != null && pain.hasPain !== '') {
|
|
139
|
+
const has = terminology.affirmative(pain.hasPain);
|
|
140
|
+
byLink.set(PAIN_PRESENCE_LINK, {
|
|
141
|
+
linkId: PAIN_PRESENCE_LINK,
|
|
142
|
+
answer: [{ valueCoding: { system: codeSystemUrl('pro-ctcae-answer'), code: String(has ? 1 : 0), display: String(pain.hasPain) } }],
|
|
143
|
+
});
|
|
144
|
+
if (has) {
|
|
145
|
+
for (const dim of terminology.dimensionsFor(catalog, PAIN_TERM)) {
|
|
146
|
+
const label = pain[dim];
|
|
147
|
+
if (!label) continue;
|
|
148
|
+
const link = linkId(PAIN_TERM, dim);
|
|
149
|
+
const ordinal = terminology.ordinalValue(catalog, PAIN_TERM, dim, String(label));
|
|
150
|
+
const answer =
|
|
151
|
+
ordinal !== null
|
|
152
|
+
? { valueCoding: { system: codeSystemUrl('pro-ctcae-answer'), code: String(ordinal), display: String(label) } }
|
|
153
|
+
: { valueString: String(label) };
|
|
154
|
+
byLink.set(link, { linkId: link, answer: [answer] });
|
|
155
|
+
}
|
|
156
|
+
const location = painLocationText(pain);
|
|
157
|
+
if (location) {
|
|
158
|
+
byLink.set(PAIN_LOCATION_LINK, { linkId: PAIN_LOCATION_LINK, answer: [{ valueString: location }] });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return QuestionnaireResponse.fromTriageProjection({ qr, items: [...byLink.values()] });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function projectTriageSymptomCondition(catalog, sym) {
|
|
166
|
+
return Condition.fromTriageProjection({
|
|
167
|
+
sym,
|
|
168
|
+
code: { coding: terminology.codingsFor(catalog, sym.term), text: terminology.codeText(catalog, sym.term) },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
projectProctcaeCanonical,
|
|
174
|
+
projectTriagePatient,
|
|
175
|
+
projectProctcaeSymptomObservation,
|
|
176
|
+
projectTriageIngestDevice,
|
|
177
|
+
projectTriageQuestionnaireResponse,
|
|
178
|
+
projectTriageSymptomCondition,
|
|
179
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { getSystems } = require('../config/fhirConfig');
|
|
2
|
-
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
-
const { identifier, patientReference, codeableConcept } = require('../helpers/elementHelper');
|
|
2
|
+
const { fhirId, codeSystemUrl } = require('../helpers/fhirHelper');
|
|
3
|
+
const { identifier, reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
|
|
4
4
|
|
|
5
5
|
class Condition {
|
|
6
6
|
constructor(payload) {
|
|
@@ -29,6 +29,28 @@ class Condition {
|
|
|
29
29
|
if (notes) payload.note = notes;
|
|
30
30
|
return new Condition(payload);
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
static fromTriageProjection({ sym, code }) {
|
|
34
|
+
const caseId = `${sym.patientId}-${sym.term}`;
|
|
35
|
+
const systems = getSystems();
|
|
36
|
+
const payload = {
|
|
37
|
+
resourceType: 'Condition',
|
|
38
|
+
id: fhirId(`symptom-${caseId}`),
|
|
39
|
+
identifier: [identifier('symptom-condition', caseId)],
|
|
40
|
+
clinicalStatus: codeableConcept('active', { code: 'active', system: systems.conditionClinical }),
|
|
41
|
+
verificationStatus: codeableConcept('provisional', { code: 'provisional', system: systems.conditionVerStatus }),
|
|
42
|
+
category: [
|
|
43
|
+
codeableConcept('problem-list-item', { code: 'problem-list-item', system: systems.conditionCategory }),
|
|
44
|
+
codeableConcept('PRO-CTCAE patient-reported symptom (triage-tracked case)', { code: 'proctcae-symptom', system: codeSystemUrl('condition-category') }),
|
|
45
|
+
],
|
|
46
|
+
code,
|
|
47
|
+
subject: patientReference(sym.patientId),
|
|
48
|
+
evidence: [{ reference: reference('Observation', String(sym.symptomId)) }],
|
|
49
|
+
extension: [extension('symptom-source', { valueString: String(sym.source || 'triage') })],
|
|
50
|
+
};
|
|
51
|
+
if (sym.effective) payload.onsetDateTime = toIso(sym.effective);
|
|
52
|
+
return new Condition(payload);
|
|
53
|
+
}
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
module.exports = { Condition };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
2
|
+
const { identifier, codeableConcept } = require('../helpers/elementHelper');
|
|
3
|
+
|
|
4
|
+
class Device {
|
|
5
|
+
constructor(payload) {
|
|
6
|
+
Object.assign(this, payload);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static fromTriageProjection({ deviceId, compositeTableHash }) {
|
|
10
|
+
const version = [{ value: 'pro-ctcae-composite' }];
|
|
11
|
+
if (compositeTableHash) version.push({ type: { text: 'composite-table' }, value: `sha256:${compositeTableHash}` });
|
|
12
|
+
return new Device({
|
|
13
|
+
resourceType: 'Device',
|
|
14
|
+
id: fhirId(deviceId),
|
|
15
|
+
identifier: [identifier('device', deviceId)],
|
|
16
|
+
status: 'active',
|
|
17
|
+
name: [{ value: 'PRO-CTCAE composite → CTCAE grade estimator', type: 'user-friendly-name' }],
|
|
18
|
+
type: [codeableConcept('Software / clinical algorithm')],
|
|
19
|
+
version,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { Device };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { getSystems } = require('../config/fhirConfig');
|
|
2
2
|
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
-
const { identifier, patientReference, codeableConcept } = require('../helpers/elementHelper');
|
|
3
|
+
const { identifier, reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
|
|
4
4
|
|
|
5
5
|
const OBSERVED_FIELD_SYSTEM_KEY = 'observedField';
|
|
6
6
|
|
|
@@ -45,6 +45,32 @@ class Observation {
|
|
|
45
45
|
if (components.length) payload.component = components;
|
|
46
46
|
return new Observation(payload);
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
static fromTriageProjection({ sym, code, aeCategoryConcept, components, gradeConcept, method, deviceId }) {
|
|
50
|
+
const sid = String(sym.symptomId);
|
|
51
|
+
const payload = {
|
|
52
|
+
resourceType: 'Observation',
|
|
53
|
+
id: fhirId(sid),
|
|
54
|
+
identifier: [identifier('pro-ctcae-symptom', sid)],
|
|
55
|
+
status: 'preliminary',
|
|
56
|
+
category: [observationCategory('survey'), ...(aeCategoryConcept ? [aeCategoryConcept] : [])],
|
|
57
|
+
code,
|
|
58
|
+
subject: patientReference(sym.patientId),
|
|
59
|
+
performer: [patientReference(sym.patientId)],
|
|
60
|
+
valueCodeableConcept: gradeConcept,
|
|
61
|
+
method,
|
|
62
|
+
extension: [
|
|
63
|
+
extension('grade-verification', { valueCode: 'provisional' }),
|
|
64
|
+
extension('grade-derivation', { valueCode: 'pro-ctcae-composite' }),
|
|
65
|
+
extension('symptom-source', { valueString: String(sym.source || 'triage') }),
|
|
66
|
+
],
|
|
67
|
+
derivedFrom: [reference('QuestionnaireResponse', `triage-${sym.triageId}`)],
|
|
68
|
+
device: reference('Device', deviceId),
|
|
69
|
+
};
|
|
70
|
+
if (sym.effective) payload.effectiveDateTime = toIso(sym.effective);
|
|
71
|
+
if (components.length) payload.component = components;
|
|
72
|
+
return new Observation(payload);
|
|
73
|
+
}
|
|
48
74
|
}
|
|
49
75
|
|
|
50
76
|
function observationCategory(code) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
2
|
+
const { identifier } = require('../helpers/elementHelper');
|
|
3
|
+
|
|
4
|
+
class Patient {
|
|
5
|
+
constructor(payload) {
|
|
6
|
+
Object.assign(this, payload);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static fromTriageProjection({ patientId }) {
|
|
10
|
+
return new Patient({
|
|
11
|
+
resourceType: 'Patient',
|
|
12
|
+
id: fhirId(String(patientId)),
|
|
13
|
+
identifier: [identifier('patient', String(patientId))],
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { Patient };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { getQuestionnaireCanonicalUrl, getProctcaeVersion } = require('../config/fhirConfig');
|
|
2
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
+
|
|
4
|
+
class Questionnaire {
|
|
5
|
+
constructor(payload) {
|
|
6
|
+
Object.assign(this, payload);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static fromProctcaeCatalog({ items }) {
|
|
10
|
+
return new Questionnaire({
|
|
11
|
+
resourceType: 'Questionnaire',
|
|
12
|
+
id: fhirId(`pro-ctcae-${getProctcaeVersion()}`),
|
|
13
|
+
url: getQuestionnaireCanonicalUrl(),
|
|
14
|
+
version: getProctcaeVersion(),
|
|
15
|
+
status: 'active',
|
|
16
|
+
name: 'PROCTCAE',
|
|
17
|
+
subjectType: ['Patient'],
|
|
18
|
+
item: items,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { Questionnaire };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const { getQuestionnaireCanonicalUrl, getProctcaeVersion } = require('../config/fhirConfig');
|
|
2
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
+
const { identifier, patientReference, toIso } = require('../helpers/elementHelper');
|
|
4
|
+
|
|
5
|
+
class QuestionnaireResponse {
|
|
6
|
+
constructor(payload) {
|
|
7
|
+
Object.assign(this, payload);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static fromTriageProjection({ qr, items }) {
|
|
11
|
+
const tid = String(qr.triageId);
|
|
12
|
+
const identifiers = [identifier('triage', tid)];
|
|
13
|
+
if (qr.questionnaireId) identifiers.push(identifier('questionnaire-week', String(qr.questionnaireId)));
|
|
14
|
+
const payload = {
|
|
15
|
+
resourceType: 'QuestionnaireResponse',
|
|
16
|
+
id: fhirId(`triage-${tid}`),
|
|
17
|
+
identifier: identifiers,
|
|
18
|
+
questionnaire: `${getQuestionnaireCanonicalUrl()}|${getProctcaeVersion()}`,
|
|
19
|
+
status: (qr.completed ?? true) ? 'completed' : 'in-progress',
|
|
20
|
+
subject: patientReference(qr.patientId),
|
|
21
|
+
source: patientReference(qr.patientId),
|
|
22
|
+
};
|
|
23
|
+
if (qr.authored) payload.authored = toIso(qr.authored);
|
|
24
|
+
if (items.length) payload.item = items;
|
|
25
|
+
return new QuestionnaireResponse(payload);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { QuestionnaireResponse };
|
|
@@ -15,11 +15,11 @@ function project(items) {
|
|
|
15
15
|
return registry.project(items);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
async function storeResources(resources, { store = null } = {}) {
|
|
18
|
+
async function storeResources(resources, { store = null, merge = null } = {}) {
|
|
19
19
|
const resourceStore = store || getFhirStore();
|
|
20
20
|
const stored = [];
|
|
21
21
|
for (const resource of [resources].flat(Infinity).filter((entry) => entry && entry.resourceType)) {
|
|
22
|
-
stored.push(await resourceStore.upsert(resource));
|
|
22
|
+
stored.push(await resourceStore.upsert(resource, { merge }));
|
|
23
23
|
}
|
|
24
24
|
return { stored };
|
|
25
25
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
2
|
+
const { getFhirStore } = require('../stores/fhirStore');
|
|
3
|
+
|
|
4
|
+
async function patientSnapshot(patientId, { resourceTypes = null } = {}) {
|
|
5
|
+
if (!String(patientId ?? '').trim()) throw new Error('patientId is required');
|
|
6
|
+
if (resourceTypes != null) {
|
|
7
|
+
if (!Array.isArray(resourceTypes)) throw new Error('resourceTypes must be an array');
|
|
8
|
+
if (resourceTypes.some((t) => typeof t !== 'string' || !t.trim())) throw new Error('resourceTypes must contain only non-empty strings');
|
|
9
|
+
}
|
|
10
|
+
const requestedTypes = resourceTypes ? [...new Set(resourceTypes.map((resourceType) => resourceType.trim()))] : null;
|
|
11
|
+
const store = getFhirStore();
|
|
12
|
+
const patient = `Patient/${fhirId(patientId)}`;
|
|
13
|
+
const linkedTypes = requestedTypes ? requestedTypes.filter((resourceType) => resourceType !== 'Patient') : null;
|
|
14
|
+
const resources = linkedTypes
|
|
15
|
+
? (await Promise.all(linkedTypes.map((resourceType) => store.find({ patient, resourceType })))).flat()
|
|
16
|
+
: await store.find({ patient });
|
|
17
|
+
if (!requestedTypes || requestedTypes.includes('Patient')) {
|
|
18
|
+
const patientResource = await store.getByReference(patient);
|
|
19
|
+
if (patientResource) resources.push(patientResource);
|
|
20
|
+
}
|
|
21
|
+
const byType = Object.create(null);
|
|
22
|
+
for (const resource of resources) {
|
|
23
|
+
(byType[resource.resourceType] = byType[resource.resourceType] || []).push(resource);
|
|
24
|
+
}
|
|
25
|
+
return byType;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { patientSnapshot };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const { PROCTCAE_ITEMS_TABLE, PROCTCAE_COMPOSITE_TABLE } = require('../config/fhirTablesConfig');
|
|
2
|
+
const { buildCatalog } = require('../helpers/terminologyHelper');
|
|
3
|
+
|
|
4
|
+
const { Ctcae_ID } = require('../../config/airtableConfig');
|
|
5
|
+
const { getRecordByFilter } = require('../../services/airtableService');
|
|
6
|
+
const { logger } = require('../../utils/logger');
|
|
7
|
+
|
|
8
|
+
let catalogPromise = null;
|
|
9
|
+
let loadedCompositeTableHash = null;
|
|
10
|
+
|
|
11
|
+
async function getCatalog() {
|
|
12
|
+
if (!catalogPromise) {
|
|
13
|
+
catalogPromise = loadCatalog().catch((error) => {
|
|
14
|
+
catalogPromise = null;
|
|
15
|
+
throw error;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return catalogPromise;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clearCache() {
|
|
22
|
+
catalogPromise = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function loadCatalog() {
|
|
26
|
+
const [items, composites] = await Promise.all([
|
|
27
|
+
getRecordByFilter(Ctcae_ID, PROCTCAE_ITEMS_TABLE, 'TRUE()', null),
|
|
28
|
+
getRecordByFilter(Ctcae_ID, PROCTCAE_COMPOSITE_TABLE, 'TRUE()', null),
|
|
29
|
+
]);
|
|
30
|
+
if (!items) throw new Error('proctcae terminology tables not readable from Airtable');
|
|
31
|
+
if (!composites) logger.warn('[terminologyService] composite table not readable; composite grading disabled');
|
|
32
|
+
|
|
33
|
+
const source = `Airtable ${Ctcae_ID} (${PROCTCAE_ITEMS_TABLE})`;
|
|
34
|
+
const catalog = buildCatalog(items, composites || [], source);
|
|
35
|
+
if (!catalog._meta.n_symptoms) throw new Error('proctcae terminology catalog is empty');
|
|
36
|
+
|
|
37
|
+
const compositeTableHash = catalog._meta.composite_table_hash ?? null;
|
|
38
|
+
if (loadedCompositeTableHash && compositeTableHash && loadedCompositeTableHash !== compositeTableHash) {
|
|
39
|
+
logger.warn('[terminologyService] composite grading table changed — grades derived after this differ from grades before it', {
|
|
40
|
+
from: loadedCompositeTableHash,
|
|
41
|
+
to: compositeTableHash,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
loadedCompositeTableHash = compositeTableHash;
|
|
45
|
+
|
|
46
|
+
logger.info('[terminologyService] catalog loaded', {
|
|
47
|
+
source,
|
|
48
|
+
symptoms: catalog._meta.n_symptoms,
|
|
49
|
+
items: catalog._meta.n_items,
|
|
50
|
+
mappedItems: catalog._meta.n_mapped_items,
|
|
51
|
+
composites: catalog._meta.n_composites,
|
|
52
|
+
compositeTableHash,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return catalog;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { getCatalog, clearCache };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const { gradeComposites } = require('../helpers/compositeHelper');
|
|
2
|
+
const { GradedSymptom } = require('../../shared/dtos/GradedSymptom');
|
|
3
|
+
const { TriageResponse } = require('../../shared/dtos/TriageResponse');
|
|
4
|
+
const {
|
|
5
|
+
projectProctcaeCanonical,
|
|
6
|
+
projectTriagePatient,
|
|
7
|
+
projectProctcaeSymptomObservation,
|
|
8
|
+
projectTriageIngestDevice,
|
|
9
|
+
projectTriageQuestionnaireResponse,
|
|
10
|
+
projectTriageSymptomCondition,
|
|
11
|
+
} = require('../projections/proctcaeProjection');
|
|
12
|
+
const { storeResources } = require('./fhirService');
|
|
13
|
+
const { getCatalog: loadTerminology } = require('./terminologyService');
|
|
14
|
+
|
|
15
|
+
const FOLLOW_UP_GRADE = 1;
|
|
16
|
+
|
|
17
|
+
async function storeTriage(payload) {
|
|
18
|
+
const input = new TriageResponse(payload);
|
|
19
|
+
const catalog = await loadTerminology();
|
|
20
|
+
const graded = gradeComposites(catalog, input).map((symptom) => new GradedSymptom(symptom));
|
|
21
|
+
const resources = [
|
|
22
|
+
projectProctcaeCanonical(catalog),
|
|
23
|
+
projectTriagePatient(input.patientId),
|
|
24
|
+
projectTriageIngestDevice(catalog),
|
|
25
|
+
projectTriageQuestionnaireResponse(catalog, input),
|
|
26
|
+
...graded.map((sym) => projectProctcaeSymptomObservation(catalog, sym)),
|
|
27
|
+
];
|
|
28
|
+
const conditions = graded
|
|
29
|
+
.filter((sym) => sym.grade >= FOLLOW_UP_GRADE)
|
|
30
|
+
.map((sym) => projectTriageSymptomCondition(catalog, sym));
|
|
31
|
+
const { stored } = await storeResources(resources);
|
|
32
|
+
const { stored: storedConditions } = await storeResources(conditions, { merge: mergeTriageSymptomCondition });
|
|
33
|
+
return { stored: [...stored, ...storedConditions] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mergeTriageSymptomCondition(existing, next) {
|
|
37
|
+
if (!existing) return next;
|
|
38
|
+
const merged = { ...next };
|
|
39
|
+
const onset = earlierOnset(existing.onsetDateTime, next.onsetDateTime);
|
|
40
|
+
if (onset) merged.onsetDateTime = onset;
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
merged.evidence = [...(existing.evidence || []), ...(next.evidence || [])].filter((entry) => {
|
|
43
|
+
const ref = entry.reference && entry.reference.reference;
|
|
44
|
+
if (!ref || seen.has(ref)) return false;
|
|
45
|
+
seen.add(ref);
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function earlierOnset(existing, next) {
|
|
52
|
+
if (!existing) return next;
|
|
53
|
+
if (!next) return existing;
|
|
54
|
+
return Date.parse(next) < Date.parse(existing) ? next : existing;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { storeTriage };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const lookupValue = (value) => {
|
|
2
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
3
|
+
return first === undefined || first === '' ? null : first;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const integerId = (value) => {
|
|
7
|
+
if (value == null || String(value).trim() === '') return null;
|
|
8
|
+
const id = Number(value);
|
|
9
|
+
return Number.isFinite(id) ? Math.trunc(id) : null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const parseList = (value) =>
|
|
13
|
+
String(value || '')
|
|
14
|
+
.split(/[;|]/)
|
|
15
|
+
.map((entry) => entry.trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
|
|
18
|
+
function termKey(name) {
|
|
19
|
+
return String(name || '')
|
|
20
|
+
.trim()
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/&/g, 'and')
|
|
23
|
+
.replace(/[/-]/g, ' ')
|
|
24
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
25
|
+
.replace(/^_+|_+$/g, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
lookupValue,
|
|
30
|
+
integerId,
|
|
31
|
+
parseList,
|
|
32
|
+
termKey,
|
|
33
|
+
};
|
package/lib/index.d.ts
CHANGED
|
@@ -416,6 +416,20 @@ declare module '@peopl-health/nexus' {
|
|
|
416
416
|
export function createQueueAdapter(type: 'local' | 'redis' | string, config?: any): QueueAdapter;
|
|
417
417
|
export function registerQueueAdapter(name: string, AdapterClass: typeof QueueAdapter): void;
|
|
418
418
|
|
|
419
|
+
// Workflows
|
|
420
|
+
export interface Workflow {
|
|
421
|
+
kind: string;
|
|
422
|
+
dedupeKey: (trigger: any) => string;
|
|
423
|
+
prepare: (trigger: any) => Promise<any>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export interface WorkflowRunner {
|
|
427
|
+
register(workflow: Workflow): Promise<Workflow>;
|
|
428
|
+
enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string>;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function createWorkflowRunner(options: { queueAdapter: QueueAdapter }): WorkflowRunner;
|
|
432
|
+
|
|
419
433
|
// Memory System
|
|
420
434
|
export interface PatientMemoryDocument {
|
|
421
435
|
_id: any;
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const { BaileysProvider } = require('./adapters/BaileysProvider');
|
|
|
27
27
|
const { setPreprocessingHandler, hasPreprocessingHandler, invokePreprocessingHandler } = require('./services/preprocessingService');
|
|
28
28
|
const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
|
|
29
29
|
const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
|
|
30
|
+
const { createWorkflowRunner } = require('./core/workflowRunner');
|
|
30
31
|
const routes = require('./routes');
|
|
31
32
|
const { resetAll } = require('./config/lifecycle');
|
|
32
33
|
const { EvalProvider } = require('./eval/EvalProvider');
|
|
@@ -214,6 +215,7 @@ class Nexus {
|
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
module.exports = {
|
|
218
|
+
createWorkflowRunner,
|
|
217
219
|
Nexus,
|
|
218
220
|
TwilioProvider,
|
|
219
221
|
BaileysProvider,
|
|
@@ -78,7 +78,8 @@ async function getRecords(baseID, tableName) {
|
|
|
78
78
|
|
|
79
79
|
async function getRecordByFilter(baseID, tableName, filter, view = 'Grid view', fields) {
|
|
80
80
|
try {
|
|
81
|
-
const selectOptions = { filterByFormula: filter
|
|
81
|
+
const selectOptions = { filterByFormula: filter };
|
|
82
|
+
if (view) selectOptions.view = view;
|
|
82
83
|
if (fields && fields.length) selectOptions.fields = fields;
|
|
83
84
|
return await withAirtableRetry(
|
|
84
85
|
() => collectRecords(getBase(baseID)(tableName).select(selectOptions)),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
|
|
3
|
+
const { BaseDto } = require('./BaseDto');
|
|
4
|
+
|
|
5
|
+
const dateTime = z.iso.datetime({ offset: true });
|
|
6
|
+
|
|
7
|
+
const schema = z.strictObject({
|
|
8
|
+
schemaVersion: z.string().default('1'),
|
|
9
|
+
symptomId: z.string(),
|
|
10
|
+
patientId: z.string(),
|
|
11
|
+
term: z.string(),
|
|
12
|
+
grade: z.number().int().min(0).max(4),
|
|
13
|
+
effective: dateTime.nullable().default(null),
|
|
14
|
+
triageId: z.string(),
|
|
15
|
+
dimensionAnswers: z.record(z.string(), z.string()).default({}),
|
|
16
|
+
source: z.string().default('triage'),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
class GradedSymptom extends BaseDto {}
|
|
20
|
+
|
|
21
|
+
GradedSymptom.schema = schema;
|
|
22
|
+
|
|
23
|
+
module.exports = { GradedSymptom };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
|
|
3
|
+
const { BaseDto } = require('./BaseDto');
|
|
4
|
+
|
|
5
|
+
const dateTime = z.iso.datetime({ offset: true });
|
|
6
|
+
|
|
7
|
+
const painSchema = z.strictObject({
|
|
8
|
+
hasPain: z.string().nullable().default(null),
|
|
9
|
+
frequency: z.string().nullable().default(null),
|
|
10
|
+
intensity: z.string().nullable().default(null),
|
|
11
|
+
interference: z.string().nullable().default(null),
|
|
12
|
+
locations: z.array(z.string()).default([]),
|
|
13
|
+
mostImportant: z.string().nullable().default(null),
|
|
14
|
+
otherText: z.string().nullable().default(null),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const schema = z.strictObject({
|
|
18
|
+
schemaVersion: z.string().default('1'),
|
|
19
|
+
triageId: z.string(),
|
|
20
|
+
patientId: z.string(),
|
|
21
|
+
authored: dateTime.nullable().default(null),
|
|
22
|
+
completed: z.boolean().default(true),
|
|
23
|
+
answers: z.record(z.string(), z.string().nullable()).default({}),
|
|
24
|
+
questionnaireId: z.string().nullable().default(null),
|
|
25
|
+
cadence: z.string().nullable().default(null),
|
|
26
|
+
satisfactionLabel: z.string().nullable().default(null),
|
|
27
|
+
satisfactionValue: z.number().int().default(-1),
|
|
28
|
+
pain: painSchema.default(() => painSchema.parse({})),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
class TriageResponse extends BaseDto {}
|
|
32
|
+
|
|
33
|
+
TriageResponse.schema = schema;
|
|
34
|
+
|
|
35
|
+
module.exports = { TriageResponse };
|