vet-sdk-core-ts 0.4.11 → 0.4.13

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
@@ -21,7 +21,17 @@ read, update and search for ResearchSubjects filtered by the exact study. It
21
21
  never requests deletion of the twin; deletion of selected clinical facts must
22
22
  target those concrete resources under an active controller-approved Consent.
23
23
  The builder fails closed unless the professional has completed DCR and the
24
- later controller-approved Consent is `active`.
24
+ later controller-approved Consent is `active`. It emits the canonical purpose
25
+ `HRESCH` and the unescaped scope
26
+ `organization/ResearchSubject.crus?study=ResearchStudy/<id>` so it can be
27
+ passed to the protected professional runtime; the DCR-bound runtime supplies
28
+ the professional actor as `sub`.
29
+
30
+ `buildVeterinaryResearchStudyActiveConsentBatch` builds the controller's
31
+ separate JSON:API `Consent/_batch`. Every POST is fixed to `active`, `permit`,
32
+ `HRESCH`, one `PractitionerRole`, its ISCO-08 role, and that same exact
33
+ ResearchStudy scope. The API does not accept caller-authored actions, so it
34
+ cannot be broadened to delete.
25
35
 
26
36
  `buildVeterinaryResearchStudyPartyInvitations` creates exactly one R5
27
37
  `Communication` per associated `PractitionerRole`. Each Communication carries
@@ -31,6 +41,19 @@ to the ResearchStudy through `Consent.provision.data.reference` and to
31
41
  only create, read, update and search. It contains no delete permission and
32
42
  never authorizes SMART access.
33
43
 
44
+ `buildVeterinaryResearchStudyGroupCreateEntry` and
45
+ `buildVeterinaryResearchStudyGroupMemberPatchEntries` build the native R5 Group
46
+ POST/PATCH entries for an enumerated professional review team. Every member is
47
+ 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.
56
+
34
57
  The complete controller-to-professional contract and a browser-safe snippet
35
58
  are in
36
59
  [`docs/101-RESEARCH-STUDY-INVITATIONS.md`](docs/101-RESEARCH-STUDY-INVITATIONS.md).
@@ -41,6 +64,8 @@ FHIR references:
41
64
  - https://hl7.org/fhir/R5/researchsubject-search.html
42
65
  - https://hl7.org/fhir/R5/communication.html
43
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
44
69
 
45
70
  ## Reusable professional BFF
46
71
 
@@ -1,14 +1,18 @@
1
1
  import { type ResearchStudyAssociatedParty } from 'vet-data-utils-ts/research-study';
2
+ import { type GroupR5ResearchTeam } from 'vet-data-utils-ts/group';
3
+ import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
4
+ import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
5
+ import { ClaimConsent, ConsentStatuses } from 'gdc-common-utils-ts/models/consent-rule';
2
6
  export declare const VeterinaryResearchStudyPermissionActions: readonly ["create", "read", "update", "search"];
3
7
  export type VeterinaryResearchStudyPermissionAction = typeof VeterinaryResearchStudyPermissionActions[number];
4
8
  export declare const VeterinaryResearchStudyInvitationClaimNames: Readonly<{
5
9
  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";
10
+ readonly Status: ClaimConsent.status;
11
+ readonly Decision: ClaimConsent.decision;
12
+ readonly ActorIdentifier: ClaimConsent.actorIdentifier;
13
+ readonly Purpose: ClaimConsent.purpose;
14
+ readonly Action: ClaimConsent.action;
15
+ readonly ResourceType: ClaimConsent.resourceType;
12
16
  }>;
13
17
  type FhirReference = Readonly<{
14
18
  reference: string;
@@ -97,11 +101,58 @@ export type VeterinaryResearchStudyBatchEntry = Readonly<{
97
101
  export type VeterinaryResearchStudyBatch = Readonly<{
98
102
  data: readonly VeterinaryResearchStudyBatchEntry[];
99
103
  }>;
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
+ }>;
111
+ export type VeterinaryResearchStudyGroupBatchEntry = Readonly<{
112
+ type: 'Group-v5.0.0';
113
+ resource: VeterinaryResearchStudyGroupResource;
114
+ request: Readonly<{
115
+ method: 'POST' | 'PATCH';
116
+ url: string;
117
+ }>;
118
+ }>;
119
+ export type VeterinaryResearchStudyGroupBatch = Readonly<{
120
+ data: readonly VeterinaryResearchStudyGroupBatchEntry[];
121
+ }>;
100
122
  export type VeterinaryResearchStudySmartAuthorization = Readonly<{
101
- purpose: 'RESEARCH';
123
+ purpose: typeof HealthcareConsentPurposes.Research;
102
124
  researchStudyReference: string;
103
125
  scopes: readonly [string];
104
126
  }>;
127
+ export type VeterinaryResearchStudyActiveConsentInput = Readonly<{
128
+ consentId: string;
129
+ researchStudyId: string;
130
+ practitionerRoleId: string;
131
+ actorRole: `ISCO-08|${string}`;
132
+ date: string;
133
+ status?: typeof ConsentStatuses.Active;
134
+ }>;
135
+ export type VeterinaryResearchStudyActiveConsent = Readonly<{
136
+ resourceType: typeof ResourceTypesFhirR5.Consent;
137
+ id: string;
138
+ status: typeof ConsentStatuses.Active;
139
+ meta: Readonly<{
140
+ claims: Readonly<Record<string, string>>;
141
+ }>;
142
+ }>;
143
+ export type VeterinaryResearchStudyActiveConsentEntry = Readonly<{
144
+ type: 'Consent-v5.0.0';
145
+ resource: VeterinaryResearchStudyActiveConsent;
146
+ request: Readonly<{
147
+ method: 'POST';
148
+ url: typeof ResourceTypesFhirR5.Consent;
149
+ }>;
150
+ }>;
151
+ export type VeterinaryResearchStudyActiveConsentBatch = Readonly<{
152
+ data: readonly VeterinaryResearchStudyActiveConsentEntry[];
153
+ }>;
154
+ /** Builds the exact GW study-pinned ResearchSubject `crus` capability. */
155
+ export declare function buildVeterinaryResearchStudyAccessScope(researchStudyId: string): string;
105
156
  /**
106
157
  * Requests the current study-scoped ResearchSubject operations. Deleting
107
158
  * selected clinical facts is a later operation on those concrete resources;
@@ -112,7 +163,73 @@ export declare function buildVeterinaryResearchStudySmartAuthorization(input: Re
112
163
  dcrCompleted: boolean;
113
164
  consentStatus: 'draft' | 'active' | 'inactive' | 'not-done' | 'entered-in-error' | 'unknown';
114
165
  }>): VeterinaryResearchStudySmartAuthorization;
166
+ /**
167
+ * Builds controller decisions for the VET ResearchStudy Consent `_batch`.
168
+ * Every entry is an explicit active/permit decision for one DCR-bound
169
+ * PractitionerRole. Invitation drafts, descriptive Group membership and
170
+ * `ResearchStudy.associatedParty` never authorize. The only action is the
171
+ * exact study-scoped ResearchSubject create/read/update/search capability;
172
+ * delete is deliberately absent.
173
+ *
174
+ * `subjectDid` is deliberately absent from the matching SMART authorization:
175
+ * the high-level professional runtime supplies the DCR-bound actor as `sub`,
176
+ * while GW derives the ResearchStudy authorization subject from this scope.
177
+ *
178
+ * @see https://hl7.org/fhir/R5/consent.html
179
+ * @see https://hl7.org/fhir/R5/researchstudy.html
180
+ */
181
+ export declare function buildVeterinaryResearchStudyActiveConsentBatch(inputs: readonly VeterinaryResearchStudyActiveConsentInput[]): VeterinaryResearchStudyActiveConsentBatch;
182
+ /** Builds one POST Consent entry accepted by the VET ResearchStudy manager. */
183
+ export declare function buildVeterinaryResearchStudyActiveConsentEntry(input: VeterinaryResearchStudyActiveConsentInput): VeterinaryResearchStudyActiveConsentEntry;
115
184
  export declare function buildVeterinaryResearchStudyBatch(entries: readonly VeterinaryResearchStudyBatchEntry[]): VeterinaryResearchStudyBatch;
185
+ /**
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.
215
+ *
216
+ * @see https://hl7.org/fhir/R5/group.html
217
+ * @see https://hl7.org/fhir/R5/group-search.html
218
+ */
219
+ export declare function buildVeterinaryResearchStudyGroupCreateEntry(input: Readonly<{
220
+ group: GroupR5ResearchTeam;
221
+ }>): VeterinaryResearchStudyGroupBatchEntry;
222
+ /**
223
+ * Builds one independently correlated PATCH per PractitionerRole member. A
224
+ * PATCH changes descriptive membership only; DCR, Consent and SMART remain
225
+ * separate authorization boundaries.
226
+ */
227
+ export declare function buildVeterinaryResearchStudyGroupMemberPatchEntries(input: Readonly<{
228
+ groupId: string;
229
+ members: unknown;
230
+ }>): readonly VeterinaryResearchStudyGroupBatchEntry[];
231
+ /** Wraps only validated Group POST/PATCH entries in one JSON:API document. */
232
+ export declare function buildVeterinaryResearchStudyGroupBatch(entries: readonly VeterinaryResearchStudyGroupBatchEntry[]): VeterinaryResearchStudyGroupBatch;
116
233
  export declare function buildVeterinaryResearchStudyCreateEntry(input: Readonly<{
117
234
  study: VeterinaryResearchStudyCreateResource;
118
235
  }>): VeterinaryResearchStudyBatchEntry;
@@ -1,5 +1,10 @@
1
1
  import { normalizeResearchStudyAssociatedParties, projectResearchStudyAssociatedPartyClaims, projectResearchStudyR5SearchClaims, } 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';
4
+ import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
5
+ import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
6
+ import { InteroperableContext } from 'gdc-common-utils-ts/constants/lifecycle';
7
+ import { ClaimConsent, ConsentDecisions, ConsentStatuses, } from 'gdc-common-utils-ts/models/consent-rule';
3
8
  const FHIR_RESTFUL_INTERACTION_SYSTEM = 'http://hl7.org/fhir/restful-interaction';
4
9
  const FHIR_RESOURCE_TYPES_SYSTEM = 'http://hl7.org/fhir/fhir-types';
5
10
  export const VeterinaryResearchStudyPermissionActions = Object.freeze([
@@ -10,13 +15,17 @@ export const VeterinaryResearchStudyPermissionActions = Object.freeze([
10
15
  ]);
11
16
  export const VeterinaryResearchStudyInvitationClaimNames = Object.freeze({
12
17
  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',
18
+ Status: ClaimConsent.status,
19
+ Decision: ClaimConsent.decision,
20
+ ActorIdentifier: ClaimConsent.actorIdentifier,
21
+ Purpose: ClaimConsent.purpose,
22
+ Action: ClaimConsent.action,
23
+ ResourceType: ClaimConsent.resourceType,
19
24
  });
25
+ /** Builds the exact GW study-pinned ResearchSubject `crus` capability. */
26
+ export function buildVeterinaryResearchStudyAccessScope(researchStudyId) {
27
+ return `organization/ResearchSubject.crus?study=ResearchStudy/${boundedId(researchStudyId)}`;
28
+ }
20
29
  /**
21
30
  * Requests the current study-scoped ResearchSubject operations. Deleting
22
31
  * selected clinical facts is a later operation on those concrete resources;
@@ -31,9 +40,70 @@ export function buildVeterinaryResearchStudySmartAuthorization(input) {
31
40
  }
32
41
  const researchStudyReference = `ResearchStudy/${boundedId(input.researchStudyId)}`;
33
42
  return deepFreeze({
34
- purpose: 'RESEARCH',
43
+ purpose: HealthcareConsentPurposes.Research,
35
44
  researchStudyReference,
36
- scopes: [`organization/ResearchSubject.crus?study=${encodeURIComponent(researchStudyReference)}`],
45
+ // This is a SMART scope value, not a URL query assembled by the browser.
46
+ // Encoding the slash would no longer equal the active Consent action.
47
+ scopes: [buildVeterinaryResearchStudyAccessScope(input.researchStudyId)],
48
+ });
49
+ }
50
+ /**
51
+ * Builds controller decisions for the VET ResearchStudy Consent `_batch`.
52
+ * Every entry is an explicit active/permit decision for one DCR-bound
53
+ * PractitionerRole. Invitation drafts, descriptive Group membership and
54
+ * `ResearchStudy.associatedParty` never authorize. The only action is the
55
+ * exact study-scoped ResearchSubject create/read/update/search capability;
56
+ * delete is deliberately absent.
57
+ *
58
+ * `subjectDid` is deliberately absent from the matching SMART authorization:
59
+ * the high-level professional runtime supplies the DCR-bound actor as `sub`,
60
+ * while GW derives the ResearchStudy authorization subject from this scope.
61
+ *
62
+ * @see https://hl7.org/fhir/R5/consent.html
63
+ * @see https://hl7.org/fhir/R5/researchstudy.html
64
+ */
65
+ export function buildVeterinaryResearchStudyActiveConsentBatch(inputs) {
66
+ if (!Array.isArray(inputs) || inputs.length === 0) {
67
+ throw new TypeError('veterinary_research_study_active_consent_batch_empty');
68
+ }
69
+ const data = inputs.map(buildVeterinaryResearchStudyActiveConsentEntry);
70
+ return deepFreeze({ data });
71
+ }
72
+ /** Builds one POST Consent entry accepted by the VET ResearchStudy manager. */
73
+ export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
74
+ if (input.status !== undefined && input.status !== ConsentStatuses.Active) {
75
+ throw new TypeError('veterinary_research_study_active_consent_status_invalid');
76
+ }
77
+ const consentId = boundedId(input.consentId);
78
+ const researchStudyReference = `ResearchStudy/${boundedId(input.researchStudyId)}`;
79
+ const practitionerRoleReference = `PractitionerRole/${boundedId(input.practitionerRoleId)}`;
80
+ const actorRole = String(input.actorRole || '').trim();
81
+ if (!/^ISCO-08\|[0-9]{1,4}$/.test(actorRole)) {
82
+ throw new TypeError('veterinary_research_study_active_consent_actor_role_invalid');
83
+ }
84
+ const date = boundedDate(input.date);
85
+ const action = buildVeterinaryResearchStudyAccessScope(input.researchStudyId);
86
+ return deepFreeze({
87
+ type: 'Consent-v5.0.0',
88
+ resource: {
89
+ resourceType: ResourceTypesFhirR5.Consent,
90
+ id: consentId,
91
+ status: ConsentStatuses.Active,
92
+ meta: {
93
+ claims: {
94
+ '@context': InteroperableContext.FhirApi,
95
+ [ClaimConsent.status]: ConsentStatuses.Active,
96
+ [ClaimConsent.decision]: ConsentDecisions.Permit,
97
+ [ClaimConsent.purpose]: HealthcareConsentPurposes.Research,
98
+ [ClaimConsent.sourceReference]: researchStudyReference,
99
+ [ClaimConsent.actorIdentifier]: practitionerRoleReference,
100
+ [ClaimConsent.actorRole]: actorRole,
101
+ [ClaimConsent.action]: action,
102
+ [ClaimConsent.date]: date,
103
+ },
104
+ },
105
+ },
106
+ request: { method: 'POST', url: ResourceTypesFhirR5.Consent },
37
107
  });
38
108
  }
39
109
  export function buildVeterinaryResearchStudyBatch(entries) {
@@ -41,6 +111,86 @@ export function buildVeterinaryResearchStudyBatch(entries) {
41
111
  throw new TypeError('veterinary_research_study_batch_empty');
42
112
  return deepFreeze({ data: entries.map(entry => clonePlain(entry)) });
43
113
  }
114
+ /**
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.
155
+ *
156
+ * @see https://hl7.org/fhir/R5/group.html
157
+ * @see https://hl7.org/fhir/R5/group-search.html
158
+ */
159
+ export function buildVeterinaryResearchStudyGroupCreateEntry(input) {
160
+ const group = normalizedResearchTeamGroup(input.group);
161
+ return deepFreeze({
162
+ type: 'Group-v5.0.0',
163
+ resource: withProjectedGroupClaims(group),
164
+ request: { method: 'POST', url: 'Group' },
165
+ });
166
+ }
167
+ /**
168
+ * Builds one independently correlated PATCH per PractitionerRole member. A
169
+ * PATCH changes descriptive membership only; DCR, Consent and SMART remain
170
+ * separate authorization boundaries.
171
+ */
172
+ export function buildVeterinaryResearchStudyGroupMemberPatchEntries(input) {
173
+ const id = boundedGroupId(input.groupId);
174
+ const members = normalizeGroupPractitionerRoleMembers(input.members);
175
+ return deepFreeze(members.map(member => ({
176
+ type: 'Group-v5.0.0',
177
+ resource: withProjectedGroupClaims(deepFreeze({
178
+ resourceType: 'Group',
179
+ id,
180
+ type: 'practitioner',
181
+ membership: 'enumerated',
182
+ member: [member],
183
+ })),
184
+ request: { method: 'PATCH', url: `Group/${id}` },
185
+ })));
186
+ }
187
+ /** Wraps only validated Group POST/PATCH entries in one JSON:API document. */
188
+ export function buildVeterinaryResearchStudyGroupBatch(entries) {
189
+ if (!Array.isArray(entries) || entries.length === 0) {
190
+ throw new TypeError('veterinary_research_study_group_batch_empty');
191
+ }
192
+ return deepFreeze({ data: entries.map(normalizeResearchStudyGroupEntry) });
193
+ }
44
194
  export function buildVeterinaryResearchStudyCreateEntry(input) {
45
195
  const study = normalizedStudy(input.study);
46
196
  return deepFreeze({
@@ -221,6 +371,37 @@ function normalizedStudy(input) {
221
371
  ...(associatedParty ? { associatedParty } : {}),
222
372
  });
223
373
  }
374
+ function normalizedResearchTeamGroup(input) {
375
+ const source = clonePlain(input);
376
+ const normalized = normalizeGroupR5ResearchTeam(source);
377
+ return deepFreeze({ ...normalized, id: boundedGroupId(normalized.id) });
378
+ }
379
+ function normalizeResearchStudyGroupEntry(input) {
380
+ if (!input || input.type !== 'Group-v5.0.0') {
381
+ throw new TypeError('veterinary_research_study_group_entry_invalid');
382
+ }
383
+ const group = normalizedResearchTeamGroup(input.resource);
384
+ const method = input.request?.method;
385
+ const expectedUrl = method === 'POST' ? 'Group' : method === 'PATCH' ? `Group/${group.id}` : '';
386
+ if (!expectedUrl || input.request.url !== expectedUrl) {
387
+ throw new TypeError('veterinary_research_study_group_request_invalid');
388
+ }
389
+ return deepFreeze({
390
+ type: 'Group-v5.0.0',
391
+ resource: withProjectedGroupClaims(group),
392
+ request: { method, url: expectedUrl },
393
+ });
394
+ }
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
+ }
224
405
  function clonePlain(value) {
225
406
  if (Array.isArray(value))
226
407
  return value.map(clonePlain);
@@ -256,6 +437,12 @@ function boundedId(value) {
256
437
  throw new TypeError('veterinary_research_study_id_invalid');
257
438
  return id;
258
439
  }
440
+ function boundedGroupId(value) {
441
+ const id = String(value || '').trim();
442
+ if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
443
+ throw new TypeError('veterinary_research_study_group_id_invalid');
444
+ return id;
445
+ }
259
446
  function boundedReference(value, resourceType) {
260
447
  const reference = String(value || '').trim();
261
448
  const prefix = `${resourceType}/`;
@@ -264,6 +451,17 @@ function boundedReference(value, resourceType) {
264
451
  boundedId(reference.slice(prefix.length));
265
452
  return reference;
266
453
  }
454
+ function boundedDate(value) {
455
+ const date = String(value || '').trim();
456
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
457
+ throw new TypeError('veterinary_research_study_active_consent_date_invalid');
458
+ }
459
+ const parsed = new Date(`${date}T00:00:00Z`);
460
+ if (!Number.isFinite(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== date) {
461
+ throw new TypeError('veterinary_research_study_active_consent_date_invalid');
462
+ }
463
+ return date;
464
+ }
267
465
  function optionalInstant(value) {
268
466
  const instant = String(value || '').trim();
269
467
  if (!instant)
@@ -272,6 +470,16 @@ function optionalInstant(value) {
272
470
  throw new TypeError('veterinary_research_study_invitation_sent_invalid');
273
471
  return instant;
274
472
  }
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
+ }
275
483
  function splitCodingToken(value) {
276
484
  const separator = value.lastIndexOf('|');
277
485
  if (separator < 1 || separator === value.length - 1) {
@@ -16,10 +16,40 @@ related to the exact ResearchStudy. Whole-twin delete is absent.
16
16
 
17
17
  ```ts
18
18
  import {
19
+ buildVeterinaryResearchStudyActiveConsentBatch,
20
+ buildVeterinaryResearchStudyBatch,
21
+ buildVeterinaryResearchStudyEligibilityGroupPatchEntry,
22
+ buildVeterinaryResearchStudyGroupBatch,
23
+ buildVeterinaryResearchStudyGroupCreateEntry,
24
+ buildVeterinaryResearchStudyGroupMemberPatchEntries,
19
25
  buildVeterinaryResearchStudyPartyInvitations,
20
26
  buildVeterinaryResearchStudySmartAuthorization,
21
27
  } from 'vet-sdk-core-ts/research-study'
22
28
 
29
+ const researchGroupId = 'review-team'
30
+ const groupCreate = buildVeterinaryResearchStudyGroupCreateEntry({
31
+ group: {
32
+ resourceType: 'Group',
33
+ id: researchGroupId,
34
+ type: 'practitioner',
35
+ membership: 'enumerated',
36
+ member: [],
37
+ },
38
+ })
39
+ const memberPatches = buildVeterinaryResearchStudyGroupMemberPatchEntries({
40
+ groupId: researchGroupId,
41
+ members: associatedParties.map(({ party }) => ({ entity: party })),
42
+ })
43
+ const groupBatch = buildVeterinaryResearchStudyGroupBatch([groupCreate, ...memberPatches])
44
+ // 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
+
23
53
  const invitations = buildVeterinaryResearchStudyPartyInvitations({
24
54
  researchStudyId: study.id,
25
55
  controllerReference: organizationReference,
@@ -37,6 +67,18 @@ for (const communication of invitations) {
37
67
  await professionalRuntime.ingestCommunicationAndUpdateIndex({ communication })
38
68
  }
39
69
 
70
+ // Once DCR is confirmed, the controller persists a new active Consent. This
71
+ // is separate from the draft inside Communication and is the actual grant.
72
+ const activeConsentBatch = buildVeterinaryResearchStudyActiveConsentBatch([{
73
+ consentId: activeConsentId,
74
+ researchStudyId: study.id,
75
+ practitionerRoleId: protectedProfessional.practitionerRoleId,
76
+ actorRole: protectedProfessional.iscoRole,
77
+ date: new Date().toISOString().slice(0, 10),
78
+ }])
79
+ // The protected BFF submits this primary document to the tenant organization
80
+ // FHIR R5 Consent/_batch boundary; the browser never authors that route.
81
+
40
82
  // This succeeds only after protected state proves both prerequisites. Browser
41
83
  // input must not be trusted as that proof.
42
84
  const authorization = buildVeterinaryResearchStudySmartAuthorization({
@@ -47,6 +89,24 @@ const authorization = buildVeterinaryResearchStudySmartAuthorization({
47
89
  await professionalRuntime.requestSmartToken(authorization)
48
90
  ```
49
91
 
92
+ The controller may also create a descriptive review team with
93
+ `buildVeterinaryResearchStudyGroupCreateEntry()` and append each registered
94
+ `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.
100
+
101
+ These builders follow the native [FHIR R5 Group resource](https://hl7.org/fhir/R5/group.html)
102
+ and its exact [resource-specific search catalogue](https://hl7.org/fhir/R5/group-search.html).
103
+
104
+ The SMART authorization intentionally does not carry `subjectDid`:
105
+ `openProfessional()` supplies its DCR-bound actor as `sub`, while GW derives
106
+ the authorization subject `ResearchStudy/<id>` from the exact unescaped scope.
107
+ Percent-encoding the `/` in that scope would no longer match the active
108
+ Consent action and is rejected.
109
+
50
110
  The Bundle is encoded as the standard
51
111
  `Communication.payload.contentAttachment` with
52
112
  `contentType = application/fhir+json`. The Consent uses:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-sdk-core-ts",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
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",
@@ -63,6 +63,7 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@noble/hashes": "^2.2.0",
66
- "vet-data-utils-ts": "0.5.0"
66
+ "gdc-common-utils-ts": "2.9.4",
67
+ "vet-data-utils-ts": "0.5.1"
67
68
  }
68
69
  }