vet-sdk-core-ts 0.4.10 → 0.4.12

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 CHANGED
@@ -6,6 +6,52 @@ 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`. It emits the canonical purpose
25
+ `HRESCH` and the unescaped scope
26
+ `organization/ResearchSubject.crus?study=ResearchStudy/<id>` so it can be
27
+ passed to the protected professional runtime; the DCR-bound runtime supplies
28
+ the professional actor as `sub`.
29
+
30
+ `buildVeterinaryResearchStudyActiveConsentBatch` builds the controller's
31
+ separate JSON:API `Consent/_batch`. Every POST is fixed to `active`, `permit`,
32
+ `HRESCH`, one `PractitionerRole`, its ISCO-08 role, and that same exact
33
+ ResearchStudy scope. The API does not accept caller-authored actions, so it
34
+ cannot be broadened to delete.
35
+
36
+ `buildVeterinaryResearchStudyPartyInvitations` creates exactly one R5
37
+ `Communication` per associated `PractitionerRole`. Each Communication carries
38
+ an `application/fhir+json` batch Bundle with exactly one draft Consent, scoped
39
+ to the ResearchStudy through `Consent.provision.data.reference` and to
40
+ `ResearchSubject` through `Consent.provision.resourceType`. The draft requests
41
+ only create, read, update and search. It contains no delete permission and
42
+ never authorizes SMART access.
43
+
44
+ The complete controller-to-professional contract and a browser-safe snippet
45
+ are in
46
+ [`docs/101-RESEARCH-STUDY-INVITATIONS.md`](docs/101-RESEARCH-STUDY-INVITATIONS.md).
47
+
48
+ FHIR references:
49
+
50
+ - https://hl7.org/fhir/R5/researchstudy.html
51
+ - https://hl7.org/fhir/R5/researchsubject-search.html
52
+ - https://hl7.org/fhir/R5/communication.html
53
+ - https://hl7.org/fhir/R5/consent-definitions.html
54
+
9
55
  ## Reusable professional BFF
10
56
 
11
57
  `ReusableProfessionalBffClient` exposes the complete business-level portal
package/dist/index.d.ts CHANGED
@@ -4,4 +4,5 @@ export * from "./gateway-contract.js";
4
4
  export * from "./card-issuance.js";
5
5
  export * from "./animal-onboarding.js";
6
6
  export * from "./reusable-bff.js";
7
+ export * from "./research-study.js";
7
8
  export * from "vet-data-utils-ts";
package/dist/index.js CHANGED
@@ -4,4 +4,5 @@ export * from "./gateway-contract.js";
4
4
  export * from "./card-issuance.js";
5
5
  export * from "./animal-onboarding.js";
6
6
  export * from "./reusable-bff.js";
7
+ export * from "./research-study.js";
7
8
  export * from "vet-data-utils-ts";
@@ -0,0 +1,222 @@
1
+ import { type ResearchStudyAssociatedParty } from 'vet-data-utils-ts/research-study';
2
+ import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
3
+ import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
4
+ import { ClaimConsent, ConsentStatuses } from 'gdc-common-utils-ts/models/consent-rule';
5
+ export declare const VeterinaryResearchStudyPermissionActions: readonly ["create", "read", "update", "search"];
6
+ export type VeterinaryResearchStudyPermissionAction = typeof VeterinaryResearchStudyPermissionActions[number];
7
+ export declare const VeterinaryResearchStudyInvitationClaimNames: Readonly<{
8
+ readonly Context: "@context";
9
+ readonly Status: ClaimConsent.status;
10
+ readonly Decision: ClaimConsent.decision;
11
+ readonly ActorIdentifier: ClaimConsent.actorIdentifier;
12
+ readonly Purpose: ClaimConsent.purpose;
13
+ readonly Action: ClaimConsent.action;
14
+ readonly ResourceType: ClaimConsent.resourceType;
15
+ }>;
16
+ type FhirReference = Readonly<{
17
+ reference: string;
18
+ type?: string;
19
+ }>;
20
+ type FhirCoding = Readonly<{
21
+ system: string;
22
+ code: string;
23
+ }>;
24
+ type FhirCodeableConcept = Readonly<{
25
+ coding: readonly FhirCoding[];
26
+ }>;
27
+ export type VeterinaryResearchStudyDraftConsent = Readonly<{
28
+ resourceType: 'Consent';
29
+ id: string;
30
+ status: 'draft';
31
+ grantor: readonly FhirReference[];
32
+ controller: readonly FhirReference[];
33
+ grantee: readonly FhirReference[];
34
+ decision: 'permit';
35
+ provision: readonly Readonly<{
36
+ actor: readonly Readonly<{
37
+ reference: FhirReference;
38
+ }>[];
39
+ action: readonly FhirCodeableConcept[];
40
+ purpose: readonly FhirCoding[];
41
+ resourceType: readonly FhirCoding[];
42
+ data: readonly Readonly<{
43
+ meaning: 'related';
44
+ reference: FhirReference;
45
+ }>[];
46
+ }>[];
47
+ meta: Readonly<{
48
+ claims: Readonly<Record<string, string>>;
49
+ }>;
50
+ }>;
51
+ export type VeterinaryResearchStudyInvitationBundle = Readonly<{
52
+ resourceType: 'Bundle';
53
+ type: 'batch';
54
+ entry: readonly Readonly<{
55
+ fullUrl: string;
56
+ resource: VeterinaryResearchStudyDraftConsent;
57
+ request: Readonly<{
58
+ method: 'POST';
59
+ url: 'Consent';
60
+ }>;
61
+ }>[];
62
+ }>;
63
+ export type VeterinaryResearchStudyPartyInvitation = Readonly<{
64
+ resourceType: 'Communication';
65
+ id: string;
66
+ status: 'completed';
67
+ category: readonly FhirCodeableConcept[];
68
+ topic: FhirCodeableConcept;
69
+ about: readonly FhirReference[];
70
+ recipient: readonly FhirReference[];
71
+ sender: FhirReference;
72
+ sent?: string;
73
+ payload: readonly Readonly<{
74
+ contentAttachment: Readonly<{
75
+ contentType: 'application/fhir+json';
76
+ title: 'research-study-draft-consent.json';
77
+ data: string;
78
+ }>;
79
+ }>[];
80
+ }>;
81
+ export type VeterinaryResearchStudyResource = Readonly<{
82
+ resourceType: 'ResearchStudy';
83
+ id: string;
84
+ status?: 'draft' | 'active' | 'retired' | 'unknown';
85
+ associatedParty?: readonly ResearchStudyAssociatedParty[];
86
+ meta?: Readonly<Record<string, unknown>>;
87
+ [key: string]: unknown;
88
+ }>;
89
+ export type VeterinaryResearchStudyCreateResource = VeterinaryResearchStudyResource & Readonly<{
90
+ status: NonNullable<VeterinaryResearchStudyResource['status']>;
91
+ }>;
92
+ export type VeterinaryResearchStudyBatchEntry = Readonly<{
93
+ type: 'ResearchStudy-v5.0.0';
94
+ resource: VeterinaryResearchStudyResource;
95
+ request: Readonly<{
96
+ method: 'POST' | 'PATCH';
97
+ url: string;
98
+ }>;
99
+ }>;
100
+ export type VeterinaryResearchStudyBatch = Readonly<{
101
+ data: readonly VeterinaryResearchStudyBatchEntry[];
102
+ }>;
103
+ export type VeterinaryResearchStudySmartAuthorization = Readonly<{
104
+ purpose: typeof HealthcareConsentPurposes.Research;
105
+ researchStudyReference: string;
106
+ scopes: readonly [string];
107
+ }>;
108
+ export type VeterinaryResearchStudyActiveConsentInput = Readonly<{
109
+ consentId: string;
110
+ researchStudyId: string;
111
+ practitionerRoleId: string;
112
+ actorRole: `ISCO-08|${string}`;
113
+ date: string;
114
+ status?: typeof ConsentStatuses.Active;
115
+ }>;
116
+ export type VeterinaryResearchStudyActiveConsent = Readonly<{
117
+ resourceType: typeof ResourceTypesFhirR5.Consent;
118
+ id: string;
119
+ status: typeof ConsentStatuses.Active;
120
+ meta: Readonly<{
121
+ claims: Readonly<Record<string, string>>;
122
+ }>;
123
+ }>;
124
+ export type VeterinaryResearchStudyActiveConsentEntry = Readonly<{
125
+ type: 'Consent-v5.0.0';
126
+ resource: VeterinaryResearchStudyActiveConsent;
127
+ request: Readonly<{
128
+ method: 'POST';
129
+ url: typeof ResourceTypesFhirR5.Consent;
130
+ }>;
131
+ }>;
132
+ export type VeterinaryResearchStudyActiveConsentBatch = Readonly<{
133
+ data: readonly VeterinaryResearchStudyActiveConsentEntry[];
134
+ }>;
135
+ /** Builds the exact GW study-pinned ResearchSubject `crus` capability. */
136
+ export declare function buildVeterinaryResearchStudyAccessScope(researchStudyId: string): string;
137
+ /**
138
+ * Requests the current study-scoped ResearchSubject operations. Deleting
139
+ * selected clinical facts is a later operation on those concrete resources;
140
+ * it must never be represented as deleting the ResearchSubject/twin itself.
141
+ */
142
+ export declare function buildVeterinaryResearchStudySmartAuthorization(input: Readonly<{
143
+ researchStudyId: string;
144
+ dcrCompleted: boolean;
145
+ consentStatus: 'draft' | 'active' | 'inactive' | 'not-done' | 'entered-in-error' | 'unknown';
146
+ }>): VeterinaryResearchStudySmartAuthorization;
147
+ /**
148
+ * Builds controller decisions for the VET ResearchStudy Consent `_batch`.
149
+ * Every entry is an explicit active/permit decision for one DCR-bound
150
+ * PractitionerRole. Invitation drafts, descriptive Group membership and
151
+ * `ResearchStudy.associatedParty` never authorize. The only action is the
152
+ * exact study-scoped ResearchSubject create/read/update/search capability;
153
+ * delete is deliberately absent.
154
+ *
155
+ * `subjectDid` is deliberately absent from the matching SMART authorization:
156
+ * the high-level professional runtime supplies the DCR-bound actor as `sub`,
157
+ * while GW derives the ResearchStudy authorization subject from this scope.
158
+ *
159
+ * @see https://hl7.org/fhir/R5/consent.html
160
+ * @see https://hl7.org/fhir/R5/researchstudy.html
161
+ */
162
+ export declare function buildVeterinaryResearchStudyActiveConsentBatch(inputs: readonly VeterinaryResearchStudyActiveConsentInput[]): VeterinaryResearchStudyActiveConsentBatch;
163
+ /** Builds one POST Consent entry accepted by the VET ResearchStudy manager. */
164
+ export declare function buildVeterinaryResearchStudyActiveConsentEntry(input: VeterinaryResearchStudyActiveConsentInput): VeterinaryResearchStudyActiveConsentEntry;
165
+ export declare function buildVeterinaryResearchStudyBatch(entries: readonly VeterinaryResearchStudyBatchEntry[]): VeterinaryResearchStudyBatch;
166
+ export declare function buildVeterinaryResearchStudyCreateEntry(input: Readonly<{
167
+ study: VeterinaryResearchStudyCreateResource;
168
+ }>): VeterinaryResearchStudyBatchEntry;
169
+ /**
170
+ * Builds one independent `_batch` PATCH entry per R5 associatedParty. Keeping
171
+ * each complete party in its own entry prevents roles, periods and classifiers
172
+ * from being correlated with the wrong PractitionerRole. These entries update
173
+ * study membership only; they do not grant CRUDS access.
174
+ */
175
+ export declare function buildVeterinaryResearchStudyPartyPatchEntries(input: Readonly<{
176
+ researchStudyId: string;
177
+ associatedParties: unknown;
178
+ }>): readonly VeterinaryResearchStudyBatchEntry[];
179
+ /**
180
+ * Builds the auditable R5 invitation sent to one ResearchStudy associated
181
+ * party. The Communication payload is an `application/fhir+json` attachment
182
+ * containing a batch Bundle with exactly one `Consent.status = draft`.
183
+ * Draft is descriptive only and can never satisfy authorization. The invitee
184
+ * must finish DCR and a controller must later persist a separate active
185
+ * Consent before the study-scoped SMART request can succeed.
186
+ *
187
+ * Native R5 fields retain the exact study boundary in
188
+ * `Consent.provision.data.reference` and the affected `ResearchSubject`
189
+ * resource family. Project authorization actions remain create/read/update/
190
+ * search; their FHIR representation uses the matching REST interaction codes,
191
+ * where search is `search-type`. Delete is deliberately absent.
192
+ *
193
+ * @see https://hl7.org/fhir/R5/communication.html
194
+ * @see https://hl7.org/fhir/R5/consent.html
195
+ * @see https://hl7.org/fhir/R5/codesystem-restful-interaction.html
196
+ */
197
+ export declare function buildVeterinaryResearchStudyPartyInvitation(input: Readonly<{
198
+ researchStudyId: string;
199
+ associatedParty: ResearchStudyAssociatedParty;
200
+ controllerReference: string;
201
+ communicationId: string;
202
+ consentId: string;
203
+ sentAt?: string;
204
+ }>): VeterinaryResearchStudyPartyInvitation;
205
+ /**
206
+ * Builds one isolated Communication/draft-Consent pair for every associated
207
+ * party. Callers provide the auditable identifiers; no identifier, recipient,
208
+ * or Consent is shared across invitations.
209
+ */
210
+ export declare function buildVeterinaryResearchStudyPartyInvitations(input: Readonly<{
211
+ researchStudyId: string;
212
+ controllerReference: string;
213
+ sentAt?: string;
214
+ invitations: readonly Readonly<{
215
+ associatedParty: ResearchStudyAssociatedParty;
216
+ communicationId: string;
217
+ consentId: string;
218
+ }>[];
219
+ }>): readonly VeterinaryResearchStudyPartyInvitation[];
220
+ /** Decodes the single attached Consent Bundle after validating its boundary. */
221
+ export declare function decodeVeterinaryResearchStudyInvitationBundle(communication: VeterinaryResearchStudyPartyInvitation): VeterinaryResearchStudyInvitationBundle;
222
+ export {};
@@ -0,0 +1,381 @@
1
+ import { normalizeResearchStudyAssociatedParties, projectResearchStudyAssociatedPartyClaims, projectResearchStudyR5SearchClaims, } from 'vet-data-utils-ts/research-study';
2
+ import { VeterinaryCommunicationPresetFilters, VeterinaryResearchCommunicationTopics, } from 'vet-data-utils-ts/communication';
3
+ import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
4
+ import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
5
+ import { InteroperableContext } from 'gdc-common-utils-ts/constants/lifecycle';
6
+ import { ClaimConsent, ConsentDecisions, ConsentStatuses, } from 'gdc-common-utils-ts/models/consent-rule';
7
+ const FHIR_RESTFUL_INTERACTION_SYSTEM = 'http://hl7.org/fhir/restful-interaction';
8
+ const FHIR_RESOURCE_TYPES_SYSTEM = 'http://hl7.org/fhir/fhir-types';
9
+ export const VeterinaryResearchStudyPermissionActions = Object.freeze([
10
+ 'create',
11
+ 'read',
12
+ 'update',
13
+ 'search',
14
+ ]);
15
+ export const VeterinaryResearchStudyInvitationClaimNames = Object.freeze({
16
+ Context: '@context',
17
+ Status: ClaimConsent.status,
18
+ Decision: ClaimConsent.decision,
19
+ ActorIdentifier: ClaimConsent.actorIdentifier,
20
+ Purpose: ClaimConsent.purpose,
21
+ Action: ClaimConsent.action,
22
+ ResourceType: ClaimConsent.resourceType,
23
+ });
24
+ /** Builds the exact GW study-pinned ResearchSubject `crus` capability. */
25
+ export function buildVeterinaryResearchStudyAccessScope(researchStudyId) {
26
+ return `organization/ResearchSubject.crus?study=ResearchStudy/${boundedId(researchStudyId)}`;
27
+ }
28
+ /**
29
+ * Requests the current study-scoped ResearchSubject operations. Deleting
30
+ * selected clinical facts is a later operation on those concrete resources;
31
+ * it must never be represented as deleting the ResearchSubject/twin itself.
32
+ */
33
+ export function buildVeterinaryResearchStudySmartAuthorization(input) {
34
+ if (input.consentStatus !== 'active') {
35
+ throw new TypeError('veterinary_research_study_active_consent_required');
36
+ }
37
+ if (input.dcrCompleted !== true) {
38
+ throw new TypeError('veterinary_research_study_dcr_required');
39
+ }
40
+ const researchStudyReference = `ResearchStudy/${boundedId(input.researchStudyId)}`;
41
+ return deepFreeze({
42
+ purpose: HealthcareConsentPurposes.Research,
43
+ researchStudyReference,
44
+ // This is a SMART scope value, not a URL query assembled by the browser.
45
+ // Encoding the slash would no longer equal the active Consent action.
46
+ scopes: [buildVeterinaryResearchStudyAccessScope(input.researchStudyId)],
47
+ });
48
+ }
49
+ /**
50
+ * Builds controller decisions for the VET ResearchStudy Consent `_batch`.
51
+ * Every entry is an explicit active/permit decision for one DCR-bound
52
+ * PractitionerRole. Invitation drafts, descriptive Group membership and
53
+ * `ResearchStudy.associatedParty` never authorize. The only action is the
54
+ * exact study-scoped ResearchSubject create/read/update/search capability;
55
+ * delete is deliberately absent.
56
+ *
57
+ * `subjectDid` is deliberately absent from the matching SMART authorization:
58
+ * the high-level professional runtime supplies the DCR-bound actor as `sub`,
59
+ * while GW derives the ResearchStudy authorization subject from this scope.
60
+ *
61
+ * @see https://hl7.org/fhir/R5/consent.html
62
+ * @see https://hl7.org/fhir/R5/researchstudy.html
63
+ */
64
+ export function buildVeterinaryResearchStudyActiveConsentBatch(inputs) {
65
+ if (!Array.isArray(inputs) || inputs.length === 0) {
66
+ throw new TypeError('veterinary_research_study_active_consent_batch_empty');
67
+ }
68
+ const data = inputs.map(buildVeterinaryResearchStudyActiveConsentEntry);
69
+ return deepFreeze({ data });
70
+ }
71
+ /** Builds one POST Consent entry accepted by the VET ResearchStudy manager. */
72
+ export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
73
+ if (input.status !== undefined && input.status !== ConsentStatuses.Active) {
74
+ throw new TypeError('veterinary_research_study_active_consent_status_invalid');
75
+ }
76
+ const consentId = boundedId(input.consentId);
77
+ const researchStudyReference = `ResearchStudy/${boundedId(input.researchStudyId)}`;
78
+ const practitionerRoleReference = `PractitionerRole/${boundedId(input.practitionerRoleId)}`;
79
+ const actorRole = String(input.actorRole || '').trim();
80
+ if (!/^ISCO-08\|[0-9]{1,4}$/.test(actorRole)) {
81
+ throw new TypeError('veterinary_research_study_active_consent_actor_role_invalid');
82
+ }
83
+ const date = boundedDate(input.date);
84
+ const action = buildVeterinaryResearchStudyAccessScope(input.researchStudyId);
85
+ return deepFreeze({
86
+ type: 'Consent-v5.0.0',
87
+ resource: {
88
+ resourceType: ResourceTypesFhirR5.Consent,
89
+ id: consentId,
90
+ status: ConsentStatuses.Active,
91
+ meta: {
92
+ claims: {
93
+ '@context': InteroperableContext.FhirApi,
94
+ [ClaimConsent.status]: ConsentStatuses.Active,
95
+ [ClaimConsent.decision]: ConsentDecisions.Permit,
96
+ [ClaimConsent.purpose]: HealthcareConsentPurposes.Research,
97
+ [ClaimConsent.sourceReference]: researchStudyReference,
98
+ [ClaimConsent.actorIdentifier]: practitionerRoleReference,
99
+ [ClaimConsent.actorRole]: actorRole,
100
+ [ClaimConsent.action]: action,
101
+ [ClaimConsent.date]: date,
102
+ },
103
+ },
104
+ },
105
+ request: { method: 'POST', url: ResourceTypesFhirR5.Consent },
106
+ });
107
+ }
108
+ export function buildVeterinaryResearchStudyBatch(entries) {
109
+ if (!Array.isArray(entries) || entries.length === 0)
110
+ throw new TypeError('veterinary_research_study_batch_empty');
111
+ return deepFreeze({ data: entries.map(entry => clonePlain(entry)) });
112
+ }
113
+ export function buildVeterinaryResearchStudyCreateEntry(input) {
114
+ const study = normalizedStudy(input.study);
115
+ return deepFreeze({
116
+ type: 'ResearchStudy-v5.0.0',
117
+ resource: withProjectedClaims(study),
118
+ request: { method: 'POST', url: 'ResearchStudy' },
119
+ });
120
+ }
121
+ /**
122
+ * Builds one independent `_batch` PATCH entry per R5 associatedParty. Keeping
123
+ * each complete party in its own entry prevents roles, periods and classifiers
124
+ * from being correlated with the wrong PractitionerRole. These entries update
125
+ * study membership only; they do not grant CRUDS access.
126
+ */
127
+ export function buildVeterinaryResearchStudyPartyPatchEntries(input) {
128
+ const id = boundedId(input.researchStudyId);
129
+ const parties = normalizeResearchStudyAssociatedParties(input.associatedParties);
130
+ return deepFreeze(parties.map(party => ({
131
+ type: 'ResearchStudy-v5.0.0',
132
+ resource: withProjectedClaims({
133
+ resourceType: 'ResearchStudy',
134
+ id,
135
+ associatedParty: [party],
136
+ }),
137
+ request: { method: 'PATCH', url: `ResearchStudy/${id}` },
138
+ })));
139
+ }
140
+ /**
141
+ * Builds the auditable R5 invitation sent to one ResearchStudy associated
142
+ * party. The Communication payload is an `application/fhir+json` attachment
143
+ * containing a batch Bundle with exactly one `Consent.status = draft`.
144
+ * Draft is descriptive only and can never satisfy authorization. The invitee
145
+ * must finish DCR and a controller must later persist a separate active
146
+ * Consent before the study-scoped SMART request can succeed.
147
+ *
148
+ * Native R5 fields retain the exact study boundary in
149
+ * `Consent.provision.data.reference` and the affected `ResearchSubject`
150
+ * resource family. Project authorization actions remain create/read/update/
151
+ * search; their FHIR representation uses the matching REST interaction codes,
152
+ * where search is `search-type`. Delete is deliberately absent.
153
+ *
154
+ * @see https://hl7.org/fhir/R5/communication.html
155
+ * @see https://hl7.org/fhir/R5/consent.html
156
+ * @see https://hl7.org/fhir/R5/codesystem-restful-interaction.html
157
+ */
158
+ export function buildVeterinaryResearchStudyPartyInvitation(input) {
159
+ const researchStudyReference = `ResearchStudy/${boundedId(input.researchStudyId)}`;
160
+ let party;
161
+ try {
162
+ party = normalizeResearchStudyAssociatedParties([input.associatedParty])[0];
163
+ }
164
+ catch {
165
+ throw new TypeError('veterinary_research_study_invitation_party_invalid');
166
+ }
167
+ const partyReference = String(party?.party?.reference || '').trim();
168
+ if (!partyReference.startsWith('PractitionerRole/')) {
169
+ throw new TypeError('veterinary_research_study_invitation_party_invalid');
170
+ }
171
+ const controllerReference = boundedReference(input.controllerReference, 'Organization');
172
+ const communicationId = boundedId(input.communicationId);
173
+ const consentId = boundedId(input.consentId);
174
+ const topic = VeterinaryResearchCommunicationTopics[0];
175
+ if (!topic)
176
+ throw new TypeError('veterinary_research_study_invitation_topic_unavailable');
177
+ const consent = {
178
+ resourceType: 'Consent',
179
+ id: consentId,
180
+ status: 'draft',
181
+ grantor: [{ reference: controllerReference, type: 'Organization' }],
182
+ controller: [{ reference: controllerReference, type: 'Organization' }],
183
+ grantee: [{ reference: partyReference, type: 'PractitionerRole' }],
184
+ decision: 'permit',
185
+ provision: [{
186
+ actor: [{ reference: { reference: partyReference, type: 'PractitionerRole' } }],
187
+ action: VeterinaryResearchStudyPermissionActions.map(action => ({
188
+ coding: [{
189
+ system: FHIR_RESTFUL_INTERACTION_SYSTEM,
190
+ code: action === 'search' ? 'search-type' : action,
191
+ }],
192
+ })),
193
+ purpose: [{ system: topic.system, code: topic.code }],
194
+ resourceType: [{ system: FHIR_RESOURCE_TYPES_SYSTEM, code: 'ResearchSubject' }],
195
+ data: [{ meaning: 'related', reference: { reference: researchStudyReference, type: 'ResearchStudy' } }],
196
+ }],
197
+ meta: {
198
+ claims: {
199
+ [VeterinaryResearchStudyInvitationClaimNames.Context]: 'org.hl7.fhir.api',
200
+ [VeterinaryResearchStudyInvitationClaimNames.Status]: 'draft',
201
+ [VeterinaryResearchStudyInvitationClaimNames.Decision]: 'permit',
202
+ [VeterinaryResearchStudyInvitationClaimNames.ActorIdentifier]: partyReference,
203
+ [VeterinaryResearchStudyInvitationClaimNames.Purpose]: topic.value,
204
+ [VeterinaryResearchStudyInvitationClaimNames.Action]: VeterinaryResearchStudyPermissionActions.join(','),
205
+ [VeterinaryResearchStudyInvitationClaimNames.ResourceType]: 'ResearchSubject',
206
+ },
207
+ },
208
+ };
209
+ const bundle = {
210
+ resourceType: 'Bundle',
211
+ type: 'batch',
212
+ entry: [{
213
+ fullUrl: `urn:uuid:${consentId}`,
214
+ resource: consent,
215
+ request: { method: 'POST', url: 'Consent' },
216
+ }],
217
+ };
218
+ const category = VeterinaryCommunicationPresetFilters.ResearchAgreements.categories[0];
219
+ const [categorySystem, categoryCode] = splitCodingToken(category);
220
+ const sent = optionalInstant(input.sentAt);
221
+ return deepFreeze({
222
+ resourceType: 'Communication',
223
+ id: communicationId,
224
+ status: 'completed',
225
+ category: [{ coding: [{ system: categorySystem, code: categoryCode }] }],
226
+ topic: { coding: [{ system: topic.system, code: topic.code }] },
227
+ about: [{ reference: researchStudyReference, type: 'ResearchStudy' }],
228
+ recipient: [{ reference: partyReference, type: 'PractitionerRole' }],
229
+ sender: { reference: controllerReference, type: 'Organization' },
230
+ ...(sent ? { sent } : {}),
231
+ payload: [{
232
+ contentAttachment: {
233
+ contentType: 'application/fhir+json',
234
+ title: 'research-study-draft-consent.json',
235
+ data: encodeJsonBase64(bundle),
236
+ },
237
+ }],
238
+ });
239
+ }
240
+ /**
241
+ * Builds one isolated Communication/draft-Consent pair for every associated
242
+ * party. Callers provide the auditable identifiers; no identifier, recipient,
243
+ * or Consent is shared across invitations.
244
+ */
245
+ export function buildVeterinaryResearchStudyPartyInvitations(input) {
246
+ if (!Array.isArray(input.invitations) || input.invitations.length === 0) {
247
+ throw new TypeError('veterinary_research_study_invitations_empty');
248
+ }
249
+ return deepFreeze(input.invitations.map(invitation => buildVeterinaryResearchStudyPartyInvitation({
250
+ researchStudyId: input.researchStudyId,
251
+ controllerReference: input.controllerReference,
252
+ sentAt: input.sentAt,
253
+ ...invitation,
254
+ })));
255
+ }
256
+ /** Decodes the single attached Consent Bundle after validating its boundary. */
257
+ export function decodeVeterinaryResearchStudyInvitationBundle(communication) {
258
+ const attachment = communication?.payload?.[0]?.contentAttachment;
259
+ if (communication?.resourceType !== 'Communication'
260
+ || attachment?.contentType !== 'application/fhir+json'
261
+ || !attachment.data) {
262
+ throw new TypeError('veterinary_research_study_invitation_bundle_invalid');
263
+ }
264
+ const decoded = JSON.parse(decodeJsonBase64(attachment.data));
265
+ if (decoded?.resourceType !== 'Bundle'
266
+ || decoded.type !== 'batch'
267
+ || decoded.entry?.length !== 1
268
+ || decoded.entry[0]?.resource?.resourceType !== 'Consent'
269
+ || decoded.entry[0].resource.status !== 'draft') {
270
+ throw new TypeError('veterinary_research_study_invitation_bundle_invalid');
271
+ }
272
+ return deepFreeze(decoded);
273
+ }
274
+ function normalizedStudy(input) {
275
+ if (!input || input.resourceType !== 'ResearchStudy')
276
+ throw new TypeError('veterinary_research_study_invalid');
277
+ const source = clonePlain(input);
278
+ const id = boundedId(input.id);
279
+ const status = String(input.status || '');
280
+ if (!['draft', 'active', 'retired', 'unknown'].includes(status)) {
281
+ throw new TypeError('veterinary_research_study_status_invalid');
282
+ }
283
+ const associatedParty = input.associatedParty === undefined
284
+ ? undefined
285
+ : normalizeResearchStudyAssociatedParties(input.associatedParty);
286
+ return deepFreeze({
287
+ ...source,
288
+ id,
289
+ status: status,
290
+ ...(associatedParty ? { associatedParty } : {}),
291
+ });
292
+ }
293
+ function clonePlain(value) {
294
+ if (Array.isArray(value))
295
+ return value.map(clonePlain);
296
+ if (value && typeof value === 'object') {
297
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, clonePlain(nested)]));
298
+ }
299
+ return value;
300
+ }
301
+ function withProjectedClaims(study) {
302
+ const standardClaims = projectResearchStudyR5SearchClaims(study);
303
+ const partyClaims = aggregatePartyClaims(study.associatedParty || []);
304
+ return deepFreeze({
305
+ ...study,
306
+ meta: {
307
+ ...(study.meta || {}),
308
+ claims: { ...standardClaims, ...partyClaims },
309
+ },
310
+ });
311
+ }
312
+ function aggregatePartyClaims(parties) {
313
+ const output = {};
314
+ for (const projection of projectResearchStudyAssociatedPartyClaims(parties)) {
315
+ for (const [name, values] of Object.entries(projection.claims)) {
316
+ if (values.length > 0)
317
+ (output[name] || (output[name] = [])).push(...values);
318
+ }
319
+ }
320
+ return Object.fromEntries(Object.entries(output).map(([name, values]) => [name, Object.freeze([...values])]));
321
+ }
322
+ function boundedId(value) {
323
+ const id = String(value || '').trim();
324
+ if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
325
+ throw new TypeError('veterinary_research_study_id_invalid');
326
+ return id;
327
+ }
328
+ function boundedReference(value, resourceType) {
329
+ const reference = String(value || '').trim();
330
+ const prefix = `${resourceType}/`;
331
+ if (!reference.startsWith(prefix))
332
+ throw new TypeError('veterinary_research_study_reference_invalid');
333
+ boundedId(reference.slice(prefix.length));
334
+ return reference;
335
+ }
336
+ function boundedDate(value) {
337
+ const date = String(value || '').trim();
338
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
339
+ throw new TypeError('veterinary_research_study_active_consent_date_invalid');
340
+ }
341
+ const parsed = new Date(`${date}T00:00:00Z`);
342
+ if (!Number.isFinite(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== date) {
343
+ throw new TypeError('veterinary_research_study_active_consent_date_invalid');
344
+ }
345
+ return date;
346
+ }
347
+ function optionalInstant(value) {
348
+ const instant = String(value || '').trim();
349
+ if (!instant)
350
+ return undefined;
351
+ if (!Number.isFinite(Date.parse(instant)))
352
+ throw new TypeError('veterinary_research_study_invitation_sent_invalid');
353
+ return instant;
354
+ }
355
+ function splitCodingToken(value) {
356
+ const separator = value.lastIndexOf('|');
357
+ if (separator < 1 || separator === value.length - 1) {
358
+ throw new TypeError('veterinary_research_study_invitation_category_invalid');
359
+ }
360
+ return [value.slice(0, separator), value.slice(separator + 1)];
361
+ }
362
+ function encodeJsonBase64(value) {
363
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
364
+ let binary = '';
365
+ for (const byte of bytes)
366
+ binary += String.fromCharCode(byte);
367
+ return btoa(binary);
368
+ }
369
+ function decodeJsonBase64(value) {
370
+ const binary = atob(value);
371
+ const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
372
+ return new TextDecoder().decode(bytes);
373
+ }
374
+ function deepFreeze(value, seen = new WeakSet()) {
375
+ if (!value || typeof value !== 'object' || seen.has(value))
376
+ return value;
377
+ seen.add(value);
378
+ for (const nested of Object.values(value))
379
+ deepFreeze(nested, seen);
380
+ return Object.freeze(value);
381
+ }
@@ -0,0 +1,88 @@
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
+ buildVeterinaryResearchStudyActiveConsentBatch,
20
+ buildVeterinaryResearchStudyPartyInvitations,
21
+ buildVeterinaryResearchStudySmartAuthorization,
22
+ } from 'vet-sdk-core-ts/research-study'
23
+
24
+ const invitations = buildVeterinaryResearchStudyPartyInvitations({
25
+ researchStudyId: study.id,
26
+ controllerReference: organizationReference,
27
+ sentAt: new Date().toISOString(),
28
+ invitations: associatedParties.map((associatedParty, index) => ({
29
+ associatedParty,
30
+ communicationId: communicationIds[index],
31
+ consentId: draftConsentIds[index],
32
+ })),
33
+ })
34
+
35
+ // Persist each Communication through the protected BFF/runtime. Its attached
36
+ // draft Consent remains non-authorizing while the recipient completes DCR.
37
+ for (const communication of invitations) {
38
+ await professionalRuntime.ingestCommunicationAndUpdateIndex({ communication })
39
+ }
40
+
41
+ // Once DCR is confirmed, the controller persists a new active Consent. This
42
+ // is separate from the draft inside Communication and is the actual grant.
43
+ const activeConsentBatch = buildVeterinaryResearchStudyActiveConsentBatch([{
44
+ consentId: activeConsentId,
45
+ researchStudyId: study.id,
46
+ practitionerRoleId: protectedProfessional.practitionerRoleId,
47
+ actorRole: protectedProfessional.iscoRole,
48
+ date: new Date().toISOString().slice(0, 10),
49
+ }])
50
+ // The protected BFF submits this primary document to the tenant organization
51
+ // FHIR R5 Consent/_batch boundary; the browser never authors that route.
52
+
53
+ // This succeeds only after protected state proves both prerequisites. Browser
54
+ // input must not be trusted as that proof.
55
+ const authorization = buildVeterinaryResearchStudySmartAuthorization({
56
+ researchStudyId: study.id,
57
+ dcrCompleted: protectedProfessional.dcrCompleted,
58
+ consentStatus: protectedStudyConsent.status,
59
+ })
60
+ await professionalRuntime.requestSmartToken(authorization)
61
+ ```
62
+
63
+ The SMART authorization intentionally does not carry `subjectDid`:
64
+ `openProfessional()` supplies its DCR-bound actor as `sub`, while GW derives
65
+ the authorization subject `ResearchStudy/<id>` from the exact unescaped scope.
66
+ Percent-encoding the `/` in that scope would no longer match the active
67
+ Consent action and is rejected.
68
+
69
+ The Bundle is encoded as the standard
70
+ `Communication.payload.contentAttachment` with
71
+ `contentType = application/fhir+json`. The Consent uses:
72
+
73
+ - `Consent.grantee` and `Consent.provision.actor` for the invited
74
+ `PractitionerRole`;
75
+ - `Consent.grantor` and `Consent.controller` for the controller organization;
76
+ - `Consent.provision.purpose` with HL7 v3 ActReason `HRESCH`;
77
+ - `Consent.provision.resourceType` for `ResearchSubject`;
78
+ - `Consent.provision.data.reference` for the exact `ResearchStudy/{id}`;
79
+ - FHIR REST interaction codes `create`, `read`, `update`, and `search-type`.
80
+
81
+ FHIR R5 references:
82
+
83
+ - [ResearchStudy](https://hl7.org/fhir/R5/researchstudy.html)
84
+ - [Communication](https://hl7.org/fhir/R5/communication.html)
85
+ - [Consent](https://hl7.org/fhir/R5/consent.html)
86
+ - [RESTful interaction codes](https://hl7.org/fhir/R5/codesystem-restful-interaction.html)
87
+ - [Communication category](https://www.hl7.org/fhir/valueset-communication-category.html)
88
+ - [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.10",
3
+ "version": "0.4.12",
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,7 @@
59
63
  },
60
64
  "dependencies": {
61
65
  "@noble/hashes": "^2.2.0",
62
- "vet-data-utils-ts": "0.4.8"
66
+ "gdc-common-utils-ts": "2.9.4",
67
+ "vet-data-utils-ts": "0.5.0"
63
68
  }
64
69
  }