vet-sdk-core-ts 0.4.12 → 0.4.14

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
@@ -8,12 +8,13 @@ owns gateway request construction. GW VET remains the policy authority.
8
8
 
9
9
  ## Research studies
10
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.
11
+ `buildVeterinaryResearchStudyCreateEntry` accepts ResearchStudy business data
12
+ and projects it into the canonical JSON:API resource object: `resourceType`,
13
+ `id` and flat `resource.meta.claims`. It does not send native `status`, `title`,
14
+ `associatedParty` or `recruitment` objects to GW. Associated parties use one
15
+ independent flat-claim PATCH entry per party, so references, roles, periods and
16
+ classifiers cannot be mixed across employees. Native FHIR JSON is produced
17
+ only by an explicit later export/projection boundary.
17
18
 
18
19
  Study participation does not itself authorize access. The separate
19
20
  `buildVeterinaryResearchStudySmartAuthorization` request is limited to create,
@@ -33,13 +34,25 @@ separate JSON:API `Consent/_batch`. Every POST is fixed to `active`, `permit`,
33
34
  ResearchStudy scope. The API does not accept caller-authored actions, so it
34
35
  cannot be broadened to delete.
35
36
 
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.
37
+ `buildVeterinaryResearchStudyPartyInvitations` creates exactly one claims-only
38
+ `Communication` per associated `PractitionerRole`. Its canonical attachment
39
+ claims carry an `application/vnd.api+json` primary document containing one
40
+ claims-only draft Consent. `Consent.source-reference` pins the ResearchStudy
41
+ and `Consent.resource-type` pins `ResearchSubject`. The draft requests only
42
+ create, read, update and search; it contains no delete permission and never
43
+ authorizes SMART access. Native FHIR Communication, Consent or Bundle objects
44
+ are accepted only by explicit import/projection/export adapters.
45
+
46
+ `buildVeterinaryResearchStudyGroupCreateEntry` and
47
+ `buildVeterinaryResearchStudyGroupMemberPatchEntries` build flat-claim Group
48
+ POST/PATCH entries for an enumerated professional review team. Every member is
49
+ a registered `PractitionerRole`; the exact twelve Group search claims are
50
+ derived from `vet-data-utils-ts/group`, and each member has its own PATCH entry.
51
+ The Group stays independent: individual professionals are related to the study
52
+ through separate `ResearchStudy.associatedParty` PATCH entries. It must not use
53
+ `ResearchStudy.recruitment.eligibility`, which represents research-subject
54
+ cohorts or eligibility criteria. Group grants no authorization, permission or
55
+ SMART scope; DCR and active Consent remain separate.
43
56
 
44
57
  The complete controller-to-professional contract and a browser-safe snippet
45
58
  are in
@@ -51,6 +64,8 @@ FHIR references:
51
64
  - https://hl7.org/fhir/R5/researchsubject-search.html
52
65
  - https://hl7.org/fhir/R5/communication.html
53
66
  - https://hl7.org/fhir/R5/consent-definitions.html
67
+ - https://hl7.org/fhir/R5/group.html
68
+ - https://hl7.org/fhir/R5/group-search.html
54
69
 
55
70
  ## Reusable professional BFF
56
71
 
@@ -1,4 +1,5 @@
1
- import { type ResearchStudyAssociatedParty } from 'vet-data-utils-ts/research-study';
1
+ import { type ResearchStudyAssociatedParty, type ResearchStudyR5FlatClaimsResource } from 'vet-data-utils-ts/research-study';
2
+ import { type GroupR5FlatClaimsResource, type GroupR5ResearchTeam } from 'vet-data-utils-ts/group';
2
3
  import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
3
4
  import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
4
5
  import { ClaimConsent, ConsentStatuses } from 'gdc-common-utils-ts/models/consent-rule';
@@ -13,46 +14,16 @@ export declare const VeterinaryResearchStudyInvitationClaimNames: Readonly<{
13
14
  readonly Action: ClaimConsent.action;
14
15
  readonly ResourceType: ClaimConsent.resourceType;
15
16
  }>;
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
17
  export type VeterinaryResearchStudyDraftConsent = Readonly<{
28
18
  resourceType: 'Consent';
29
19
  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
20
  meta: Readonly<{
48
21
  claims: Readonly<Record<string, string>>;
49
22
  }>;
50
23
  }>;
51
- export type VeterinaryResearchStudyInvitationBundle = Readonly<{
52
- resourceType: 'Bundle';
53
- type: 'batch';
54
- entry: readonly Readonly<{
55
- fullUrl: string;
24
+ export type VeterinaryResearchStudyInvitationDocument = Readonly<{
25
+ data: readonly Readonly<{
26
+ type: 'Consent-v5.0.0';
56
27
  resource: VeterinaryResearchStudyDraftConsent;
57
28
  request: Readonly<{
58
29
  method: 'POST';
@@ -63,22 +34,12 @@ export type VeterinaryResearchStudyInvitationBundle = Readonly<{
63
34
  export type VeterinaryResearchStudyPartyInvitation = Readonly<{
64
35
  resourceType: 'Communication';
65
36
  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
- }>[];
37
+ meta: Readonly<{
38
+ claims: Readonly<Record<string, string>>;
39
+ }>;
80
40
  }>;
81
- export type VeterinaryResearchStudyResource = Readonly<{
41
+ /** Native-shaped builder input used only to derive the persisted flat claims. */
42
+ export type VeterinaryResearchStudySourceResource = Readonly<{
82
43
  resourceType: 'ResearchStudy';
83
44
  id: string;
84
45
  status?: 'draft' | 'active' | 'retired' | 'unknown';
@@ -86,9 +47,10 @@ export type VeterinaryResearchStudyResource = Readonly<{
86
47
  meta?: Readonly<Record<string, unknown>>;
87
48
  [key: string]: unknown;
88
49
  }>;
89
- export type VeterinaryResearchStudyCreateResource = VeterinaryResearchStudyResource & Readonly<{
90
- status: NonNullable<VeterinaryResearchStudyResource['status']>;
50
+ export type VeterinaryResearchStudyCreateResource = VeterinaryResearchStudySourceResource & Readonly<{
51
+ status: NonNullable<VeterinaryResearchStudySourceResource['status']>;
91
52
  }>;
53
+ export type VeterinaryResearchStudyResource = ResearchStudyR5FlatClaimsResource;
92
54
  export type VeterinaryResearchStudyBatchEntry = Readonly<{
93
55
  type: 'ResearchStudy-v5.0.0';
94
56
  resource: VeterinaryResearchStudyResource;
@@ -100,6 +62,18 @@ export type VeterinaryResearchStudyBatchEntry = Readonly<{
100
62
  export type VeterinaryResearchStudyBatch = Readonly<{
101
63
  data: readonly VeterinaryResearchStudyBatchEntry[];
102
64
  }>;
65
+ export type VeterinaryResearchStudyGroupResource = GroupR5FlatClaimsResource;
66
+ export type VeterinaryResearchStudyGroupBatchEntry = Readonly<{
67
+ type: 'Group-v5.0.0';
68
+ resource: VeterinaryResearchStudyGroupResource;
69
+ request: Readonly<{
70
+ method: 'POST' | 'PATCH';
71
+ url: string;
72
+ }>;
73
+ }>;
74
+ export type VeterinaryResearchStudyGroupBatch = Readonly<{
75
+ data: readonly VeterinaryResearchStudyGroupBatchEntry[];
76
+ }>;
103
77
  export type VeterinaryResearchStudySmartAuthorization = Readonly<{
104
78
  purpose: typeof HealthcareConsentPurposes.Research;
105
79
  researchStudyReference: string;
@@ -116,7 +90,6 @@ export type VeterinaryResearchStudyActiveConsentInput = Readonly<{
116
90
  export type VeterinaryResearchStudyActiveConsent = Readonly<{
117
91
  resourceType: typeof ResourceTypesFhirR5.Consent;
118
92
  id: string;
119
- status: typeof ConsentStatuses.Active;
120
93
  meta: Readonly<{
121
94
  claims: Readonly<Record<string, string>>;
122
95
  }>;
@@ -160,16 +133,39 @@ export declare function buildVeterinaryResearchStudySmartAuthorization(input: Re
160
133
  * @see https://hl7.org/fhir/R5/researchstudy.html
161
134
  */
162
135
  export declare function buildVeterinaryResearchStudyActiveConsentBatch(inputs: readonly VeterinaryResearchStudyActiveConsentInput[]): VeterinaryResearchStudyActiveConsentBatch;
163
- /** Builds one POST Consent entry accepted by the VET ResearchStudy manager. */
136
+ /** Builds one flat-claims POST Consent entry accepted by the VET ResearchStudy manager. */
164
137
  export declare function buildVeterinaryResearchStudyActiveConsentEntry(input: VeterinaryResearchStudyActiveConsentInput): VeterinaryResearchStudyActiveConsentEntry;
165
138
  export declare function buildVeterinaryResearchStudyBatch(entries: readonly VeterinaryResearchStudyBatchEntry[]): VeterinaryResearchStudyBatch;
139
+ /**
140
+ * Builds the flat-claims Group POST used to create one enumerated ResearchStudy
141
+ * review team. Every member is added later as its own PractitionerRole claim
142
+ * PATCH. Group remains a description only and cannot carry access policy.
143
+ *
144
+ * @see https://hl7.org/fhir/R5/group.html
145
+ * @see https://hl7.org/fhir/R5/group-search.html
146
+ */
147
+ export declare function buildVeterinaryResearchStudyGroupCreateEntry(input: Readonly<{
148
+ group: GroupR5ResearchTeam;
149
+ }>): VeterinaryResearchStudyGroupBatchEntry;
150
+ /**
151
+ * Builds one independently correlated PATCH per PractitionerRole member. A
152
+ * PATCH changes descriptive membership only; DCR, Consent and SMART remain
153
+ * separate authorization boundaries.
154
+ */
155
+ export declare function buildVeterinaryResearchStudyGroupMemberPatchEntries(input: Readonly<{
156
+ groupId: string;
157
+ members: unknown;
158
+ }>): readonly VeterinaryResearchStudyGroupBatchEntry[];
159
+ /** Wraps only validated Group POST/PATCH entries in one JSON:API document. */
160
+ export declare function buildVeterinaryResearchStudyGroupBatch(entries: readonly VeterinaryResearchStudyGroupBatchEntry[]): VeterinaryResearchStudyGroupBatch;
166
161
  export declare function buildVeterinaryResearchStudyCreateEntry(input: Readonly<{
167
162
  study: VeterinaryResearchStudyCreateResource;
168
163
  }>): VeterinaryResearchStudyBatchEntry;
169
164
  /**
170
165
  * 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
166
+ * each party's claim map in its own entry prevents roles, periods and
167
+ * classifiers from being correlated with the wrong PractitionerRole. Native
168
+ * `associatedParty` JSON is not sent to persistence. These entries update
173
169
  * study membership only; they do not grant CRUDS access.
174
170
  */
175
171
  export declare function buildVeterinaryResearchStudyPartyPatchEntries(input: Readonly<{
@@ -178,17 +174,15 @@ export declare function buildVeterinaryResearchStudyPartyPatchEntries(input: Rea
178
174
  }>): readonly VeterinaryResearchStudyBatchEntry[];
179
175
  /**
180
176
  * 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`.
177
+ * party. The claims-only Communication carries an `application/vnd.api+json`
178
+ * primary document with exactly one claims-only `Consent.status = draft`.
183
179
  * Draft is descriptive only and can never satisfy authorization. The invitee
184
180
  * must finish DCR and a controller must later persist a separate active
185
181
  * Consent before the study-scoped SMART request can succeed.
186
182
  *
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.
183
+ * The flat Consent claims retain the exact study boundary and affected
184
+ * `ResearchSubject` resource family. Delete is deliberately absent. Native
185
+ * FHIR projection belongs only at an explicit import/export boundary.
192
186
  *
193
187
  * @see https://hl7.org/fhir/R5/communication.html
194
188
  * @see https://hl7.org/fhir/R5/consent.html
@@ -217,6 +211,5 @@ export declare function buildVeterinaryResearchStudyPartyInvitations(input: Read
217
211
  consentId: string;
218
212
  }>[];
219
213
  }>): readonly VeterinaryResearchStudyPartyInvitation[];
220
- /** Decodes the single attached Consent Bundle after validating its boundary. */
221
- export declare function decodeVeterinaryResearchStudyInvitationBundle(communication: VeterinaryResearchStudyPartyInvitation): VeterinaryResearchStudyInvitationBundle;
222
- export {};
214
+ /** Decodes the attached JSON:API Consent document after validating its claims-only boundary. */
215
+ export declare function decodeVeterinaryResearchStudyInvitationDocument(communication: VeterinaryResearchStudyPartyInvitation): VeterinaryResearchStudyInvitationDocument;
@@ -1,11 +1,12 @@
1
- import { normalizeResearchStudyAssociatedParties, projectResearchStudyAssociatedPartyClaims, projectResearchStudyR5SearchClaims, } from 'vet-data-utils-ts/research-study';
1
+ import { normalizeResearchStudyAssociatedParties, normalizeResearchStudyR5FlatClaimsResource, projectResearchStudyAssociatedPartyFlatClaimResources, projectResearchStudyR5FlatClaimsResource, } from 'vet-data-utils-ts/research-study';
2
2
  import { VeterinaryCommunicationPresetFilters, VeterinaryResearchCommunicationTopics, } from 'vet-data-utils-ts/communication';
3
+ import { normalizeGroupR5FlatClaimsResource, normalizeGroupR5ResearchTeam, projectGroupPractitionerRoleMemberFlatClaimResources, projectGroupR5FlatClaimsResource, } from 'vet-data-utils-ts/group';
4
+ import { HttpMediaTypes } from 'gdc-common-utils-ts/constants/http';
3
5
  import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
4
6
  import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
5
7
  import { InteroperableContext } from 'gdc-common-utils-ts/constants/lifecycle';
6
8
  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
+ import { CommunicationClaim } from 'gdc-common-utils-ts/models/interoperable-claims/communication-claims';
9
10
  export const VeterinaryResearchStudyPermissionActions = Object.freeze([
10
11
  'create',
11
12
  'read',
@@ -68,7 +69,7 @@ export function buildVeterinaryResearchStudyActiveConsentBatch(inputs) {
68
69
  const data = inputs.map(buildVeterinaryResearchStudyActiveConsentEntry);
69
70
  return deepFreeze({ data });
70
71
  }
71
- /** Builds one POST Consent entry accepted by the VET ResearchStudy manager. */
72
+ /** Builds one flat-claims POST Consent entry accepted by the VET ResearchStudy manager. */
72
73
  export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
73
74
  if (input.status !== undefined && input.status !== ConsentStatuses.Active) {
74
75
  throw new TypeError('veterinary_research_study_active_consent_status_invalid');
@@ -87,7 +88,6 @@ export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
87
88
  resource: {
88
89
  resourceType: ResourceTypesFhirR5.Consent,
89
90
  id: consentId,
90
- status: ConsentStatuses.Active,
91
91
  meta: {
92
92
  claims: {
93
93
  '@context': InteroperableContext.FhirApi,
@@ -108,48 +108,85 @@ export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
108
108
  export function buildVeterinaryResearchStudyBatch(entries) {
109
109
  if (!Array.isArray(entries) || entries.length === 0)
110
110
  throw new TypeError('veterinary_research_study_batch_empty');
111
- return deepFreeze({ data: entries.map(entry => clonePlain(entry)) });
111
+ return deepFreeze({ data: entries.map(normalizeResearchStudyBatchEntry) });
112
+ }
113
+ /**
114
+ * Builds the flat-claims Group POST used to create one enumerated ResearchStudy
115
+ * review team. Every member is added later as its own PractitionerRole claim
116
+ * PATCH. Group remains a description only and cannot carry access policy.
117
+ *
118
+ * @see https://hl7.org/fhir/R5/group.html
119
+ * @see https://hl7.org/fhir/R5/group-search.html
120
+ */
121
+ export function buildVeterinaryResearchStudyGroupCreateEntry(input) {
122
+ const group = normalizedResearchTeamGroup(input.group);
123
+ if (group.member.length > 0)
124
+ throw new TypeError('group_members_require_patch_entries');
125
+ return deepFreeze({
126
+ type: 'Group-v5.0.0',
127
+ resource: projectGroupR5FlatClaimsResource(group),
128
+ request: { method: 'POST', url: 'Group' },
129
+ });
130
+ }
131
+ /**
132
+ * Builds one independently correlated PATCH per PractitionerRole member. A
133
+ * PATCH changes descriptive membership only; DCR, Consent and SMART remain
134
+ * separate authorization boundaries.
135
+ */
136
+ export function buildVeterinaryResearchStudyGroupMemberPatchEntries(input) {
137
+ const id = boundedGroupId(input.groupId);
138
+ const resources = projectGroupPractitionerRoleMemberFlatClaimResources({ groupId: id, members: input.members });
139
+ return deepFreeze(resources.map(resource => ({
140
+ type: 'Group-v5.0.0',
141
+ resource,
142
+ request: { method: 'PATCH', url: `Group/${id}` },
143
+ })));
144
+ }
145
+ /** Wraps only validated Group POST/PATCH entries in one JSON:API document. */
146
+ export function buildVeterinaryResearchStudyGroupBatch(entries) {
147
+ if (!Array.isArray(entries) || entries.length === 0) {
148
+ throw new TypeError('veterinary_research_study_group_batch_empty');
149
+ }
150
+ return deepFreeze({ data: entries.map(normalizeResearchStudyGroupEntry) });
112
151
  }
113
152
  export function buildVeterinaryResearchStudyCreateEntry(input) {
114
153
  const study = normalizedStudy(input.study);
115
154
  return deepFreeze({
116
155
  type: 'ResearchStudy-v5.0.0',
117
- resource: withProjectedClaims(study),
156
+ resource: projectResearchStudyR5FlatClaimsResource(study),
118
157
  request: { method: 'POST', url: 'ResearchStudy' },
119
158
  });
120
159
  }
121
160
  /**
122
161
  * 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
162
+ * each party's claim map in its own entry prevents roles, periods and
163
+ * classifiers from being correlated with the wrong PractitionerRole. Native
164
+ * `associatedParty` JSON is not sent to persistence. These entries update
125
165
  * study membership only; they do not grant CRUDS access.
126
166
  */
127
167
  export function buildVeterinaryResearchStudyPartyPatchEntries(input) {
128
168
  const id = boundedId(input.researchStudyId);
129
- const parties = normalizeResearchStudyAssociatedParties(input.associatedParties);
130
- return deepFreeze(parties.map(party => ({
169
+ const resources = projectResearchStudyAssociatedPartyFlatClaimResources({
170
+ researchStudyId: id,
171
+ associatedParties: input.associatedParties,
172
+ });
173
+ return deepFreeze(resources.map(resource => ({
131
174
  type: 'ResearchStudy-v5.0.0',
132
- resource: withProjectedClaims({
133
- resourceType: 'ResearchStudy',
134
- id,
135
- associatedParty: [party],
136
- }),
175
+ resource,
137
176
  request: { method: 'PATCH', url: `ResearchStudy/${id}` },
138
177
  })));
139
178
  }
140
179
  /**
141
180
  * 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`.
181
+ * party. The claims-only Communication carries an `application/vnd.api+json`
182
+ * primary document with exactly one claims-only `Consent.status = draft`.
144
183
  * Draft is descriptive only and can never satisfy authorization. The invitee
145
184
  * must finish DCR and a controller must later persist a separate active
146
185
  * Consent before the study-scoped SMART request can succeed.
147
186
  *
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.
187
+ * The flat Consent claims retain the exact study boundary and affected
188
+ * `ResearchSubject` resource family. Delete is deliberately absent. Native
189
+ * FHIR projection belongs only at an explicit import/export boundary.
153
190
  *
154
191
  * @see https://hl7.org/fhir/R5/communication.html
155
192
  * @see https://hl7.org/fhir/R5/consent.html
@@ -177,64 +214,46 @@ export function buildVeterinaryResearchStudyPartyInvitation(input) {
177
214
  const consent = {
178
215
  resourceType: 'Consent',
179
216
  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
217
  meta: {
198
218
  claims: {
199
- [VeterinaryResearchStudyInvitationClaimNames.Context]: 'org.hl7.fhir.api',
200
- [VeterinaryResearchStudyInvitationClaimNames.Status]: 'draft',
201
- [VeterinaryResearchStudyInvitationClaimNames.Decision]: 'permit',
219
+ [VeterinaryResearchStudyInvitationClaimNames.Context]: InteroperableContext.FhirApi,
220
+ [VeterinaryResearchStudyInvitationClaimNames.Status]: ConsentStatuses.Draft,
221
+ [VeterinaryResearchStudyInvitationClaimNames.Decision]: ConsentDecisions.Permit,
202
222
  [VeterinaryResearchStudyInvitationClaimNames.ActorIdentifier]: partyReference,
223
+ [ClaimConsent.grantee]: partyReference,
203
224
  [VeterinaryResearchStudyInvitationClaimNames.Purpose]: topic.value,
204
225
  [VeterinaryResearchStudyInvitationClaimNames.Action]: VeterinaryResearchStudyPermissionActions.join(','),
205
226
  [VeterinaryResearchStudyInvitationClaimNames.ResourceType]: 'ResearchSubject',
227
+ [ClaimConsent.sourceReference]: researchStudyReference,
206
228
  },
207
229
  },
208
230
  };
209
- const bundle = {
210
- resourceType: 'Bundle',
211
- type: 'batch',
212
- entry: [{
213
- fullUrl: `urn:uuid:${consentId}`,
231
+ const document = {
232
+ data: [{
233
+ type: 'Consent-v5.0.0',
214
234
  resource: consent,
215
235
  request: { method: 'POST', url: 'Consent' },
216
236
  }],
217
237
  };
218
238
  const category = VeterinaryCommunicationPresetFilters.ResearchAgreements.categories[0];
219
- const [categorySystem, categoryCode] = splitCodingToken(category);
220
239
  const sent = optionalInstant(input.sentAt);
221
240
  return deepFreeze({
222
241
  resourceType: 'Communication',
223
242
  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
- }],
243
+ meta: { claims: {
244
+ '@context': InteroperableContext.FhirApi,
245
+ [CommunicationClaim.Identifier]: communicationId,
246
+ [CommunicationClaim.Status]: 'completed',
247
+ [CommunicationClaim.Category]: category,
248
+ [CommunicationClaim.Topic]: topic.value,
249
+ [CommunicationClaim.ContentReference]: researchStudyReference,
250
+ [CommunicationClaim.Recipient]: partyReference,
251
+ [CommunicationClaim.Sender]: controllerReference,
252
+ ...(sent ? { [CommunicationClaim.Sent]: sent } : {}),
253
+ [CommunicationClaim.ContentAttachmentType]: HttpMediaTypes.JsonApi,
254
+ [CommunicationClaim.ContentAttachmentTitle]: 'research-study-draft-consent.json',
255
+ [CommunicationClaim.ContentAttachmentData]: encodeJsonBase64(document),
256
+ } },
238
257
  });
239
258
  }
240
259
  /**
@@ -253,41 +272,77 @@ export function buildVeterinaryResearchStudyPartyInvitations(input) {
253
272
  ...invitation,
254
273
  })));
255
274
  }
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');
275
+ /** Decodes the attached JSON:API Consent document after validating its claims-only boundary. */
276
+ export function decodeVeterinaryResearchStudyInvitationDocument(communication) {
277
+ if (!isClaimsOnlyResource(communication, 'Communication')) {
278
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
279
+ }
280
+ const claims = communication.meta.claims;
281
+ const encoded = claims[CommunicationClaim.ContentAttachmentData];
282
+ if (claims[CommunicationClaim.ContentAttachmentType] !== HttpMediaTypes.JsonApi || !encoded) {
283
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
263
284
  }
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');
285
+ let decoded;
286
+ try {
287
+ decoded = JSON.parse(decodeJsonBase64(encoded));
288
+ }
289
+ catch {
290
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
291
+ }
292
+ const entry = decoded?.data?.[0];
293
+ if (decoded?.data?.length !== 1
294
+ || entry?.type !== 'Consent-v5.0.0'
295
+ || entry.request?.method !== 'POST'
296
+ || entry.request.url !== 'Consent'
297
+ || !isClaimsOnlyResource(entry.resource, 'Consent')
298
+ || entry.resource.meta.claims[ClaimConsent.status] !== ConsentStatuses.Draft) {
299
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
271
300
  }
272
301
  return deepFreeze(decoded);
273
302
  }
274
303
  function normalizedStudy(input) {
275
304
  if (!input || input.resourceType !== 'ResearchStudy')
276
305
  throw new TypeError('veterinary_research_study_invalid');
306
+ if (input.associatedParty !== undefined) {
307
+ throw new TypeError('veterinary_research_study_parties_require_patch_entries');
308
+ }
277
309
  const source = clonePlain(input);
278
310
  const id = boundedId(input.id);
279
311
  const status = String(input.status || '');
280
312
  if (!['draft', 'active', 'retired', 'unknown'].includes(status)) {
281
313
  throw new TypeError('veterinary_research_study_status_invalid');
282
314
  }
283
- const associatedParty = input.associatedParty === undefined
284
- ? undefined
285
- : normalizeResearchStudyAssociatedParties(input.associatedParty);
286
315
  return deepFreeze({
287
316
  ...source,
288
317
  id,
289
318
  status: status,
290
- ...(associatedParty ? { associatedParty } : {}),
319
+ });
320
+ }
321
+ function normalizedResearchTeamGroup(input) {
322
+ const source = clonePlain(input);
323
+ const normalized = normalizeGroupR5ResearchTeam(source);
324
+ return deepFreeze({ ...normalized, id: boundedGroupId(normalized.id) });
325
+ }
326
+ function normalizeResearchStudyGroupEntry(input) {
327
+ if (!input || input.type !== 'Group-v5.0.0') {
328
+ throw new TypeError('veterinary_research_study_group_entry_invalid');
329
+ }
330
+ let resource;
331
+ try {
332
+ resource = normalizeGroupR5FlatClaimsResource(input.resource);
333
+ }
334
+ catch {
335
+ throw new TypeError('veterinary_research_study_group_entry_invalid');
336
+ }
337
+ const method = input.request?.method;
338
+ const expectedUrl = method === 'POST' ? 'Group' : method === 'PATCH' ? `Group/${resource.id}` : '';
339
+ if (!expectedUrl || input.request.url !== expectedUrl) {
340
+ throw new TypeError('veterinary_research_study_group_request_invalid');
341
+ }
342
+ return deepFreeze({
343
+ type: 'Group-v5.0.0',
344
+ resource,
345
+ request: { method, url: expectedUrl },
291
346
  });
292
347
  }
293
348
  function clonePlain(value) {
@@ -298,33 +353,44 @@ function clonePlain(value) {
298
353
  }
299
354
  return value;
300
355
  }
301
- function withProjectedClaims(study) {
302
- const standardClaims = projectResearchStudyR5SearchClaims(study);
303
- const partyClaims = aggregatePartyClaims(study.associatedParty || []);
356
+ function normalizeResearchStudyBatchEntry(input) {
357
+ if (!input || input.type !== 'ResearchStudy-v5.0.0') {
358
+ throw new TypeError('veterinary_research_study_entry_invalid');
359
+ }
360
+ let resource;
361
+ try {
362
+ resource = normalizeResearchStudyR5FlatClaimsResource(input.resource);
363
+ }
364
+ catch {
365
+ throw new TypeError('veterinary_research_study_entry_invalid');
366
+ }
367
+ const method = input.request?.method;
368
+ const expectedUrl = method === 'POST'
369
+ ? 'ResearchStudy'
370
+ : method === 'PATCH'
371
+ ? `ResearchStudy/${resource.id}`
372
+ : '';
373
+ if (!expectedUrl || input.request.url !== expectedUrl) {
374
+ throw new TypeError('veterinary_research_study_request_invalid');
375
+ }
304
376
  return deepFreeze({
305
- ...study,
306
- meta: {
307
- ...(study.meta || {}),
308
- claims: { ...standardClaims, ...partyClaims },
309
- },
377
+ type: 'ResearchStudy-v5.0.0',
378
+ resource,
379
+ request: { method, url: expectedUrl },
310
380
  });
311
381
  }
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
382
  function boundedId(value) {
323
383
  const id = String(value || '').trim();
324
384
  if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
325
385
  throw new TypeError('veterinary_research_study_id_invalid');
326
386
  return id;
327
387
  }
388
+ function boundedGroupId(value) {
389
+ const id = String(value || '').trim();
390
+ if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
391
+ throw new TypeError('veterinary_research_study_group_id_invalid');
392
+ return id;
393
+ }
328
394
  function boundedReference(value, resourceType) {
329
395
  const reference = String(value || '').trim();
330
396
  const prefix = `${resourceType}/`;
@@ -352,13 +418,6 @@ function optionalInstant(value) {
352
418
  throw new TypeError('veterinary_research_study_invitation_sent_invalid');
353
419
  return instant;
354
420
  }
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
421
  function encodeJsonBase64(value) {
363
422
  const bytes = new TextEncoder().encode(JSON.stringify(value));
364
423
  let binary = '';
@@ -379,3 +438,15 @@ function deepFreeze(value, seen = new WeakSet()) {
379
438
  deepFreeze(nested, seen);
380
439
  return Object.freeze(value);
381
440
  }
441
+ function isRecord(value) {
442
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
443
+ }
444
+ function isClaimsOnlyResource(value, resourceType) {
445
+ if (!isRecord(value) || value.resourceType !== resourceType)
446
+ return false;
447
+ if (Object.keys(value).some(key => !['resourceType', 'id', 'meta'].includes(key)))
448
+ return false;
449
+ if (typeof value.id !== 'string' || !isRecord(value.meta) || !isRecord(value.meta.claims))
450
+ return false;
451
+ return Object.values(value.meta.claims).every(claim => typeof claim === 'string' && claim.trim().length > 0);
452
+ }
@@ -4,8 +4,9 @@ This SDK separates study membership, an invitation, and authorization. They
4
4
  are three different facts:
5
5
 
6
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`.
7
+ 2. One claims-only `Communication` invites that exact `PractitionerRole` and
8
+ carries a JSON:API primary document with one claims-only
9
+ `Consent.status = draft`.
9
10
  3. Only after the professional completes DCR and the controller persists a
10
11
  later `Consent.status = active` may the professional request the
11
12
  study-scoped SMART authorization.
@@ -17,10 +18,30 @@ related to the exact ResearchStudy. Whole-twin delete is absent.
17
18
  ```ts
18
19
  import {
19
20
  buildVeterinaryResearchStudyActiveConsentBatch,
21
+ buildVeterinaryResearchStudyGroupBatch,
22
+ buildVeterinaryResearchStudyGroupCreateEntry,
23
+ buildVeterinaryResearchStudyGroupMemberPatchEntries,
20
24
  buildVeterinaryResearchStudyPartyInvitations,
21
25
  buildVeterinaryResearchStudySmartAuthorization,
22
26
  } from 'vet-sdk-core-ts/research-study'
23
27
 
28
+ const researchGroupId = 'review-team'
29
+ const groupCreate = buildVeterinaryResearchStudyGroupCreateEntry({
30
+ group: {
31
+ resourceType: 'Group',
32
+ id: researchGroupId,
33
+ type: 'practitioner',
34
+ membership: 'enumerated',
35
+ member: [],
36
+ },
37
+ })
38
+ const memberPatches = buildVeterinaryResearchStudyGroupMemberPatchEntries({
39
+ groupId: researchGroupId,
40
+ members: associatedParties.map(({ party }) => ({ entity: party })),
41
+ })
42
+ const groupBatch = buildVeterinaryResearchStudyGroupBatch([groupCreate, ...memberPatches])
43
+ // The protected controller BFF submits `groupBatch`; membership grants no access.
44
+
24
45
  const invitations = buildVeterinaryResearchStudyPartyInvitations({
25
46
  researchStudyId: study.id,
26
47
  controllerReference: organizationReference,
@@ -60,23 +81,48 @@ const authorization = buildVeterinaryResearchStudySmartAuthorization({
60
81
  await professionalRuntime.requestSmartToken(authorization)
61
82
  ```
62
83
 
84
+ The controller may independently create a descriptive review team with
85
+ `buildVeterinaryResearchStudyGroupCreateEntry()` and append each registered
86
+ `PractitionerRole` with
87
+ `buildVeterinaryResearchStudyGroupMemberPatchEntries()`. Individual
88
+ professionals are represented in the study by their separate
89
+ `ResearchStudy.associatedParty` PATCH entries. The professional Group is not
90
+ `ResearchStudy.recruitment.eligibility`: that FHIR element describes the
91
+ research-subject cohort or its eligibility criteria. Group membership is not
92
+ evidence of DCR or Consent and never grants the SMART authorization above.
93
+
94
+ GW persistence entries for ResearchStudy, professional Group and active
95
+ Consent contain only `resourceType`, `id` and `resource.meta.claims`. Their
96
+ FHIR-like field names are flat claims. Native nested FHIR JSON is reserved for
97
+ an explicit export/projection boundary, not these GW builders.
98
+
99
+ The draft Consent inside the Communication attachment is an explicit
100
+ `application/vnd.api+json` proposal for recipient review. Both the outer
101
+ Communication and the attached Consent contain only `resourceType`, `id` and
102
+ `resource.meta.claims`. It never authorizes access; only the later flat-claims
103
+ active Consent POST is evaluated by GW.
104
+
105
+ These builders follow the native [FHIR R5 Group resource](https://hl7.org/fhir/R5/group.html)
106
+ and its exact [resource-specific search catalogue](https://hl7.org/fhir/R5/group-search.html).
107
+
63
108
  The SMART authorization intentionally does not carry `subjectDid`:
64
109
  `openProfessional()` supplies its DCR-bound actor as `sub`, while GW derives
65
110
  the authorization subject `ResearchStudy/<id>` from the exact unescaped scope.
66
111
  Percent-encoding the `/` in that scope would no longer match the active
67
112
  Consent action and is rejected.
68
113
 
69
- The Bundle is encoded as the standard
70
- `Communication.payload.contentAttachment` with
71
- `contentType = application/fhir+json`. The Consent uses:
114
+ The JSON:API primary document is base64 encoded in canonical
115
+ `Communication.content-attachment-data`, paired with
116
+ `Communication.content-attachment-type = application/vnd.api+json`. The
117
+ Consent claims use:
72
118
 
73
- - `Consent.grantee` and `Consent.provision.actor` for the invited
119
+ - `Consent.grantee` and `Consent.actor-identifier` for the invited
74
120
  `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`.
121
+ - outer `Communication.sender` for the controller organization;
122
+ - `Consent.purpose` with HL7 v3 ActReason `HRESCH`;
123
+ - `Consent.resource-type` for `ResearchSubject`;
124
+ - `Consent.source-reference` for the exact `ResearchStudy/{id}`;
125
+ - `Consent.action` for `create`, `read`, `update`, and `search`.
80
126
 
81
127
  FHIR R5 references:
82
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-sdk-core-ts",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
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",
@@ -64,6 +64,6 @@
64
64
  "dependencies": {
65
65
  "@noble/hashes": "^2.2.0",
66
66
  "gdc-common-utils-ts": "2.9.4",
67
- "vet-data-utils-ts": "0.5.0"
67
+ "vet-data-utils-ts": "0.5.2"
68
68
  }
69
69
  }