@peopl-health/nexus 5.10.0-dev.1051 → 5.10.0-dev.1054

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.
@@ -0,0 +1,76 @@
1
+ const crypto = require('node:crypto');
2
+
3
+ const { readSymptomCases, storeIntervention } = require('../../fhir');
4
+ const { Intervention } = require('../../shared/dtos/Intervention');
5
+
6
+ const OPEN_CASE_STATUSES = ['characterizing', 'monitoring'];
7
+
8
+ const interventionUid = () => `intv_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
9
+
10
+ async function findOpenCase(patientId, ctcaeTerms) {
11
+ for (const term of ctcaeTerms) {
12
+ const cases = await readSymptomCases({ patientId, ctcaeTerm: String(term).trim().toLowerCase() });
13
+ const open = cases.find((managed) => OPEN_CASE_STATUSES.includes(managed.condition.clinicalStatus));
14
+ if (open) return open.condition.conditionId;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function recordsForRecommendation(recommendation, { patientId, caseId, careplanId, deliveredAt }) {
20
+ const rationale = `auto-derived from palliative careplan ${careplanId} (rank ${recommendation.rank})`;
21
+ const records = [];
22
+ const otc = recommendation.otcOffer;
23
+ if (otc && typeof otc === 'object' && otc.label) {
24
+ const label = ['OTC:', otc.label, otc.dose, otc.frequency ? `cada ${otc.frequency}` : null].filter(Boolean).join(' ');
25
+ records.push({ kind: 'otc', interventionLabel: label, rationale });
26
+ }
27
+ if (typeof recommendation.mainRecommendation === 'string' && recommendation.mainRecommendation.trim()) {
28
+ records.push({
29
+ kind: 'self_care',
30
+ interventionLabel: `self-care: ${recommendation.elementNameShort}`,
31
+ rationale: `${rationale}: ${recommendation.mainRecommendation.trim().slice(0, 200)}`,
32
+ });
33
+ }
34
+ const followupType = recommendation.followup && recommendation.followup.type
35
+ ? String(recommendation.followup.type).trim().toLowerCase() : '';
36
+ if (followupType === 'checkin_48h') {
37
+ records.push({ kind: 'follow_up_check', interventionLabel: `check-in 48h — ${recommendation.elementNameShort}`, rationale });
38
+ } else if (followupType === 'team_referral') {
39
+ records.push({ kind: 'team_escalation', interventionLabel: `team referral — ${recommendation.elementNameShort}`, rationale });
40
+ }
41
+ return records.map((record) => new Intervention({
42
+ ...record,
43
+ interventionId: interventionUid(),
44
+ patientId,
45
+ caseId,
46
+ deliveredAt,
47
+ }));
48
+ }
49
+
50
+ async function deriveInterventionsFromCarePlan({ carePlan }) {
51
+ const created = [];
52
+ let skippedNoCase = 0;
53
+ const deliveredAt = new Date().toISOString();
54
+ for (const recommendation of carePlan.recommendations) {
55
+ const caseId = await findOpenCase(carePlan.patientId, recommendation.ctcaeTerms);
56
+ if (!caseId) {
57
+ skippedNoCase += 1;
58
+ continue;
59
+ }
60
+ const interventions = recordsForRecommendation(recommendation, {
61
+ patientId: carePlan.patientId,
62
+ caseId,
63
+ careplanId: carePlan.careplanId,
64
+ deliveredAt,
65
+ });
66
+ for (const intervention of interventions) {
67
+ await storeIntervention({ patientId: carePlan.patientId, intervention });
68
+ created.push(intervention.interventionId);
69
+ }
70
+ }
71
+ return { created, skippedNoCase };
72
+ }
73
+
74
+ module.exports = {
75
+ deriveInterventionsFromCarePlan,
76
+ };
@@ -0,0 +1,62 @@
1
+ const MISMATCH = 'ASSESSMENT_CAREPLAN_MISMATCH';
2
+ const NO_PATIENT_ACTION = 'CAREPLAN_NO_PATIENT_ACTION';
3
+ const MALFORMED_RECOMMENDATION = 'MALFORMED_RECOMMENDATION';
4
+
5
+ function checkCarePlanAgainstAssessment(recommendations, findings) {
6
+ const malformedIndex = recommendations.findIndex((rec) => !rec || typeof rec !== 'object');
7
+ if (malformedIndex !== -1) {
8
+ return { errorCode: MALFORMED_RECOMMENDATION, path: `recommendations[${malformedIndex}]`, message: `recommendation at index ${malformedIndex} is null or not an object` };
9
+ }
10
+ if (recommendations.length !== findings.length) {
11
+ return { errorCode: MISMATCH, path: 'recommendations', message: `recommendations length ${recommendations.length} != assessment findings length ${findings.length}` };
12
+ }
13
+ const byRank = new Map(findings.map((finding) => [finding.rank, finding]));
14
+ const seen = new Set();
15
+ for (const rec of recommendations) {
16
+ if (typeof rec.rank !== 'number' || !Number.isInteger(rec.rank)) {
17
+ return { errorCode: MISMATCH, path: 'recommendations[rank]', message: `recommendation rank must be an integer, got ${JSON.stringify(rec.rank)}` };
18
+ }
19
+ if (seen.has(rec.rank)) {
20
+ return { errorCode: MISMATCH, path: `recommendations[rank=${rec.rank}]`, message: 'duplicate rank in recommendations' };
21
+ }
22
+ seen.add(rec.rank);
23
+ }
24
+ for (const rec of recommendations) {
25
+ const finding = byRank.get(rec.rank);
26
+ if (!finding) {
27
+ return { errorCode: MISMATCH, path: `recommendations[rank=${rec.rank}]`, message: `rank=${rec.rank} has no matching assessment finding` };
28
+ }
29
+ if (typeof rec.elementNameShort === 'string' && rec.elementNameShort && rec.elementNameShort !== finding.nameShort) {
30
+ return { errorCode: MISMATCH, path: `recommendations[rank=${rec.rank}].element_name_short`, message: 'element_name_short must match the assessment finding name_short' };
31
+ }
32
+ if (typeof rec.isPharmacologicalMedNeeded === 'boolean' && finding.areMedsNeeded !== null) {
33
+ if (finding.areMedsNeeded && !rec.isPharmacologicalMedNeeded) {
34
+ return { errorCode: MISMATCH, path: `recommendations[rank=${rec.rank}].is_pharmacological_med_needed`, message: 'assessment has are_meds_needed=true but careplan set is_pharmacological_med_needed=false' };
35
+ }
36
+ if (!finding.areMedsNeeded && rec.isPharmacologicalMedNeeded) {
37
+ return { errorCode: MISMATCH, path: `recommendations[rank=${rec.rank}].is_pharmacological_med_needed`, message: 'assessment has are_meds_needed=false but careplan set is_pharmacological_med_needed=true' };
38
+ }
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function checkCarePlanHasPatientAction(recommendations) {
45
+ for (const rec of recommendations) {
46
+ const hasMain = typeof rec.mainRecommendation === 'string' && rec.mainRecommendation.trim();
47
+ const hasOtc = rec.otcOffer && typeof rec.otcOffer === 'object' && Object.keys(rec.otcOffer).length > 0;
48
+ const isReferral = rec.followup && typeof rec.followup.type === 'string' && rec.followup.type.trim().toLowerCase() === 'team_referral';
49
+ if (!hasMain && !hasOtc && !isReferral) {
50
+ return { errorCode: NO_PATIENT_ACTION, path: `recommendations[rank=${rec.rank}]`, message: 'careplan recommendation delivers nothing to the patient: main_recommendation and otc_offer are both empty and followup is not team_referral' };
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ module.exports = {
57
+ checkCarePlanAgainstAssessment,
58
+ checkCarePlanHasPatientAction,
59
+ MISMATCH,
60
+ NO_PATIENT_ACTION,
61
+ MALFORMED_RECOMMENDATION,
62
+ };
@@ -0,0 +1,68 @@
1
+ const crypto = require('node:crypto');
2
+
3
+ const { PalliativeCarePlan } = require('../../shared/dtos/PalliativeCarePlan');
4
+
5
+ const uid = (prefix) => `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
6
+
7
+ function carePlanUid() {
8
+ return uid('cp');
9
+ }
10
+
11
+ function mapOtcOffer(raw) {
12
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || typeof raw.label !== 'string') return null;
13
+ return {
14
+ label: raw.label,
15
+ dose: typeof raw.dose === 'string' ? raw.dose : null,
16
+ frequency: typeof raw.frequency === 'string' ? raw.frequency : null,
17
+ duration: typeof raw.duration === 'string' ? raw.duration : null,
18
+ warnings: (Array.isArray(raw.warnings) ? raw.warnings : []).filter((warning) => typeof warning === 'string'),
19
+ };
20
+ }
21
+
22
+ function mapRecommendation(raw) {
23
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
24
+ const patient = (raw.recommendations_patient && typeof raw.recommendations_patient === 'object')
25
+ ? raw.recommendations_patient : {};
26
+ const followupRaw = patient.followup;
27
+ const followup = (followupRaw && typeof followupRaw === 'object')
28
+ ? { type: followupRaw.type ?? null, urgency: followupRaw.urgency ?? null }
29
+ : (typeof followupRaw === 'string' ? { type: followupRaw, urgency: null } : null);
30
+ const target = (raw.target && typeof raw.target === 'object') ? raw.target : {};
31
+ return {
32
+ rank: raw.rank,
33
+ elementNameShort: raw.element_name_short,
34
+ isPharmacologicalMedNeeded: typeof raw.is_pharmacological_med_needed === 'boolean'
35
+ ? raw.is_pharmacological_med_needed : null,
36
+ ctcaeTerms: (Array.isArray(target.CTCAE_term) ? target.CTCAE_term : []).filter((term) => term),
37
+ mainRecommendation: typeof patient.main_recommendation === 'string' ? patient.main_recommendation : null,
38
+ otcOffer: mapOtcOffer(patient.otc_offer),
39
+ followup,
40
+ whenToEscalate: typeof patient.when_to_escalate === 'string' ? patient.when_to_escalate : null,
41
+ expectedEffectiveness: typeof patient.expected_effectiveness === 'string' ? patient.expected_effectiveness : null,
42
+ };
43
+ }
44
+
45
+ function normalizeRecommendations(raw) {
46
+ const recs = Array.isArray(raw.recommendations) ? raw.recommendations : [];
47
+ return recs.map(mapRecommendation);
48
+ }
49
+
50
+ function buildCarePlan({ raw, carePlanId, patientId, turnId, assessmentId, committedAt, recommendations }) {
51
+ const takeaways = (raw.careplan_takeaways && typeof raw.careplan_takeaways === 'object') ? raw.careplan_takeaways : {};
52
+ return new PalliativeCarePlan({
53
+ careplanId: carePlanId,
54
+ patientId,
55
+ turnId,
56
+ assessmentId,
57
+ committedAt,
58
+ summary: typeof takeaways.careplan_summary_internal === 'string' ? takeaways.careplan_summary_internal : null,
59
+ recommendations,
60
+ });
61
+ }
62
+
63
+ module.exports = {
64
+ carePlanUid,
65
+ mapRecommendation,
66
+ normalizeRecommendations,
67
+ buildCarePlan,
68
+ };
@@ -0,0 +1,101 @@
1
+ const { carePlanUid, normalizeRecommendations, buildCarePlan } = require('../helpers/palliativeCarePlanHelper');
2
+ const { checkCarePlanAgainstAssessment, checkCarePlanHasPatientAction } = require('../helpers/carePlanValidationHelper');
3
+ const { deriveInterventionsFromCarePlan } = require('../helpers/carePlanInterventionDerivation');
4
+ const { readPalliativeByTurn, readPalliativeCarePlanByTurn, storePalliativeCarePlan } = require('../../fhir');
5
+ const { fhirId } = require('../../fhir/helpers/fhirHelper');
6
+ const { logger } = require('../../utils/logger');
7
+
8
+ const definition = {
9
+ name: 'submitPalliativeCarePlan',
10
+ description: '**Does:** Terminal Phase-2 commit of the `accompaniment` skill — persists a structured palliative CARE PLAN for the turn\'s committed assessment as a FHIR `CarePlan` (+ one `Task` per recommendation). One recommendation per assessment finding, aligned by `rank`.\n\n**Required inputs:** `careplan` (one object) with `recommendations[]` — each `{rank, element_name_short, is_pharmacological_med_needed, target:{CTCAE_term[]}, recommendations_patient:{main_recommendation, otc_offer, followup, when_to_escalate, expected_effectiveness}}` — plus `careplan_takeaways`.\n\n**When NOT to call:**\n- Before a Phase-1 assessment exists for the turn — returns `assessment_required`; call `submitPalliativeAssessment` first.\n- Twice in the same turn — returns `careplan_already_committed_for_turn`.\n\n**Validation (fail-hard before persisting):**\n- `ASSESSMENT_CAREPLAN_MISMATCH` — recommendations must be one-per-finding with unique ranks matching the assessment, `element_name_short` matching the finding, and `is_pharmacological_med_needed` coherent with the finding\'s `are_meds_needed`.\n- `CAREPLAN_NO_PATIENT_ACTION` — each recommendation must hand the patient something (`main_recommendation` | `otc_offer` | `followup=team_referral`).\n\n**Returns:** `accepted`, `careplan_id`, `assessment_id`, `turn_id`, `committed_at`, plus best-effort `derived_intervention_ids` / `derived_interventions_skipped_no_case`.\n\n**Side effects:** persists the FHIR `CarePlan` + `Task[]` + `Provenance`; auto-derives case-layer Interventions (never blocks the commit).',
11
+ strict: false,
12
+ parameters: {
13
+ type: 'object',
14
+ properties: {
15
+ careplan: {
16
+ type: 'object',
17
+ description: 'The full palliative care plan. `recommendations[]` (one per assessment finding, aligned by `rank`) each carry patient-facing advice; plus `careplan_takeaways`.',
18
+ },
19
+ },
20
+ required: ['careplan'],
21
+ },
22
+ };
23
+
24
+ function fail(error, data = {}) {
25
+ return JSON.stringify({ success: false, error, data });
26
+ }
27
+
28
+ async function handler(args = {}, context = {}) {
29
+ try {
30
+ const runtime = context?.toolRuntimeContext || null;
31
+ if (!runtime?.turnId || !runtime?.patientCode) {
32
+ return fail('submitPalliativeCarePlan requires an active turn context (turnId, patientCode).');
33
+ }
34
+ const raw = args?.careplan;
35
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
36
+ return fail('careplan is required (non-empty object)');
37
+ }
38
+
39
+ const patientId = runtime.patientCode;
40
+ const turnId = runtime.turnId;
41
+
42
+ const assessment = await readPalliativeByTurn({ patientId, turnId });
43
+ if (!assessment) {
44
+ return fail('assessment_required — call submitPalliativeAssessment first', { turn_id: turnId });
45
+ }
46
+ const existing = await readPalliativeCarePlanByTurn({ patientId, turnId });
47
+ if (existing) {
48
+ return fail('careplan_already_committed_for_turn', { existing_careplan_id: existing.careplanId, turn_id: turnId });
49
+ }
50
+
51
+ const recommendations = normalizeRecommendations(raw);
52
+ const mismatch = checkCarePlanAgainstAssessment(recommendations, assessment.findings);
53
+ if (mismatch) return fail(mismatch.errorCode, { turn_id: turnId, ...mismatch });
54
+ const hollow = checkCarePlanHasPatientAction(recommendations);
55
+ if (hollow) return fail(hollow.errorCode, { turn_id: turnId, ...hollow });
56
+
57
+ const carePlan = buildCarePlan({
58
+ raw,
59
+ carePlanId: carePlanUid(),
60
+ patientId,
61
+ turnId,
62
+ assessmentId: assessment.assessmentId,
63
+ committedAt: new Date().toISOString(),
64
+ recommendations,
65
+ });
66
+ await storePalliativeCarePlan({ patientId, carePlan });
67
+
68
+ runtime.setCareplan({ careplanId: carePlan.careplanId, assessmentId: carePlan.assessmentId, turnId });
69
+ runtime.trace.setSignals({ palliativeCareplanId: carePlan.careplanId });
70
+
71
+ const data = {
72
+ accepted: true,
73
+ careplan_id: fhirId(carePlan.careplanId),
74
+ assessment_id: carePlan.assessmentId,
75
+ turn_id: turnId,
76
+ committed_at: carePlan.committedAt,
77
+ };
78
+ const derived = await deriveInterventions(carePlan);
79
+ if (derived.created.length) data.derived_intervention_ids = derived.created;
80
+ if (derived.skippedNoCase) data.derived_interventions_skipped_no_case = derived.skippedNoCase;
81
+ if (derived.error) data.derived_interventions_error = true;
82
+
83
+ return JSON.stringify({ success: true, data });
84
+ } catch (err) {
85
+ return fail(err?.message || 'submitPalliativeCarePlan failed');
86
+ }
87
+ }
88
+
89
+ async function deriveInterventions(carePlan) {
90
+ try {
91
+ return await deriveInterventionsFromCarePlan({ carePlan });
92
+ } catch (err) {
93
+ logger.warn({ careplanId: carePlan.careplanId, err: err?.message }, 'palliative careplan intervention derivation failed');
94
+ return { created: [], skippedNoCase: 0, error: true };
95
+ }
96
+ }
97
+
98
+ module.exports = {
99
+ definition,
100
+ handler,
101
+ };
@@ -1,10 +1,10 @@
1
1
  const { assessmentUid, buildAssessment } = require('../helpers/palliativeAssessmentHelper');
2
- const { storePalliativeAssessment, readPalliativeByTurn } = require('../../fhir');
2
+ const { storePalliativeAssessment, readPalliativeByTurn, readPalliativeCarePlanByTurn } = require('../../fhir');
3
3
  const { fhirId } = require('../../fhir/helpers/fhirHelper');
4
4
 
5
5
  const definition = {
6
6
  name: 'updatePalliativeAssessment',
7
- description: '**Does:** Full-replacement correction path for the Phase-1 palliative assessment — issues a NEW assessment (new `assessment_id`); it is NOT a field-level patch. The prior assessment_id is returned as `replaced_prior_assessment_id` for audit lineage.\n\n**Required inputs:** `assessment` (one object, identical shape to `submitPalliativeAssessment`).\n\n**When NOT to call:**\n- Before a Phase-1 assessment exists for the turn — returns `no_assessment_to_update`; call `submitPalliativeAssessment` first.\n\n**Returns:** `accepted`, `assessment_id` (new), `replaced_prior_assessment_id`, `turn_id`.\n\n**Side effects:** persists a replacement FHIR `ClinicalImpression` (+ a `Task` per urgent finding) + `Provenance`.',
7
+ description: '**Does:** Full-replacement correction path for the Phase-1 palliative assessment — issues a NEW assessment (new `assessment_id`); it is NOT a field-level patch. The prior assessment_id is returned as `replaced_prior_assessment_id` for audit lineage.\n\n**Required inputs:** `assessment` (one object, identical shape to `submitPalliativeAssessment`).\n\n**When NOT to call:**\n- Before a Phase-1 assessment exists for the turn — returns `no_assessment_to_update`; call `submitPalliativeAssessment` first.\n- After a care plan has been committed for the turn — returns `careplan_already_committed`; the assessment is locked.\n\n**Returns:** `accepted`, `assessment_id` (new), `replaced_prior_assessment_id`, `turn_id`.\n\n**Side effects:** persists a replacement FHIR `ClinicalImpression` (+ a `Task` per urgent finding) + `Provenance`.',
8
8
  strict: false,
9
9
  parameters: {
10
10
  type: 'object',
@@ -40,6 +40,10 @@ async function handler(args = {}, context = {}) {
40
40
  if (!prior) {
41
41
  return fail('no_assessment_to_update — call submitPalliativeAssessment first', { turn_id: turnId });
42
42
  }
43
+ const careplan = await readPalliativeCarePlanByTurn({ patientId, turnId });
44
+ if (careplan) {
45
+ return fail('careplan_already_committed — the assessment is locked once a careplan exists for the turn', { turn_id: turnId, careplan_id: careplan.careplanId });
46
+ }
43
47
 
44
48
  const assessment = buildAssessment({
45
49
  raw,
@@ -24,6 +24,8 @@ const ROUTING_ASSESSMENT_SLUG = 'routing-assessment';
24
24
  const ROUTING_DISPOSITION_SLUG = 'routing-disposition';
25
25
  const PALLIATIVE_ASSESSMENT_SLUG = 'palliative-assessment';
26
26
  const PALLIATIVE_ESCALATION_SLUG = 'palliative-escalation';
27
+ const PALLIATIVE_CAREPLAN_SLUG = 'palliative-careplan';
28
+ const PALLIATIVE_CAREPLAN_REC_SLUG = 'palliative-careplan-rec';
27
29
 
28
30
  module.exports = {
29
31
  MENTION_SLUG,
@@ -52,4 +54,6 @@ module.exports = {
52
54
  ROUTING_DISPOSITION_SLUG,
53
55
  PALLIATIVE_ASSESSMENT_SLUG,
54
56
  PALLIATIVE_ESCALATION_SLUG,
57
+ PALLIATIVE_CAREPLAN_SLUG,
58
+ PALLIATIVE_CAREPLAN_REC_SLUG,
55
59
  };
package/lib/fhir/index.js CHANGED
@@ -8,7 +8,12 @@ const { storeContingency, readContingencies } = require('./services/contingencyS
8
8
  const { storeIntervention, readInterventions } = require('./services/interventionService');
9
9
  const { storeCluster, readClusters } = require('./services/clusterService');
10
10
  const { storeRoutingDecision, readRoutingByTurn } = require('./services/routingService');
11
- const { storePalliativeAssessment, readPalliativeByTurn } = require('./services/palliativeService');
11
+ const {
12
+ storePalliativeAssessment,
13
+ readPalliativeByTurn,
14
+ storePalliativeCarePlan,
15
+ readPalliativeCarePlanByTurn,
16
+ } = require('./services/palliativeService');
12
17
  const { storeTriage } = require('./services/triageService');
13
18
  const { patientSnapshot } = require('./services/snapshotService');
14
19
  const { registerAllProjectors } = require('./projections/registerProjectors');
@@ -46,6 +51,8 @@ module.exports = {
46
51
  readRoutingByTurn,
47
52
  storePalliativeAssessment,
48
53
  readPalliativeByTurn,
54
+ storePalliativeCarePlan,
55
+ readPalliativeCarePlanByTurn,
49
56
  storeTriage,
50
57
  patientSnapshot,
51
58
  getFhirStore,
@@ -0,0 +1,38 @@
1
+ const { getDeviceId } = require('../config/fhirConfig');
2
+ const { CarePlan } = require('../resources/CarePlan');
3
+ const { Task } = require('../resources/Task');
4
+ const { Provenance } = require('../resources/Provenance');
5
+
6
+ const PROJECTOR_NAME = 'palliativeCarePlan';
7
+ const ACTIVITY_TEXT = 'palliative-careplan';
8
+
9
+ function projectPalliativeCarePlan(carePlan) {
10
+ const deviceId = getDeviceId();
11
+ const tasks = carePlan.recommendations.map((recommendation) => Task.fromPalliativeRecommendation({
12
+ carePlanId: carePlan.careplanId,
13
+ recommendation,
14
+ patientId: carePlan.patientId,
15
+ committedAt: carePlan.committedAt,
16
+ deviceId,
17
+ }));
18
+ const plan = CarePlan.fromPalliativeCarePlan({
19
+ carePlan,
20
+ taskIds: tasks.map((task) => task.identifier[0].value),
21
+ });
22
+ const targetRefs = [
23
+ { reference: `CarePlan/${plan.id}` },
24
+ ...tasks.map((task) => ({ reference: `Task/${task.id}` })),
25
+ ];
26
+ const provenance = Provenance.fromSymptomCase({
27
+ provenanceId: `${carePlan.careplanId}-prov`,
28
+ targetRefs,
29
+ recordedAt: carePlan.committedAt,
30
+ activityText: ACTIVITY_TEXT,
31
+ });
32
+ return [plan, ...tasks, provenance];
33
+ }
34
+
35
+ module.exports = {
36
+ projectPalliativeCarePlan,
37
+ PROJECTOR_NAME,
38
+ };
@@ -11,6 +11,7 @@ const { projectIntervention, PROJECTOR_NAME: INTERVENTION_PROJECTOR_NAME } = req
11
11
  const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
12
12
  const { projectRoutingDecision, PROJECTOR_NAME: ROUTING_PROJECTOR_NAME } = require('./routingProjection');
13
13
  const { projectPalliativeAssessment, PROJECTOR_NAME: PALLIATIVE_PROJECTOR_NAME } = require('./palliativeProjection');
14
+ const { projectPalliativeCarePlan, PROJECTOR_NAME: PALLIATIVE_CAREPLAN_PROJECTOR_NAME } = require('./palliativeCarePlanProjection');
14
15
 
15
16
  const PROJECTORS = [
16
17
  { name: MENTION_PROJECTOR_NAME, project: projectClinicalMention },
@@ -25,6 +26,7 @@ const PROJECTORS = [
25
26
  { name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
26
27
  { name: ROUTING_PROJECTOR_NAME, project: projectRoutingDecision },
27
28
  { name: PALLIATIVE_PROJECTOR_NAME, project: projectPalliativeAssessment },
29
+ { name: PALLIATIVE_CAREPLAN_PROJECTOR_NAME, project: projectPalliativeCarePlan },
28
30
  ];
29
31
 
30
32
  function registerAllProjectors() {
@@ -7,7 +7,7 @@ const {
7
7
  extension,
8
8
  toIso,
9
9
  } = require('../helpers/elementHelper');
10
- const { CONTINGENCY_PLAN_SLUG, ROUTING_DISPOSITION_SLUG, RECOMMENDATION_PLAN_SLUG } = require('../constants/projectionSlugs');
10
+ const { CONTINGENCY_PLAN_SLUG, ROUTING_DISPOSITION_SLUG, RECOMMENDATION_PLAN_SLUG, PALLIATIVE_CAREPLAN_SLUG } = require('../constants/projectionSlugs');
11
11
 
12
12
  const CONTINGENCY_STATUS_TO_FHIR = {
13
13
  armed: 'active',
@@ -67,6 +67,29 @@ class CarePlan {
67
67
  return new CarePlan(payload);
68
68
  }
69
69
 
70
+ static fromPalliativeCarePlan({ carePlan, taskIds }) {
71
+ const payload = {
72
+ resourceType: 'CarePlan',
73
+ id: fhirId(carePlan.careplanId),
74
+ identifier: [identifier(PALLIATIVE_CAREPLAN_SLUG, carePlan.careplanId)],
75
+ status: 'active',
76
+ intent: 'plan',
77
+ category: [codeableConcept(PALLIATIVE_CAREPLAN_SLUG)],
78
+ subject: patientReference(carePlan.patientId),
79
+ created: toIso(carePlan.committedAt),
80
+ extension: [extension('palliative-turn-id', { valueString: carePlan.turnId })],
81
+ };
82
+ if (carePlan.summary) payload.description = carePlan.summary;
83
+ if (carePlan.assessmentId) {
84
+ payload.extension.push(extension('palliative-assessment-id', { valueString: carePlan.assessmentId }));
85
+ payload.supportingInfo = [reference('ClinicalImpression', carePlan.assessmentId)];
86
+ }
87
+ if (taskIds.length) {
88
+ payload.activity = taskIds.map((taskId) => ({ plannedActivityReference: reference('Task', taskId) }));
89
+ }
90
+ return new CarePlan(payload);
91
+ }
92
+
70
93
  static fromRoutingDisposition({ disposition }) {
71
94
  const payload = {
72
95
  resourceType: 'CarePlan',
@@ -18,6 +18,7 @@ const {
18
18
  ROUTING_DISPOSITION_SLUG,
19
19
  RECOMMENDATION_REC_SLUG,
20
20
  PALLIATIVE_ESCALATION_SLUG,
21
+ PALLIATIVE_CAREPLAN_REC_SLUG,
21
22
  } = require('../constants/projectionSlugs');
22
23
  const { SEVERITY_TO_PRIORITY } = require('../constants/severityPriority');
23
24
  const { interventionExtensions } = require('./interventionExtensions');
@@ -140,6 +141,43 @@ class Task {
140
141
  return new Task(payload);
141
142
  }
142
143
 
144
+ static fromPalliativeRecommendation({ carePlanId, recommendation, patientId, committedAt, deviceId }) {
145
+ const recId = `${carePlanId}-rank${recommendation.rank}`;
146
+ const code = codeableConcept(recommendation.elementNameShort);
147
+ const ctcaeTerms = (recommendation.ctcaeTerms || []).filter(Boolean);
148
+ if (ctcaeTerms.length) {
149
+ code.coding = ctcaeTerms.map((term) => ({ system: codeSystemUrl('pro-ctcae'), code: term, display: term }));
150
+ }
151
+ const payload = {
152
+ resourceType: 'Task',
153
+ id: fhirId(recId),
154
+ identifier: [identifier(PALLIATIVE_CAREPLAN_REC_SLUG, recId)],
155
+ status: 'requested',
156
+ intent: 'plan',
157
+ code,
158
+ for: patientReference(patientId),
159
+ authoredOn: toIso(committedAt),
160
+ requester: reference('Device', deviceId),
161
+ extension: [extension('palliative-careplan-rec-rank', { valueInteger: recommendation.rank })],
162
+ };
163
+ if (recommendation.otcOffer && recommendation.otcOffer.label) {
164
+ payload.extension.push(extension('palliative-careplan-otc-offer', { valueString: JSON.stringify(recommendation.otcOffer) }));
165
+ }
166
+ if (recommendation.mainRecommendation) payload.description = recommendation.mainRecommendation;
167
+ const noteTexts = [];
168
+ if (recommendation.whenToEscalate) noteTexts.push(`when_to_escalate: ${recommendation.whenToEscalate}`);
169
+ if (recommendation.expectedEffectiveness) noteTexts.push(`expected_effectiveness: ${recommendation.expectedEffectiveness}`);
170
+ if (recommendation.followup && recommendation.followup.type) {
171
+ noteTexts.push(`followup: ${recommendation.followup.type} / ${recommendation.followup.urgency || ''}`);
172
+ }
173
+ if (recommendation.isPharmacologicalMedNeeded !== null) {
174
+ noteTexts.push(`is_pharmacological_med_needed: ${recommendation.isPharmacologicalMedNeeded}`);
175
+ }
176
+ const note = notes(...noteTexts);
177
+ if (note) payload.note = note;
178
+ return new Task(payload);
179
+ }
180
+
143
181
  static fromRoutingDisposition({ disposition }) {
144
182
  const payload = {
145
183
  resourceType: 'Task',
@@ -2,10 +2,12 @@ const { ZodError } = require('zod');
2
2
 
3
3
  const { fhirId } = require('../helpers/fhirHelper');
4
4
  const { extMap, extValues, identifierValue } = require('../helpers/fhirReadHelper');
5
- const { PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
5
+ const { PALLIATIVE_ASSESSMENT_SLUG, PALLIATIVE_ESCALATION_SLUG, PALLIATIVE_CAREPLAN_SLUG } = require('../constants/projectionSlugs');
6
6
  const { PROJECTOR_NAME } = require('../projections/palliativeProjection');
7
+ const { PROJECTOR_NAME: CAREPLAN_PROJECTOR_NAME } = require('../projections/palliativeCarePlanProjection');
7
8
  const { getFhirStore } = require('../stores/fhirStore');
8
9
  const { PalliativeAssessment } = require('../../shared/dtos/PalliativeAssessment');
10
+ const { PalliativeCarePlan } = require('../../shared/dtos/PalliativeCarePlan');
9
11
  const { logger } = require('../../utils/logger');
10
12
  const { project, storeResources } = require('./fhirService');
11
13
 
@@ -29,6 +31,95 @@ async function readPalliativeByTurn({ patientId, turnId }) {
29
31
  return reconstruct(store, impression, provenance, patientId, turnId);
30
32
  }
31
33
 
34
+ async function storePalliativeCarePlan({ patientId, carePlan }) {
35
+ if (carePlan.patientId !== patientId) {
36
+ throw new Error(`storePalliativeCarePlan patientId mismatch: expected ${patientId}`);
37
+ }
38
+ const bundle = project([{ name: CAREPLAN_PROJECTOR_NAME, aggregate: carePlan }]);
39
+ const resources = bundle.entry.map((entry) => entry.resource);
40
+ const { stored } = await storeResources(resources);
41
+ return { count: stored.length, ids: stored };
42
+ }
43
+
44
+ async function readPalliativeCarePlanByTurn({ patientId, turnId }) {
45
+ const store = getFhirStore();
46
+ const patient = `Patient/${fhirId(patientId)}`;
47
+ const plans = (await store.find({ patient, resourceType: 'CarePlan' }))
48
+ .filter((resource) => identifierValue(resource, PALLIATIVE_CAREPLAN_SLUG));
49
+ const plan = plans.find((resource) => extMap(resource)['palliative-turn-id'] === turnId) || null;
50
+ if (!plan) return null;
51
+ const careplanId = identifierValue(plan, PALLIATIVE_CAREPLAN_SLUG);
52
+ const provenance = await store.getByReference(`Provenance/${fhirId(`${careplanId}-prov`)}`);
53
+ if (!provenance) return null;
54
+ return reconstructCarePlan(store, plan, patientId, turnId);
55
+ }
56
+
57
+ async function reconstructCarePlan(store, plan, patientId, turnId) {
58
+ const careplanId = identifierValue(plan, PALLIATIVE_CAREPLAN_SLUG);
59
+ const assessmentId = extMap(plan)['palliative-assessment-id'];
60
+ const taskRefs = (plan.activity || [])
61
+ .map((activity) => activity.plannedActivityReference && activity.plannedActivityReference.reference)
62
+ .filter(Boolean);
63
+ const recommendations = [];
64
+ for (const ref of taskRefs) {
65
+ const task = await store.getByReference(ref);
66
+ if (task) recommendations.push(reconstructRecommendation(task));
67
+ }
68
+ try {
69
+ return new PalliativeCarePlan({
70
+ careplanId,
71
+ patientId,
72
+ turnId,
73
+ assessmentId: assessmentId || '',
74
+ committedAt: plan.created,
75
+ summary: plan.description || null,
76
+ recommendations,
77
+ });
78
+ } catch (error) {
79
+ if (error instanceof ZodError) {
80
+ logger.warn({ careplanId }, 'skipping malformed palliative CarePlan');
81
+ return null;
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ function reconstructRecommendation(task) {
88
+ const noteMap = {};
89
+ for (const note of task.note || []) {
90
+ const [key, ...rest] = String(note.text || '').split(': ');
91
+ noteMap[key] = rest.join(': ');
92
+ }
93
+ const followupText = noteMap.followup;
94
+ const followup = followupText
95
+ ? { type: (followupText.split(' / ')[0] || '').trim() || null, urgency: (followupText.split(' / ')[1] || '').trim() || null }
96
+ : null;
97
+ const medsText = noteMap.is_pharmacological_med_needed;
98
+ const ext = extMap(task);
99
+ const ctcaeTerms = ((task.code && task.code.coding) || []).map((coding) => coding.code).filter(Boolean);
100
+ return {
101
+ rank: ext['palliative-careplan-rec-rank'],
102
+ elementNameShort: (task.code && task.code.text) || 'recommendation',
103
+ isPharmacologicalMedNeeded: medsText === undefined ? null : medsText === 'true',
104
+ ctcaeTerms,
105
+ mainRecommendation: task.description || null,
106
+ otcOffer: parseOtcOffer(ext['palliative-careplan-otc-offer']),
107
+ whenToEscalate: noteMap.when_to_escalate || null,
108
+ expectedEffectiveness: noteMap.expected_effectiveness || null,
109
+ followup,
110
+ };
111
+ }
112
+
113
+ function parseOtcOffer(raw) {
114
+ if (typeof raw !== 'string' || !raw) return null;
115
+ try {
116
+ const parsed = JSON.parse(raw);
117
+ return parsed && typeof parsed === 'object' ? parsed : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
32
123
  async function findImpression(store, patientId, turnId) {
33
124
  const patient = `Patient/${fhirId(patientId)}`;
34
125
  const impressions = (await store.find({ patient, resourceType: 'ClinicalImpression' }))
@@ -118,4 +209,6 @@ const ccText = (concept) => (concept && concept.text) || null;
118
209
  module.exports = {
119
210
  storePalliativeAssessment,
120
211
  readPalliativeByTurn,
212
+ storePalliativeCarePlan,
213
+ readPalliativeCarePlanByTurn,
121
214
  };
@@ -0,0 +1,47 @@
1
+ const { z } = require('zod');
2
+
3
+ const { BaseDto } = require('./BaseDto');
4
+
5
+ const dateTime = z.iso.datetime({ offset: true });
6
+
7
+ const followupSchema = z.strictObject({
8
+ type: z.string().nullable().default(null),
9
+ urgency: z.string().nullable().default(null),
10
+ }).nullable().default(null);
11
+
12
+ const otcOfferSchema = z.strictObject({
13
+ label: z.string(),
14
+ dose: z.string().nullable().default(null),
15
+ frequency: z.string().nullable().default(null),
16
+ duration: z.string().nullable().default(null),
17
+ warnings: z.array(z.string()).default([]),
18
+ }).nullable().default(null);
19
+
20
+ const recommendationSchema = z.strictObject({
21
+ rank: z.number().int().min(1),
22
+ elementNameShort: z.string().min(1),
23
+ isPharmacologicalMedNeeded: z.boolean().nullable().default(null),
24
+ ctcaeTerms: z.array(z.string()).default([]),
25
+ mainRecommendation: z.string().nullable().default(null),
26
+ otcOffer: otcOfferSchema,
27
+ followup: followupSchema,
28
+ whenToEscalate: z.string().nullable().default(null),
29
+ expectedEffectiveness: z.string().nullable().default(null),
30
+ });
31
+
32
+ const schema = z.strictObject({
33
+ schemaVersion: z.string().default('1'),
34
+ careplanId: z.string(),
35
+ patientId: z.string(),
36
+ turnId: z.string(),
37
+ assessmentId: z.string(),
38
+ committedAt: dateTime,
39
+ summary: z.string().nullable().default(null),
40
+ recommendations: z.array(recommendationSchema).default([]),
41
+ });
42
+
43
+ class PalliativeCarePlan extends BaseDto {}
44
+
45
+ PalliativeCarePlan.schema = schema;
46
+
47
+ module.exports = { PalliativeCarePlan };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.10.0-dev.1051",
3
+ "version": "5.10.0-dev.1054",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",