vet-data-utils-ts 0.5.17 → 0.5.20

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
@@ -30,6 +30,30 @@ shows FHIR `Immunization.occurrenceDateTime`, `vaccineCode` and
30
30
  `protocolApplied.targetDisease`. Its issuer-supplied validity interval is not
31
31
  FHIR `Immunization.expirationDate`, which means vaccine-batch expiry.
32
32
 
33
+ `prepareVeterinaryIndexTaggedBundle(...)` creates the interoperable messaging
34
+ copy of a native FHIR Bundle document. It derives only the caller-allowlisted
35
+ `<ResourceType>.<search-param-or-custom>` Coding systems in
36
+ `resource.meta.tag[]`, preserves repeated values as repeated tags for later CSV
37
+ projection, rejects generic `Resource.*` systems, and never mutates the source
38
+ Bundle.
39
+
40
+ `projectVeterinaryClaimsIndexForUi(...)` is the reverse BFF boundary for an
41
+ already role-authorized GW search result. It removes every clinical field and
42
+ `meta.claims`, returning only `fullUrl`, `resourceType`, `id` and allowlisted
43
+ `meta.tag[]`; CSV claims are restored as repeated tags.
44
+ `summarizeVeterinaryTaggedIndex(...)` then sorts and counts that minimized copy
45
+ without inspecting resource content.
46
+
47
+ `readVeterinaryBundleTags(...)` is the fail-closed UI reader for that minimized
48
+ shape. It is intentionally separate from the clinical Bundle Reader and rejects
49
+ `meta.claims`, clinical fields, resource types or tags outside the caller's
50
+ already server-authorized policy. Its `cards` output groups repeated Coding
51
+ values by resource-qualified search parameter, so an index-only screen can use
52
+ the same card shell without constructing a clinical-resource view. An
53
+ authorized full Bundle may first be reduced with
54
+ `projectVeterinaryClaimsIndexForUi(...)` when it only needs that summary; the
55
+ full clinical reader itself remains claims-first and unchanged.
56
+
33
57
  `buildSmartHealthCardJwsProofReference(...)` serves a different purpose: it
34
58
  hashes the exact compact SHC JWS, including its ES256 signature, with a domain
35
59
  separator. A product ledger/resolver may use that key to retrieve the existing
@@ -0,0 +1,52 @@
1
+ type JsonObject = Record<string, unknown>;
2
+ export type VeterinaryIndexTagPolicy = Readonly<{
3
+ allowedTagSystems: ReadonlySet<string>;
4
+ }>;
5
+ export type VeterinaryUiIndexPolicy = VeterinaryIndexTagPolicy & Readonly<{
6
+ allowedResourceTypes: ReadonlySet<string>;
7
+ }>;
8
+ export type VeterinaryIndexCardView = Readonly<{
9
+ fullUrl: string;
10
+ resourceType: string;
11
+ id: string;
12
+ /** Only server-authorized index values; never clinical resource content. */
13
+ tags: Readonly<Record<string, readonly string[]>>;
14
+ }>;
15
+ /**
16
+ * Creates the interoperable message/index copy of a native FHIR Bundle.
17
+ * Only allowlisted, resource-qualified Coding tags are retained or derived;
18
+ * display/text are deliberately omitted and the caller's Bundle is unchanged.
19
+ */
20
+ export declare function prepareVeterinaryIndexTaggedBundle<T extends JsonObject>(source: T, policy: VeterinaryIndexTagPolicy): T;
21
+ /**
22
+ * Converts an authorized GW claims search response into the only shape exposed
23
+ * to an index-only UI. Clinical fields and `meta.claims` are discarded.
24
+ */
25
+ export declare function projectVeterinaryClaimsIndexForUi(source: JsonObject, policy: VeterinaryUiIndexPolicy): JsonObject;
26
+ /** Counts and sorts an already minimized tagged index without reading clinical content. */
27
+ export declare function summarizeVeterinaryTaggedIndex(source: JsonObject, options?: Readonly<{
28
+ sortSystem?: string;
29
+ }>): Readonly<{
30
+ total: number;
31
+ countsByResourceType: Record<string, number>;
32
+ entries: JsonObject[];
33
+ }>;
34
+ /**
35
+ * Strict UI reader for an index-only Bundle. Unlike the clinical Bundle
36
+ * Reader, this refuses claims and every clinical resource field.
37
+ */
38
+ export declare function readVeterinaryBundleTags(source: JsonObject, options: VeterinaryUiIndexPolicy & Readonly<{
39
+ sortSystem?: string;
40
+ }>): {
41
+ cards: Readonly<{
42
+ fullUrl: string;
43
+ resourceType: string;
44
+ id: string;
45
+ /** Only server-authorized index values; never clinical resource content. */
46
+ tags: Readonly<Record<string, readonly string[]>>;
47
+ }>[];
48
+ total: number;
49
+ countsByResourceType: Record<string, number>;
50
+ entries: JsonObject[];
51
+ };
52
+ export {};
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Creates the interoperable message/index copy of a native FHIR Bundle.
3
+ * Only allowlisted, resource-qualified Coding tags are retained or derived;
4
+ * display/text are deliberately omitted and the caller's Bundle is unchanged.
5
+ */
6
+ export function prepareVeterinaryIndexTaggedBundle(source, policy) {
7
+ if (source.resourceType !== 'Bundle' || source.type !== 'document' || !Array.isArray(source.entry)) {
8
+ throw new TypeError('veterinary_index_bundle_document_required');
9
+ }
10
+ const result = structuredClone(source);
11
+ const entries = result.entry;
12
+ for (const entry of entries) {
13
+ const resource = isObject(entry.resource) ? entry.resource : undefined;
14
+ const resourceType = typeof resource?.resourceType === 'string' ? resource.resourceType : '';
15
+ if (!resource || !resourceType)
16
+ continue;
17
+ const retained = existingTags(resource).filter(tag => (policy.allowedTagSystems.has(tag.system)
18
+ && tag.system.startsWith(`${resourceType}.`)));
19
+ const derived = [...policy.allowedTagSystems]
20
+ .filter(system => system.startsWith(`${resourceType}.`))
21
+ .flatMap(system => deriveValues(resource, system.slice(resourceType.length + 1))
22
+ .map(code => ({ system, code })));
23
+ const tags = uniqueTags([...retained, ...derived])
24
+ .sort((left, right) => left.system.localeCompare(right.system) || left.code.localeCompare(right.code));
25
+ if (!tags.length)
26
+ continue;
27
+ const meta = isObject(resource.meta) ? resource.meta : {};
28
+ resource.meta = { ...meta, tag: tags };
29
+ }
30
+ return result;
31
+ }
32
+ /**
33
+ * Converts an authorized GW claims search response into the only shape exposed
34
+ * to an index-only UI. Clinical fields and `meta.claims` are discarded.
35
+ */
36
+ export function projectVeterinaryClaimsIndexForUi(source, policy) {
37
+ if (source.resourceType !== 'Bundle' || !Array.isArray(source.entry)) {
38
+ throw new TypeError('veterinary_claims_index_bundle_required');
39
+ }
40
+ const entry = source.entry.flatMap(rawEntry => {
41
+ if (!isObject(rawEntry) || typeof rawEntry.fullUrl !== 'string' || !isObject(rawEntry.resource))
42
+ return [];
43
+ const resource = rawEntry.resource;
44
+ const resourceType = typeof resource.resourceType === 'string' ? resource.resourceType : '';
45
+ const id = typeof resource.id === 'string' ? resource.id : '';
46
+ if (!resourceType || !id || !policy.allowedResourceTypes.has(resourceType))
47
+ return [];
48
+ const meta = isObject(resource.meta) ? resource.meta : undefined;
49
+ const claims = isObject(meta?.claims) ? meta.claims : undefined;
50
+ if (!claims)
51
+ return [];
52
+ const tag = Object.entries(claims)
53
+ .filter(([system]) => policy.allowedTagSystems.has(system) && system.startsWith(`${resourceType}.`))
54
+ .flatMap(([system, raw]) => claimValues(raw).map(code => ({ system, code })))
55
+ .sort((left, right) => left.system.localeCompare(right.system) || left.code.localeCompare(right.code));
56
+ if (!tag.length)
57
+ return [];
58
+ return [{
59
+ fullUrl: rawEntry.fullUrl,
60
+ resource: { resourceType, id, meta: { tag } },
61
+ }];
62
+ });
63
+ return { resourceType: 'Bundle', type: 'searchset', total: entry.length, entry };
64
+ }
65
+ /** Counts and sorts an already minimized tagged index without reading clinical content. */
66
+ export function summarizeVeterinaryTaggedIndex(source, options = {}) {
67
+ if (source.resourceType !== 'Bundle' || !Array.isArray(source.entry)) {
68
+ throw new TypeError('veterinary_tagged_index_bundle_required');
69
+ }
70
+ const entries = source.entry.filter(isObject).map(entry => structuredClone(entry));
71
+ if (options.sortSystem)
72
+ entries.sort((left, right) => (taggedValue(right, options.sortSystem) || '').localeCompare(taggedValue(left, options.sortSystem) || ''));
73
+ const countsByResourceType = {};
74
+ for (const entry of entries) {
75
+ const resource = isObject(entry.resource) ? entry.resource : undefined;
76
+ if (typeof resource?.resourceType !== 'string')
77
+ continue;
78
+ countsByResourceType[resource.resourceType] = (countsByResourceType[resource.resourceType] || 0) + 1;
79
+ }
80
+ return { total: entries.length, countsByResourceType, entries };
81
+ }
82
+ /**
83
+ * Strict UI reader for an index-only Bundle. Unlike the clinical Bundle
84
+ * Reader, this refuses claims and every clinical resource field.
85
+ */
86
+ export function readVeterinaryBundleTags(source, options) {
87
+ if (source.resourceType !== 'Bundle' || source.type !== 'searchset' || !Array.isArray(source.entry)) {
88
+ throw new TypeError('bundle_tags_reader_searchset_required');
89
+ }
90
+ for (const rawEntry of source.entry) {
91
+ if (!isObject(rawEntry) || !onlyKeys(rawEntry, ['fullUrl', 'resource'])
92
+ || typeof rawEntry.fullUrl !== 'string' || !isObject(rawEntry.resource)) {
93
+ throw new TypeError('bundle_tags_reader_unsafe_shape');
94
+ }
95
+ const resource = rawEntry.resource;
96
+ if (!onlyKeys(resource, ['resourceType', 'id', 'meta'])
97
+ || typeof resource.resourceType !== 'string' || typeof resource.id !== 'string'
98
+ || !isObject(resource.meta) || !onlyKeys(resource.meta, ['tag'])
99
+ || !Array.isArray(resource.meta.tag)) {
100
+ throw new TypeError('bundle_tags_reader_unsafe_shape');
101
+ }
102
+ if (!options.allowedResourceTypes.has(resource.resourceType)) {
103
+ throw new TypeError('bundle_tags_reader_resource_forbidden');
104
+ }
105
+ for (const rawTag of resource.meta.tag) {
106
+ if (!isObject(rawTag) || !onlyKeys(rawTag, ['system', 'code'])
107
+ || typeof rawTag.system !== 'string' || typeof rawTag.code !== 'string') {
108
+ throw new TypeError('bundle_tags_reader_unsafe_shape');
109
+ }
110
+ if (!options.allowedTagSystems.has(rawTag.system)
111
+ || !rawTag.system.startsWith(`${resource.resourceType}.`)) {
112
+ throw new TypeError('bundle_tags_reader_tag_forbidden');
113
+ }
114
+ }
115
+ }
116
+ const summary = summarizeVeterinaryTaggedIndex(source, { sortSystem: options.sortSystem });
117
+ const cards = summary.entries.map(entry => {
118
+ const resource = entry.resource;
119
+ const meta = resource.meta;
120
+ const tags = {};
121
+ for (const rawTag of meta.tag) {
122
+ const values = tags[rawTag.system] || [];
123
+ values.push(rawTag.code);
124
+ tags[rawTag.system] = values;
125
+ }
126
+ return {
127
+ fullUrl: entry.fullUrl,
128
+ resourceType: resource.resourceType,
129
+ id: resource.id,
130
+ tags,
131
+ };
132
+ });
133
+ return { ...summary, cards };
134
+ }
135
+ function deriveValues(resource, parameter) {
136
+ switch (`${String(resource.resourceType)}.${parameter}`) {
137
+ case 'Composition.date': return scalar(resource.date);
138
+ case 'Composition.language': return scalar(resource.language);
139
+ case 'Composition.type': return conceptCodes(resource.type);
140
+ case 'Immunization.date': return scalar(resource.occurrenceDateTime);
141
+ case 'Immunization.status': return scalar(resource.status);
142
+ case 'Immunization.language': return scalar(resource.language);
143
+ case 'Immunization.reason-code': return conceptArrayCodes(resource.reasonCode);
144
+ case 'Immunization.vaccine-code': return conceptCodes(resource.vaccineCode);
145
+ case 'AllergyIntolerance.onset-date': return dateChoice(resource, 'onset');
146
+ case 'AllergyIntolerance.criticality': return scalar(resource.criticality);
147
+ case 'AllergyIntolerance.clinical-status': return conceptCodes(resource.clinicalStatus);
148
+ case 'AllergyIntolerance.category': return stringArray(resource.category);
149
+ case 'AllergyIntolerance.code': return conceptCodes(resource.code);
150
+ case 'AllergyIntolerance.language': return scalar(resource.language);
151
+ case 'Condition.onset-date': return dateChoice(resource, 'onset');
152
+ case 'Condition.abatement-date': return dateChoice(resource, 'abatement');
153
+ case 'Condition.clinical-status': return conceptCodes(resource.clinicalStatus);
154
+ case 'Condition.severity': return conceptCodes(resource.severity);
155
+ case 'Condition.code': return conceptCodes(resource.code);
156
+ case 'Condition.language': return scalar(resource.language);
157
+ case 'Observation.date': return [...dateChoice(resource, 'effective'), ...scalar(resource.issued)];
158
+ case 'Observation.status': return scalar(resource.status);
159
+ case 'Observation.language': return scalar(resource.language);
160
+ case 'MedicationStatement.effective': return dateChoice(resource, 'effective');
161
+ case 'MedicationStatement.status': return scalar(resource.status);
162
+ case 'MedicationStatement.medication': return conceptCodes(resource.medicationCodeableConcept);
163
+ case 'MedicationStatement.language': return scalar(resource.language);
164
+ case 'Appointment.date': return scalar(resource.start);
165
+ case 'Appointment.status': return scalar(resource.status);
166
+ case 'Appointment.service-type': return conceptArrayCodes(resource.serviceType);
167
+ case 'Appointment.language': return scalar(resource.language);
168
+ case 'Coverage.period': return periodValues(resource.period);
169
+ case 'Coverage.status': return scalar(resource.status);
170
+ case 'Coverage.type': return conceptCodes(resource.type);
171
+ case 'Coverage.language': return scalar(resource.language);
172
+ case 'Contract.issued': return scalar(resource.issued);
173
+ case 'Contract.status': return scalar(resource.status);
174
+ case 'Contract.type': return conceptCodes(resource.type);
175
+ case 'Contract.language': return scalar(resource.language);
176
+ case 'DocumentReference.date': return scalar(resource.date);
177
+ case 'DocumentReference.status': return scalar(resource.status);
178
+ case 'DocumentReference.type': return conceptCodes(resource.type);
179
+ case 'DocumentReference.language': return scalar(resource.language);
180
+ default: return [];
181
+ }
182
+ }
183
+ function existingTags(resource) {
184
+ const meta = isObject(resource.meta) ? resource.meta : undefined;
185
+ return Array.isArray(meta?.tag) ? meta.tag.flatMap(value => {
186
+ if (!isObject(value) || typeof value.system !== 'string' || typeof value.code !== 'string')
187
+ return [];
188
+ return value.system && value.code ? [{ system: value.system, code: value.code }] : [];
189
+ }) : [];
190
+ }
191
+ function conceptCodes(value) {
192
+ if (!isObject(value) || !Array.isArray(value.coding))
193
+ return [];
194
+ return value.coding.flatMap(coding => isObject(coding) ? scalar(coding.code) : []);
195
+ }
196
+ function conceptArrayCodes(value) {
197
+ return Array.isArray(value) ? value.flatMap(conceptCodes) : conceptCodes(value);
198
+ }
199
+ function dateChoice(resource, prefix) {
200
+ return [
201
+ ...scalar(resource[`${prefix}DateTime`]),
202
+ ...scalar(resource[`${prefix}Date`]),
203
+ ...periodValues(resource[`${prefix}Period`]),
204
+ ];
205
+ }
206
+ function periodValues(value) {
207
+ if (!isObject(value))
208
+ return [];
209
+ return [...scalar(value.start), ...scalar(value.end)];
210
+ }
211
+ function scalar(value) {
212
+ return typeof value === 'string' && value.trim() ? [value.trim()] : [];
213
+ }
214
+ function stringArray(value) {
215
+ return Array.isArray(value) ? value.flatMap(scalar) : scalar(value);
216
+ }
217
+ function claimValues(value) {
218
+ return (Array.isArray(value) ? value : [value]).flatMap(item => (typeof item === 'string' ? item.split(',').map(part => part.trim()).filter(Boolean) : []));
219
+ }
220
+ function taggedValue(entry, system) {
221
+ const resource = isObject(entry.resource) ? entry.resource : undefined;
222
+ const meta = isObject(resource?.meta) ? resource.meta : undefined;
223
+ const tags = Array.isArray(meta?.tag) ? meta.tag : [];
224
+ const tag = tags.find(value => isObject(value) && value.system === system);
225
+ return isObject(tag) && typeof tag.code === 'string' ? tag.code : undefined;
226
+ }
227
+ function uniqueTags(tags) {
228
+ const seen = new Set();
229
+ return tags.filter(tag => {
230
+ const key = `${tag.system}\0${tag.code}`;
231
+ if (seen.has(key))
232
+ return false;
233
+ seen.add(key);
234
+ return true;
235
+ });
236
+ }
237
+ function isObject(value) {
238
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
239
+ }
240
+ function onlyKeys(value, allowed) {
241
+ const names = new Set(allowed);
242
+ return Object.keys(value).every(key => names.has(key));
243
+ }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './financial.js';
9
9
  export * from './group.js';
10
10
  export * from './immunization.js';
11
11
  export * from './international-health-card.js';
12
+ export * from './index-projection-tags.js';
12
13
  export * from './iso-jurisdictions.js';
13
14
  export * from './organization-application.js';
14
15
  export * from './payment.js';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from './financial.js';
9
9
  export * from './group.js';
10
10
  export * from './immunization.js';
11
11
  export * from './international-health-card.js';
12
+ export * from './index-projection-tags.js';
12
13
  export * from './iso-jurisdictions.js';
13
14
  export * from './organization-application.js';
14
15
  export * from './payment.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-data-utils-ts",
3
- "version": "0.5.17",
3
+ "version": "0.5.20",
4
4
  "description": "Browser-safe governed VetChain data contracts",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Connecting Solution & Applications Ltd",
@@ -52,6 +52,10 @@
52
52
  "types": "./dist/international-health-card.d.ts",
53
53
  "default": "./dist/international-health-card.js"
54
54
  },
55
+ "./index-projection-tags": {
56
+ "types": "./dist/index-projection-tags.d.ts",
57
+ "default": "./dist/index-projection-tags.js"
58
+ },
55
59
  "./iso-jurisdictions": {
56
60
  "types": "./dist/iso-jurisdictions.d.ts",
57
61
  "default": "./dist/iso-jurisdictions.js"