fhir-data-utils-ts 0.3.6 → 0.4.0
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/.codex/skills/govern-fhir-data-utilities/SKILL.md +12 -0
- package/.codex/skills/govern-fhir-observation-components/SKILL.md +11 -0
- package/CHANGELOG.md +20 -0
- package/README.md +18 -0
- package/dist/care-emergency-alert.d.ts +70 -0
- package/dist/care-emergency-alert.js +135 -0
- package/dist/claims/communication-request-claims.d.ts +21 -0
- package/dist/claims/communication-request-claims.js +27 -0
- package/dist/claims/composition-claims.d.ts +1 -0
- package/dist/claims/composition-claims.js +1 -0
- package/dist/claims/encounter-claims.d.ts +1 -0
- package/dist/claims/encounter-claims.js +2 -0
- package/dist/claims/index.d.ts +2 -0
- package/dist/claims/index.js +2 -0
- package/dist/claims/service-request-claims.d.ts +24 -0
- package/dist/claims/service-request-claims.js +33 -0
- package/dist/constants/fhir-resource-types.d.ts +3 -0
- package/dist/constants/fhir-resource-types.js +1 -0
- package/dist/converters/convert-communication-request.d.ts +5 -0
- package/dist/converters/convert-communication-request.js +40 -0
- package/dist/converters/convert-composition.js +12 -2
- package/dist/converters/convert-encounter.js +2 -0
- package/dist/converters/convert-service-request.d.ts +5 -0
- package/dist/converters/convert-service-request.js +47 -0
- package/dist/converters/index.d.ts +2 -0
- package/dist/converters/index.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/observation-claims.d.ts +3 -0
- package/dist/observation-claims.js +3 -0
- package/dist/vital-sign-observations.d.ts +75 -1
- package/dist/vital-sign-observations.js +225 -9
- package/docs/CARE_EMERGENCY_ALERT_CONTRACTS.md +50 -0
- package/docs/VITAL_SIGN_DAILY_DOCUMENT.md +79 -0
- package/package.json +5 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ServiceRequestClaim } from '../claims/service-request-claims.js';
|
|
2
|
+
import { codingFromValue, codingListFromCsv, codingListToCsv, codingToValue, referenceListToCsv, referenceToValue } from './convert-shared.js';
|
|
3
|
+
/** Projects canonical ServiceRequest flat claims to the FHIR R4 wire shape. */
|
|
4
|
+
export function serviceRequestFlatToFhirR4(claims) {
|
|
5
|
+
const codeCoding = codingFromValue(claims[ServiceRequestClaim.Code])?.[0];
|
|
6
|
+
return {
|
|
7
|
+
resourceType: 'ServiceRequest',
|
|
8
|
+
identifier: claims[ServiceRequestClaim.Identifier] ? [{ value: claims[ServiceRequestClaim.Identifier] }] : undefined,
|
|
9
|
+
status: claims[ServiceRequestClaim.Status], intent: claims[ServiceRequestClaim.Intent], priority: claims[ServiceRequestClaim.Priority],
|
|
10
|
+
category: claims[ServiceRequestClaim.Category] ? [{ coding: codingFromValue(claims[ServiceRequestClaim.Category]) }] : undefined,
|
|
11
|
+
code: codeCoding || claims[ServiceRequestClaim.CodeText] ? { coding: codeCoding ? [codeCoding] : undefined, text: claims[ServiceRequestClaim.CodeText] } : undefined,
|
|
12
|
+
subject: claims[ServiceRequestClaim.Subject] ? { reference: claims[ServiceRequestClaim.Subject] } : undefined,
|
|
13
|
+
encounter: claims[ServiceRequestClaim.Encounter] ? { reference: claims[ServiceRequestClaim.Encounter] } : undefined,
|
|
14
|
+
requester: claims[ServiceRequestClaim.Requester] ? { reference: claims[ServiceRequestClaim.Requester] } : undefined,
|
|
15
|
+
performer: referenceCsv(claims[ServiceRequestClaim.Performer]), authoredOn: claims[ServiceRequestClaim.AuthoredOn],
|
|
16
|
+
occurrenceDateTime: claims[ServiceRequestClaim.Occurrence],
|
|
17
|
+
reasonCode: claims[ServiceRequestClaim.ReasonCode] ? [{ coding: codingListFromCsv(claims[ServiceRequestClaim.ReasonCode]) }] : undefined,
|
|
18
|
+
reasonReference: referenceCsv(claims[ServiceRequestClaim.ReasonReference]),
|
|
19
|
+
supportingInfo: referenceCsv(claims[ServiceRequestClaim.SupportingInfo]), specimen: referenceCsv(claims[ServiceRequestClaim.Specimen]),
|
|
20
|
+
bodySite: claims[ServiceRequestClaim.BodySite] ? [{ coding: codingListFromCsv(claims[ServiceRequestClaim.BodySite]) }] : undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Reads a FHIR R4 ServiceRequest into canonical compact-token flat claims. */
|
|
24
|
+
export function serviceRequestFhirR4ToFlat(resource) {
|
|
25
|
+
const code = resource.code;
|
|
26
|
+
return {
|
|
27
|
+
[ServiceRequestClaim.Identifier]: resource.identifier?.[0]?.value,
|
|
28
|
+
[ServiceRequestClaim.Status]: resource.status, [ServiceRequestClaim.Intent]: resource.intent,
|
|
29
|
+
[ServiceRequestClaim.Priority]: resource.priority,
|
|
30
|
+
[ServiceRequestClaim.Category]: codingToValue(resource.category?.[0]?.coding?.[0]),
|
|
31
|
+
[ServiceRequestClaim.Code]: codingToValue(code?.coding?.[0]), [ServiceRequestClaim.CodeText]: code?.text,
|
|
32
|
+
[ServiceRequestClaim.Subject]: referenceToValue(resource.subject),
|
|
33
|
+
[ServiceRequestClaim.Encounter]: referenceToValue(resource.encounter),
|
|
34
|
+
[ServiceRequestClaim.Requester]: referenceToValue(resource.requester),
|
|
35
|
+
[ServiceRequestClaim.Performer]: referenceListToCsv(resource.performer),
|
|
36
|
+
[ServiceRequestClaim.AuthoredOn]: resource.authoredOn, [ServiceRequestClaim.Occurrence]: resource.occurrenceDateTime,
|
|
37
|
+
[ServiceRequestClaim.ReasonCode]: codingListToCsv(resource.reasonCode?.flatMap(value => value.coding ?? [])),
|
|
38
|
+
[ServiceRequestClaim.ReasonReference]: referenceListToCsv(resource.reasonReference),
|
|
39
|
+
[ServiceRequestClaim.SupportingInfo]: referenceListToCsv(resource.supportingInfo),
|
|
40
|
+
[ServiceRequestClaim.Specimen]: referenceListToCsv(resource.specimen),
|
|
41
|
+
[ServiceRequestClaim.BodySite]: codingListToCsv(resource.bodySite?.flatMap(value => value.coding ?? [])),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function referenceCsv(value) {
|
|
45
|
+
const references = value?.split(',').map(reference => reference.trim()).filter(Boolean) ?? [];
|
|
46
|
+
return references.length ? references.map(reference => ({ reference })) : undefined;
|
|
47
|
+
}
|
|
@@ -4,6 +4,7 @@ export * from './convert-appointment-response.js';
|
|
|
4
4
|
export * from './convert-appointment.js';
|
|
5
5
|
export * from './convert-care-plan.js';
|
|
6
6
|
export * from './convert-clinical-impression.js';
|
|
7
|
+
export * from './convert-communication-request.js';
|
|
7
8
|
export * from './convert-composition.js';
|
|
8
9
|
export * from './convert-consent.js';
|
|
9
10
|
export * from './convert-condition.js';
|
|
@@ -23,3 +24,4 @@ export * from './convert-organization.js';
|
|
|
23
24
|
export * from './convert-practitioner-role.js';
|
|
24
25
|
export * from './convert-procedure.js';
|
|
25
26
|
export * from './convert-related-person.js';
|
|
27
|
+
export * from './convert-service-request.js';
|
package/dist/converters/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export * from './convert-appointment-response.js';
|
|
|
4
4
|
export * from './convert-appointment.js';
|
|
5
5
|
export * from './convert-care-plan.js';
|
|
6
6
|
export * from './convert-clinical-impression.js';
|
|
7
|
+
export * from './convert-communication-request.js';
|
|
7
8
|
export * from './convert-composition.js';
|
|
8
9
|
export * from './convert-consent.js';
|
|
9
10
|
export * from './convert-condition.js';
|
|
@@ -23,3 +24,4 @@ export * from './convert-organization.js';
|
|
|
23
24
|
export * from './convert-practitioner-role.js';
|
|
24
25
|
export * from './convert-procedure.js';
|
|
25
26
|
export * from './convert-related-person.js';
|
|
27
|
+
export * from './convert-service-request.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from './fhir-bundle.js';
|
|
|
14
14
|
export * from './terminology-models/index.js';
|
|
15
15
|
export * from './constants/index.js';
|
|
16
16
|
export * from './clinical-terminology.js';
|
|
17
|
+
export * from './care-emergency-alert.js';
|
|
17
18
|
export * from './immunization-flat-claims.js';
|
|
18
19
|
export * from './immunization-reminder-plan.js';
|
|
19
20
|
export * from './builders/index.js';
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export * from './fhir-bundle.js';
|
|
|
14
14
|
export * from './terminology-models/index.js';
|
|
15
15
|
export * from './constants/index.js';
|
|
16
16
|
export * from './clinical-terminology.js';
|
|
17
|
+
export * from './care-emergency-alert.js';
|
|
17
18
|
export * from './immunization-flat-claims.js';
|
|
18
19
|
export * from './immunization-reminder-plan.js';
|
|
19
20
|
export * from './builders/index.js';
|
|
@@ -85,6 +85,7 @@ export declare const ObservationClaim: Readonly<{
|
|
|
85
85
|
readonly Code: "Observation.code";
|
|
86
86
|
readonly Date: "Observation.date";
|
|
87
87
|
readonly Device: "Observation.device";
|
|
88
|
+
readonly DerivedFrom: "Observation.derived-from";
|
|
88
89
|
readonly Encounter: "Observation.encounter";
|
|
89
90
|
readonly Focus: "Observation.focus";
|
|
90
91
|
readonly HasMember: "Observation.has-member";
|
|
@@ -122,6 +123,8 @@ export declare const ObservationClaim: Readonly<{
|
|
|
122
123
|
readonly ValueString: "Observation.value-string";
|
|
123
124
|
readonly Note: "Observation.note";
|
|
124
125
|
readonly EffectiveDateTime: "Observation.effective-datetime";
|
|
126
|
+
/** FHIR EventTiming bucket used by recurring morning/evening capture. */
|
|
127
|
+
readonly DateWhen: "Observation.date-when";
|
|
125
128
|
/** @deprecated Read-only camelCase key from historical persisted rows. */
|
|
126
129
|
readonly EffectiveDateTimeLegacy: "Observation.effectiveDateTime";
|
|
127
130
|
/** Custom provenance flag for an explicitly entered or selected result. */
|
|
@@ -36,6 +36,7 @@ export const ObservationClaim = Object.freeze({
|
|
|
36
36
|
Code: 'Observation.code',
|
|
37
37
|
Date: 'Observation.date',
|
|
38
38
|
Device: 'Observation.device',
|
|
39
|
+
DerivedFrom: 'Observation.derived-from',
|
|
39
40
|
Encounter: 'Observation.encounter',
|
|
40
41
|
Focus: 'Observation.focus',
|
|
41
42
|
HasMember: 'Observation.has-member',
|
|
@@ -73,6 +74,8 @@ export const ObservationClaim = Object.freeze({
|
|
|
73
74
|
ValueString: 'Observation.value-string',
|
|
74
75
|
Note: 'Observation.note',
|
|
75
76
|
EffectiveDateTime: 'Observation.effective-datetime',
|
|
77
|
+
/** FHIR EventTiming bucket used by recurring morning/evening capture. */
|
|
78
|
+
DateWhen: 'Observation.date-when',
|
|
76
79
|
/** @deprecated Read-only camelCase key from historical persisted rows. */
|
|
77
80
|
EffectiveDateTimeLegacy: 'Observation.effectiveDateTime',
|
|
78
81
|
/** Custom provenance flag for an explicitly entered or selected result. */
|
|
@@ -71,6 +71,12 @@ export declare const VitalSignsUnits: Readonly<{
|
|
|
71
71
|
display?: string;
|
|
72
72
|
claim: string;
|
|
73
73
|
}>;
|
|
74
|
+
readonly Fahrenheit: Readonly<{
|
|
75
|
+
system: string;
|
|
76
|
+
code: string;
|
|
77
|
+
display?: string;
|
|
78
|
+
claim: string;
|
|
79
|
+
}>;
|
|
74
80
|
readonly Percent: Readonly<{
|
|
75
81
|
system: string;
|
|
76
82
|
code: string;
|
|
@@ -83,6 +89,12 @@ export declare const VitalSignsUnits: Readonly<{
|
|
|
83
89
|
display?: string;
|
|
84
90
|
claim: string;
|
|
85
91
|
}>;
|
|
92
|
+
readonly Pound: Readonly<{
|
|
93
|
+
system: string;
|
|
94
|
+
code: string;
|
|
95
|
+
display?: string;
|
|
96
|
+
claim: string;
|
|
97
|
+
}>;
|
|
86
98
|
}>;
|
|
87
99
|
/** Governed FHIR/LOINC tokens used by every channel that captures vital signs. */
|
|
88
100
|
export declare const VitalSignCode: Readonly<{
|
|
@@ -99,19 +111,24 @@ export declare const VitalSignCode: Readonly<{
|
|
|
99
111
|
export declare const VitalSignUnit: Readonly<{
|
|
100
112
|
readonly bloodPressure: string;
|
|
101
113
|
readonly temperature: string;
|
|
114
|
+
readonly temperatureFahrenheit: string;
|
|
102
115
|
readonly heartRate: string;
|
|
103
116
|
readonly oxygenSaturation: string;
|
|
104
117
|
readonly respiratoryRate: string;
|
|
105
118
|
readonly bodyWeight: string;
|
|
119
|
+
readonly bodyWeightPound: string;
|
|
106
120
|
}>;
|
|
121
|
+
export type VitalSignQuantityUnit = typeof VitalSignUnit[keyof typeof VitalSignUnit];
|
|
107
122
|
export type VitalSignKind = 'blood-pressure' | 'temperature' | 'heart-rate' | 'oxygen-saturation' | 'respiratory-rate' | 'body-weight';
|
|
108
|
-
export type VitalSignObservationStatus = 'preliminary' | 'final';
|
|
123
|
+
export type VitalSignObservationStatus = 'registered' | 'preliminary' | 'final';
|
|
124
|
+
export type VitalSignEventTiming = 'MORN' | 'AFT' | 'EVE' | 'NIGHT';
|
|
109
125
|
type CommonMeasurement = Readonly<{
|
|
110
126
|
entryId: string;
|
|
111
127
|
subjectReference: string;
|
|
112
128
|
kind: VitalSignKind;
|
|
113
129
|
measuredAt: string;
|
|
114
130
|
status: VitalSignObservationStatus;
|
|
131
|
+
eventTiming?: VitalSignEventTiming;
|
|
115
132
|
deviceReference?: string;
|
|
116
133
|
note?: string;
|
|
117
134
|
userSelected?: boolean;
|
|
@@ -123,6 +140,7 @@ export type VitalSignMeasurement = CommonMeasurement & (Readonly<{
|
|
|
123
140
|
}> | Readonly<{
|
|
124
141
|
kind: Exclude<VitalSignKind, 'blood-pressure'>;
|
|
125
142
|
value: number;
|
|
143
|
+
unit?: VitalSignQuantityUnit;
|
|
126
144
|
}>);
|
|
127
145
|
/**
|
|
128
146
|
* Canonical claims-first representation of one native FHIR Observation.
|
|
@@ -218,6 +236,7 @@ export type VitalSignDeviceReading = Readonly<{
|
|
|
218
236
|
kind: Exclude<VitalSignKind, 'blood-pressure'>;
|
|
219
237
|
measuredAt: string;
|
|
220
238
|
value: number;
|
|
239
|
+
unit?: VitalSignQuantityUnit;
|
|
221
240
|
note?: string;
|
|
222
241
|
}>;
|
|
223
242
|
/** Converts a device/API batch to independent preliminary Observations without aggregation. */
|
|
@@ -226,6 +245,61 @@ export declare function normalizeVitalSignDeviceBatch(input: Readonly<{
|
|
|
226
245
|
deviceReference: string;
|
|
227
246
|
readings: readonly VitalSignDeviceReading[];
|
|
228
247
|
}>): readonly VitalSignObservationGraph[];
|
|
248
|
+
export type RepeatedVitalSignReading = Readonly<{
|
|
249
|
+
entryId: string;
|
|
250
|
+
measuredAt: string;
|
|
251
|
+
systolic: number;
|
|
252
|
+
diastolic: number;
|
|
253
|
+
note?: string;
|
|
254
|
+
}> | Readonly<{
|
|
255
|
+
entryId: string;
|
|
256
|
+
measuredAt: string;
|
|
257
|
+
value: number;
|
|
258
|
+
unit?: VitalSignQuantityUnit;
|
|
259
|
+
note?: string;
|
|
260
|
+
}>;
|
|
261
|
+
export type RepeatedVitalSignSession = Readonly<{
|
|
262
|
+
eventTiming: VitalSignEventTiming;
|
|
263
|
+
sourceGraphs: readonly VitalSignObservationGraph[];
|
|
264
|
+
averageGraph: VitalSignObservationGraph;
|
|
265
|
+
entries: readonly FlatClaimResourceEntry[];
|
|
266
|
+
}>;
|
|
267
|
+
/**
|
|
268
|
+
* Converts exactly three readings taken two minutes apart into registered raw
|
|
269
|
+
* evidence plus one preliminary arithmetic mean. Confirmation belongs to the
|
|
270
|
+
* daily document boundary; this helper never invents an attester.
|
|
271
|
+
*/
|
|
272
|
+
export declare function buildRepeatedVitalSignAverage(input: Readonly<{
|
|
273
|
+
entryId: string;
|
|
274
|
+
subjectReference: string;
|
|
275
|
+
deviceReference?: string;
|
|
276
|
+
kind: VitalSignKind;
|
|
277
|
+
eventTiming: VitalSignEventTiming;
|
|
278
|
+
readings: readonly RepeatedVitalSignReading[];
|
|
279
|
+
}>): RepeatedVitalSignSession;
|
|
280
|
+
export type VitalSignDailyDocument = Readonly<{
|
|
281
|
+
localDate: string;
|
|
282
|
+
timeZone: string;
|
|
283
|
+
status: 'preliminary' | 'final';
|
|
284
|
+
composition: FlatClaimResourceEntry;
|
|
285
|
+
observations: readonly VitalSignObservationGraph[];
|
|
286
|
+
entries: readonly FlatClaimResourceEntry[];
|
|
287
|
+
}>;
|
|
288
|
+
/** Builds one daily FHIR document graph; hashing, indexing and research remain service policy. */
|
|
289
|
+
export declare function buildVitalSignDailyDocument(input: Readonly<{
|
|
290
|
+
entryId: string;
|
|
291
|
+
identifier: string;
|
|
292
|
+
subjectReference: string;
|
|
293
|
+
authorReference: string;
|
|
294
|
+
localDate: string;
|
|
295
|
+
timeZone: string;
|
|
296
|
+
observationGraphs: readonly VitalSignObservationGraph[];
|
|
297
|
+
}>): VitalSignDailyDocument;
|
|
298
|
+
/** Confirms a preliminary daily document and finalizes its top-level Observations atomically in memory. */
|
|
299
|
+
export declare function finalizeVitalSignDailyDocument(draft: VitalSignDailyDocument, confirmation: Readonly<{
|
|
300
|
+
attesterReference: string;
|
|
301
|
+
confirmedAt: string;
|
|
302
|
+
}>): VitalSignDailyDocument;
|
|
229
303
|
/** Reusable neutral fixtures; consumers and tests must not duplicate wire examples. */
|
|
230
304
|
export declare const VITAL_SIGN_EXAMPLES: Readonly<{
|
|
231
305
|
subjectReference: "Patient/example-subject";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildFlatClaimResourceEntry } from './flat-claim-resource-graph.js';
|
|
2
|
+
import { CompositionAttesterModes, CompositionClaim } from './claims/composition-claims.js';
|
|
2
3
|
import { FhirCodeSystem, ObservationCategoryCodes, ObservationClaim, decodeObservationClaimList, } from './observation-claims.js';
|
|
3
4
|
export { ObservationCategoryCodes, ObservationClaim } from './observation-claims.js';
|
|
4
5
|
function defineCoding(system, code, display) {
|
|
@@ -20,8 +21,10 @@ export const VitalSignsUnits = Object.freeze({
|
|
|
20
21
|
BeatsPerMinute: defineCoding(FhirCodeSystem.Ucum, '/min', 'beats/minute'),
|
|
21
22
|
MillimeterOfMercury: defineCoding(FhirCodeSystem.Ucum, 'mm[Hg]', 'mmHg'),
|
|
22
23
|
Celsius: defineCoding(FhirCodeSystem.Ucum, 'Cel', 'degrees Celsius'),
|
|
24
|
+
Fahrenheit: defineCoding(FhirCodeSystem.Ucum, '[degF]', 'degrees Fahrenheit'),
|
|
23
25
|
Percent: defineCoding(FhirCodeSystem.Ucum, '%', 'percent'),
|
|
24
26
|
Kilogram: defineCoding(FhirCodeSystem.Ucum, 'kg', 'kilogram'),
|
|
27
|
+
Pound: defineCoding(FhirCodeSystem.Ucum, '[lb_av]', 'pound'),
|
|
25
28
|
});
|
|
26
29
|
/** Governed FHIR/LOINC tokens used by every channel that captures vital signs. */
|
|
27
30
|
export const VitalSignCode = Object.freeze({
|
|
@@ -38,10 +41,12 @@ export const VitalSignCode = Object.freeze({
|
|
|
38
41
|
export const VitalSignUnit = Object.freeze({
|
|
39
42
|
bloodPressure: VitalSignsUnits.MillimeterOfMercury.claim,
|
|
40
43
|
temperature: VitalSignsUnits.Celsius.claim,
|
|
44
|
+
temperatureFahrenheit: VitalSignsUnits.Fahrenheit.claim,
|
|
41
45
|
heartRate: VitalSignsUnits.BeatsPerMinute.claim,
|
|
42
46
|
oxygenSaturation: VitalSignsUnits.Percent.claim,
|
|
43
47
|
respiratoryRate: VitalSignsUnits.BeatsPerMinute.claim,
|
|
44
48
|
bodyWeight: VitalSignsUnits.Kilogram.claim,
|
|
49
|
+
bodyWeightPound: VitalSignsUnits.Pound.claim,
|
|
45
50
|
});
|
|
46
51
|
/**
|
|
47
52
|
* Builds one timestamped Observation. The device is retained as source; this
|
|
@@ -58,6 +63,8 @@ export function buildVitalSignObservation(input) {
|
|
|
58
63
|
};
|
|
59
64
|
if (input.deviceReference)
|
|
60
65
|
claims[ObservationClaim.Device] = required(input.deviceReference, 'vital_sign_device_required');
|
|
66
|
+
if (input.eventTiming)
|
|
67
|
+
claims[ObservationClaim.DateWhen] = normalizeEventTiming(input.eventTiming);
|
|
61
68
|
if (input.note?.trim())
|
|
62
69
|
claims[ObservationClaim.Note] = input.note.trim();
|
|
63
70
|
if (input.userSelected !== undefined)
|
|
@@ -79,7 +86,7 @@ export function buildVitalSignObservation(input) {
|
|
|
79
86
|
else {
|
|
80
87
|
if (!validPositive(input.value))
|
|
81
88
|
throw new TypeError('vital_sign_value_invalid');
|
|
82
|
-
const scalar = scalarDefinition(input.kind);
|
|
89
|
+
const scalar = scalarDefinition(input.kind, input.unit);
|
|
83
90
|
claims[ObservationClaim.Code] = scalar.coding.claim;
|
|
84
91
|
if (scalar.coding.display)
|
|
85
92
|
claims[ObservationClaim.CodeDisplay] = scalar.coding.display;
|
|
@@ -213,6 +220,123 @@ export function normalizeVitalSignDeviceBatch(input) {
|
|
|
213
220
|
});
|
|
214
221
|
}));
|
|
215
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Converts exactly three readings taken two minutes apart into registered raw
|
|
225
|
+
* evidence plus one preliminary arithmetic mean. Confirmation belongs to the
|
|
226
|
+
* daily document boundary; this helper never invents an attester.
|
|
227
|
+
*/
|
|
228
|
+
export function buildRepeatedVitalSignAverage(input) {
|
|
229
|
+
if (input.readings.length !== 3)
|
|
230
|
+
throw new TypeError('vital_sign_repeated_reading_count_invalid');
|
|
231
|
+
const instants = input.readings.map(reading => normalizeDateTime(reading.measuredAt));
|
|
232
|
+
for (let index = 1; index < instants.length; index += 1) {
|
|
233
|
+
if (Date.parse(instants[index]) - Date.parse(instants[index - 1]) !== 120_000) {
|
|
234
|
+
throw new TypeError('vital_sign_repeated_reading_cadence_invalid');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const eventTiming = normalizeEventTiming(input.eventTiming);
|
|
238
|
+
const sourceGraphs = Object.freeze(input.readings.map(reading => buildVitalSignObservation({
|
|
239
|
+
...reading,
|
|
240
|
+
subjectReference: input.subjectReference,
|
|
241
|
+
...(input.deviceReference ? { deviceReference: input.deviceReference } : {}),
|
|
242
|
+
kind: input.kind,
|
|
243
|
+
status: 'registered',
|
|
244
|
+
eventTiming,
|
|
245
|
+
})));
|
|
246
|
+
const common = {
|
|
247
|
+
entryId: input.entryId,
|
|
248
|
+
subjectReference: input.subjectReference,
|
|
249
|
+
...(input.deviceReference ? { deviceReference: input.deviceReference } : {}),
|
|
250
|
+
kind: input.kind,
|
|
251
|
+
measuredAt: instants[1],
|
|
252
|
+
status: 'preliminary',
|
|
253
|
+
eventTiming,
|
|
254
|
+
...(input.kind === 'blood-pressure' ? {} : { unit: repeatedScalarUnit(input.kind, input.readings) }),
|
|
255
|
+
};
|
|
256
|
+
const average = input.kind === 'blood-pressure'
|
|
257
|
+
? buildVitalSignObservation({
|
|
258
|
+
...common,
|
|
259
|
+
kind: 'blood-pressure',
|
|
260
|
+
systolic: mean(input.readings.map(reading => bloodPressureValue(reading, 'systolic'))),
|
|
261
|
+
diastolic: mean(input.readings.map(reading => bloodPressureValue(reading, 'diastolic'))),
|
|
262
|
+
})
|
|
263
|
+
: buildVitalSignObservation({
|
|
264
|
+
...common,
|
|
265
|
+
kind: input.kind,
|
|
266
|
+
value: mean(input.readings.map(scalarReadingValue)),
|
|
267
|
+
});
|
|
268
|
+
const derivedFrom = sourceGraphs.map(graph => graph.primary.reference).join(',');
|
|
269
|
+
const averageGraph = replacePrimaryClaims(average, {
|
|
270
|
+
...average.primary.claims,
|
|
271
|
+
[ObservationClaim.DerivedFrom]: derivedFrom,
|
|
272
|
+
});
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
eventTiming,
|
|
275
|
+
sourceGraphs,
|
|
276
|
+
averageGraph,
|
|
277
|
+
entries: Object.freeze([...sourceGraphs.flatMap(graph => graph.entries), ...averageGraph.entries]),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/** Builds one daily FHIR document graph; hashing, indexing and research remain service policy. */
|
|
281
|
+
export function buildVitalSignDailyDocument(input) {
|
|
282
|
+
const localDate = normalizeLocalDate(input.localDate);
|
|
283
|
+
const timeZone = normalizeTimeZone(input.timeZone);
|
|
284
|
+
if (!input.observationGraphs.length)
|
|
285
|
+
throw new TypeError('vital_sign_daily_observations_required');
|
|
286
|
+
const seen = new Set();
|
|
287
|
+
for (const graph of input.observationGraphs) {
|
|
288
|
+
if (graph.primary.claims[ObservationClaim.Subject] !== input.subjectReference)
|
|
289
|
+
throw new TypeError('vital_sign_daily_subject_mismatch');
|
|
290
|
+
const measuredAt = required(graph.primary.claims[ObservationClaim.EffectiveDateTime] ?? '', 'vital_sign_measured_at_required');
|
|
291
|
+
if (localDateAt(measuredAt, timeZone) !== localDate)
|
|
292
|
+
throw new TypeError('vital_sign_daily_local_date_mismatch');
|
|
293
|
+
for (const entry of graph.entries) {
|
|
294
|
+
if (seen.has(entry.reference))
|
|
295
|
+
throw new TypeError('vital_sign_daily_entry_duplicate');
|
|
296
|
+
seen.add(entry.reference);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const observationReferences = input.observationGraphs.map(graph => graph.primary.reference);
|
|
300
|
+
const composition = buildFlatClaimResourceEntry({
|
|
301
|
+
entryId: input.entryId,
|
|
302
|
+
resourceType: 'Composition',
|
|
303
|
+
claims: {
|
|
304
|
+
[CompositionClaim.Identifier]: required(input.identifier, 'vital_sign_daily_identifier_required'),
|
|
305
|
+
[CompositionClaim.Status]: 'preliminary',
|
|
306
|
+
[CompositionClaim.Subject]: required(input.subjectReference, 'vital_sign_subject_required'),
|
|
307
|
+
[CompositionClaim.Author]: required(input.authorReference, 'vital_sign_daily_author_required'),
|
|
308
|
+
[CompositionClaim.Date]: latestEffectiveDateTime(input.observationGraphs),
|
|
309
|
+
[CompositionClaim.Type]: `${FhirCodeSystem.Loinc}|8716-3`,
|
|
310
|
+
[CompositionClaim.Title]: `Daily vital signs ${localDate}`,
|
|
311
|
+
[CompositionClaim.Entry]: observationReferences.join(','),
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
return freezeDailyDocument(localDate, timeZone, 'preliminary', composition, input.observationGraphs);
|
|
315
|
+
}
|
|
316
|
+
/** Confirms a preliminary daily document and finalizes its top-level Observations atomically in memory. */
|
|
317
|
+
export function finalizeVitalSignDailyDocument(draft, confirmation) {
|
|
318
|
+
if (draft.status !== 'preliminary' || draft.composition.claims[CompositionClaim.Status] !== 'preliminary') {
|
|
319
|
+
throw new TypeError('vital_sign_daily_preliminary_required');
|
|
320
|
+
}
|
|
321
|
+
const confirmedAt = normalizeDateTime(confirmation.confirmedAt);
|
|
322
|
+
const attesterReference = required(confirmation.attesterReference, 'vital_sign_daily_attester_required');
|
|
323
|
+
const observations = Object.freeze(draft.observations.map(graph => replacePrimaryClaims(graph, {
|
|
324
|
+
...graph.primary.claims,
|
|
325
|
+
[ObservationClaim.Status]: 'final',
|
|
326
|
+
})));
|
|
327
|
+
const composition = buildFlatClaimResourceEntry({
|
|
328
|
+
entryId: draft.composition.entryId,
|
|
329
|
+
resourceType: 'Composition',
|
|
330
|
+
claims: {
|
|
331
|
+
...draft.composition.claims,
|
|
332
|
+
[CompositionClaim.Status]: 'final',
|
|
333
|
+
[CompositionClaim.Attester]: attesterReference,
|
|
334
|
+
[CompositionClaim.AttesterMode]: CompositionAttesterModes.Professional,
|
|
335
|
+
[CompositionClaim.AttesterTime]: confirmedAt,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
return freezeDailyDocument(draft.localDate, draft.timeZone, 'final', composition, observations);
|
|
339
|
+
}
|
|
216
340
|
function buildBloodPressureComponent(input, role, value, coding) {
|
|
217
341
|
const entryId = `${required(input.entryId, 'vital_sign_entry_id_required')}-${role}`;
|
|
218
342
|
const claims = {
|
|
@@ -228,6 +352,8 @@ function buildBloodPressureComponent(input, role, value, coding) {
|
|
|
228
352
|
claims[ObservationClaim.CodeDisplay] = coding.display;
|
|
229
353
|
if (input.deviceReference)
|
|
230
354
|
claims[ObservationClaim.Device] = required(input.deviceReference, 'vital_sign_device_required');
|
|
355
|
+
if (input.eventTiming)
|
|
356
|
+
claims[ObservationClaim.DateWhen] = normalizeEventTiming(input.eventTiming);
|
|
231
357
|
return buildFlatClaimResourceEntry({ entryId, resourceType: 'Observation', claims });
|
|
232
358
|
}
|
|
233
359
|
function freezeObservationGraph(primary, components) {
|
|
@@ -248,14 +374,20 @@ function componentQuantityFromEntry(entry) {
|
|
|
248
374
|
const display = entry.claims[ObservationClaim.CodeDisplay];
|
|
249
375
|
return Object.freeze({ code, ...(display ? { display } : {}), value, unit });
|
|
250
376
|
}
|
|
251
|
-
function scalarDefinition(kind) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
377
|
+
function scalarDefinition(kind, requestedUnit) {
|
|
378
|
+
const definition = (() => {
|
|
379
|
+
switch (kind) {
|
|
380
|
+
case 'temperature': return { coding: VitalSignsCodes.BodyTemperature, units: [VitalSignUnit.temperature, VitalSignUnit.temperatureFahrenheit] };
|
|
381
|
+
case 'heart-rate': return { coding: VitalSignsCodes.HeartRate, units: [VitalSignUnit.heartRate] };
|
|
382
|
+
case 'oxygen-saturation': return { coding: VitalSignsCodes.OxygenSaturation, units: [VitalSignUnit.oxygenSaturation] };
|
|
383
|
+
case 'respiratory-rate': return { coding: VitalSignsCodes.RespiratoryRate, units: [VitalSignUnit.respiratoryRate] };
|
|
384
|
+
case 'body-weight': return { coding: VitalSignsCodes.BodyWeight, units: [VitalSignUnit.bodyWeight, VitalSignUnit.bodyWeightPound] };
|
|
385
|
+
}
|
|
386
|
+
})();
|
|
387
|
+
const unit = requestedUnit ?? definition.units[0];
|
|
388
|
+
if (!definition.units.includes(unit))
|
|
389
|
+
throw new TypeError('vital_sign_unit_invalid');
|
|
390
|
+
return { coding: definition.coding, unit };
|
|
259
391
|
}
|
|
260
392
|
function normalizeDateTime(value) {
|
|
261
393
|
const date = new Date(value);
|
|
@@ -263,6 +395,90 @@ function normalizeDateTime(value) {
|
|
|
263
395
|
throw new TypeError('vital_sign_measured_at_invalid');
|
|
264
396
|
return date.toISOString();
|
|
265
397
|
}
|
|
398
|
+
function normalizeEventTiming(value) {
|
|
399
|
+
if (value === 'MORN' || value === 'AFT' || value === 'EVE' || value === 'NIGHT')
|
|
400
|
+
return value;
|
|
401
|
+
throw new TypeError('vital_sign_event_timing_invalid');
|
|
402
|
+
}
|
|
403
|
+
function mean(values) {
|
|
404
|
+
if (values.length === 0 || values.some(value => !validPositive(value)))
|
|
405
|
+
throw new TypeError('vital_sign_value_invalid');
|
|
406
|
+
return Math.round((values.reduce((total, value) => total + value, 0) / values.length) * 10) / 10;
|
|
407
|
+
}
|
|
408
|
+
function bloodPressureValue(reading, key) {
|
|
409
|
+
if (!('systolic' in reading) || !('diastolic' in reading))
|
|
410
|
+
throw new TypeError('blood_pressure_values_invalid');
|
|
411
|
+
const value = key === 'systolic' ? reading.systolic : reading.diastolic;
|
|
412
|
+
if (typeof value !== 'number')
|
|
413
|
+
throw new TypeError('blood_pressure_values_invalid');
|
|
414
|
+
return value;
|
|
415
|
+
}
|
|
416
|
+
function scalarReadingValue(reading) {
|
|
417
|
+
if (!('value' in reading) || typeof reading.value !== 'number')
|
|
418
|
+
throw new TypeError('vital_sign_value_invalid');
|
|
419
|
+
return reading.value;
|
|
420
|
+
}
|
|
421
|
+
function repeatedScalarUnit(kind, readings) {
|
|
422
|
+
const units = readings.map(reading => 'unit' in reading ? reading.unit : undefined);
|
|
423
|
+
const selected = units.find((unit) => Boolean(unit));
|
|
424
|
+
if (units.some(unit => unit !== undefined && unit !== selected))
|
|
425
|
+
throw new TypeError('vital_sign_repeated_unit_mismatch');
|
|
426
|
+
return scalarDefinition(kind, selected).unit;
|
|
427
|
+
}
|
|
428
|
+
function replacePrimaryClaims(graph, claims) {
|
|
429
|
+
const primary = buildFlatClaimResourceEntry({
|
|
430
|
+
entryId: graph.primary.entryId,
|
|
431
|
+
resourceType: 'Observation',
|
|
432
|
+
claims,
|
|
433
|
+
});
|
|
434
|
+
return freezeObservationGraph(primary, graph.components);
|
|
435
|
+
}
|
|
436
|
+
function freezeDailyDocument(localDate, timeZone, status, composition, observations) {
|
|
437
|
+
const frozenObservations = Object.freeze([...observations]);
|
|
438
|
+
return Object.freeze({
|
|
439
|
+
localDate,
|
|
440
|
+
timeZone,
|
|
441
|
+
status,
|
|
442
|
+
composition,
|
|
443
|
+
observations: frozenObservations,
|
|
444
|
+
entries: Object.freeze([composition, ...frozenObservations.flatMap(graph => graph.entries)]),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
function normalizeLocalDate(value) {
|
|
448
|
+
const normalized = value.trim();
|
|
449
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized))
|
|
450
|
+
throw new TypeError('vital_sign_daily_local_date_invalid');
|
|
451
|
+
const parsed = new Date(`${normalized}T00:00:00.000Z`);
|
|
452
|
+
if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== normalized) {
|
|
453
|
+
throw new TypeError('vital_sign_daily_local_date_invalid');
|
|
454
|
+
}
|
|
455
|
+
return normalized;
|
|
456
|
+
}
|
|
457
|
+
function normalizeTimeZone(value) {
|
|
458
|
+
const normalized = value.trim();
|
|
459
|
+
if (!normalized)
|
|
460
|
+
throw new TypeError('vital_sign_daily_time_zone_invalid');
|
|
461
|
+
try {
|
|
462
|
+
new Intl.DateTimeFormat('en', { timeZone: normalized }).format(0);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
throw new TypeError('vital_sign_daily_time_zone_invalid');
|
|
466
|
+
}
|
|
467
|
+
return normalized;
|
|
468
|
+
}
|
|
469
|
+
function localDateAt(dateTime, timeZone) {
|
|
470
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
471
|
+
timeZone,
|
|
472
|
+
year: 'numeric',
|
|
473
|
+
month: '2-digit',
|
|
474
|
+
day: '2-digit',
|
|
475
|
+
}).formatToParts(new Date(dateTime));
|
|
476
|
+
const value = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
|
477
|
+
return `${value.year}-${value.month}-${value.day}`;
|
|
478
|
+
}
|
|
479
|
+
function latestEffectiveDateTime(graphs) {
|
|
480
|
+
return graphs.map(graph => required(graph.primary.claims[ObservationClaim.EffectiveDateTime] ?? '', 'vital_sign_measured_at_required')).sort().at(-1);
|
|
481
|
+
}
|
|
266
482
|
function validPositive(value) {
|
|
267
483
|
return Number.isFinite(value) && value > 0;
|
|
268
484
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Care, emergency and population-alert FHIR contracts
|
|
2
|
+
|
|
3
|
+
This package owns only the product-neutral FHIR representation used by the
|
|
4
|
+
three SOSChain journeys that are first proven in VetChain and PetChain.
|
|
5
|
+
Authorization, controller discovery, encryption, transport, storage and UI
|
|
6
|
+
policy remain in their owning product or service layers.
|
|
7
|
+
|
|
8
|
+
## Hospital or clinic care journey
|
|
9
|
+
|
|
10
|
+
An `Appointment` plans care. Marking it fulfilled can originate one parent
|
|
11
|
+
`Encounter`; it must not erase the `Encounter.appointment` reference. Admission,
|
|
12
|
+
imaging, intensive care and discharge are independent child `Encounter`
|
|
13
|
+
resources linked through `Encounter.partOf`, each with its actual `Location`.
|
|
14
|
+
This graph supports a bounded location timeline. It does not itself grant a
|
|
15
|
+
relative permission to see locations or clinical records.
|
|
16
|
+
|
|
17
|
+
A voice/video call or encrypted missed-call message may occur before a report
|
|
18
|
+
is published. Transport events are not clinical attestation and must not
|
|
19
|
+
silently change an Encounter, a daily draft or the individual's index.
|
|
20
|
+
|
|
21
|
+
## Resilient emergency request
|
|
22
|
+
|
|
23
|
+
An emergency request is a standard claims-first `ServiceRequest`. Its service
|
|
24
|
+
and category are compact `system|code` tokens; writers never emit split
|
|
25
|
+
`code-system`, `code-value` or stored `code-display` claims. A local phrase may
|
|
26
|
+
remain in `ServiceRequest.code-text`; a trusted display is resolved from the
|
|
27
|
+
code at the terminology boundary.
|
|
28
|
+
|
|
29
|
+
Acknowledgement and triage use a separate durable `Task` whose `focus`
|
|
30
|
+
references the request. Requester, subject, intended performer and task owner
|
|
31
|
+
are explicit inputs. None of those references is inferred as authorization.
|
|
32
|
+
|
|
33
|
+
## Donation and public-risk campaign
|
|
34
|
+
|
|
35
|
+
The campaign-level `CommunicationRequest.subject` is a frozen, enumerated
|
|
36
|
+
`Group` snapshot. Its `reasonReference` points to the definitional cohort used
|
|
37
|
+
to create that snapshot. Per-recipient eligibility, current Consent,
|
|
38
|
+
subscription/channel status, exclusions and delivery audit must be rechecked
|
|
39
|
+
outside this neutral package immediately before creating each individual
|
|
40
|
+
`Communication`.
|
|
41
|
+
|
|
42
|
+
The snapshot and request are evidence of evaluation and intent, not permission
|
|
43
|
+
to disclose group membership or send a message.
|
|
44
|
+
|
|
45
|
+
## Public API
|
|
46
|
+
|
|
47
|
+
Use `fhir-data-utils-ts/care-emergency-alert` for graph builders and the package
|
|
48
|
+
root or `fhir-data-utils-ts/converters` for R4 projections. Persistence remains
|
|
49
|
+
flat claims. Native FHIR is materialized only at an explicit import/export
|
|
50
|
+
boundary.
|