@peopl-health/nexus 5.10.0-dev.1075 → 5.10.0-dev.1078
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/clinical/tools/getActiveRecommendationsTool.js +43 -0
- package/lib/clinical/tools/recordRecommendationOutcomeTool.js +72 -0
- package/lib/clinical/tools/registerClinicalTools.js +7 -0
- package/lib/fhir/index.js +8 -1
- package/lib/fhir/services/recommendationService.js +187 -1
- package/package.json +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const { readActiveRecommendations } = require('../../fhir');
|
|
2
|
+
|
|
3
|
+
const definition = {
|
|
4
|
+
name: 'getActiveRecommendations',
|
|
5
|
+
description: '**Does:** Returns the patient\'s active symptom-management recommendations from both origins — in-chat plans (`chat`) and weekly-triage plans (`triage`) — merged and source-tagged. Read-only.\n\n**Required inputs:** none. Optional `ctcae_term` (PRO-CTCAE-tolerant symptom filter), `include_inactive` (bool; also returns superseded plans).\n\n**When to call:** before recommending, to see what the patient was already advised, whether it helped (`latest_outcome`), and avoid contradicting an active plan.\n\n**Returns:** `has_any`, `ctcae_filter`, `include_inactive`, `sources_present[]`, `recommendations[]` (each: `source`, `is_active`, `recommendation_id` (the handle for recordRecommendationOutcome), `symptoms[]`, `recommendation`, `when_to_escalate`, `expected_effectiveness`, `is_pharmacological_med_needed`, `latest_outcome` `{outcome, adherence, source, notes}`|null, `generated_at`).',
|
|
6
|
+
strict: false,
|
|
7
|
+
parameters: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
ctcae_term: {
|
|
11
|
+
type: 'string',
|
|
12
|
+
description: 'Filter to recommendations targeting this PRO-CTCAE symptom. Omit for all.',
|
|
13
|
+
},
|
|
14
|
+
include_inactive: {
|
|
15
|
+
type: 'boolean',
|
|
16
|
+
description: 'Include superseded / expired plans. Defaults to false (active only).',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
required: [],
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function handler(args = {}, context = {}) {
|
|
24
|
+
try {
|
|
25
|
+
const runtime = context?.toolRuntimeContext || null;
|
|
26
|
+
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
27
|
+
return JSON.stringify({ success: false, error: 'getActiveRecommendations requires an active turn context (turnId, patientCode).', data: {} });
|
|
28
|
+
}
|
|
29
|
+
const data = await readActiveRecommendations({
|
|
30
|
+
patientId: runtime.patientCode,
|
|
31
|
+
ctcaeTerm: typeof args?.ctcae_term === 'string' ? args.ctcae_term : null,
|
|
32
|
+
includeInactive: args?.include_inactive === true,
|
|
33
|
+
});
|
|
34
|
+
return JSON.stringify({ success: true, data });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return JSON.stringify({ success: false, error: err?.message || 'getActiveRecommendations failed', data: {} });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
definition,
|
|
42
|
+
handler,
|
|
43
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const { recordRecommendationOutcome } = require('../../fhir');
|
|
2
|
+
|
|
3
|
+
const NO_MATCH_HINT = 'No active recommendation matched — there may be no current care plan, or none targeting that symptom. Handle the symptom normally (open a case / route).';
|
|
4
|
+
|
|
5
|
+
const definition = {
|
|
6
|
+
name: 'recordRecommendationOutcome',
|
|
7
|
+
description: '**Does:** Records how a symptom-management recommendation went for the patient — appended to the recommendation\'s history so the next plan can adapt. Feeds the SAME outcome loop as the weekly-triage feedback form.\n\n**Required inputs:** one of `recommendation_id` (preferred — from getActiveRecommendations) or `ctcae_term` (fallback; resolves the active recommendation for that symptom), plus at least one of `outcome` (`improved`|`partial`|`no_relief`|`worsened`|`not_tried`|`unknown`) or `adherence` (`full`|`partial`|`none`) — an adherence-only report defaults `outcome` to `unknown`. Optional `notes`.\n\n**Returns:** `recorded` (bool). When true: `recommendation_id`, `outcome`, optional `adherence`. When false: `hint` (no active recommendation matched).',
|
|
8
|
+
strict: false,
|
|
9
|
+
parameters: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
recommendation_id: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
description: 'The recommendation to attach the outcome to (from getActiveRecommendations). Preferred over ctcae_term.',
|
|
15
|
+
},
|
|
16
|
+
ctcae_term: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Fallback when recommendation_id is unknown: the PRO-CTCAE symptom whose active recommendation the outcome is about.',
|
|
19
|
+
},
|
|
20
|
+
outcome: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
enum: ['improved', 'partial', 'no_relief', 'worsened', 'not_tried', 'unknown'],
|
|
23
|
+
description: 'How the recommendation went.',
|
|
24
|
+
},
|
|
25
|
+
adherence: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
enum: ['full', 'partial', 'none'],
|
|
28
|
+
description: 'Whether the patient followed it (distinct axis from outcome).',
|
|
29
|
+
},
|
|
30
|
+
notes: { type: 'string', description: 'Optional free-text detail from the patient.' },
|
|
31
|
+
},
|
|
32
|
+
required: [],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function handler(args = {}, context = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const runtime = context?.toolRuntimeContext || null;
|
|
39
|
+
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
40
|
+
return JSON.stringify({ success: false, error: 'recordRecommendationOutcome requires an active turn context (turnId, patientCode).', data: {} });
|
|
41
|
+
}
|
|
42
|
+
const recommendationId = typeof args?.recommendation_id === 'string' ? args.recommendation_id.trim() || null : null;
|
|
43
|
+
const ctcaeTerm = typeof args?.ctcae_term === 'string' ? args.ctcae_term.trim() || null : null;
|
|
44
|
+
if (!recommendationId && !ctcaeTerm) {
|
|
45
|
+
return JSON.stringify({ success: false, error: 'provide recommendation_id or ctcae_term.', data: {} });
|
|
46
|
+
}
|
|
47
|
+
const outcome = typeof args?.outcome === 'string' ? args.outcome.trim().toLowerCase() || null : null;
|
|
48
|
+
const adherence = typeof args?.adherence === 'string' ? args.adherence.trim().toLowerCase() || null : null;
|
|
49
|
+
if (!outcome && !adherence) {
|
|
50
|
+
return JSON.stringify({ success: false, error: 'provide outcome or adherence — nothing to record.', data: {} });
|
|
51
|
+
}
|
|
52
|
+
const result = await recordRecommendationOutcome({
|
|
53
|
+
patientId: runtime.patientCode,
|
|
54
|
+
recommendationId,
|
|
55
|
+
ctcaeTerm,
|
|
56
|
+
outcome,
|
|
57
|
+
adherence,
|
|
58
|
+
notes: typeof args?.notes === 'string' ? args.notes.trim() || null : null,
|
|
59
|
+
});
|
|
60
|
+
if (!result.recorded) {
|
|
61
|
+
return JSON.stringify({ success: true, data: { recorded: false, hint: NO_MATCH_HINT } });
|
|
62
|
+
}
|
|
63
|
+
return JSON.stringify({ success: true, data: result });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return JSON.stringify({ success: false, error: err?.message || 'recordRecommendationOutcome failed', data: {} });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
definition,
|
|
71
|
+
handler,
|
|
72
|
+
};
|
|
@@ -20,6 +20,13 @@ const TOOLS = [
|
|
|
20
20
|
require('./getActiveSymptomLandscapeTool'),
|
|
21
21
|
require('./recordInterventionTool'),
|
|
22
22
|
require('./setContingencyPlanTool'),
|
|
23
|
+
require('./submitPalliativeAssessmentTool'),
|
|
24
|
+
require('./updatePalliativeAssessmentTool'),
|
|
25
|
+
require('./submitPalliativeCarePlanTool'),
|
|
26
|
+
require('./submitSafetyGateTool'),
|
|
27
|
+
require('./submitRecommendationPlanTool'),
|
|
28
|
+
require('./getActiveRecommendationsTool'),
|
|
29
|
+
require('./recordRecommendationOutcomeTool'),
|
|
23
30
|
require('./getPatientRiskAssessmentTool'),
|
|
24
31
|
require('./getPatientHistoryTool'),
|
|
25
32
|
require('./getRouterContextBundleTool'),
|
package/lib/fhir/index.js
CHANGED
|
@@ -3,7 +3,12 @@ const { storeClinicalMentions } = require('./services/clinicalMentionService');
|
|
|
3
3
|
const { storeResultObservations } = require('./services/resultObservationService');
|
|
4
4
|
const { storeSymptomCase, readSymptomCases } = require('./services/symptomCaseService');
|
|
5
5
|
const { storePatientRisk, readPatientRisk, storeSafetyGate, safetyGateExists } = require('./services/riskService');
|
|
6
|
-
const {
|
|
6
|
+
const {
|
|
7
|
+
storeRecommendationPlan,
|
|
8
|
+
recommendationPlanExists,
|
|
9
|
+
readActiveRecommendations,
|
|
10
|
+
recordRecommendationOutcome,
|
|
11
|
+
} = require('./services/recommendationService');
|
|
7
12
|
const { storeContingency, readContingencies } = require('./services/contingencyService');
|
|
8
13
|
const { storeIntervention, readInterventions } = require('./services/interventionService');
|
|
9
14
|
const { storeCluster, readClusters } = require('./services/clusterService');
|
|
@@ -41,6 +46,8 @@ module.exports = {
|
|
|
41
46
|
safetyGateExists,
|
|
42
47
|
storeRecommendationPlan,
|
|
43
48
|
recommendationPlanExists,
|
|
49
|
+
readActiveRecommendations,
|
|
50
|
+
recordRecommendationOutcome,
|
|
44
51
|
storeContingency,
|
|
45
52
|
readContingencies,
|
|
46
53
|
storeIntervention,
|
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
const { fhirId } = require('../helpers/fhirHelper');
|
|
1
|
+
const { fhirId, codeSystemUrl, identifierSystem } = require('../helpers/fhirHelper');
|
|
2
|
+
const { codeableConcept, extension, toIso } = require('../helpers/elementHelper');
|
|
3
|
+
const { identifierValue, extMap } = require('../helpers/fhirReadHelper');
|
|
2
4
|
const { PROJECTOR_NAME } = require('../projections/recommendationPlanProjection');
|
|
5
|
+
const { RECOMMENDATION_REC_SLUG } = require('../constants/projectionSlugs');
|
|
3
6
|
const { getFhirStore } = require('../stores/fhirStore');
|
|
4
7
|
const { project, storeResources } = require('./fhirService');
|
|
5
8
|
|
|
9
|
+
const OUTCOME_SYSTEM_KEY = 'recommendation-outcome';
|
|
10
|
+
const OUTCOME_VALUES = ['improved', 'partial', 'no_relief', 'worsened', 'not_tried', 'unknown'];
|
|
11
|
+
const ADHERENCE_VALUES = ['full', 'partial', 'none'];
|
|
12
|
+
const CHAT_ACTIVE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
const NOTE_KEYS = {
|
|
14
|
+
'cuándo escalar': 'whenToEscalate',
|
|
15
|
+
'efectividad esperada': 'expectedEffectiveness',
|
|
16
|
+
contraindicaciones: 'contraindications',
|
|
17
|
+
'requiere coordinación farmacológica': 'isPharmacologicalMedNeeded',
|
|
18
|
+
};
|
|
19
|
+
|
|
6
20
|
async function storeRecommendationPlan({ patientId, plan }) {
|
|
7
21
|
if (plan.patientId !== patientId) {
|
|
8
22
|
throw new Error(`storeRecommendationPlan patientId mismatch: expected ${patientId}`);
|
|
@@ -21,7 +35,179 @@ async function recommendationPlanExists({ runId }) {
|
|
|
21
35
|
return Boolean(resource);
|
|
22
36
|
}
|
|
23
37
|
|
|
38
|
+
async function readActiveRecommendations({ patientId, ctcaeTerm = null, includeInactive = false }) {
|
|
39
|
+
const store = getFhirStore();
|
|
40
|
+
const patient = `Patient/${fhirId(patientId)}`;
|
|
41
|
+
const filter = (ctcaeTerm || '').trim().toLowerCase() || null;
|
|
42
|
+
const recSystem = identifierSystem(RECOMMENDATION_REC_SLUG);
|
|
43
|
+
|
|
44
|
+
const entries = (await store.find({ patient, resourceType: 'Task' }))
|
|
45
|
+
.filter((task) => (task.identifier || []).some((id) => id.system === recSystem))
|
|
46
|
+
.map((task) => {
|
|
47
|
+
const recommendationId = identifierValue(task, RECOMMENDATION_REC_SLUG);
|
|
48
|
+
if (!recommendationId) return null;
|
|
49
|
+
const runId = recommendationId.replace(/-rec[^-]*$/, '');
|
|
50
|
+
return { task, recommendationId, runId, source: sourceOf(runId), authoredOn: task.authoredOn || null };
|
|
51
|
+
})
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
|
|
54
|
+
const latestTriageRunId = latestTriageRun(entries);
|
|
55
|
+
const chatCutoffMs = Date.now() - CHAT_ACTIVE_WINDOW_MS;
|
|
56
|
+
|
|
57
|
+
const recommendations = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const isActive = entry.source === 'triage'
|
|
60
|
+
? entry.runId === latestTriageRunId
|
|
61
|
+
: Boolean(entry.authoredOn) && Date.parse(toIso(entry.authoredOn)) >= chatCutoffMs;
|
|
62
|
+
if (!includeInactive && !isActive) continue;
|
|
63
|
+
const item = reconstructRecommendation(entry.task, entry.recommendationId, entry.runId, isActive);
|
|
64
|
+
if (filter && !item.symptoms.some((term) => term.toLowerCase() === filter)) continue;
|
|
65
|
+
recommendations.push(item);
|
|
66
|
+
}
|
|
67
|
+
recommendations.sort((a, b) => a.recommendation_id.localeCompare(b.recommendation_id));
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
has_any: recommendations.length > 0,
|
|
71
|
+
ctcae_filter: filter,
|
|
72
|
+
include_inactive: includeInactive,
|
|
73
|
+
sources_present: [...new Set(recommendations.map((item) => item.source))].sort(),
|
|
74
|
+
recommendations,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function recordRecommendationOutcome({ patientId, recommendationId = null, ctcaeTerm = null, outcome = null, adherence = null, notes = null }) {
|
|
79
|
+
if (adherence !== null && !ADHERENCE_VALUES.includes(adherence)) {
|
|
80
|
+
throw new Error(`invalid adherence: ${JSON.stringify(adherence)}. Must be full | partial | none (or omitted).`);
|
|
81
|
+
}
|
|
82
|
+
const resolvedOutcome = outcome || (adherence !== null ? 'unknown' : outcome);
|
|
83
|
+
if (!OUTCOME_VALUES.includes(resolvedOutcome)) {
|
|
84
|
+
throw new Error(`invalid outcome: ${JSON.stringify(resolvedOutcome)}. Must be one of ${JSON.stringify(OUTCOME_VALUES)}.`);
|
|
85
|
+
}
|
|
86
|
+
const target = await resolveTask({ patientId, recommendationId, ctcaeTerm });
|
|
87
|
+
if (!target) return { recorded: false };
|
|
88
|
+
const output = buildOutcomeOutput({ outcome: resolvedOutcome, adherence, notes });
|
|
89
|
+
await storeResources([target.task], { merge: appendOutput(output) });
|
|
90
|
+
return {
|
|
91
|
+
recorded: true,
|
|
92
|
+
recommendation_id: target.recommendationId,
|
|
93
|
+
outcome: resolvedOutcome,
|
|
94
|
+
...(adherence ? { adherence } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function resolveTask({ patientId, recommendationId, ctcaeTerm }) {
|
|
99
|
+
const store = getFhirStore();
|
|
100
|
+
if (recommendationId) {
|
|
101
|
+
const task = await store.getByReference(`Task/${fhirId(recommendationId)}`);
|
|
102
|
+
return task ? { task, recommendationId: identifierValue(task, RECOMMENDATION_REC_SLUG) || recommendationId } : null;
|
|
103
|
+
}
|
|
104
|
+
const term = (ctcaeTerm || '').trim().toLowerCase();
|
|
105
|
+
if (!term) return null;
|
|
106
|
+
const active = await readActiveRecommendations({ patientId });
|
|
107
|
+
const match = active.recommendations.find((item) => item.symptoms.some((symptom) => symptom.toLowerCase() === term));
|
|
108
|
+
if (!match) return null;
|
|
109
|
+
const task = await store.getByReference(`Task/${fhirId(match.recommendation_id)}`);
|
|
110
|
+
return task ? { task, recommendationId: match.recommendation_id } : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildOutcomeOutput({ outcome, adherence, notes }) {
|
|
114
|
+
const output = {
|
|
115
|
+
type: codeableConcept(OUTCOME_SYSTEM_KEY),
|
|
116
|
+
valueCodeableConcept: codeableConcept(outcome, { code: outcome, system: codeSystemUrl(OUTCOME_SYSTEM_KEY) }),
|
|
117
|
+
extension: [extension('recommendation-outcome-source', { valueString: 'chat' })],
|
|
118
|
+
};
|
|
119
|
+
if (adherence) output.extension.push(extension('recommendation-outcome-adherence', { valueString: adherence }));
|
|
120
|
+
if (notes) output.extension.push(extension('recommendation-outcome-notes', { valueString: notes }));
|
|
121
|
+
output.extension.push(extension('recommendation-outcome-recorded-at', { valueDateTime: toIso(new Date()) }));
|
|
122
|
+
return output;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const appendOutput = (output) => (prior, incoming) => ({
|
|
126
|
+
...incoming,
|
|
127
|
+
output: [...(prior.output || []), output],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
function reconstructRecommendation(task, recommendationId, runId, isActive) {
|
|
131
|
+
const noteMap = parseNotes(task.note);
|
|
132
|
+
const symptoms = ((task.code && task.code.coding) || []).map((coding) => coding.code).filter(Boolean);
|
|
133
|
+
return {
|
|
134
|
+
source: sourceOf(runId),
|
|
135
|
+
is_active: isActive,
|
|
136
|
+
recommendation_id: recommendationId,
|
|
137
|
+
symptoms,
|
|
138
|
+
recommendation: task.description || null,
|
|
139
|
+
when_to_escalate: noteMap.whenToEscalate || null,
|
|
140
|
+
expected_effectiveness: noteMap.expectedEffectiveness || null,
|
|
141
|
+
is_pharmacological_med_needed: noteMap.isPharmacologicalMedNeeded === 'true',
|
|
142
|
+
latest_outcome: latestOutcome(task.output),
|
|
143
|
+
generated_at: task.authoredOn || null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseNotes(note) {
|
|
148
|
+
const out = {};
|
|
149
|
+
for (const entry of note || []) {
|
|
150
|
+
const text = String(entry.text || '');
|
|
151
|
+
const separator = text.indexOf(': ');
|
|
152
|
+
if (separator < 0) continue;
|
|
153
|
+
const key = NOTE_KEYS[text.slice(0, separator)];
|
|
154
|
+
if (key) out[key] = text.slice(separator + 2);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function latestOutcome(output) {
|
|
160
|
+
const rows = (output || [])
|
|
161
|
+
.filter((entry) => (entry.type || {}).text === OUTCOME_SYSTEM_KEY)
|
|
162
|
+
.map((entry) => {
|
|
163
|
+
const ext = extMap(entry);
|
|
164
|
+
return {
|
|
165
|
+
outcome: ((entry.valueCodeableConcept || {}).coding || [])[0]?.code || (entry.valueCodeableConcept || {}).text || null,
|
|
166
|
+
adherence: ext['recommendation-outcome-adherence'] || null,
|
|
167
|
+
source: ext['recommendation-outcome-source'] || null,
|
|
168
|
+
notes: ext['recommendation-outcome-notes'] || null,
|
|
169
|
+
recordedAt: ext['recommendation-outcome-recorded-at'] || null,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
if (!rows.length) return null;
|
|
173
|
+
const merged = rows.reduce((prior, row) => ({
|
|
174
|
+
outcome: row.outcome === 'unknown' && prior.outcome && prior.outcome !== 'unknown' ? prior.outcome : row.outcome,
|
|
175
|
+
adherence: row.adherence === null ? prior.adherence : row.adherence,
|
|
176
|
+
source: row.source === null ? prior.source : row.source,
|
|
177
|
+
notes: row.notes === null ? prior.notes : row.notes,
|
|
178
|
+
recordedAt: row.recordedAt === null ? prior.recordedAt : row.recordedAt,
|
|
179
|
+
}));
|
|
180
|
+
return {
|
|
181
|
+
outcome: merged.outcome,
|
|
182
|
+
adherence: merged.adherence,
|
|
183
|
+
source: merged.source || 'chat',
|
|
184
|
+
notes: merged.notes,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sourceOf(runId) {
|
|
189
|
+
return runId.startsWith('recrun-triage-') ? 'triage' : 'chat';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function latestTriageRun(entries) {
|
|
193
|
+
let latest = null;
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (entry.source !== 'triage') continue;
|
|
196
|
+
if (!latest || supersedesTriage(entry, latest)) latest = entry;
|
|
197
|
+
}
|
|
198
|
+
return latest ? latest.runId : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function supersedesTriage(candidate, current) {
|
|
202
|
+
const a = candidate.authoredOn ? Date.parse(toIso(candidate.authoredOn)) : -Infinity;
|
|
203
|
+
const b = current.authoredOn ? Date.parse(toIso(current.authoredOn)) : -Infinity;
|
|
204
|
+
if (a !== b) return a > b;
|
|
205
|
+
return candidate.runId > current.runId;
|
|
206
|
+
}
|
|
207
|
+
|
|
24
208
|
module.exports = {
|
|
25
209
|
storeRecommendationPlan,
|
|
26
210
|
recommendationPlanExists,
|
|
211
|
+
readActiveRecommendations,
|
|
212
|
+
recordRecommendationOutcome,
|
|
27
213
|
};
|