vet-data-utils-ts 0.5.18 → 0.5.21
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 +18 -0
- package/dist/index-projection-tags.d.ts +25 -0
- package/dist/index-projection-tags.js +60 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/place-service-directory.d.ts +223 -0
- package/dist/place-service-directory.js +332 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# vet-data-utils-ts
|
|
2
2
|
|
|
3
|
+
The product-local `place-service-directory` export models persistent public
|
|
4
|
+
sites as claims-only Schema.org `Place` resources and their offerings as
|
|
5
|
+
claims-only `Service` resources. Photos, accessibility, coordinates, address
|
|
6
|
+
and opening-hours specifications remain neutral indexed claims. Native FHIR R5
|
|
7
|
+
`Location` and `HealthcareService` are produced only by explicit projection
|
|
8
|
+
helpers; the shared `gdc-*` claim catalog is unchanged while this profile is
|
|
9
|
+
validated in VetChain.
|
|
10
|
+
|
|
3
11
|
## Veterinary immunization credentials
|
|
4
12
|
|
|
5
13
|
`buildVeterinaryImmunization(...)` creates a FHIR R4 `Immunization` with the
|
|
@@ -44,6 +52,16 @@ already role-authorized GW search result. It removes every clinical field and
|
|
|
44
52
|
`summarizeVeterinaryTaggedIndex(...)` then sorts and counts that minimized copy
|
|
45
53
|
without inspecting resource content.
|
|
46
54
|
|
|
55
|
+
`readVeterinaryBundleTags(...)` is the fail-closed UI reader for that minimized
|
|
56
|
+
shape. It is intentionally separate from the clinical Bundle Reader and rejects
|
|
57
|
+
`meta.claims`, clinical fields, resource types or tags outside the caller's
|
|
58
|
+
already server-authorized policy. Its `cards` output groups repeated Coding
|
|
59
|
+
values by resource-qualified search parameter, so an index-only screen can use
|
|
60
|
+
the same card shell without constructing a clinical-resource view. An
|
|
61
|
+
authorized full Bundle may first be reduced with
|
|
62
|
+
`projectVeterinaryClaimsIndexForUi(...)` when it only needs that summary; the
|
|
63
|
+
full clinical reader itself remains claims-first and unchanged.
|
|
64
|
+
|
|
47
65
|
`buildSmartHealthCardJwsProofReference(...)` serves a different purpose: it
|
|
48
66
|
hashes the exact compact SHC JWS, including its ES256 signature, with a domain
|
|
49
67
|
separator. A product ledger/resolver may use that key to retrieve the existing
|
|
@@ -5,6 +5,13 @@ export type VeterinaryIndexTagPolicy = Readonly<{
|
|
|
5
5
|
export type VeterinaryUiIndexPolicy = VeterinaryIndexTagPolicy & Readonly<{
|
|
6
6
|
allowedResourceTypes: ReadonlySet<string>;
|
|
7
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
|
+
}>;
|
|
8
15
|
/**
|
|
9
16
|
* Creates the interoperable message/index copy of a native FHIR Bundle.
|
|
10
17
|
* Only allowlisted, resource-qualified Coding tags are retained or derived;
|
|
@@ -24,4 +31,22 @@ export declare function summarizeVeterinaryTaggedIndex(source: JsonObject, optio
|
|
|
24
31
|
countsByResourceType: Record<string, number>;
|
|
25
32
|
entries: JsonObject[];
|
|
26
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
|
+
};
|
|
27
52
|
export {};
|
|
@@ -79,6 +79,59 @@ export function summarizeVeterinaryTaggedIndex(source, options = {}) {
|
|
|
79
79
|
}
|
|
80
80
|
return { total: entries.length, countsByResourceType, entries };
|
|
81
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
|
+
}
|
|
82
135
|
function deriveValues(resource, parameter) {
|
|
83
136
|
switch (`${String(resource.resourceType)}.${parameter}`) {
|
|
84
137
|
case 'Composition.date': return scalar(resource.date);
|
|
@@ -101,6 +154,9 @@ function deriveValues(resource, parameter) {
|
|
|
101
154
|
case 'Condition.severity': return conceptCodes(resource.severity);
|
|
102
155
|
case 'Condition.code': return conceptCodes(resource.code);
|
|
103
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);
|
|
104
160
|
case 'MedicationStatement.effective': return dateChoice(resource, 'effective');
|
|
105
161
|
case 'MedicationStatement.status': return scalar(resource.status);
|
|
106
162
|
case 'MedicationStatement.medication': return conceptCodes(resource.medicationCodeableConcept);
|
|
@@ -181,3 +237,7 @@ function uniqueTags(tags) {
|
|
|
181
237
|
function isObject(value) {
|
|
182
238
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
183
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
|
@@ -13,6 +13,7 @@ export * from './index-projection-tags.js';
|
|
|
13
13
|
export * from './iso-jurisdictions.js';
|
|
14
14
|
export * from './organization-application.js';
|
|
15
15
|
export * from './payment.js';
|
|
16
|
+
export * from './place-service-directory.js';
|
|
16
17
|
export * from './research-study.js';
|
|
17
18
|
export * from './sectors.js';
|
|
18
19
|
export * from './shc.js';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export * from './index-projection-tags.js';
|
|
|
13
13
|
export * from './iso-jurisdictions.js';
|
|
14
14
|
export * from './organization-application.js';
|
|
15
15
|
export * from './payment.js';
|
|
16
|
+
export * from './place-service-directory.js';
|
|
16
17
|
export * from './research-study.js';
|
|
17
18
|
export * from './sectors.js';
|
|
18
19
|
export * from './shc.js';
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/** Schema.org flat claims for a persistent, sector-neutral public Place. */
|
|
2
|
+
export declare enum ClaimsPlaceSchemaorg {
|
|
3
|
+
identifier = "org.schema.Place.identifier",
|
|
4
|
+
additionalType = "org.schema.Place.additionalType",
|
|
5
|
+
name = "org.schema.Place.name",
|
|
6
|
+
description = "org.schema.Place.description",
|
|
7
|
+
ownerIdentifier = "org.schema.Place.owner.identifier",
|
|
8
|
+
addressCountry = "org.schema.Place.address.addressCountry",
|
|
9
|
+
addressRegion = "org.schema.Place.address.addressRegion",
|
|
10
|
+
addressLocality = "org.schema.Place.address.addressLocality",
|
|
11
|
+
extendedAddress = "org.schema.Place.address.extendedAddress",
|
|
12
|
+
postalCode = "org.schema.Place.address.postalCode",
|
|
13
|
+
streetAddress = "org.schema.Place.address.streetAddress",
|
|
14
|
+
latitude = "org.schema.Place.geo.latitude",
|
|
15
|
+
longitude = "org.schema.Place.geo.longitude",
|
|
16
|
+
elevation = "org.schema.Place.geo.elevation",
|
|
17
|
+
photoContentUrl = "org.schema.Place.photo.contentUrl",
|
|
18
|
+
amenityFeature = "org.schema.Place.amenityFeature",
|
|
19
|
+
publicAccess = "org.schema.Place.publicAccess",
|
|
20
|
+
openingHoursSpecification = "org.schema.Place.openingHoursSpecification",
|
|
21
|
+
specialOpeningHoursSpecification = "org.schema.Place.specialOpeningHoursSpecification",
|
|
22
|
+
userSelected = "Place.user-selected"
|
|
23
|
+
}
|
|
24
|
+
/** Schema.org Service claims needed to join a public service to one or more Places. */
|
|
25
|
+
export declare enum ClaimsDirectoryServiceSchemaorg {
|
|
26
|
+
identifier = "org.schema.Service.identifier",
|
|
27
|
+
name = "org.schema.Service.name",
|
|
28
|
+
description = "org.schema.Service.description",
|
|
29
|
+
providerIdentifier = "org.schema.Service.provider.identifier",
|
|
30
|
+
placeIdentifier = "org.schema.Service.availableChannel.serviceLocation.identifier",
|
|
31
|
+
serviceType = "org.schema.Service.serviceType",
|
|
32
|
+
category = "org.schema.Service.category",
|
|
33
|
+
areaServed = "org.schema.Service.areaServed",
|
|
34
|
+
userSelected = "Service.user-selected"
|
|
35
|
+
}
|
|
36
|
+
/** Product-local allowlist for the experimental Place and Service directory profile. */
|
|
37
|
+
export declare const DirectoryFlatClaimCatalog: Readonly<{
|
|
38
|
+
readonly Place: readonly ClaimsPlaceSchemaorg[];
|
|
39
|
+
readonly Service: readonly ClaimsDirectoryServiceSchemaorg[];
|
|
40
|
+
}>;
|
|
41
|
+
export type DirectoryResourceType = keyof typeof DirectoryFlatClaimCatalog;
|
|
42
|
+
export type DirectoryClaimValue = string | number | boolean | readonly string[];
|
|
43
|
+
export type DirectoryResource = Readonly<{
|
|
44
|
+
resourceType: DirectoryResourceType;
|
|
45
|
+
id: string;
|
|
46
|
+
meta: Readonly<{
|
|
47
|
+
claims: Readonly<Record<string, DirectoryClaimValue>>;
|
|
48
|
+
}>;
|
|
49
|
+
}>;
|
|
50
|
+
export type DirectoryFlatClaimsResource = Readonly<{
|
|
51
|
+
resourceType: DirectoryResourceType;
|
|
52
|
+
id: string;
|
|
53
|
+
meta: Readonly<{
|
|
54
|
+
claims: Readonly<Record<string, readonly string[]>>;
|
|
55
|
+
}>;
|
|
56
|
+
}>;
|
|
57
|
+
export type OpeningHoursInput = Readonly<{
|
|
58
|
+
daysOfWeek: readonly string[];
|
|
59
|
+
opens: string;
|
|
60
|
+
closes: string;
|
|
61
|
+
validFrom?: string;
|
|
62
|
+
validThrough?: string;
|
|
63
|
+
}>;
|
|
64
|
+
export type SpecialOpeningHoursInput = Readonly<{
|
|
65
|
+
validFrom: string;
|
|
66
|
+
validThrough: string;
|
|
67
|
+
closed?: boolean;
|
|
68
|
+
opens?: string;
|
|
69
|
+
closes?: string;
|
|
70
|
+
}>;
|
|
71
|
+
export type PlaceDirectoryInput = Readonly<{
|
|
72
|
+
id: string;
|
|
73
|
+
organizationIdentifier: string;
|
|
74
|
+
name: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
additionalType?: string;
|
|
77
|
+
address: Readonly<{
|
|
78
|
+
addressCountry: string;
|
|
79
|
+
addressRegion?: string;
|
|
80
|
+
addressLocality?: string;
|
|
81
|
+
extendedAddress?: string;
|
|
82
|
+
postalCode: string;
|
|
83
|
+
streetAddress?: string;
|
|
84
|
+
}>;
|
|
85
|
+
position: Readonly<{
|
|
86
|
+
latitude: number;
|
|
87
|
+
longitude: number;
|
|
88
|
+
elevation?: number;
|
|
89
|
+
}>;
|
|
90
|
+
photoUrl?: string;
|
|
91
|
+
accessibilityFeatures?: readonly string[];
|
|
92
|
+
publicAccess?: boolean;
|
|
93
|
+
openingHours?: readonly OpeningHoursInput[];
|
|
94
|
+
specialOpeningHours?: readonly SpecialOpeningHoursInput[];
|
|
95
|
+
userSelected?: boolean;
|
|
96
|
+
}>;
|
|
97
|
+
export type ServiceDirectoryInput = Readonly<{
|
|
98
|
+
id: string;
|
|
99
|
+
organizationIdentifier: string;
|
|
100
|
+
placeIdentifiers: readonly string[];
|
|
101
|
+
name: string;
|
|
102
|
+
description?: string;
|
|
103
|
+
serviceTypes: readonly string[];
|
|
104
|
+
categories?: readonly string[];
|
|
105
|
+
areaServed?: readonly string[];
|
|
106
|
+
userSelected?: boolean;
|
|
107
|
+
}>;
|
|
108
|
+
/** Builds the claims-only public Place authored by an organization. */
|
|
109
|
+
export declare function buildPlaceDirectoryResource(input: PlaceDirectoryInput): DirectoryResource;
|
|
110
|
+
/** Builds a claims-only Schema.org Service joined to its provider and public Places. */
|
|
111
|
+
export declare function buildServiceDirectoryResource(input: ServiceDirectoryInput): DirectoryResource;
|
|
112
|
+
/** Rejects native fields and converts a Place or Service into deterministic array-valued claims. */
|
|
113
|
+
export declare function normalizeDirectoryFlatClaimsResource(input: unknown): DirectoryFlatClaimsResource;
|
|
114
|
+
/** Explicitly projects one neutral Place to native FHIR R5 Location for export or interop. */
|
|
115
|
+
export declare function projectPlaceToFhirR5Location(place: DirectoryResource | DirectoryFlatClaimsResource): Readonly<{
|
|
116
|
+
resourceType: "Location";
|
|
117
|
+
id: string;
|
|
118
|
+
status: "active";
|
|
119
|
+
mode: "instance";
|
|
120
|
+
name: string | undefined;
|
|
121
|
+
description: string | undefined;
|
|
122
|
+
address: {
|
|
123
|
+
line?: (string | undefined)[] | undefined;
|
|
124
|
+
country: string | undefined;
|
|
125
|
+
state: string | undefined;
|
|
126
|
+
city: string | undefined;
|
|
127
|
+
postalCode: string;
|
|
128
|
+
};
|
|
129
|
+
position: {
|
|
130
|
+
altitude?: number | undefined;
|
|
131
|
+
latitude: number;
|
|
132
|
+
longitude: number;
|
|
133
|
+
};
|
|
134
|
+
managingOrganization: {
|
|
135
|
+
reference: string;
|
|
136
|
+
};
|
|
137
|
+
characteristic: {
|
|
138
|
+
coding: {
|
|
139
|
+
code: string;
|
|
140
|
+
}[];
|
|
141
|
+
}[];
|
|
142
|
+
hoursOfOperation: {
|
|
143
|
+
availableTime: {
|
|
144
|
+
daysOfWeek: string[];
|
|
145
|
+
availableStartTime: string;
|
|
146
|
+
availableEndTime: string;
|
|
147
|
+
}[];
|
|
148
|
+
notAvailableTime: {
|
|
149
|
+
description: string;
|
|
150
|
+
during: {
|
|
151
|
+
start: string;
|
|
152
|
+
end: string;
|
|
153
|
+
};
|
|
154
|
+
}[];
|
|
155
|
+
}[];
|
|
156
|
+
meta: {
|
|
157
|
+
claims: {
|
|
158
|
+
'Location.characteristic'?: readonly string[] | undefined;
|
|
159
|
+
'Location.identifier': string;
|
|
160
|
+
'Location.status': string;
|
|
161
|
+
'Location.name': string | undefined;
|
|
162
|
+
'Location.address-postalcode': string;
|
|
163
|
+
'Location.near': string;
|
|
164
|
+
'Location.organization': string;
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
}>;
|
|
168
|
+
/** Explicitly projects a neutral Service and its Places to native FHIR R5 HealthcareService. */
|
|
169
|
+
export declare function projectServiceToFhirR5HealthcareService(service: DirectoryResource | DirectoryFlatClaimsResource, input: Readonly<{
|
|
170
|
+
places: readonly (DirectoryResource | DirectoryFlatClaimsResource)[];
|
|
171
|
+
}>): Readonly<{
|
|
172
|
+
meta: {
|
|
173
|
+
claims: {
|
|
174
|
+
'HealthcareService.service-category'?: readonly string[] | undefined;
|
|
175
|
+
'HealthcareService.identifier': string;
|
|
176
|
+
'HealthcareService.organization': string;
|
|
177
|
+
'HealthcareService.name': string | undefined;
|
|
178
|
+
'HealthcareService.location': string[];
|
|
179
|
+
'HealthcareService.service-type': readonly string[];
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
photo?: {
|
|
183
|
+
url: string;
|
|
184
|
+
} | undefined;
|
|
185
|
+
resourceType: "HealthcareService";
|
|
186
|
+
id: string;
|
|
187
|
+
active: true;
|
|
188
|
+
providedBy: {
|
|
189
|
+
reference: string;
|
|
190
|
+
};
|
|
191
|
+
name: string | undefined;
|
|
192
|
+
comment: string | undefined;
|
|
193
|
+
category: {
|
|
194
|
+
coding: {
|
|
195
|
+
code: string;
|
|
196
|
+
}[];
|
|
197
|
+
}[];
|
|
198
|
+
type: {
|
|
199
|
+
coding: {
|
|
200
|
+
code: string;
|
|
201
|
+
}[];
|
|
202
|
+
}[];
|
|
203
|
+
location: {
|
|
204
|
+
reference: string;
|
|
205
|
+
}[];
|
|
206
|
+
}>;
|
|
207
|
+
export type ClinicMarketplaceResult = Readonly<{
|
|
208
|
+
place: DirectoryResource | DirectoryFlatClaimsResource;
|
|
209
|
+
services: readonly (DirectoryResource | DirectoryFlatClaimsResource)[];
|
|
210
|
+
distanceKm?: number;
|
|
211
|
+
}>;
|
|
212
|
+
/** Joins and filters a public clinic directory by postal code, service and optional geographic radius. */
|
|
213
|
+
export declare function searchClinicMarketplace(input: Readonly<{
|
|
214
|
+
places: readonly (DirectoryResource | DirectoryFlatClaimsResource)[];
|
|
215
|
+
services: readonly (DirectoryResource | DirectoryFlatClaimsResource)[];
|
|
216
|
+
postalCode?: string;
|
|
217
|
+
serviceTypes?: readonly string[];
|
|
218
|
+
near?: Readonly<{
|
|
219
|
+
latitude: number;
|
|
220
|
+
longitude: number;
|
|
221
|
+
radiusKm: number;
|
|
222
|
+
}>;
|
|
223
|
+
}>): readonly ClinicMarketplaceResult[];
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Copyright 2026 Connecting Solution & Applications Ltd under the Apache License, Version 2.0.
|
|
2
|
+
/** Schema.org flat claims for a persistent, sector-neutral public Place. */
|
|
3
|
+
export var ClaimsPlaceSchemaorg;
|
|
4
|
+
(function (ClaimsPlaceSchemaorg) {
|
|
5
|
+
ClaimsPlaceSchemaorg["identifier"] = "org.schema.Place.identifier";
|
|
6
|
+
ClaimsPlaceSchemaorg["additionalType"] = "org.schema.Place.additionalType";
|
|
7
|
+
ClaimsPlaceSchemaorg["name"] = "org.schema.Place.name";
|
|
8
|
+
ClaimsPlaceSchemaorg["description"] = "org.schema.Place.description";
|
|
9
|
+
ClaimsPlaceSchemaorg["ownerIdentifier"] = "org.schema.Place.owner.identifier";
|
|
10
|
+
ClaimsPlaceSchemaorg["addressCountry"] = "org.schema.Place.address.addressCountry";
|
|
11
|
+
ClaimsPlaceSchemaorg["addressRegion"] = "org.schema.Place.address.addressRegion";
|
|
12
|
+
ClaimsPlaceSchemaorg["addressLocality"] = "org.schema.Place.address.addressLocality";
|
|
13
|
+
ClaimsPlaceSchemaorg["extendedAddress"] = "org.schema.Place.address.extendedAddress";
|
|
14
|
+
ClaimsPlaceSchemaorg["postalCode"] = "org.schema.Place.address.postalCode";
|
|
15
|
+
ClaimsPlaceSchemaorg["streetAddress"] = "org.schema.Place.address.streetAddress";
|
|
16
|
+
ClaimsPlaceSchemaorg["latitude"] = "org.schema.Place.geo.latitude";
|
|
17
|
+
ClaimsPlaceSchemaorg["longitude"] = "org.schema.Place.geo.longitude";
|
|
18
|
+
ClaimsPlaceSchemaorg["elevation"] = "org.schema.Place.geo.elevation";
|
|
19
|
+
ClaimsPlaceSchemaorg["photoContentUrl"] = "org.schema.Place.photo.contentUrl";
|
|
20
|
+
ClaimsPlaceSchemaorg["amenityFeature"] = "org.schema.Place.amenityFeature";
|
|
21
|
+
ClaimsPlaceSchemaorg["publicAccess"] = "org.schema.Place.publicAccess";
|
|
22
|
+
ClaimsPlaceSchemaorg["openingHoursSpecification"] = "org.schema.Place.openingHoursSpecification";
|
|
23
|
+
ClaimsPlaceSchemaorg["specialOpeningHoursSpecification"] = "org.schema.Place.specialOpeningHoursSpecification";
|
|
24
|
+
ClaimsPlaceSchemaorg["userSelected"] = "Place.user-selected";
|
|
25
|
+
})(ClaimsPlaceSchemaorg || (ClaimsPlaceSchemaorg = {}));
|
|
26
|
+
/** Schema.org Service claims needed to join a public service to one or more Places. */
|
|
27
|
+
export var ClaimsDirectoryServiceSchemaorg;
|
|
28
|
+
(function (ClaimsDirectoryServiceSchemaorg) {
|
|
29
|
+
ClaimsDirectoryServiceSchemaorg["identifier"] = "org.schema.Service.identifier";
|
|
30
|
+
ClaimsDirectoryServiceSchemaorg["name"] = "org.schema.Service.name";
|
|
31
|
+
ClaimsDirectoryServiceSchemaorg["description"] = "org.schema.Service.description";
|
|
32
|
+
ClaimsDirectoryServiceSchemaorg["providerIdentifier"] = "org.schema.Service.provider.identifier";
|
|
33
|
+
ClaimsDirectoryServiceSchemaorg["placeIdentifier"] = "org.schema.Service.availableChannel.serviceLocation.identifier";
|
|
34
|
+
ClaimsDirectoryServiceSchemaorg["serviceType"] = "org.schema.Service.serviceType";
|
|
35
|
+
ClaimsDirectoryServiceSchemaorg["category"] = "org.schema.Service.category";
|
|
36
|
+
ClaimsDirectoryServiceSchemaorg["areaServed"] = "org.schema.Service.areaServed";
|
|
37
|
+
ClaimsDirectoryServiceSchemaorg["userSelected"] = "Service.user-selected";
|
|
38
|
+
})(ClaimsDirectoryServiceSchemaorg || (ClaimsDirectoryServiceSchemaorg = {}));
|
|
39
|
+
/** Product-local allowlist for the experimental Place and Service directory profile. */
|
|
40
|
+
export const DirectoryFlatClaimCatalog = Object.freeze({
|
|
41
|
+
Place: Object.freeze(Object.values(ClaimsPlaceSchemaorg)),
|
|
42
|
+
Service: Object.freeze(Object.values(ClaimsDirectoryServiceSchemaorg)),
|
|
43
|
+
});
|
|
44
|
+
const RESOURCE_ID_PATTERN = /^[A-Za-z0-9\-.]{1,64}$/;
|
|
45
|
+
const TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
46
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
47
|
+
const DAY_NAMES = new Set(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']);
|
|
48
|
+
function required(value, code) {
|
|
49
|
+
const normalized = String(value ?? '').trim();
|
|
50
|
+
if (!normalized)
|
|
51
|
+
throw new TypeError(code);
|
|
52
|
+
return normalized;
|
|
53
|
+
}
|
|
54
|
+
function optional(value) {
|
|
55
|
+
const normalized = String(value ?? '').trim();
|
|
56
|
+
return normalized || undefined;
|
|
57
|
+
}
|
|
58
|
+
function unique(values, code) {
|
|
59
|
+
const result = [...new Set((values ?? []).map(value => required(value, code)))];
|
|
60
|
+
return Object.freeze(result);
|
|
61
|
+
}
|
|
62
|
+
function assertCoordinate(value, minimum, maximum, code) {
|
|
63
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum)
|
|
64
|
+
throw new TypeError(code);
|
|
65
|
+
}
|
|
66
|
+
function assertDate(value, code) {
|
|
67
|
+
if (!DATE_PATTERN.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`)))
|
|
68
|
+
throw new TypeError(code);
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function timeMinutes(value, code) {
|
|
72
|
+
if (!TIME_PATTERN.test(value))
|
|
73
|
+
throw new TypeError(code);
|
|
74
|
+
const [hour, minute] = value.split(':').map(Number);
|
|
75
|
+
return hour * 60 + minute;
|
|
76
|
+
}
|
|
77
|
+
function encodeOpeningHours(value) {
|
|
78
|
+
const daysOfWeek = unique(value.daysOfWeek, 'place_opening_hours_invalid');
|
|
79
|
+
if (!daysOfWeek.length || daysOfWeek.some(day => !DAY_NAMES.has(day)))
|
|
80
|
+
throw new TypeError('place_opening_hours_invalid');
|
|
81
|
+
if (timeMinutes(value.opens, 'place_opening_hours_invalid') >= timeMinutes(value.closes, 'place_opening_hours_invalid'))
|
|
82
|
+
throw new TypeError('place_opening_hours_invalid');
|
|
83
|
+
const validFrom = value.validFrom ? assertDate(value.validFrom, 'place_opening_hours_invalid') : undefined;
|
|
84
|
+
const validThrough = value.validThrough ? assertDate(value.validThrough, 'place_opening_hours_invalid') : undefined;
|
|
85
|
+
if (Boolean(validFrom) !== Boolean(validThrough) || (validFrom && validThrough && validFrom > validThrough))
|
|
86
|
+
throw new TypeError('place_opening_hours_invalid');
|
|
87
|
+
return JSON.stringify({ daysOfWeek, opens: value.opens, closes: value.closes, ...(validFrom ? { validFrom, validThrough } : {}) });
|
|
88
|
+
}
|
|
89
|
+
function encodeSpecialOpeningHours(value) {
|
|
90
|
+
const validFrom = assertDate(value.validFrom, 'place_special_opening_hours_invalid');
|
|
91
|
+
const validThrough = assertDate(value.validThrough, 'place_special_opening_hours_invalid');
|
|
92
|
+
if (validFrom > validThrough)
|
|
93
|
+
throw new TypeError('place_special_opening_hours_invalid');
|
|
94
|
+
if (value.closed)
|
|
95
|
+
return JSON.stringify({ validFrom, validThrough, closed: true });
|
|
96
|
+
if (!value.opens || !value.closes || timeMinutes(value.opens, 'place_special_opening_hours_invalid') >= timeMinutes(value.closes, 'place_special_opening_hours_invalid'))
|
|
97
|
+
throw new TypeError('place_special_opening_hours_invalid');
|
|
98
|
+
return JSON.stringify({ validFrom, validThrough, opens: value.opens, closes: value.closes });
|
|
99
|
+
}
|
|
100
|
+
/** Builds the claims-only public Place authored by an organization. */
|
|
101
|
+
export function buildPlaceDirectoryResource(input) {
|
|
102
|
+
const id = required(input.id, 'place_id_required');
|
|
103
|
+
if (!RESOURCE_ID_PATTERN.test(id))
|
|
104
|
+
throw new TypeError('place_id_invalid');
|
|
105
|
+
assertCoordinate(input.position.latitude, -90, 90, 'place_latitude_invalid');
|
|
106
|
+
assertCoordinate(input.position.longitude, -180, 180, 'place_longitude_invalid');
|
|
107
|
+
if (input.position.elevation !== undefined && !Number.isFinite(input.position.elevation))
|
|
108
|
+
throw new TypeError('place_elevation_invalid');
|
|
109
|
+
let photoUrl;
|
|
110
|
+
if (input.photoUrl) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = new URL(input.photoUrl);
|
|
113
|
+
if (parsed.protocol !== 'https:')
|
|
114
|
+
throw new Error();
|
|
115
|
+
photoUrl = parsed.toString();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new TypeError('place_photo_url_invalid');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const accessibilityFeatures = unique(input.accessibilityFeatures, 'place_amenity_feature_invalid');
|
|
122
|
+
const openingHours = Object.freeze((input.openingHours ?? []).map(encodeOpeningHours));
|
|
123
|
+
const specialOpeningHours = Object.freeze((input.specialOpeningHours ?? []).map(encodeSpecialOpeningHours));
|
|
124
|
+
const claims = {
|
|
125
|
+
[ClaimsPlaceSchemaorg.identifier]: id,
|
|
126
|
+
[ClaimsPlaceSchemaorg.ownerIdentifier]: required(input.organizationIdentifier, 'place_organization_required'),
|
|
127
|
+
[ClaimsPlaceSchemaorg.name]: required(input.name, 'place_name_required'),
|
|
128
|
+
[ClaimsPlaceSchemaorg.addressCountry]: required(input.address.addressCountry, 'place_country_required').toUpperCase(),
|
|
129
|
+
[ClaimsPlaceSchemaorg.postalCode]: required(input.address.postalCode, 'place_postal_code_required'),
|
|
130
|
+
[ClaimsPlaceSchemaorg.latitude]: input.position.latitude,
|
|
131
|
+
[ClaimsPlaceSchemaorg.longitude]: input.position.longitude,
|
|
132
|
+
[ClaimsPlaceSchemaorg.publicAccess]: input.publicAccess ?? true,
|
|
133
|
+
[ClaimsPlaceSchemaorg.userSelected]: input.userSelected ?? false,
|
|
134
|
+
};
|
|
135
|
+
const textValues = [
|
|
136
|
+
[ClaimsPlaceSchemaorg.additionalType, input.additionalType], [ClaimsPlaceSchemaorg.description, input.description],
|
|
137
|
+
[ClaimsPlaceSchemaorg.addressRegion, input.address.addressRegion], [ClaimsPlaceSchemaorg.addressLocality, input.address.addressLocality],
|
|
138
|
+
[ClaimsPlaceSchemaorg.extendedAddress, input.address.extendedAddress], [ClaimsPlaceSchemaorg.streetAddress, input.address.streetAddress],
|
|
139
|
+
];
|
|
140
|
+
for (const [name, raw] of textValues) {
|
|
141
|
+
const value = optional(raw);
|
|
142
|
+
if (value)
|
|
143
|
+
claims[name] = value;
|
|
144
|
+
}
|
|
145
|
+
if (input.position.elevation !== undefined)
|
|
146
|
+
claims[ClaimsPlaceSchemaorg.elevation] = input.position.elevation;
|
|
147
|
+
if (photoUrl)
|
|
148
|
+
claims[ClaimsPlaceSchemaorg.photoContentUrl] = photoUrl;
|
|
149
|
+
if (accessibilityFeatures.length)
|
|
150
|
+
claims[ClaimsPlaceSchemaorg.amenityFeature] = accessibilityFeatures;
|
|
151
|
+
if (openingHours.length)
|
|
152
|
+
claims[ClaimsPlaceSchemaorg.openingHoursSpecification] = openingHours;
|
|
153
|
+
if (specialOpeningHours.length)
|
|
154
|
+
claims[ClaimsPlaceSchemaorg.specialOpeningHoursSpecification] = specialOpeningHours;
|
|
155
|
+
return Object.freeze({ resourceType: 'Place', id, meta: Object.freeze({ claims: Object.freeze(claims) }) });
|
|
156
|
+
}
|
|
157
|
+
/** Builds a claims-only Schema.org Service joined to its provider and public Places. */
|
|
158
|
+
export function buildServiceDirectoryResource(input) {
|
|
159
|
+
const id = required(input.id, 'service_id_required');
|
|
160
|
+
if (!RESOURCE_ID_PATTERN.test(id))
|
|
161
|
+
throw new TypeError('service_id_invalid');
|
|
162
|
+
const placeIdentifiers = unique(input.placeIdentifiers, 'service_place_required');
|
|
163
|
+
const serviceTypes = unique(input.serviceTypes, 'service_type_required');
|
|
164
|
+
if (!placeIdentifiers.length)
|
|
165
|
+
throw new TypeError('service_place_required');
|
|
166
|
+
if (!serviceTypes.length)
|
|
167
|
+
throw new TypeError('service_type_required');
|
|
168
|
+
const categories = unique(input.categories, 'service_category_invalid');
|
|
169
|
+
const areaServed = unique(input.areaServed, 'service_area_invalid');
|
|
170
|
+
const claims = {
|
|
171
|
+
[ClaimsDirectoryServiceSchemaorg.identifier]: id,
|
|
172
|
+
[ClaimsDirectoryServiceSchemaorg.providerIdentifier]: required(input.organizationIdentifier, 'service_organization_required'),
|
|
173
|
+
[ClaimsDirectoryServiceSchemaorg.placeIdentifier]: placeIdentifiers,
|
|
174
|
+
[ClaimsDirectoryServiceSchemaorg.name]: required(input.name, 'service_name_required'),
|
|
175
|
+
[ClaimsDirectoryServiceSchemaorg.serviceType]: serviceTypes,
|
|
176
|
+
[ClaimsDirectoryServiceSchemaorg.userSelected]: input.userSelected ?? false,
|
|
177
|
+
};
|
|
178
|
+
const description = optional(input.description);
|
|
179
|
+
if (description)
|
|
180
|
+
claims[ClaimsDirectoryServiceSchemaorg.description] = description;
|
|
181
|
+
if (categories.length)
|
|
182
|
+
claims[ClaimsDirectoryServiceSchemaorg.category] = categories;
|
|
183
|
+
if (areaServed.length)
|
|
184
|
+
claims[ClaimsDirectoryServiceSchemaorg.areaServed] = areaServed;
|
|
185
|
+
return Object.freeze({ resourceType: 'Service', id, meta: Object.freeze({ claims: Object.freeze(claims) }) });
|
|
186
|
+
}
|
|
187
|
+
/** Rejects native fields and converts a Place or Service into deterministic array-valued claims. */
|
|
188
|
+
export function normalizeDirectoryFlatClaimsResource(input) {
|
|
189
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
190
|
+
throw new TypeError('directory_flat_resource_invalid');
|
|
191
|
+
const value = input;
|
|
192
|
+
if ((value.resourceType !== 'Place' && value.resourceType !== 'Service') || Object.keys(value).some(key => !['resourceType', 'id', 'meta'].includes(key)))
|
|
193
|
+
throw new TypeError('directory_flat_resource_invalid');
|
|
194
|
+
const resourceType = value.resourceType;
|
|
195
|
+
const id = required(value.id, 'directory_flat_resource_invalid');
|
|
196
|
+
if (!RESOURCE_ID_PATTERN.test(id))
|
|
197
|
+
throw new TypeError('directory_flat_resource_invalid');
|
|
198
|
+
const rawClaims = value.meta?.claims;
|
|
199
|
+
if (!rawClaims || typeof rawClaims !== 'object' || Array.isArray(rawClaims))
|
|
200
|
+
throw new TypeError('directory_flat_resource_invalid');
|
|
201
|
+
const allowed = new Set(DirectoryFlatClaimCatalog[resourceType]);
|
|
202
|
+
const claims = {};
|
|
203
|
+
for (const [name, raw] of Object.entries(rawClaims)) {
|
|
204
|
+
if (!allowed.has(name))
|
|
205
|
+
throw new TypeError('directory_flat_claim_invalid');
|
|
206
|
+
const values = (Array.isArray(raw) ? raw : [raw]).map(item => String(item).trim());
|
|
207
|
+
if (!values.length || values.some(item => !item))
|
|
208
|
+
throw new TypeError('directory_flat_claim_invalid');
|
|
209
|
+
claims[name] = Object.freeze([...new Set(values)]);
|
|
210
|
+
}
|
|
211
|
+
return Object.freeze({ resourceType, id, meta: Object.freeze({ claims: Object.freeze(claims) }) });
|
|
212
|
+
}
|
|
213
|
+
function values(resource, name) {
|
|
214
|
+
const raw = resource.meta.claims[name];
|
|
215
|
+
return (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map(String);
|
|
216
|
+
}
|
|
217
|
+
function first(resource, name) {
|
|
218
|
+
return values(resource, name)[0];
|
|
219
|
+
}
|
|
220
|
+
function parseJson(value, code) {
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(value);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
throw new TypeError(code);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Explicitly projects one neutral Place to native FHIR R5 Location for export or interop. */
|
|
229
|
+
export function projectPlaceToFhirR5Location(place) {
|
|
230
|
+
if (place.resourceType !== 'Place')
|
|
231
|
+
throw new TypeError('place_resource_required');
|
|
232
|
+
const latitude = Number(first(place, ClaimsPlaceSchemaorg.latitude));
|
|
233
|
+
const longitude = Number(first(place, ClaimsPlaceSchemaorg.longitude));
|
|
234
|
+
const postalCode = required(first(place, ClaimsPlaceSchemaorg.postalCode), 'place_postal_code_required');
|
|
235
|
+
const owner = required(first(place, ClaimsPlaceSchemaorg.ownerIdentifier), 'place_organization_required');
|
|
236
|
+
const opening = values(place, ClaimsPlaceSchemaorg.openingHoursSpecification).map(value => parseJson(value, 'place_opening_hours_invalid'));
|
|
237
|
+
const special = values(place, ClaimsPlaceSchemaorg.specialOpeningHoursSpecification).map(value => parseJson(value, 'place_special_opening_hours_invalid'));
|
|
238
|
+
const address = {
|
|
239
|
+
country: first(place, ClaimsPlaceSchemaorg.addressCountry), state: first(place, ClaimsPlaceSchemaorg.addressRegion),
|
|
240
|
+
city: first(place, ClaimsPlaceSchemaorg.addressLocality), postalCode,
|
|
241
|
+
...(first(place, ClaimsPlaceSchemaorg.streetAddress) ? { line: [first(place, ClaimsPlaceSchemaorg.streetAddress)] } : {}),
|
|
242
|
+
};
|
|
243
|
+
const availableTime = opening.map(period => ({
|
|
244
|
+
daysOfWeek: period.daysOfWeek.map(day => day.slice(0, 3).toLowerCase()),
|
|
245
|
+
availableStartTime: period.opens, availableEndTime: period.closes,
|
|
246
|
+
}));
|
|
247
|
+
const notAvailableTime = special.filter(period => period.closed).map(period => ({
|
|
248
|
+
description: 'Special closure', during: { start: period.validFrom, end: period.validThrough },
|
|
249
|
+
}));
|
|
250
|
+
return Object.freeze({
|
|
251
|
+
resourceType: 'Location', id: place.id, status: 'active', mode: 'instance',
|
|
252
|
+
name: first(place, ClaimsPlaceSchemaorg.name), description: first(place, ClaimsPlaceSchemaorg.description),
|
|
253
|
+
address, position: { latitude, longitude, ...(first(place, ClaimsPlaceSchemaorg.elevation) ? { altitude: Number(first(place, ClaimsPlaceSchemaorg.elevation)) } : {}) },
|
|
254
|
+
managingOrganization: { reference: owner },
|
|
255
|
+
characteristic: values(place, ClaimsPlaceSchemaorg.amenityFeature).map(code => ({ coding: [{ code }] })),
|
|
256
|
+
hoursOfOperation: [{ availableTime, notAvailableTime }],
|
|
257
|
+
meta: { claims: {
|
|
258
|
+
'Location.identifier': place.id, 'Location.status': 'active', 'Location.name': first(place, ClaimsPlaceSchemaorg.name),
|
|
259
|
+
'Location.address-postalcode': postalCode, 'Location.near': `${latitude}|${longitude}`, 'Location.organization': owner,
|
|
260
|
+
...(values(place, ClaimsPlaceSchemaorg.amenityFeature).length ? { 'Location.characteristic': values(place, ClaimsPlaceSchemaorg.amenityFeature) } : {}),
|
|
261
|
+
} },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/** Explicitly projects a neutral Service and its Places to native FHIR R5 HealthcareService. */
|
|
265
|
+
export function projectServiceToFhirR5HealthcareService(service, input) {
|
|
266
|
+
if (service.resourceType !== 'Service')
|
|
267
|
+
throw new TypeError('service_resource_required');
|
|
268
|
+
const placeIdentifiers = values(service, ClaimsDirectoryServiceSchemaorg.placeIdentifier);
|
|
269
|
+
const places = placeIdentifiers.map(identifier => {
|
|
270
|
+
const place = input.places.find(candidate => candidate.resourceType === 'Place' && candidate.id === identifier);
|
|
271
|
+
if (!place)
|
|
272
|
+
throw new TypeError('service_place_not_found');
|
|
273
|
+
return place;
|
|
274
|
+
});
|
|
275
|
+
const serviceTypes = values(service, ClaimsDirectoryServiceSchemaorg.serviceType);
|
|
276
|
+
const categories = values(service, ClaimsDirectoryServiceSchemaorg.category);
|
|
277
|
+
const organization = required(first(service, ClaimsDirectoryServiceSchemaorg.providerIdentifier), 'service_organization_required');
|
|
278
|
+
const photoUrl = places.map(place => first(place, ClaimsPlaceSchemaorg.photoContentUrl)).find(Boolean);
|
|
279
|
+
const concept = (code) => ({ coding: [{ code }] });
|
|
280
|
+
return Object.freeze({
|
|
281
|
+
resourceType: 'HealthcareService', id: service.id, active: true, providedBy: { reference: organization },
|
|
282
|
+
name: first(service, ClaimsDirectoryServiceSchemaorg.name), comment: first(service, ClaimsDirectoryServiceSchemaorg.description),
|
|
283
|
+
category: categories.map(concept), type: serviceTypes.map(concept),
|
|
284
|
+
location: places.map(place => ({ reference: `Location/${place.id}` })),
|
|
285
|
+
...(photoUrl ? { photo: { url: photoUrl } } : {}),
|
|
286
|
+
meta: { claims: {
|
|
287
|
+
'HealthcareService.identifier': service.id, 'HealthcareService.organization': organization,
|
|
288
|
+
'HealthcareService.name': first(service, ClaimsDirectoryServiceSchemaorg.name),
|
|
289
|
+
'HealthcareService.location': places.map(place => `Location/${place.id}`),
|
|
290
|
+
'HealthcareService.service-type': serviceTypes,
|
|
291
|
+
...(categories.length ? { 'HealthcareService.service-category': categories } : {}),
|
|
292
|
+
} },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function normalizedPostal(value) { return value.replace(/[^A-Za-z0-9]/g, '').toUpperCase(); }
|
|
296
|
+
function distanceKm(left, right) {
|
|
297
|
+
const radians = (value) => value * Math.PI / 180;
|
|
298
|
+
const lat = radians(right.latitude - left.latitude);
|
|
299
|
+
const lon = radians(right.longitude - left.longitude);
|
|
300
|
+
const a = Math.sin(lat / 2) ** 2 + Math.cos(radians(left.latitude)) * Math.cos(radians(right.latitude)) * Math.sin(lon / 2) ** 2;
|
|
301
|
+
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
302
|
+
}
|
|
303
|
+
/** Joins and filters a public clinic directory by postal code, service and optional geographic radius. */
|
|
304
|
+
export function searchClinicMarketplace(input) {
|
|
305
|
+
const postalCode = input.postalCode ? normalizedPostal(required(input.postalCode, 'marketplace_postal_code_invalid')) : undefined;
|
|
306
|
+
const requestedTypes = new Set(unique(input.serviceTypes, 'marketplace_service_type_invalid'));
|
|
307
|
+
if (input.near) {
|
|
308
|
+
assertCoordinate(input.near.latitude, -90, 90, 'marketplace_latitude_invalid');
|
|
309
|
+
assertCoordinate(input.near.longitude, -180, 180, 'marketplace_longitude_invalid');
|
|
310
|
+
if (!Number.isFinite(input.near.radiusKm) || input.near.radiusKm <= 0)
|
|
311
|
+
throw new TypeError('marketplace_radius_invalid');
|
|
312
|
+
}
|
|
313
|
+
const results = [];
|
|
314
|
+
for (const place of input.places) {
|
|
315
|
+
if (place.resourceType !== 'Place')
|
|
316
|
+
throw new TypeError('place_resource_required');
|
|
317
|
+
if (postalCode && normalizedPostal(String(first(place, ClaimsPlaceSchemaorg.postalCode) || '')) !== postalCode)
|
|
318
|
+
continue;
|
|
319
|
+
const services = input.services.filter(service => service.resourceType === 'Service'
|
|
320
|
+
&& values(service, ClaimsDirectoryServiceSchemaorg.placeIdentifier).includes(place.id)
|
|
321
|
+
&& (!requestedTypes.size || values(service, ClaimsDirectoryServiceSchemaorg.serviceType).some(type => requestedTypes.has(type))));
|
|
322
|
+
if (!services.length)
|
|
323
|
+
continue;
|
|
324
|
+
const distance = input.near ? distanceKm(input.near, {
|
|
325
|
+
latitude: Number(first(place, ClaimsPlaceSchemaorg.latitude)), longitude: Number(first(place, ClaimsPlaceSchemaorg.longitude)),
|
|
326
|
+
}) : undefined;
|
|
327
|
+
if (input.near && (distance === undefined || distance > input.near.radiusKm))
|
|
328
|
+
continue;
|
|
329
|
+
results.push(Object.freeze({ place, services: Object.freeze(services), ...(distance === undefined ? {} : { distanceKm: distance }) }));
|
|
330
|
+
}
|
|
331
|
+
return Object.freeze(results.sort((left, right) => (left.distanceKm ?? 0) - (right.distanceKm ?? 0) || left.place.id.localeCompare(right.place.id)));
|
|
332
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vet-data-utils-ts",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.21",
|
|
4
4
|
"description": "Browser-safe governed VetChain data contracts",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Connecting Solution & Applications Ltd",
|
|
@@ -68,6 +68,10 @@
|
|
|
68
68
|
"types": "./dist/payment.d.ts",
|
|
69
69
|
"default": "./dist/payment.js"
|
|
70
70
|
},
|
|
71
|
+
"./place-service-directory": {
|
|
72
|
+
"types": "./dist/place-service-directory.d.ts",
|
|
73
|
+
"default": "./dist/place-service-directory.js"
|
|
74
|
+
},
|
|
71
75
|
"./research-study": {
|
|
72
76
|
"types": "./dist/research-study.d.ts",
|
|
73
77
|
"default": "./dist/research-study.js"
|