vet-sdk-core-ts 0.4.10 → 0.4.11
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/README.md +36 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/research-study.d.ts +172 -0
- package/dist/research-study.js +301 -0
- package/docs/101-RESEARCH-STUDY-INVITATIONS.md +69 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -6,6 +6,42 @@ UHC SDK packages and must not import them.
|
|
|
6
6
|
The SDK consumes governed browser-safe values from `vet-data-utils-ts` and
|
|
7
7
|
owns gateway request construction. GW VET remains the policy authority.
|
|
8
8
|
|
|
9
|
+
## Research studies
|
|
10
|
+
|
|
11
|
+
`buildVeterinaryResearchStudyCreateEntry` preserves a native FHIR R5
|
|
12
|
+
ResearchStudy and derives its standard flat search claims. Associated parties
|
|
13
|
+
are retained as complete objects. Later changes use one independent PATCH
|
|
14
|
+
entry per party, so the party reference, role, periods and classifiers cannot
|
|
15
|
+
be mixed across employees. `buildVeterinaryResearchStudyBatch` wraps these
|
|
16
|
+
entries in JSON:API `data[]` for GW VET.
|
|
17
|
+
|
|
18
|
+
Study participation does not itself authorize access. The separate
|
|
19
|
+
`buildVeterinaryResearchStudySmartAuthorization` request is limited to create,
|
|
20
|
+
read, update and search for ResearchSubjects filtered by the exact study. It
|
|
21
|
+
never requests deletion of the twin; deletion of selected clinical facts must
|
|
22
|
+
target those concrete resources under an active controller-approved Consent.
|
|
23
|
+
The builder fails closed unless the professional has completed DCR and the
|
|
24
|
+
later controller-approved Consent is `active`.
|
|
25
|
+
|
|
26
|
+
`buildVeterinaryResearchStudyPartyInvitations` creates exactly one R5
|
|
27
|
+
`Communication` per associated `PractitionerRole`. Each Communication carries
|
|
28
|
+
an `application/fhir+json` batch Bundle with exactly one draft Consent, scoped
|
|
29
|
+
to the ResearchStudy through `Consent.provision.data.reference` and to
|
|
30
|
+
`ResearchSubject` through `Consent.provision.resourceType`. The draft requests
|
|
31
|
+
only create, read, update and search. It contains no delete permission and
|
|
32
|
+
never authorizes SMART access.
|
|
33
|
+
|
|
34
|
+
The complete controller-to-professional contract and a browser-safe snippet
|
|
35
|
+
are in
|
|
36
|
+
[`docs/101-RESEARCH-STUDY-INVITATIONS.md`](docs/101-RESEARCH-STUDY-INVITATIONS.md).
|
|
37
|
+
|
|
38
|
+
FHIR references:
|
|
39
|
+
|
|
40
|
+
- https://hl7.org/fhir/R5/researchstudy.html
|
|
41
|
+
- https://hl7.org/fhir/R5/researchsubject-search.html
|
|
42
|
+
- https://hl7.org/fhir/R5/communication.html
|
|
43
|
+
- https://hl7.org/fhir/R5/consent-definitions.html
|
|
44
|
+
|
|
9
45
|
## Reusable professional BFF
|
|
10
46
|
|
|
11
47
|
`ReusableProfessionalBffClient` exposes the complete business-level portal
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { type ResearchStudyAssociatedParty } from 'vet-data-utils-ts/research-study';
|
|
2
|
+
export declare const VeterinaryResearchStudyPermissionActions: readonly ["create", "read", "update", "search"];
|
|
3
|
+
export type VeterinaryResearchStudyPermissionAction = typeof VeterinaryResearchStudyPermissionActions[number];
|
|
4
|
+
export declare const VeterinaryResearchStudyInvitationClaimNames: Readonly<{
|
|
5
|
+
readonly Context: "@context";
|
|
6
|
+
readonly Status: "Consent.status";
|
|
7
|
+
readonly Decision: "Consent.decision";
|
|
8
|
+
readonly ActorIdentifier: "Consent.actor-identifier";
|
|
9
|
+
readonly Purpose: "Consent.purpose";
|
|
10
|
+
readonly Action: "Consent.action";
|
|
11
|
+
readonly ResourceType: "Consent.resource-type";
|
|
12
|
+
}>;
|
|
13
|
+
type FhirReference = Readonly<{
|
|
14
|
+
reference: string;
|
|
15
|
+
type?: string;
|
|
16
|
+
}>;
|
|
17
|
+
type FhirCoding = Readonly<{
|
|
18
|
+
system: string;
|
|
19
|
+
code: string;
|
|
20
|
+
}>;
|
|
21
|
+
type FhirCodeableConcept = Readonly<{
|
|
22
|
+
coding: readonly FhirCoding[];
|
|
23
|
+
}>;
|
|
24
|
+
export type VeterinaryResearchStudyDraftConsent = Readonly<{
|
|
25
|
+
resourceType: 'Consent';
|
|
26
|
+
id: string;
|
|
27
|
+
status: 'draft';
|
|
28
|
+
grantor: readonly FhirReference[];
|
|
29
|
+
controller: readonly FhirReference[];
|
|
30
|
+
grantee: readonly FhirReference[];
|
|
31
|
+
decision: 'permit';
|
|
32
|
+
provision: readonly Readonly<{
|
|
33
|
+
actor: readonly Readonly<{
|
|
34
|
+
reference: FhirReference;
|
|
35
|
+
}>[];
|
|
36
|
+
action: readonly FhirCodeableConcept[];
|
|
37
|
+
purpose: readonly FhirCoding[];
|
|
38
|
+
resourceType: readonly FhirCoding[];
|
|
39
|
+
data: readonly Readonly<{
|
|
40
|
+
meaning: 'related';
|
|
41
|
+
reference: FhirReference;
|
|
42
|
+
}>[];
|
|
43
|
+
}>[];
|
|
44
|
+
meta: Readonly<{
|
|
45
|
+
claims: Readonly<Record<string, string>>;
|
|
46
|
+
}>;
|
|
47
|
+
}>;
|
|
48
|
+
export type VeterinaryResearchStudyInvitationBundle = Readonly<{
|
|
49
|
+
resourceType: 'Bundle';
|
|
50
|
+
type: 'batch';
|
|
51
|
+
entry: readonly Readonly<{
|
|
52
|
+
fullUrl: string;
|
|
53
|
+
resource: VeterinaryResearchStudyDraftConsent;
|
|
54
|
+
request: Readonly<{
|
|
55
|
+
method: 'POST';
|
|
56
|
+
url: 'Consent';
|
|
57
|
+
}>;
|
|
58
|
+
}>[];
|
|
59
|
+
}>;
|
|
60
|
+
export type VeterinaryResearchStudyPartyInvitation = Readonly<{
|
|
61
|
+
resourceType: 'Communication';
|
|
62
|
+
id: string;
|
|
63
|
+
status: 'completed';
|
|
64
|
+
category: readonly FhirCodeableConcept[];
|
|
65
|
+
topic: FhirCodeableConcept;
|
|
66
|
+
about: readonly FhirReference[];
|
|
67
|
+
recipient: readonly FhirReference[];
|
|
68
|
+
sender: FhirReference;
|
|
69
|
+
sent?: string;
|
|
70
|
+
payload: readonly Readonly<{
|
|
71
|
+
contentAttachment: Readonly<{
|
|
72
|
+
contentType: 'application/fhir+json';
|
|
73
|
+
title: 'research-study-draft-consent.json';
|
|
74
|
+
data: string;
|
|
75
|
+
}>;
|
|
76
|
+
}>[];
|
|
77
|
+
}>;
|
|
78
|
+
export type VeterinaryResearchStudyResource = Readonly<{
|
|
79
|
+
resourceType: 'ResearchStudy';
|
|
80
|
+
id: string;
|
|
81
|
+
status?: 'draft' | 'active' | 'retired' | 'unknown';
|
|
82
|
+
associatedParty?: readonly ResearchStudyAssociatedParty[];
|
|
83
|
+
meta?: Readonly<Record<string, unknown>>;
|
|
84
|
+
[key: string]: unknown;
|
|
85
|
+
}>;
|
|
86
|
+
export type VeterinaryResearchStudyCreateResource = VeterinaryResearchStudyResource & Readonly<{
|
|
87
|
+
status: NonNullable<VeterinaryResearchStudyResource['status']>;
|
|
88
|
+
}>;
|
|
89
|
+
export type VeterinaryResearchStudyBatchEntry = Readonly<{
|
|
90
|
+
type: 'ResearchStudy-v5.0.0';
|
|
91
|
+
resource: VeterinaryResearchStudyResource;
|
|
92
|
+
request: Readonly<{
|
|
93
|
+
method: 'POST' | 'PATCH';
|
|
94
|
+
url: string;
|
|
95
|
+
}>;
|
|
96
|
+
}>;
|
|
97
|
+
export type VeterinaryResearchStudyBatch = Readonly<{
|
|
98
|
+
data: readonly VeterinaryResearchStudyBatchEntry[];
|
|
99
|
+
}>;
|
|
100
|
+
export type VeterinaryResearchStudySmartAuthorization = Readonly<{
|
|
101
|
+
purpose: 'RESEARCH';
|
|
102
|
+
researchStudyReference: string;
|
|
103
|
+
scopes: readonly [string];
|
|
104
|
+
}>;
|
|
105
|
+
/**
|
|
106
|
+
* Requests the current study-scoped ResearchSubject operations. Deleting
|
|
107
|
+
* selected clinical facts is a later operation on those concrete resources;
|
|
108
|
+
* it must never be represented as deleting the ResearchSubject/twin itself.
|
|
109
|
+
*/
|
|
110
|
+
export declare function buildVeterinaryResearchStudySmartAuthorization(input: Readonly<{
|
|
111
|
+
researchStudyId: string;
|
|
112
|
+
dcrCompleted: boolean;
|
|
113
|
+
consentStatus: 'draft' | 'active' | 'inactive' | 'not-done' | 'entered-in-error' | 'unknown';
|
|
114
|
+
}>): VeterinaryResearchStudySmartAuthorization;
|
|
115
|
+
export declare function buildVeterinaryResearchStudyBatch(entries: readonly VeterinaryResearchStudyBatchEntry[]): VeterinaryResearchStudyBatch;
|
|
116
|
+
export declare function buildVeterinaryResearchStudyCreateEntry(input: Readonly<{
|
|
117
|
+
study: VeterinaryResearchStudyCreateResource;
|
|
118
|
+
}>): VeterinaryResearchStudyBatchEntry;
|
|
119
|
+
/**
|
|
120
|
+
* Builds one independent `_batch` PATCH entry per R5 associatedParty. Keeping
|
|
121
|
+
* each complete party in its own entry prevents roles, periods and classifiers
|
|
122
|
+
* from being correlated with the wrong PractitionerRole. These entries update
|
|
123
|
+
* study membership only; they do not grant CRUDS access.
|
|
124
|
+
*/
|
|
125
|
+
export declare function buildVeterinaryResearchStudyPartyPatchEntries(input: Readonly<{
|
|
126
|
+
researchStudyId: string;
|
|
127
|
+
associatedParties: unknown;
|
|
128
|
+
}>): readonly VeterinaryResearchStudyBatchEntry[];
|
|
129
|
+
/**
|
|
130
|
+
* Builds the auditable R5 invitation sent to one ResearchStudy associated
|
|
131
|
+
* party. The Communication payload is an `application/fhir+json` attachment
|
|
132
|
+
* containing a batch Bundle with exactly one `Consent.status = draft`.
|
|
133
|
+
* Draft is descriptive only and can never satisfy authorization. The invitee
|
|
134
|
+
* must finish DCR and a controller must later persist a separate active
|
|
135
|
+
* Consent before the study-scoped SMART request can succeed.
|
|
136
|
+
*
|
|
137
|
+
* Native R5 fields retain the exact study boundary in
|
|
138
|
+
* `Consent.provision.data.reference` and the affected `ResearchSubject`
|
|
139
|
+
* resource family. Project authorization actions remain create/read/update/
|
|
140
|
+
* search; their FHIR representation uses the matching REST interaction codes,
|
|
141
|
+
* where search is `search-type`. Delete is deliberately absent.
|
|
142
|
+
*
|
|
143
|
+
* @see https://hl7.org/fhir/R5/communication.html
|
|
144
|
+
* @see https://hl7.org/fhir/R5/consent.html
|
|
145
|
+
* @see https://hl7.org/fhir/R5/codesystem-restful-interaction.html
|
|
146
|
+
*/
|
|
147
|
+
export declare function buildVeterinaryResearchStudyPartyInvitation(input: Readonly<{
|
|
148
|
+
researchStudyId: string;
|
|
149
|
+
associatedParty: ResearchStudyAssociatedParty;
|
|
150
|
+
controllerReference: string;
|
|
151
|
+
communicationId: string;
|
|
152
|
+
consentId: string;
|
|
153
|
+
sentAt?: string;
|
|
154
|
+
}>): VeterinaryResearchStudyPartyInvitation;
|
|
155
|
+
/**
|
|
156
|
+
* Builds one isolated Communication/draft-Consent pair for every associated
|
|
157
|
+
* party. Callers provide the auditable identifiers; no identifier, recipient,
|
|
158
|
+
* or Consent is shared across invitations.
|
|
159
|
+
*/
|
|
160
|
+
export declare function buildVeterinaryResearchStudyPartyInvitations(input: Readonly<{
|
|
161
|
+
researchStudyId: string;
|
|
162
|
+
controllerReference: string;
|
|
163
|
+
sentAt?: string;
|
|
164
|
+
invitations: readonly Readonly<{
|
|
165
|
+
associatedParty: ResearchStudyAssociatedParty;
|
|
166
|
+
communicationId: string;
|
|
167
|
+
consentId: string;
|
|
168
|
+
}>[];
|
|
169
|
+
}>): readonly VeterinaryResearchStudyPartyInvitation[];
|
|
170
|
+
/** Decodes the single attached Consent Bundle after validating its boundary. */
|
|
171
|
+
export declare function decodeVeterinaryResearchStudyInvitationBundle(communication: VeterinaryResearchStudyPartyInvitation): VeterinaryResearchStudyInvitationBundle;
|
|
172
|
+
export {};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { normalizeResearchStudyAssociatedParties, projectResearchStudyAssociatedPartyClaims, projectResearchStudyR5SearchClaims, } from 'vet-data-utils-ts/research-study';
|
|
2
|
+
import { VeterinaryCommunicationPresetFilters, VeterinaryResearchCommunicationTopics, } from 'vet-data-utils-ts/communication';
|
|
3
|
+
const FHIR_RESTFUL_INTERACTION_SYSTEM = 'http://hl7.org/fhir/restful-interaction';
|
|
4
|
+
const FHIR_RESOURCE_TYPES_SYSTEM = 'http://hl7.org/fhir/fhir-types';
|
|
5
|
+
export const VeterinaryResearchStudyPermissionActions = Object.freeze([
|
|
6
|
+
'create',
|
|
7
|
+
'read',
|
|
8
|
+
'update',
|
|
9
|
+
'search',
|
|
10
|
+
]);
|
|
11
|
+
export const VeterinaryResearchStudyInvitationClaimNames = Object.freeze({
|
|
12
|
+
Context: '@context',
|
|
13
|
+
Status: 'Consent.status',
|
|
14
|
+
Decision: 'Consent.decision',
|
|
15
|
+
ActorIdentifier: 'Consent.actor-identifier',
|
|
16
|
+
Purpose: 'Consent.purpose',
|
|
17
|
+
Action: 'Consent.action',
|
|
18
|
+
ResourceType: 'Consent.resource-type',
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Requests the current study-scoped ResearchSubject operations. Deleting
|
|
22
|
+
* selected clinical facts is a later operation on those concrete resources;
|
|
23
|
+
* it must never be represented as deleting the ResearchSubject/twin itself.
|
|
24
|
+
*/
|
|
25
|
+
export function buildVeterinaryResearchStudySmartAuthorization(input) {
|
|
26
|
+
if (input.consentStatus !== 'active') {
|
|
27
|
+
throw new TypeError('veterinary_research_study_active_consent_required');
|
|
28
|
+
}
|
|
29
|
+
if (input.dcrCompleted !== true) {
|
|
30
|
+
throw new TypeError('veterinary_research_study_dcr_required');
|
|
31
|
+
}
|
|
32
|
+
const researchStudyReference = `ResearchStudy/${boundedId(input.researchStudyId)}`;
|
|
33
|
+
return deepFreeze({
|
|
34
|
+
purpose: 'RESEARCH',
|
|
35
|
+
researchStudyReference,
|
|
36
|
+
scopes: [`organization/ResearchSubject.crus?study=${encodeURIComponent(researchStudyReference)}`],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function buildVeterinaryResearchStudyBatch(entries) {
|
|
40
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
41
|
+
throw new TypeError('veterinary_research_study_batch_empty');
|
|
42
|
+
return deepFreeze({ data: entries.map(entry => clonePlain(entry)) });
|
|
43
|
+
}
|
|
44
|
+
export function buildVeterinaryResearchStudyCreateEntry(input) {
|
|
45
|
+
const study = normalizedStudy(input.study);
|
|
46
|
+
return deepFreeze({
|
|
47
|
+
type: 'ResearchStudy-v5.0.0',
|
|
48
|
+
resource: withProjectedClaims(study),
|
|
49
|
+
request: { method: 'POST', url: 'ResearchStudy' },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Builds one independent `_batch` PATCH entry per R5 associatedParty. Keeping
|
|
54
|
+
* each complete party in its own entry prevents roles, periods and classifiers
|
|
55
|
+
* from being correlated with the wrong PractitionerRole. These entries update
|
|
56
|
+
* study membership only; they do not grant CRUDS access.
|
|
57
|
+
*/
|
|
58
|
+
export function buildVeterinaryResearchStudyPartyPatchEntries(input) {
|
|
59
|
+
const id = boundedId(input.researchStudyId);
|
|
60
|
+
const parties = normalizeResearchStudyAssociatedParties(input.associatedParties);
|
|
61
|
+
return deepFreeze(parties.map(party => ({
|
|
62
|
+
type: 'ResearchStudy-v5.0.0',
|
|
63
|
+
resource: withProjectedClaims({
|
|
64
|
+
resourceType: 'ResearchStudy',
|
|
65
|
+
id,
|
|
66
|
+
associatedParty: [party],
|
|
67
|
+
}),
|
|
68
|
+
request: { method: 'PATCH', url: `ResearchStudy/${id}` },
|
|
69
|
+
})));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds the auditable R5 invitation sent to one ResearchStudy associated
|
|
73
|
+
* party. The Communication payload is an `application/fhir+json` attachment
|
|
74
|
+
* containing a batch Bundle with exactly one `Consent.status = draft`.
|
|
75
|
+
* Draft is descriptive only and can never satisfy authorization. The invitee
|
|
76
|
+
* must finish DCR and a controller must later persist a separate active
|
|
77
|
+
* Consent before the study-scoped SMART request can succeed.
|
|
78
|
+
*
|
|
79
|
+
* Native R5 fields retain the exact study boundary in
|
|
80
|
+
* `Consent.provision.data.reference` and the affected `ResearchSubject`
|
|
81
|
+
* resource family. Project authorization actions remain create/read/update/
|
|
82
|
+
* search; their FHIR representation uses the matching REST interaction codes,
|
|
83
|
+
* where search is `search-type`. Delete is deliberately absent.
|
|
84
|
+
*
|
|
85
|
+
* @see https://hl7.org/fhir/R5/communication.html
|
|
86
|
+
* @see https://hl7.org/fhir/R5/consent.html
|
|
87
|
+
* @see https://hl7.org/fhir/R5/codesystem-restful-interaction.html
|
|
88
|
+
*/
|
|
89
|
+
export function buildVeterinaryResearchStudyPartyInvitation(input) {
|
|
90
|
+
const researchStudyReference = `ResearchStudy/${boundedId(input.researchStudyId)}`;
|
|
91
|
+
let party;
|
|
92
|
+
try {
|
|
93
|
+
party = normalizeResearchStudyAssociatedParties([input.associatedParty])[0];
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new TypeError('veterinary_research_study_invitation_party_invalid');
|
|
97
|
+
}
|
|
98
|
+
const partyReference = String(party?.party?.reference || '').trim();
|
|
99
|
+
if (!partyReference.startsWith('PractitionerRole/')) {
|
|
100
|
+
throw new TypeError('veterinary_research_study_invitation_party_invalid');
|
|
101
|
+
}
|
|
102
|
+
const controllerReference = boundedReference(input.controllerReference, 'Organization');
|
|
103
|
+
const communicationId = boundedId(input.communicationId);
|
|
104
|
+
const consentId = boundedId(input.consentId);
|
|
105
|
+
const topic = VeterinaryResearchCommunicationTopics[0];
|
|
106
|
+
if (!topic)
|
|
107
|
+
throw new TypeError('veterinary_research_study_invitation_topic_unavailable');
|
|
108
|
+
const consent = {
|
|
109
|
+
resourceType: 'Consent',
|
|
110
|
+
id: consentId,
|
|
111
|
+
status: 'draft',
|
|
112
|
+
grantor: [{ reference: controllerReference, type: 'Organization' }],
|
|
113
|
+
controller: [{ reference: controllerReference, type: 'Organization' }],
|
|
114
|
+
grantee: [{ reference: partyReference, type: 'PractitionerRole' }],
|
|
115
|
+
decision: 'permit',
|
|
116
|
+
provision: [{
|
|
117
|
+
actor: [{ reference: { reference: partyReference, type: 'PractitionerRole' } }],
|
|
118
|
+
action: VeterinaryResearchStudyPermissionActions.map(action => ({
|
|
119
|
+
coding: [{
|
|
120
|
+
system: FHIR_RESTFUL_INTERACTION_SYSTEM,
|
|
121
|
+
code: action === 'search' ? 'search-type' : action,
|
|
122
|
+
}],
|
|
123
|
+
})),
|
|
124
|
+
purpose: [{ system: topic.system, code: topic.code }],
|
|
125
|
+
resourceType: [{ system: FHIR_RESOURCE_TYPES_SYSTEM, code: 'ResearchSubject' }],
|
|
126
|
+
data: [{ meaning: 'related', reference: { reference: researchStudyReference, type: 'ResearchStudy' } }],
|
|
127
|
+
}],
|
|
128
|
+
meta: {
|
|
129
|
+
claims: {
|
|
130
|
+
[VeterinaryResearchStudyInvitationClaimNames.Context]: 'org.hl7.fhir.api',
|
|
131
|
+
[VeterinaryResearchStudyInvitationClaimNames.Status]: 'draft',
|
|
132
|
+
[VeterinaryResearchStudyInvitationClaimNames.Decision]: 'permit',
|
|
133
|
+
[VeterinaryResearchStudyInvitationClaimNames.ActorIdentifier]: partyReference,
|
|
134
|
+
[VeterinaryResearchStudyInvitationClaimNames.Purpose]: topic.value,
|
|
135
|
+
[VeterinaryResearchStudyInvitationClaimNames.Action]: VeterinaryResearchStudyPermissionActions.join(','),
|
|
136
|
+
[VeterinaryResearchStudyInvitationClaimNames.ResourceType]: 'ResearchSubject',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
const bundle = {
|
|
141
|
+
resourceType: 'Bundle',
|
|
142
|
+
type: 'batch',
|
|
143
|
+
entry: [{
|
|
144
|
+
fullUrl: `urn:uuid:${consentId}`,
|
|
145
|
+
resource: consent,
|
|
146
|
+
request: { method: 'POST', url: 'Consent' },
|
|
147
|
+
}],
|
|
148
|
+
};
|
|
149
|
+
const category = VeterinaryCommunicationPresetFilters.ResearchAgreements.categories[0];
|
|
150
|
+
const [categorySystem, categoryCode] = splitCodingToken(category);
|
|
151
|
+
const sent = optionalInstant(input.sentAt);
|
|
152
|
+
return deepFreeze({
|
|
153
|
+
resourceType: 'Communication',
|
|
154
|
+
id: communicationId,
|
|
155
|
+
status: 'completed',
|
|
156
|
+
category: [{ coding: [{ system: categorySystem, code: categoryCode }] }],
|
|
157
|
+
topic: { coding: [{ system: topic.system, code: topic.code }] },
|
|
158
|
+
about: [{ reference: researchStudyReference, type: 'ResearchStudy' }],
|
|
159
|
+
recipient: [{ reference: partyReference, type: 'PractitionerRole' }],
|
|
160
|
+
sender: { reference: controllerReference, type: 'Organization' },
|
|
161
|
+
...(sent ? { sent } : {}),
|
|
162
|
+
payload: [{
|
|
163
|
+
contentAttachment: {
|
|
164
|
+
contentType: 'application/fhir+json',
|
|
165
|
+
title: 'research-study-draft-consent.json',
|
|
166
|
+
data: encodeJsonBase64(bundle),
|
|
167
|
+
},
|
|
168
|
+
}],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Builds one isolated Communication/draft-Consent pair for every associated
|
|
173
|
+
* party. Callers provide the auditable identifiers; no identifier, recipient,
|
|
174
|
+
* or Consent is shared across invitations.
|
|
175
|
+
*/
|
|
176
|
+
export function buildVeterinaryResearchStudyPartyInvitations(input) {
|
|
177
|
+
if (!Array.isArray(input.invitations) || input.invitations.length === 0) {
|
|
178
|
+
throw new TypeError('veterinary_research_study_invitations_empty');
|
|
179
|
+
}
|
|
180
|
+
return deepFreeze(input.invitations.map(invitation => buildVeterinaryResearchStudyPartyInvitation({
|
|
181
|
+
researchStudyId: input.researchStudyId,
|
|
182
|
+
controllerReference: input.controllerReference,
|
|
183
|
+
sentAt: input.sentAt,
|
|
184
|
+
...invitation,
|
|
185
|
+
})));
|
|
186
|
+
}
|
|
187
|
+
/** Decodes the single attached Consent Bundle after validating its boundary. */
|
|
188
|
+
export function decodeVeterinaryResearchStudyInvitationBundle(communication) {
|
|
189
|
+
const attachment = communication?.payload?.[0]?.contentAttachment;
|
|
190
|
+
if (communication?.resourceType !== 'Communication'
|
|
191
|
+
|| attachment?.contentType !== 'application/fhir+json'
|
|
192
|
+
|| !attachment.data) {
|
|
193
|
+
throw new TypeError('veterinary_research_study_invitation_bundle_invalid');
|
|
194
|
+
}
|
|
195
|
+
const decoded = JSON.parse(decodeJsonBase64(attachment.data));
|
|
196
|
+
if (decoded?.resourceType !== 'Bundle'
|
|
197
|
+
|| decoded.type !== 'batch'
|
|
198
|
+
|| decoded.entry?.length !== 1
|
|
199
|
+
|| decoded.entry[0]?.resource?.resourceType !== 'Consent'
|
|
200
|
+
|| decoded.entry[0].resource.status !== 'draft') {
|
|
201
|
+
throw new TypeError('veterinary_research_study_invitation_bundle_invalid');
|
|
202
|
+
}
|
|
203
|
+
return deepFreeze(decoded);
|
|
204
|
+
}
|
|
205
|
+
function normalizedStudy(input) {
|
|
206
|
+
if (!input || input.resourceType !== 'ResearchStudy')
|
|
207
|
+
throw new TypeError('veterinary_research_study_invalid');
|
|
208
|
+
const source = clonePlain(input);
|
|
209
|
+
const id = boundedId(input.id);
|
|
210
|
+
const status = String(input.status || '');
|
|
211
|
+
if (!['draft', 'active', 'retired', 'unknown'].includes(status)) {
|
|
212
|
+
throw new TypeError('veterinary_research_study_status_invalid');
|
|
213
|
+
}
|
|
214
|
+
const associatedParty = input.associatedParty === undefined
|
|
215
|
+
? undefined
|
|
216
|
+
: normalizeResearchStudyAssociatedParties(input.associatedParty);
|
|
217
|
+
return deepFreeze({
|
|
218
|
+
...source,
|
|
219
|
+
id,
|
|
220
|
+
status: status,
|
|
221
|
+
...(associatedParty ? { associatedParty } : {}),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function clonePlain(value) {
|
|
225
|
+
if (Array.isArray(value))
|
|
226
|
+
return value.map(clonePlain);
|
|
227
|
+
if (value && typeof value === 'object') {
|
|
228
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, clonePlain(nested)]));
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
function withProjectedClaims(study) {
|
|
233
|
+
const standardClaims = projectResearchStudyR5SearchClaims(study);
|
|
234
|
+
const partyClaims = aggregatePartyClaims(study.associatedParty || []);
|
|
235
|
+
return deepFreeze({
|
|
236
|
+
...study,
|
|
237
|
+
meta: {
|
|
238
|
+
...(study.meta || {}),
|
|
239
|
+
claims: { ...standardClaims, ...partyClaims },
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
function aggregatePartyClaims(parties) {
|
|
244
|
+
const output = {};
|
|
245
|
+
for (const projection of projectResearchStudyAssociatedPartyClaims(parties)) {
|
|
246
|
+
for (const [name, values] of Object.entries(projection.claims)) {
|
|
247
|
+
if (values.length > 0)
|
|
248
|
+
(output[name] || (output[name] = [])).push(...values);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return Object.fromEntries(Object.entries(output).map(([name, values]) => [name, Object.freeze([...values])]));
|
|
252
|
+
}
|
|
253
|
+
function boundedId(value) {
|
|
254
|
+
const id = String(value || '').trim();
|
|
255
|
+
if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
|
|
256
|
+
throw new TypeError('veterinary_research_study_id_invalid');
|
|
257
|
+
return id;
|
|
258
|
+
}
|
|
259
|
+
function boundedReference(value, resourceType) {
|
|
260
|
+
const reference = String(value || '').trim();
|
|
261
|
+
const prefix = `${resourceType}/`;
|
|
262
|
+
if (!reference.startsWith(prefix))
|
|
263
|
+
throw new TypeError('veterinary_research_study_reference_invalid');
|
|
264
|
+
boundedId(reference.slice(prefix.length));
|
|
265
|
+
return reference;
|
|
266
|
+
}
|
|
267
|
+
function optionalInstant(value) {
|
|
268
|
+
const instant = String(value || '').trim();
|
|
269
|
+
if (!instant)
|
|
270
|
+
return undefined;
|
|
271
|
+
if (!Number.isFinite(Date.parse(instant)))
|
|
272
|
+
throw new TypeError('veterinary_research_study_invitation_sent_invalid');
|
|
273
|
+
return instant;
|
|
274
|
+
}
|
|
275
|
+
function splitCodingToken(value) {
|
|
276
|
+
const separator = value.lastIndexOf('|');
|
|
277
|
+
if (separator < 1 || separator === value.length - 1) {
|
|
278
|
+
throw new TypeError('veterinary_research_study_invitation_category_invalid');
|
|
279
|
+
}
|
|
280
|
+
return [value.slice(0, separator), value.slice(separator + 1)];
|
|
281
|
+
}
|
|
282
|
+
function encodeJsonBase64(value) {
|
|
283
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
284
|
+
let binary = '';
|
|
285
|
+
for (const byte of bytes)
|
|
286
|
+
binary += String.fromCharCode(byte);
|
|
287
|
+
return btoa(binary);
|
|
288
|
+
}
|
|
289
|
+
function decodeJsonBase64(value) {
|
|
290
|
+
const binary = atob(value);
|
|
291
|
+
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
292
|
+
return new TextDecoder().decode(bytes);
|
|
293
|
+
}
|
|
294
|
+
function deepFreeze(value, seen = new WeakSet()) {
|
|
295
|
+
if (!value || typeof value !== 'object' || seen.has(value))
|
|
296
|
+
return value;
|
|
297
|
+
seen.add(value);
|
|
298
|
+
for (const nested of Object.values(value))
|
|
299
|
+
deepFreeze(nested, seen);
|
|
300
|
+
return Object.freeze(value);
|
|
301
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# ResearchStudy party invitations
|
|
2
|
+
|
|
3
|
+
This SDK separates study membership, an invitation, and authorization. They
|
|
4
|
+
are three different facts:
|
|
5
|
+
|
|
6
|
+
1. `ResearchStudy.associatedParty` records who is expected to collaborate.
|
|
7
|
+
2. One R5 `Communication` invites that exact `PractitionerRole` and carries a
|
|
8
|
+
batch Bundle with one `Consent.status = draft`.
|
|
9
|
+
3. Only after the professional completes DCR and the controller persists a
|
|
10
|
+
later `Consent.status = active` may the professional request the
|
|
11
|
+
study-scoped SMART authorization.
|
|
12
|
+
|
|
13
|
+
A draft Consent is inbox/audit evidence. It never grants access. The requested
|
|
14
|
+
operations are create, read, update and search for `ResearchSubject` resources
|
|
15
|
+
related to the exact ResearchStudy. Whole-twin delete is absent.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
buildVeterinaryResearchStudyPartyInvitations,
|
|
20
|
+
buildVeterinaryResearchStudySmartAuthorization,
|
|
21
|
+
} from 'vet-sdk-core-ts/research-study'
|
|
22
|
+
|
|
23
|
+
const invitations = buildVeterinaryResearchStudyPartyInvitations({
|
|
24
|
+
researchStudyId: study.id,
|
|
25
|
+
controllerReference: organizationReference,
|
|
26
|
+
sentAt: new Date().toISOString(),
|
|
27
|
+
invitations: associatedParties.map((associatedParty, index) => ({
|
|
28
|
+
associatedParty,
|
|
29
|
+
communicationId: communicationIds[index],
|
|
30
|
+
consentId: draftConsentIds[index],
|
|
31
|
+
})),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Persist each Communication through the protected BFF/runtime. Its attached
|
|
35
|
+
// draft Consent remains non-authorizing while the recipient completes DCR.
|
|
36
|
+
for (const communication of invitations) {
|
|
37
|
+
await professionalRuntime.ingestCommunicationAndUpdateIndex({ communication })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// This succeeds only after protected state proves both prerequisites. Browser
|
|
41
|
+
// input must not be trusted as that proof.
|
|
42
|
+
const authorization = buildVeterinaryResearchStudySmartAuthorization({
|
|
43
|
+
researchStudyId: study.id,
|
|
44
|
+
dcrCompleted: protectedProfessional.dcrCompleted,
|
|
45
|
+
consentStatus: protectedStudyConsent.status,
|
|
46
|
+
})
|
|
47
|
+
await professionalRuntime.requestSmartToken(authorization)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The Bundle is encoded as the standard
|
|
51
|
+
`Communication.payload.contentAttachment` with
|
|
52
|
+
`contentType = application/fhir+json`. The Consent uses:
|
|
53
|
+
|
|
54
|
+
- `Consent.grantee` and `Consent.provision.actor` for the invited
|
|
55
|
+
`PractitionerRole`;
|
|
56
|
+
- `Consent.grantor` and `Consent.controller` for the controller organization;
|
|
57
|
+
- `Consent.provision.purpose` with HL7 v3 ActReason `HRESCH`;
|
|
58
|
+
- `Consent.provision.resourceType` for `ResearchSubject`;
|
|
59
|
+
- `Consent.provision.data.reference` for the exact `ResearchStudy/{id}`;
|
|
60
|
+
- FHIR REST interaction codes `create`, `read`, `update`, and `search-type`.
|
|
61
|
+
|
|
62
|
+
FHIR R5 references:
|
|
63
|
+
|
|
64
|
+
- [ResearchStudy](https://hl7.org/fhir/R5/researchstudy.html)
|
|
65
|
+
- [Communication](https://hl7.org/fhir/R5/communication.html)
|
|
66
|
+
- [Consent](https://hl7.org/fhir/R5/consent.html)
|
|
67
|
+
- [RESTful interaction codes](https://hl7.org/fhir/R5/codesystem-restful-interaction.html)
|
|
68
|
+
- [Communication category](https://www.hl7.org/fhir/valueset-communication-category.html)
|
|
69
|
+
- [HL7 v3 ActReason](http://terminology.hl7.org/CodeSystem/v3-ActReason)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vet-sdk-core-ts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"description": "Browser-safe VetChain core contracts and governed animal species identifiers",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Connecting Solution & Applications Ltd",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"./reusable-bff": {
|
|
36
36
|
"types": "./dist/reusable-bff.d.ts",
|
|
37
37
|
"default": "./dist/reusable-bff.js"
|
|
38
|
+
},
|
|
39
|
+
"./research-study": {
|
|
40
|
+
"types": "./dist/research-study.d.ts",
|
|
41
|
+
"default": "./dist/research-study.js"
|
|
38
42
|
}
|
|
39
43
|
},
|
|
40
44
|
"files": [
|
|
@@ -59,6 +63,6 @@
|
|
|
59
63
|
},
|
|
60
64
|
"dependencies": {
|
|
61
65
|
"@noble/hashes": "^2.2.0",
|
|
62
|
-
"vet-data-utils-ts": "0.
|
|
66
|
+
"vet-data-utils-ts": "0.5.0"
|
|
63
67
|
}
|
|
64
68
|
}
|