vet-sdk-core-ts 0.4.13 → 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,26 +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.
43
45
 
44
46
  `buildVeterinaryResearchStudyGroupCreateEntry` and
45
- `buildVeterinaryResearchStudyGroupMemberPatchEntries` build the native R5 Group
47
+ `buildVeterinaryResearchStudyGroupMemberPatchEntries` build flat-claim Group
46
48
  POST/PATCH entries for an enumerated professional review team. Every member is
47
49
  a registered `PractitionerRole`; the exact twelve Group search claims are
48
- derived from `vet-data-utils-ts/group`. The ResearchStudy points to the team
49
- through the reference returned by
50
- `buildVeterinaryResearchStudyEligibilityGroupReference`, used as
51
- `ResearchStudy.recruitment.eligibility`.
52
- `buildVeterinaryResearchStudyEligibilityGroupPatchEntry` links an existing
53
- study and is wrapped by `buildVeterinaryResearchStudyBatch`; it does not invent
54
- a mixed-resource gateway batch. Group itself contains no reverse study link
55
- and grants no authorization, permission or SMART scope.
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.
56
56
 
57
57
  The complete controller-to-professional contract and a browser-safe snippet
58
58
  are in
@@ -1,5 +1,5 @@
1
- import { type ResearchStudyAssociatedParty } from 'vet-data-utils-ts/research-study';
2
- import { type GroupR5ResearchTeam } from 'vet-data-utils-ts/group';
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';
3
3
  import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
4
4
  import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
5
5
  import { ClaimConsent, ConsentStatuses } from 'gdc-common-utils-ts/models/consent-rule';
@@ -14,46 +14,16 @@ export declare const VeterinaryResearchStudyInvitationClaimNames: Readonly<{
14
14
  readonly Action: ClaimConsent.action;
15
15
  readonly ResourceType: ClaimConsent.resourceType;
16
16
  }>;
17
- type FhirReference = Readonly<{
18
- reference: string;
19
- type?: string;
20
- }>;
21
- type FhirCoding = Readonly<{
22
- system: string;
23
- code: string;
24
- }>;
25
- type FhirCodeableConcept = Readonly<{
26
- coding: readonly FhirCoding[];
27
- }>;
28
17
  export type VeterinaryResearchStudyDraftConsent = Readonly<{
29
18
  resourceType: 'Consent';
30
19
  id: string;
31
- status: 'draft';
32
- grantor: readonly FhirReference[];
33
- controller: readonly FhirReference[];
34
- grantee: readonly FhirReference[];
35
- decision: 'permit';
36
- provision: readonly Readonly<{
37
- actor: readonly Readonly<{
38
- reference: FhirReference;
39
- }>[];
40
- action: readonly FhirCodeableConcept[];
41
- purpose: readonly FhirCoding[];
42
- resourceType: readonly FhirCoding[];
43
- data: readonly Readonly<{
44
- meaning: 'related';
45
- reference: FhirReference;
46
- }>[];
47
- }>[];
48
20
  meta: Readonly<{
49
21
  claims: Readonly<Record<string, string>>;
50
22
  }>;
51
23
  }>;
52
- export type VeterinaryResearchStudyInvitationBundle = Readonly<{
53
- resourceType: 'Bundle';
54
- type: 'batch';
55
- entry: readonly Readonly<{
56
- fullUrl: string;
24
+ export type VeterinaryResearchStudyInvitationDocument = Readonly<{
25
+ data: readonly Readonly<{
26
+ type: 'Consent-v5.0.0';
57
27
  resource: VeterinaryResearchStudyDraftConsent;
58
28
  request: Readonly<{
59
29
  method: 'POST';
@@ -64,22 +34,12 @@ export type VeterinaryResearchStudyInvitationBundle = Readonly<{
64
34
  export type VeterinaryResearchStudyPartyInvitation = Readonly<{
65
35
  resourceType: 'Communication';
66
36
  id: string;
67
- status: 'completed';
68
- category: readonly FhirCodeableConcept[];
69
- topic: FhirCodeableConcept;
70
- about: readonly FhirReference[];
71
- recipient: readonly FhirReference[];
72
- sender: FhirReference;
73
- sent?: string;
74
- payload: readonly Readonly<{
75
- contentAttachment: Readonly<{
76
- contentType: 'application/fhir+json';
77
- title: 'research-study-draft-consent.json';
78
- data: string;
79
- }>;
80
- }>[];
37
+ meta: Readonly<{
38
+ claims: Readonly<Record<string, string>>;
39
+ }>;
81
40
  }>;
82
- export type VeterinaryResearchStudyResource = Readonly<{
41
+ /** Native-shaped builder input used only to derive the persisted flat claims. */
42
+ export type VeterinaryResearchStudySourceResource = Readonly<{
83
43
  resourceType: 'ResearchStudy';
84
44
  id: string;
85
45
  status?: 'draft' | 'active' | 'retired' | 'unknown';
@@ -87,9 +47,10 @@ export type VeterinaryResearchStudyResource = Readonly<{
87
47
  meta?: Readonly<Record<string, unknown>>;
88
48
  [key: string]: unknown;
89
49
  }>;
90
- export type VeterinaryResearchStudyCreateResource = VeterinaryResearchStudyResource & Readonly<{
91
- status: NonNullable<VeterinaryResearchStudyResource['status']>;
50
+ export type VeterinaryResearchStudyCreateResource = VeterinaryResearchStudySourceResource & Readonly<{
51
+ status: NonNullable<VeterinaryResearchStudySourceResource['status']>;
92
52
  }>;
53
+ export type VeterinaryResearchStudyResource = ResearchStudyR5FlatClaimsResource;
93
54
  export type VeterinaryResearchStudyBatchEntry = Readonly<{
94
55
  type: 'ResearchStudy-v5.0.0';
95
56
  resource: VeterinaryResearchStudyResource;
@@ -101,13 +62,7 @@ export type VeterinaryResearchStudyBatchEntry = Readonly<{
101
62
  export type VeterinaryResearchStudyBatch = Readonly<{
102
63
  data: readonly VeterinaryResearchStudyBatchEntry[];
103
64
  }>;
104
- export type VeterinaryResearchStudyGroupResource = GroupR5ResearchTeam & Readonly<{
105
- id: string;
106
- meta: Readonly<{
107
- claims: Readonly<Record<string, readonly string[]>>;
108
- [key: string]: unknown;
109
- }>;
110
- }>;
65
+ export type VeterinaryResearchStudyGroupResource = GroupR5FlatClaimsResource;
111
66
  export type VeterinaryResearchStudyGroupBatchEntry = Readonly<{
112
67
  type: 'Group-v5.0.0';
113
68
  resource: VeterinaryResearchStudyGroupResource;
@@ -135,7 +90,6 @@ export type VeterinaryResearchStudyActiveConsentInput = Readonly<{
135
90
  export type VeterinaryResearchStudyActiveConsent = Readonly<{
136
91
  resourceType: typeof ResourceTypesFhirR5.Consent;
137
92
  id: string;
138
- status: typeof ConsentStatuses.Active;
139
93
  meta: Readonly<{
140
94
  claims: Readonly<Record<string, string>>;
141
95
  }>;
@@ -179,39 +133,13 @@ export declare function buildVeterinaryResearchStudySmartAuthorization(input: Re
179
133
  * @see https://hl7.org/fhir/R5/researchstudy.html
180
134
  */
181
135
  export declare function buildVeterinaryResearchStudyActiveConsentBatch(inputs: readonly VeterinaryResearchStudyActiveConsentInput[]): VeterinaryResearchStudyActiveConsentBatch;
182
- /** 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. */
183
137
  export declare function buildVeterinaryResearchStudyActiveConsentEntry(input: VeterinaryResearchStudyActiveConsentInput): VeterinaryResearchStudyActiveConsentEntry;
184
138
  export declare function buildVeterinaryResearchStudyBatch(entries: readonly VeterinaryResearchStudyBatchEntry[]): VeterinaryResearchStudyBatch;
185
139
  /**
186
- * Returns the native eligibility reference by which an R5 ResearchStudy links
187
- * to its descriptive professional Group. The direction is deliberately from
188
- * `ResearchStudy.recruitment.eligibility` to Group; no invented study field or
189
- * search claim is added to Group.
190
- *
191
- * @see https://hl7.org/fhir/R5/researchstudy.html
192
- * @see https://hl7.org/fhir/R5/group.html
193
- */
194
- export declare function buildVeterinaryResearchStudyEligibilityGroupReference(groupId: string): Readonly<{
195
- reference: `Group/${string}`;
196
- type: 'Group';
197
- }>;
198
- /**
199
- * Builds the native ResearchStudy PATCH that links a descriptive Group through
200
- * `ResearchStudy.recruitment.eligibility`. The existing ResearchStudy batch
201
- * builder wraps this entry; Group and ResearchStudy remain separate resource
202
- * endpoints and no mixed-resource transport envelope is invented.
203
- *
204
- * @see https://hl7.org/fhir/R5/researchstudy.html
205
- * @see https://hl7.org/fhir/R5/researchstudy-search.html
206
- */
207
- export declare function buildVeterinaryResearchStudyEligibilityGroupPatchEntry(input: Readonly<{
208
- researchStudyId: string;
209
- groupId: string;
210
- }>): VeterinaryResearchStudyBatchEntry;
211
- /**
212
- * Builds the Group POST used to create one enumerated ResearchStudy review
213
- * team. Every member is a native PractitionerRole reference. Group remains a
214
- * description only and the generated resource cannot carry access policy.
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.
215
143
  *
216
144
  * @see https://hl7.org/fhir/R5/group.html
217
145
  * @see https://hl7.org/fhir/R5/group-search.html
@@ -235,8 +163,9 @@ export declare function buildVeterinaryResearchStudyCreateEntry(input: Readonly<
235
163
  }>): VeterinaryResearchStudyBatchEntry;
236
164
  /**
237
165
  * Builds one independent `_batch` PATCH entry per R5 associatedParty. Keeping
238
- * each complete party in its own entry prevents roles, periods and classifiers
239
- * 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
240
169
  * study membership only; they do not grant CRUDS access.
241
170
  */
242
171
  export declare function buildVeterinaryResearchStudyPartyPatchEntries(input: Readonly<{
@@ -245,17 +174,15 @@ export declare function buildVeterinaryResearchStudyPartyPatchEntries(input: Rea
245
174
  }>): readonly VeterinaryResearchStudyBatchEntry[];
246
175
  /**
247
176
  * Builds the auditable R5 invitation sent to one ResearchStudy associated
248
- * party. The Communication payload is an `application/fhir+json` attachment
249
- * 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`.
250
179
  * Draft is descriptive only and can never satisfy authorization. The invitee
251
180
  * must finish DCR and a controller must later persist a separate active
252
181
  * Consent before the study-scoped SMART request can succeed.
253
182
  *
254
- * Native R5 fields retain the exact study boundary in
255
- * `Consent.provision.data.reference` and the affected `ResearchSubject`
256
- * resource family. Project authorization actions remain create/read/update/
257
- * search; their FHIR representation uses the matching REST interaction codes,
258
- * 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.
259
186
  *
260
187
  * @see https://hl7.org/fhir/R5/communication.html
261
188
  * @see https://hl7.org/fhir/R5/consent.html
@@ -284,6 +211,5 @@ export declare function buildVeterinaryResearchStudyPartyInvitations(input: Read
284
211
  consentId: string;
285
212
  }>[];
286
213
  }>): readonly VeterinaryResearchStudyPartyInvitation[];
287
- /** Decodes the single attached Consent Bundle after validating its boundary. */
288
- export declare function decodeVeterinaryResearchStudyInvitationBundle(communication: VeterinaryResearchStudyPartyInvitation): VeterinaryResearchStudyInvitationBundle;
289
- export {};
214
+ /** Decodes the attached JSON:API Consent document after validating its claims-only boundary. */
215
+ export declare function decodeVeterinaryResearchStudyInvitationDocument(communication: VeterinaryResearchStudyPartyInvitation): VeterinaryResearchStudyInvitationDocument;
@@ -1,12 +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 { normalizeGroupPractitionerRoleMembers, normalizeGroupR5ResearchTeam, projectGroupR5SearchClaims, } from 'vet-data-utils-ts/group';
3
+ import { normalizeGroupR5FlatClaimsResource, normalizeGroupR5ResearchTeam, projectGroupPractitionerRoleMemberFlatClaimResources, projectGroupR5FlatClaimsResource, } from 'vet-data-utils-ts/group';
4
+ import { HttpMediaTypes } from 'gdc-common-utils-ts/constants/http';
4
5
  import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
5
6
  import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
6
7
  import { InteroperableContext } from 'gdc-common-utils-ts/constants/lifecycle';
7
8
  import { ClaimConsent, ConsentDecisions, ConsentStatuses, } from 'gdc-common-utils-ts/models/consent-rule';
8
- const FHIR_RESTFUL_INTERACTION_SYSTEM = 'http://hl7.org/fhir/restful-interaction';
9
- const FHIR_RESOURCE_TYPES_SYSTEM = 'http://hl7.org/fhir/fhir-types';
9
+ import { CommunicationClaim } from 'gdc-common-utils-ts/models/interoperable-claims/communication-claims';
10
10
  export const VeterinaryResearchStudyPermissionActions = Object.freeze([
11
11
  'create',
12
12
  'read',
@@ -69,7 +69,7 @@ export function buildVeterinaryResearchStudyActiveConsentBatch(inputs) {
69
69
  const data = inputs.map(buildVeterinaryResearchStudyActiveConsentEntry);
70
70
  return deepFreeze({ data });
71
71
  }
72
- /** 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. */
73
73
  export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
74
74
  if (input.status !== undefined && input.status !== ConsentStatuses.Active) {
75
75
  throw new TypeError('veterinary_research_study_active_consent_status_invalid');
@@ -88,7 +88,6 @@ export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
88
88
  resource: {
89
89
  resourceType: ResourceTypesFhirR5.Consent,
90
90
  id: consentId,
91
- status: ConsentStatuses.Active,
92
91
  meta: {
93
92
  claims: {
94
93
  '@context': InteroperableContext.FhirApi,
@@ -109,58 +108,23 @@ export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
109
108
  export function buildVeterinaryResearchStudyBatch(entries) {
110
109
  if (!Array.isArray(entries) || entries.length === 0)
111
110
  throw new TypeError('veterinary_research_study_batch_empty');
112
- return deepFreeze({ data: entries.map(entry => clonePlain(entry)) });
111
+ return deepFreeze({ data: entries.map(normalizeResearchStudyBatchEntry) });
113
112
  }
114
113
  /**
115
- * Returns the native eligibility reference by which an R5 ResearchStudy links
116
- * to its descriptive professional Group. The direction is deliberately from
117
- * `ResearchStudy.recruitment.eligibility` to Group; no invented study field or
118
- * search claim is added to Group.
119
- *
120
- * @see https://hl7.org/fhir/R5/researchstudy.html
121
- * @see https://hl7.org/fhir/R5/group.html
122
- */
123
- export function buildVeterinaryResearchStudyEligibilityGroupReference(groupId) {
124
- return deepFreeze({ reference: `Group/${boundedGroupId(groupId)}`, type: 'Group' });
125
- }
126
- /**
127
- * Builds the native ResearchStudy PATCH that links a descriptive Group through
128
- * `ResearchStudy.recruitment.eligibility`. The existing ResearchStudy batch
129
- * builder wraps this entry; Group and ResearchStudy remain separate resource
130
- * endpoints and no mixed-resource transport envelope is invented.
131
- *
132
- * @see https://hl7.org/fhir/R5/researchstudy.html
133
- * @see https://hl7.org/fhir/R5/researchstudy-search.html
134
- */
135
- export function buildVeterinaryResearchStudyEligibilityGroupPatchEntry(input) {
136
- if (containsAuthorizationProperty(input)) {
137
- throw new TypeError('veterinary_research_study_group_authorization_forbidden');
138
- }
139
- const researchStudyId = boundedId(input.researchStudyId);
140
- const eligibility = buildVeterinaryResearchStudyEligibilityGroupReference(input.groupId);
141
- return deepFreeze({
142
- type: 'ResearchStudy-v5.0.0',
143
- resource: withProjectedClaims({
144
- resourceType: 'ResearchStudy',
145
- id: researchStudyId,
146
- recruitment: { eligibility },
147
- }),
148
- request: { method: 'PATCH', url: `ResearchStudy/${researchStudyId}` },
149
- });
150
- }
151
- /**
152
- * Builds the Group POST used to create one enumerated ResearchStudy review
153
- * team. Every member is a native PractitionerRole reference. Group remains a
154
- * description only and the generated resource cannot carry access policy.
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.
155
117
  *
156
118
  * @see https://hl7.org/fhir/R5/group.html
157
119
  * @see https://hl7.org/fhir/R5/group-search.html
158
120
  */
159
121
  export function buildVeterinaryResearchStudyGroupCreateEntry(input) {
160
122
  const group = normalizedResearchTeamGroup(input.group);
123
+ if (group.member.length > 0)
124
+ throw new TypeError('group_members_require_patch_entries');
161
125
  return deepFreeze({
162
126
  type: 'Group-v5.0.0',
163
- resource: withProjectedGroupClaims(group),
127
+ resource: projectGroupR5FlatClaimsResource(group),
164
128
  request: { method: 'POST', url: 'Group' },
165
129
  });
166
130
  }
@@ -171,16 +135,10 @@ export function buildVeterinaryResearchStudyGroupCreateEntry(input) {
171
135
  */
172
136
  export function buildVeterinaryResearchStudyGroupMemberPatchEntries(input) {
173
137
  const id = boundedGroupId(input.groupId);
174
- const members = normalizeGroupPractitionerRoleMembers(input.members);
175
- return deepFreeze(members.map(member => ({
138
+ const resources = projectGroupPractitionerRoleMemberFlatClaimResources({ groupId: id, members: input.members });
139
+ return deepFreeze(resources.map(resource => ({
176
140
  type: 'Group-v5.0.0',
177
- resource: withProjectedGroupClaims(deepFreeze({
178
- resourceType: 'Group',
179
- id,
180
- type: 'practitioner',
181
- membership: 'enumerated',
182
- member: [member],
183
- })),
141
+ resource,
184
142
  request: { method: 'PATCH', url: `Group/${id}` },
185
143
  })));
186
144
  }
@@ -195,42 +153,40 @@ export function buildVeterinaryResearchStudyCreateEntry(input) {
195
153
  const study = normalizedStudy(input.study);
196
154
  return deepFreeze({
197
155
  type: 'ResearchStudy-v5.0.0',
198
- resource: withProjectedClaims(study),
156
+ resource: projectResearchStudyR5FlatClaimsResource(study),
199
157
  request: { method: 'POST', url: 'ResearchStudy' },
200
158
  });
201
159
  }
202
160
  /**
203
161
  * Builds one independent `_batch` PATCH entry per R5 associatedParty. Keeping
204
- * each complete party in its own entry prevents roles, periods and classifiers
205
- * 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
206
165
  * study membership only; they do not grant CRUDS access.
207
166
  */
208
167
  export function buildVeterinaryResearchStudyPartyPatchEntries(input) {
209
168
  const id = boundedId(input.researchStudyId);
210
- const parties = normalizeResearchStudyAssociatedParties(input.associatedParties);
211
- return deepFreeze(parties.map(party => ({
169
+ const resources = projectResearchStudyAssociatedPartyFlatClaimResources({
170
+ researchStudyId: id,
171
+ associatedParties: input.associatedParties,
172
+ });
173
+ return deepFreeze(resources.map(resource => ({
212
174
  type: 'ResearchStudy-v5.0.0',
213
- resource: withProjectedClaims({
214
- resourceType: 'ResearchStudy',
215
- id,
216
- associatedParty: [party],
217
- }),
175
+ resource,
218
176
  request: { method: 'PATCH', url: `ResearchStudy/${id}` },
219
177
  })));
220
178
  }
221
179
  /**
222
180
  * Builds the auditable R5 invitation sent to one ResearchStudy associated
223
- * party. The Communication payload is an `application/fhir+json` attachment
224
- * 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`.
225
183
  * Draft is descriptive only and can never satisfy authorization. The invitee
226
184
  * must finish DCR and a controller must later persist a separate active
227
185
  * Consent before the study-scoped SMART request can succeed.
228
186
  *
229
- * Native R5 fields retain the exact study boundary in
230
- * `Consent.provision.data.reference` and the affected `ResearchSubject`
231
- * resource family. Project authorization actions remain create/read/update/
232
- * search; their FHIR representation uses the matching REST interaction codes,
233
- * 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.
234
190
  *
235
191
  * @see https://hl7.org/fhir/R5/communication.html
236
192
  * @see https://hl7.org/fhir/R5/consent.html
@@ -258,64 +214,46 @@ export function buildVeterinaryResearchStudyPartyInvitation(input) {
258
214
  const consent = {
259
215
  resourceType: 'Consent',
260
216
  id: consentId,
261
- status: 'draft',
262
- grantor: [{ reference: controllerReference, type: 'Organization' }],
263
- controller: [{ reference: controllerReference, type: 'Organization' }],
264
- grantee: [{ reference: partyReference, type: 'PractitionerRole' }],
265
- decision: 'permit',
266
- provision: [{
267
- actor: [{ reference: { reference: partyReference, type: 'PractitionerRole' } }],
268
- action: VeterinaryResearchStudyPermissionActions.map(action => ({
269
- coding: [{
270
- system: FHIR_RESTFUL_INTERACTION_SYSTEM,
271
- code: action === 'search' ? 'search-type' : action,
272
- }],
273
- })),
274
- purpose: [{ system: topic.system, code: topic.code }],
275
- resourceType: [{ system: FHIR_RESOURCE_TYPES_SYSTEM, code: 'ResearchSubject' }],
276
- data: [{ meaning: 'related', reference: { reference: researchStudyReference, type: 'ResearchStudy' } }],
277
- }],
278
217
  meta: {
279
218
  claims: {
280
- [VeterinaryResearchStudyInvitationClaimNames.Context]: 'org.hl7.fhir.api',
281
- [VeterinaryResearchStudyInvitationClaimNames.Status]: 'draft',
282
- [VeterinaryResearchStudyInvitationClaimNames.Decision]: 'permit',
219
+ [VeterinaryResearchStudyInvitationClaimNames.Context]: InteroperableContext.FhirApi,
220
+ [VeterinaryResearchStudyInvitationClaimNames.Status]: ConsentStatuses.Draft,
221
+ [VeterinaryResearchStudyInvitationClaimNames.Decision]: ConsentDecisions.Permit,
283
222
  [VeterinaryResearchStudyInvitationClaimNames.ActorIdentifier]: partyReference,
223
+ [ClaimConsent.grantee]: partyReference,
284
224
  [VeterinaryResearchStudyInvitationClaimNames.Purpose]: topic.value,
285
225
  [VeterinaryResearchStudyInvitationClaimNames.Action]: VeterinaryResearchStudyPermissionActions.join(','),
286
226
  [VeterinaryResearchStudyInvitationClaimNames.ResourceType]: 'ResearchSubject',
227
+ [ClaimConsent.sourceReference]: researchStudyReference,
287
228
  },
288
229
  },
289
230
  };
290
- const bundle = {
291
- resourceType: 'Bundle',
292
- type: 'batch',
293
- entry: [{
294
- fullUrl: `urn:uuid:${consentId}`,
231
+ const document = {
232
+ data: [{
233
+ type: 'Consent-v5.0.0',
295
234
  resource: consent,
296
235
  request: { method: 'POST', url: 'Consent' },
297
236
  }],
298
237
  };
299
238
  const category = VeterinaryCommunicationPresetFilters.ResearchAgreements.categories[0];
300
- const [categorySystem, categoryCode] = splitCodingToken(category);
301
239
  const sent = optionalInstant(input.sentAt);
302
240
  return deepFreeze({
303
241
  resourceType: 'Communication',
304
242
  id: communicationId,
305
- status: 'completed',
306
- category: [{ coding: [{ system: categorySystem, code: categoryCode }] }],
307
- topic: { coding: [{ system: topic.system, code: topic.code }] },
308
- about: [{ reference: researchStudyReference, type: 'ResearchStudy' }],
309
- recipient: [{ reference: partyReference, type: 'PractitionerRole' }],
310
- sender: { reference: controllerReference, type: 'Organization' },
311
- ...(sent ? { sent } : {}),
312
- payload: [{
313
- contentAttachment: {
314
- contentType: 'application/fhir+json',
315
- title: 'research-study-draft-consent.json',
316
- data: encodeJsonBase64(bundle),
317
- },
318
- }],
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
+ } },
319
257
  });
320
258
  }
321
259
  /**
@@ -334,41 +272,50 @@ export function buildVeterinaryResearchStudyPartyInvitations(input) {
334
272
  ...invitation,
335
273
  })));
336
274
  }
337
- /** Decodes the single attached Consent Bundle after validating its boundary. */
338
- export function decodeVeterinaryResearchStudyInvitationBundle(communication) {
339
- const attachment = communication?.payload?.[0]?.contentAttachment;
340
- if (communication?.resourceType !== 'Communication'
341
- || attachment?.contentType !== 'application/fhir+json'
342
- || !attachment.data) {
343
- 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');
284
+ }
285
+ let decoded;
286
+ try {
287
+ decoded = JSON.parse(decodeJsonBase64(encoded));
288
+ }
289
+ catch {
290
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
344
291
  }
345
- const decoded = JSON.parse(decodeJsonBase64(attachment.data));
346
- if (decoded?.resourceType !== 'Bundle'
347
- || decoded.type !== 'batch'
348
- || decoded.entry?.length !== 1
349
- || decoded.entry[0]?.resource?.resourceType !== 'Consent'
350
- || decoded.entry[0].resource.status !== 'draft') {
351
- throw new TypeError('veterinary_research_study_invitation_bundle_invalid');
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');
352
300
  }
353
301
  return deepFreeze(decoded);
354
302
  }
355
303
  function normalizedStudy(input) {
356
304
  if (!input || input.resourceType !== 'ResearchStudy')
357
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
+ }
358
309
  const source = clonePlain(input);
359
310
  const id = boundedId(input.id);
360
311
  const status = String(input.status || '');
361
312
  if (!['draft', 'active', 'retired', 'unknown'].includes(status)) {
362
313
  throw new TypeError('veterinary_research_study_status_invalid');
363
314
  }
364
- const associatedParty = input.associatedParty === undefined
365
- ? undefined
366
- : normalizeResearchStudyAssociatedParties(input.associatedParty);
367
315
  return deepFreeze({
368
316
  ...source,
369
317
  id,
370
318
  status: status,
371
- ...(associatedParty ? { associatedParty } : {}),
372
319
  });
373
320
  }
374
321
  function normalizedResearchTeamGroup(input) {
@@ -380,28 +327,24 @@ function normalizeResearchStudyGroupEntry(input) {
380
327
  if (!input || input.type !== 'Group-v5.0.0') {
381
328
  throw new TypeError('veterinary_research_study_group_entry_invalid');
382
329
  }
383
- const group = normalizedResearchTeamGroup(input.resource);
330
+ let resource;
331
+ try {
332
+ resource = normalizeGroupR5FlatClaimsResource(input.resource);
333
+ }
334
+ catch {
335
+ throw new TypeError('veterinary_research_study_group_entry_invalid');
336
+ }
384
337
  const method = input.request?.method;
385
- const expectedUrl = method === 'POST' ? 'Group' : method === 'PATCH' ? `Group/${group.id}` : '';
338
+ const expectedUrl = method === 'POST' ? 'Group' : method === 'PATCH' ? `Group/${resource.id}` : '';
386
339
  if (!expectedUrl || input.request.url !== expectedUrl) {
387
340
  throw new TypeError('veterinary_research_study_group_request_invalid');
388
341
  }
389
342
  return deepFreeze({
390
343
  type: 'Group-v5.0.0',
391
- resource: withProjectedGroupClaims(group),
344
+ resource,
392
345
  request: { method, url: expectedUrl },
393
346
  });
394
347
  }
395
- function withProjectedGroupClaims(group) {
396
- const claims = projectGroupR5SearchClaims(group);
397
- return deepFreeze({
398
- ...group,
399
- meta: {
400
- ...(typeof group.meta === 'object' && group.meta !== null ? group.meta : {}),
401
- claims,
402
- },
403
- });
404
- }
405
348
  function clonePlain(value) {
406
349
  if (Array.isArray(value))
407
350
  return value.map(clonePlain);
@@ -410,27 +353,32 @@ function clonePlain(value) {
410
353
  }
411
354
  return value;
412
355
  }
413
- function withProjectedClaims(study) {
414
- const standardClaims = projectResearchStudyR5SearchClaims(study);
415
- 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
+ }
416
376
  return deepFreeze({
417
- ...study,
418
- meta: {
419
- ...(study.meta || {}),
420
- claims: { ...standardClaims, ...partyClaims },
421
- },
377
+ type: 'ResearchStudy-v5.0.0',
378
+ resource,
379
+ request: { method, url: expectedUrl },
422
380
  });
423
381
  }
424
- function aggregatePartyClaims(parties) {
425
- const output = {};
426
- for (const projection of projectResearchStudyAssociatedPartyClaims(parties)) {
427
- for (const [name, values] of Object.entries(projection.claims)) {
428
- if (values.length > 0)
429
- (output[name] || (output[name] = [])).push(...values);
430
- }
431
- }
432
- return Object.fromEntries(Object.entries(output).map(([name, values]) => [name, Object.freeze([...values])]));
433
- }
434
382
  function boundedId(value) {
435
383
  const id = String(value || '').trim();
436
384
  if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
@@ -470,23 +418,6 @@ function optionalInstant(value) {
470
418
  throw new TypeError('veterinary_research_study_invitation_sent_invalid');
471
419
  return instant;
472
420
  }
473
- function containsAuthorizationProperty(value, seen = new WeakSet()) {
474
- if (!value || typeof value !== 'object')
475
- return false;
476
- if (seen.has(value))
477
- return false;
478
- seen.add(value);
479
- if (Array.isArray(value))
480
- return value.some(item => containsAuthorizationProperty(item, seen));
481
- return Object.entries(value).some(([name, nested]) => ['authorization', 'permission', 'scope'].includes(name) || containsAuthorizationProperty(nested, seen));
482
- }
483
- function splitCodingToken(value) {
484
- const separator = value.lastIndexOf('|');
485
- if (separator < 1 || separator === value.length - 1) {
486
- throw new TypeError('veterinary_research_study_invitation_category_invalid');
487
- }
488
- return [value.slice(0, separator), value.slice(separator + 1)];
489
- }
490
421
  function encodeJsonBase64(value) {
491
422
  const bytes = new TextEncoder().encode(JSON.stringify(value));
492
423
  let binary = '';
@@ -507,3 +438,15 @@ function deepFreeze(value, seen = new WeakSet()) {
507
438
  deepFreeze(nested, seen);
508
439
  return Object.freeze(value);
509
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,8 +18,6 @@ related to the exact ResearchStudy. Whole-twin delete is absent.
17
18
  ```ts
18
19
  import {
19
20
  buildVeterinaryResearchStudyActiveConsentBatch,
20
- buildVeterinaryResearchStudyBatch,
21
- buildVeterinaryResearchStudyEligibilityGroupPatchEntry,
22
21
  buildVeterinaryResearchStudyGroupBatch,
23
22
  buildVeterinaryResearchStudyGroupCreateEntry,
24
23
  buildVeterinaryResearchStudyGroupMemberPatchEntries,
@@ -42,13 +41,6 @@ const memberPatches = buildVeterinaryResearchStudyGroupMemberPatchEntries({
42
41
  })
43
42
  const groupBatch = buildVeterinaryResearchStudyGroupBatch([groupCreate, ...memberPatches])
44
43
  // The protected controller BFF submits `groupBatch`; membership grants no access.
45
- const eligibilityPatch = buildVeterinaryResearchStudyEligibilityGroupPatchEntry({
46
- researchStudyId: study.id,
47
- groupId: researchGroupId,
48
- })
49
- const studyPatchBatch = buildVeterinaryResearchStudyBatch([eligibilityPatch])
50
- // Submit `studyPatchBatch` through the protected ResearchStudy BFF after the
51
- // Group exists. The two native resource endpoints remain separate.
52
44
 
53
45
  const invitations = buildVeterinaryResearchStudyPartyInvitations({
54
46
  researchStudyId: study.id,
@@ -89,14 +81,26 @@ const authorization = buildVeterinaryResearchStudySmartAuthorization({
89
81
  await professionalRuntime.requestSmartToken(authorization)
90
82
  ```
91
83
 
92
- The controller may also create a descriptive review team with
84
+ The controller may independently create a descriptive review team with
93
85
  `buildVeterinaryResearchStudyGroupCreateEntry()` and append each registered
94
86
  `PractitionerRole` with
95
- `buildVeterinaryResearchStudyGroupMemberPatchEntries()`. The Group is linked
96
- from `ResearchStudy.recruitment.eligibility` using
97
- `buildVeterinaryResearchStudyEligibilityGroupPatchEntry()` and the existing
98
- ResearchStudy batch builder. Group membership is not evidence of DCR or
99
- Consent and never grants the SMART authorization above.
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.
100
104
 
101
105
  These builders follow the native [FHIR R5 Group resource](https://hl7.org/fhir/R5/group.html)
102
106
  and its exact [resource-specific search catalogue](https://hl7.org/fhir/R5/group-search.html).
@@ -107,17 +111,18 @@ the authorization subject `ResearchStudy/<id>` from the exact unescaped scope.
107
111
  Percent-encoding the `/` in that scope would no longer match the active
108
112
  Consent action and is rejected.
109
113
 
110
- The Bundle is encoded as the standard
111
- `Communication.payload.contentAttachment` with
112
- `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:
113
118
 
114
- - `Consent.grantee` and `Consent.provision.actor` for the invited
119
+ - `Consent.grantee` and `Consent.actor-identifier` for the invited
115
120
  `PractitionerRole`;
116
- - `Consent.grantor` and `Consent.controller` for the controller organization;
117
- - `Consent.provision.purpose` with HL7 v3 ActReason `HRESCH`;
118
- - `Consent.provision.resourceType` for `ResearchSubject`;
119
- - `Consent.provision.data.reference` for the exact `ResearchStudy/{id}`;
120
- - 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`.
121
126
 
122
127
  FHIR R5 references:
123
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-sdk-core-ts",
3
- "version": "0.4.13",
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.1"
67
+ "vet-data-utils-ts": "0.5.2"
68
68
  }
69
69
  }