vet-sdk-core-ts 0.4.9 → 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 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
@@ -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,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.9",
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.4.7"
66
+ "vet-data-utils-ts": "0.5.0"
63
67
  }
64
68
  }