vet-data-utils-ts 0.5.9 → 0.5.10
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 +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scheduling.d.ts +139 -0
- package/dist/scheduling.js +220 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -41,6 +41,25 @@ Current surfaces cover the canonical animal-card DID, veterinary summary
|
|
|
41
41
|
sections, animal-only emergency data, pseudonymous DigitalTwin search input and
|
|
42
42
|
veterinary assistant intents.
|
|
43
43
|
|
|
44
|
+
## Veterinary scheduling
|
|
45
|
+
|
|
46
|
+
`vet-data-utils-ts/scheduling` owns claims-first builders for FHIR R5
|
|
47
|
+
`Location`, `Schedule`, `Slot`, `Appointment` and `AppointmentResponse`.
|
|
48
|
+
`expandVeterinaryAvailability(...)` expands bounded weekly periods into Slots
|
|
49
|
+
and applies weekly or monthly-ordinal exceptions that restrict or extend one or
|
|
50
|
+
more consultation Locations. It converts clinic-local times through an IANA
|
|
51
|
+
time zone, including daylight-saving changes.
|
|
52
|
+
|
|
53
|
+
The official searchable facts remain resource-qualified flat claims. The
|
|
54
|
+
exported `VeterinarySlotSearchParameters` catalogue identifies the supported
|
|
55
|
+
FHIR R5 Slot query codes; generated resources include `Slot.identifier`,
|
|
56
|
+
`Slot.schedule`, `Slot.status`, `Slot.start`, `Appointment.status`,
|
|
57
|
+
`Appointment.start`, `Appointment.slot` and `Appointment.actor`. Product-only
|
|
58
|
+
facts use the explicit `VeterinaryLocation`, `VeterinarySlot` or
|
|
59
|
+
`VeterinaryAppointment` prefix; a Location photo and the seven-day
|
|
60
|
+
confirmation due time are not presented as official FHIR SearchParameters.
|
|
61
|
+
All builders return only `resourceType`, `id` and `meta.claims`.
|
|
62
|
+
|
|
44
63
|
FHIR R5 ResearchStudy screens consume the exact 25 resource-specific search
|
|
45
64
|
parameters and the separate associated-party projection from
|
|
46
65
|
`vet-data-utils-ts/research-study`. GW persistence receives only
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** FHIR R5 Slot statuses accepted by veterinary scheduling. */
|
|
2
|
+
export declare const SlotStatuses: Readonly<{
|
|
3
|
+
readonly Busy: "busy";
|
|
4
|
+
readonly Free: "free";
|
|
5
|
+
readonly BusyUnavailable: "busy-unavailable";
|
|
6
|
+
readonly BusyTentative: "busy-tentative";
|
|
7
|
+
readonly EnteredInError: "entered-in-error";
|
|
8
|
+
}>;
|
|
9
|
+
/** FHIR R5 AppointmentResponse participant statuses. */
|
|
10
|
+
export declare const AppointmentResponseStatuses: Readonly<{
|
|
11
|
+
readonly Accepted: "accepted";
|
|
12
|
+
readonly Declined: "declined";
|
|
13
|
+
readonly Tentative: "tentative";
|
|
14
|
+
readonly NeedsAction: "needs-action";
|
|
15
|
+
readonly EnteredInError: "entered-in-error";
|
|
16
|
+
}>;
|
|
17
|
+
/** FHIR R5 Appointment statuses used by the import and booking flow. */
|
|
18
|
+
export declare const AppointmentStatuses: Readonly<{
|
|
19
|
+
readonly Proposed: "proposed";
|
|
20
|
+
readonly Pending: "pending";
|
|
21
|
+
readonly Booked: "booked";
|
|
22
|
+
readonly Arrived: "arrived";
|
|
23
|
+
readonly Fulfilled: "fulfilled";
|
|
24
|
+
readonly Cancelled: "cancelled";
|
|
25
|
+
readonly NoShow: "noshow";
|
|
26
|
+
readonly EnteredInError: "entered-in-error";
|
|
27
|
+
readonly CheckedIn: "checked-in";
|
|
28
|
+
readonly Waitlist: "waitlist";
|
|
29
|
+
}>;
|
|
30
|
+
/** Official FHIR R5 Slot resource-specific SearchParameter codes supported by flat claims. */
|
|
31
|
+
export declare const VeterinarySlotSearchParameters: readonly ["identifier", "schedule", "service-category", "service-type", "specialty", "start", "status"];
|
|
32
|
+
/** Governed operational flat claims accepted by GW VET scheduling managers. */
|
|
33
|
+
export declare const VeterinarySchedulingFlatClaimCatalog: Readonly<{
|
|
34
|
+
readonly Location: readonly string[];
|
|
35
|
+
readonly Schedule: readonly string[];
|
|
36
|
+
readonly Slot: readonly string[];
|
|
37
|
+
readonly Appointment: readonly string[];
|
|
38
|
+
readonly AppointmentResponse: readonly string[];
|
|
39
|
+
}>;
|
|
40
|
+
export type VeterinarySchedulingResourceType = keyof typeof VeterinarySchedulingFlatClaimCatalog;
|
|
41
|
+
export type VeterinarySchedulingFlatClaimsResource = Readonly<{
|
|
42
|
+
resourceType: VeterinarySchedulingResourceType;
|
|
43
|
+
id: string;
|
|
44
|
+
meta: Readonly<{
|
|
45
|
+
claims: Readonly<Record<string, readonly string[]>>;
|
|
46
|
+
}>;
|
|
47
|
+
}>;
|
|
48
|
+
/** Validates the claims-only wire shape and normalizes scalar builder values to indexed strings. */
|
|
49
|
+
export declare function normalizeVeterinarySchedulingFlatClaimsResource(input: unknown): VeterinarySchedulingFlatClaimsResource;
|
|
50
|
+
export type ClaimsFirstResource = Readonly<{
|
|
51
|
+
resourceType: string;
|
|
52
|
+
id: string;
|
|
53
|
+
meta: Readonly<{
|
|
54
|
+
claims: Readonly<Record<string, unknown>>;
|
|
55
|
+
}>;
|
|
56
|
+
}>;
|
|
57
|
+
/** Builds the claims-first veterinary projection of one physical FHIR R5 Location. */
|
|
58
|
+
export declare function buildVeterinaryLocationResource(input: Readonly<{
|
|
59
|
+
id: string;
|
|
60
|
+
organizationReference: string;
|
|
61
|
+
name: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
latitude: number;
|
|
64
|
+
longitude: number;
|
|
65
|
+
altitude?: number;
|
|
66
|
+
characteristicCodes?: readonly string[];
|
|
67
|
+
photoUrl?: string;
|
|
68
|
+
}>): ClaimsFirstResource;
|
|
69
|
+
/** Builds the claims-first FHIR R5 Schedule joining one practitioner and consultation Location. */
|
|
70
|
+
export declare function buildVeterinaryScheduleResource(input: Readonly<{
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
practitionerReference: string;
|
|
74
|
+
locationReference: string;
|
|
75
|
+
planningHorizonStart: string;
|
|
76
|
+
planningHorizonEnd: string;
|
|
77
|
+
}>): ClaimsFirstResource;
|
|
78
|
+
export type VeterinaryAvailabilityPeriod = Readonly<{
|
|
79
|
+
daysOfWeek: readonly number[];
|
|
80
|
+
startTime: string;
|
|
81
|
+
endTime: string;
|
|
82
|
+
locationReferences: readonly string[];
|
|
83
|
+
}>;
|
|
84
|
+
export type VeterinaryAvailabilityException = Readonly<{
|
|
85
|
+
effect: 'restrict' | 'expand';
|
|
86
|
+
recurrence: Readonly<{
|
|
87
|
+
frequency: 'weekly' | 'monthly';
|
|
88
|
+
dayOfWeek: number;
|
|
89
|
+
ordinal?: number;
|
|
90
|
+
}>;
|
|
91
|
+
startTime: string;
|
|
92
|
+
endTime: string;
|
|
93
|
+
locationReferences: readonly string[];
|
|
94
|
+
}>;
|
|
95
|
+
export type ExpandedVeterinarySlot = Readonly<{
|
|
96
|
+
localDate: string;
|
|
97
|
+
localStart: string;
|
|
98
|
+
localEnd: string;
|
|
99
|
+
locationReference: string;
|
|
100
|
+
resource: ClaimsFirstResource;
|
|
101
|
+
}>;
|
|
102
|
+
/** Expands bounded weekly availability into claims-first Slots, applying recurring restrictions or expansions. */
|
|
103
|
+
export declare function expandVeterinaryAvailability(input: Readonly<{
|
|
104
|
+
scheduleId: string;
|
|
105
|
+
startDate: string;
|
|
106
|
+
endDate: string;
|
|
107
|
+
timeZone: string;
|
|
108
|
+
slotMinutes: number;
|
|
109
|
+
periods: readonly VeterinaryAvailabilityPeriod[];
|
|
110
|
+
exceptions: readonly VeterinaryAvailabilityException[];
|
|
111
|
+
}>): readonly ExpandedVeterinarySlot[];
|
|
112
|
+
/** Builds a claims-first response that confirms, declines or proposes a new appointment time. */
|
|
113
|
+
export declare function buildVeterinaryAppointmentResponseResource(input: Readonly<{
|
|
114
|
+
id: string;
|
|
115
|
+
appointmentReference: string;
|
|
116
|
+
actorReference: string;
|
|
117
|
+
participantStatus: string;
|
|
118
|
+
proposedNewTime?: boolean;
|
|
119
|
+
start?: string;
|
|
120
|
+
end?: string;
|
|
121
|
+
}>): ClaimsFirstResource;
|
|
122
|
+
/** Builds a searchable claims-first FHIR R5 Appointment with a seven-day confirmation due time. */
|
|
123
|
+
export declare function buildVeterinaryAppointmentResource(input: Readonly<{
|
|
124
|
+
id: string;
|
|
125
|
+
status: string;
|
|
126
|
+
start: string;
|
|
127
|
+
end: string;
|
|
128
|
+
slotReferences: readonly string[];
|
|
129
|
+
actorReferences: readonly string[];
|
|
130
|
+
locationReference?: string;
|
|
131
|
+
confirmationDueAt?: string;
|
|
132
|
+
}>): ClaimsFirstResource;
|
|
133
|
+
/** Builds the durable web inbox notification when a schedule change displaces an appointment. */
|
|
134
|
+
export declare function buildVeterinaryAppointmentNotificationResource(input: Readonly<{
|
|
135
|
+
id: string;
|
|
136
|
+
appointmentReference: string;
|
|
137
|
+
subjectReference: string;
|
|
138
|
+
reason: string;
|
|
139
|
+
}>): ClaimsFirstResource;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/** FHIR R5 Slot statuses accepted by veterinary scheduling. */
|
|
2
|
+
export const SlotStatuses = Object.freeze({ Busy: 'busy', Free: 'free', BusyUnavailable: 'busy-unavailable', BusyTentative: 'busy-tentative', EnteredInError: 'entered-in-error' });
|
|
3
|
+
/** FHIR R5 AppointmentResponse participant statuses. */
|
|
4
|
+
export const AppointmentResponseStatuses = Object.freeze({ Accepted: 'accepted', Declined: 'declined', Tentative: 'tentative', NeedsAction: 'needs-action', EnteredInError: 'entered-in-error' });
|
|
5
|
+
/** FHIR R5 Appointment statuses used by the import and booking flow. */
|
|
6
|
+
export const AppointmentStatuses = Object.freeze({
|
|
7
|
+
Proposed: 'proposed', Pending: 'pending', Booked: 'booked', Arrived: 'arrived', Fulfilled: 'fulfilled',
|
|
8
|
+
Cancelled: 'cancelled', NoShow: 'noshow', EnteredInError: 'entered-in-error', CheckedIn: 'checked-in', Waitlist: 'waitlist',
|
|
9
|
+
});
|
|
10
|
+
/** Official FHIR R5 Slot resource-specific SearchParameter codes supported by flat claims. */
|
|
11
|
+
export const VeterinarySlotSearchParameters = Object.freeze(['identifier', 'schedule', 'service-category', 'service-type', 'specialty', 'start', 'status']);
|
|
12
|
+
/** Governed operational flat claims accepted by GW VET scheduling managers. */
|
|
13
|
+
export const VeterinarySchedulingFlatClaimCatalog = Object.freeze({
|
|
14
|
+
Location: Object.freeze(['Location.identifier', 'Location.status', 'Location.mode', 'Location.name', 'Location.description', 'Location.position.latitude', 'Location.position.longitude', 'Location.position.altitude', 'Location.managingOrganization', 'Location.characteristic', 'VeterinaryLocation.photoUrl']),
|
|
15
|
+
Schedule: Object.freeze(['Schedule.identifier', 'Schedule.active', 'Schedule.name', 'Schedule.actor', 'Schedule.planningHorizon.start', 'Schedule.planningHorizon.end']),
|
|
16
|
+
Slot: Object.freeze(['Slot.identifier', 'Slot.schedule', 'Slot.status', 'Slot.start', 'Slot.end', 'Slot.service-category', 'Slot.service-type', 'Slot.specialty', 'VeterinarySlot.location', 'VeterinarySlot.timeZone']),
|
|
17
|
+
Appointment: Object.freeze(['Appointment.identifier', 'Appointment.status', 'Appointment.description', 'Appointment.note', 'Appointment.start', 'Appointment.end', 'Appointment.slot', 'Appointment.actor', 'Appointment.location', 'VeterinaryAppointment.confirmationDueAt', 'VeterinaryAppointment.reschedulingReason']),
|
|
18
|
+
AppointmentResponse: Object.freeze(['AppointmentResponse.identifier', 'AppointmentResponse.appointment', 'AppointmentResponse.actor', 'AppointmentResponse.participantStatus', 'AppointmentResponse.proposedNewTime', 'AppointmentResponse.start', 'AppointmentResponse.end']),
|
|
19
|
+
});
|
|
20
|
+
/** Validates the claims-only wire shape and normalizes scalar builder values to indexed strings. */
|
|
21
|
+
export function normalizeVeterinarySchedulingFlatClaimsResource(input) {
|
|
22
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
23
|
+
throw new TypeError('veterinary_scheduling_flat_resource_invalid');
|
|
24
|
+
const value = input;
|
|
25
|
+
if (typeof value.resourceType !== 'string' || !(value.resourceType in VeterinarySchedulingFlatClaimCatalog) || Object.keys(value).some(key => !['resourceType', 'id', 'meta'].includes(key)))
|
|
26
|
+
throw new TypeError('veterinary_scheduling_flat_resource_invalid');
|
|
27
|
+
const id = required(String(value.id || ''), 'veterinary_scheduling_flat_resource_invalid');
|
|
28
|
+
if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
|
|
29
|
+
throw new TypeError('veterinary_scheduling_flat_resource_invalid');
|
|
30
|
+
const meta = value.meta;
|
|
31
|
+
if (!meta || typeof meta !== 'object' || !meta.claims || typeof meta.claims !== 'object' || Array.isArray(meta.claims) || Object.keys(meta).some(key => key !== 'claims'))
|
|
32
|
+
throw new TypeError('veterinary_scheduling_flat_resource_invalid');
|
|
33
|
+
const allowed = new Set(VeterinarySchedulingFlatClaimCatalog[value.resourceType]);
|
|
34
|
+
const claims = {};
|
|
35
|
+
for (const [name, raw] of Object.entries(meta.claims)) {
|
|
36
|
+
if (!allowed.has(name))
|
|
37
|
+
throw new TypeError('veterinary_scheduling_flat_claim_invalid');
|
|
38
|
+
const values = (Array.isArray(raw) ? raw : [raw]).map(item => String(item).trim());
|
|
39
|
+
if (!values.length || values.some(item => !item))
|
|
40
|
+
throw new TypeError('veterinary_scheduling_flat_claim_invalid');
|
|
41
|
+
claims[name] = Object.freeze([...new Set(values)]);
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze({ resourceType: value.resourceType, id, meta: Object.freeze({ claims: Object.freeze(claims) }) });
|
|
44
|
+
}
|
|
45
|
+
const required = (value, code) => { const normalized = value.trim(); if (!normalized)
|
|
46
|
+
throw new Error(code); return normalized; };
|
|
47
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
48
|
+
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
49
|
+
const assertDate = (value) => { if (!datePattern.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`)))
|
|
50
|
+
throw new Error('date_invalid'); return value; };
|
|
51
|
+
const assertTime = (value) => { if (!timePattern.test(value))
|
|
52
|
+
throw new Error('time_invalid'); return value; };
|
|
53
|
+
const claimsResource = (resourceType, id, claims) => Object.freeze({ resourceType, id: required(id, 'resource_id_required'), meta: Object.freeze({ claims: Object.freeze(claims) }) });
|
|
54
|
+
/** Builds the claims-first veterinary projection of one physical FHIR R5 Location. */
|
|
55
|
+
export function buildVeterinaryLocationResource(input) {
|
|
56
|
+
if (!Number.isFinite(input.latitude) || input.latitude < -90 || input.latitude > 90)
|
|
57
|
+
throw new Error('location_latitude_invalid');
|
|
58
|
+
if (!Number.isFinite(input.longitude) || input.longitude < -180 || input.longitude > 180)
|
|
59
|
+
throw new Error('location_longitude_invalid');
|
|
60
|
+
if (input.photoUrl) {
|
|
61
|
+
const parsed = new URL(input.photoUrl);
|
|
62
|
+
if (parsed.protocol !== 'https:')
|
|
63
|
+
throw new Error('location_photo_url_invalid');
|
|
64
|
+
}
|
|
65
|
+
return claimsResource('Location', input.id, {
|
|
66
|
+
'Location.status': 'active', 'Location.mode': 'instance', 'Location.name': required(input.name, 'location_name_required'),
|
|
67
|
+
...(input.description ? { 'Location.description': input.description.trim() } : {}),
|
|
68
|
+
'Location.position.latitude': input.latitude, 'Location.position.longitude': input.longitude,
|
|
69
|
+
...(input.altitude === undefined ? {} : { 'Location.position.altitude': input.altitude }),
|
|
70
|
+
'Location.managingOrganization': required(input.organizationReference, 'location_organization_required'),
|
|
71
|
+
...(input.characteristicCodes?.length ? { 'Location.characteristic': Object.freeze([...input.characteristicCodes]) } : {}),
|
|
72
|
+
...(input.photoUrl ? { 'VeterinaryLocation.photoUrl': input.photoUrl } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Builds the claims-first FHIR R5 Schedule joining one practitioner and consultation Location. */
|
|
76
|
+
export function buildVeterinaryScheduleResource(input) {
|
|
77
|
+
const start = assertDate(input.planningHorizonStart);
|
|
78
|
+
const end = assertDate(input.planningHorizonEnd);
|
|
79
|
+
if (start > end)
|
|
80
|
+
throw new Error('schedule_horizon_invalid');
|
|
81
|
+
return claimsResource('Schedule', input.id, {
|
|
82
|
+
'Schedule.active': true, 'Schedule.name': required(input.name, 'schedule_name_required'),
|
|
83
|
+
'Schedule.actor': Object.freeze([required(input.practitionerReference, 'schedule_practitioner_required'), required(input.locationReference, 'schedule_location_required')]),
|
|
84
|
+
'Schedule.planningHorizon.start': start, 'Schedule.planningHorizon.end': end,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const minutes = (value) => { const [hour, minute] = assertTime(value).split(':').map(Number); return hour * 60 + minute; };
|
|
88
|
+
const timeFromMinutes = (value) => `${String(Math.floor(value / 60)).padStart(2, '0')}:${String(value % 60).padStart(2, '0')}`;
|
|
89
|
+
const dates = (start, end) => { const result = []; const cursor = new Date(`${start}T00:00:00Z`); const last = new Date(`${end}T00:00:00Z`); while (cursor <= last) {
|
|
90
|
+
result.push(cursor.toISOString().slice(0, 10));
|
|
91
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
92
|
+
} ; return result; };
|
|
93
|
+
const dateParts = (date) => date.split('-').map(Number);
|
|
94
|
+
const zonedInstant = (date, time, timeZone) => {
|
|
95
|
+
const [year, month, day] = dateParts(date);
|
|
96
|
+
const [hour, minute] = time.split(':').map(Number);
|
|
97
|
+
const desired = Date.UTC(year, month - 1, day, hour, minute);
|
|
98
|
+
let candidate = desired;
|
|
99
|
+
const formatter = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
100
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
101
|
+
const parts = Object.fromEntries(formatter.formatToParts(new Date(candidate)).map(part => [part.type, part.value]));
|
|
102
|
+
const observed = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute));
|
|
103
|
+
candidate += desired - observed;
|
|
104
|
+
}
|
|
105
|
+
return new Date(candidate).toISOString();
|
|
106
|
+
};
|
|
107
|
+
const matchesExceptionDate = (date, recurrence) => { const [, , day] = dateParts(date); const weekday = new Date(`${date}T00:00:00Z`).getUTCDay(); if (weekday !== recurrence.dayOfWeek)
|
|
108
|
+
return false; return recurrence.frequency === 'weekly' || Math.ceil(day / 7) === recurrence.ordinal; };
|
|
109
|
+
const validateRange = (startTime, endTime, code) => { const start = minutes(startTime); const end = minutes(endTime); if (start >= end)
|
|
110
|
+
throw new Error(code); return [start, end]; };
|
|
111
|
+
const slotKey = (location, date, start, end) => `${location}|${date}|${start}|${end}`;
|
|
112
|
+
/** Expands bounded weekly availability into claims-first Slots, applying recurring restrictions or expansions. */
|
|
113
|
+
export function expandVeterinaryAvailability(input) {
|
|
114
|
+
const startDate = assertDate(input.startDate);
|
|
115
|
+
const endDate = assertDate(input.endDate);
|
|
116
|
+
if (startDate > endDate)
|
|
117
|
+
throw new Error('availability_horizon_invalid');
|
|
118
|
+
if (!Number.isInteger(input.slotMinutes) || input.slotMinutes < 5 || input.slotMinutes > 1440)
|
|
119
|
+
throw new Error('slot_duration_invalid');
|
|
120
|
+
try {
|
|
121
|
+
new Intl.DateTimeFormat('en', { timeZone: input.timeZone }).format();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error('time_zone_invalid');
|
|
125
|
+
}
|
|
126
|
+
const base = new Map();
|
|
127
|
+
const allDates = dates(startDate, endDate);
|
|
128
|
+
for (const period of input.periods) {
|
|
129
|
+
const [start, end] = validateRange(period.startTime, period.endTime, 'availability_period_invalid');
|
|
130
|
+
if (!period.daysOfWeek.length || period.daysOfWeek.some(day => !Number.isInteger(day) || day < 0 || day > 6))
|
|
131
|
+
throw new Error('availability_days_invalid');
|
|
132
|
+
if (!period.locationReferences.length)
|
|
133
|
+
throw new Error('availability_location_required');
|
|
134
|
+
for (const date of allDates) {
|
|
135
|
+
if (!period.daysOfWeek.includes(new Date(`${date}T00:00:00Z`).getUTCDay()))
|
|
136
|
+
continue;
|
|
137
|
+
for (const locationReference of period.locationReferences)
|
|
138
|
+
for (let cursor = start; cursor + input.slotMinutes <= end; cursor += input.slotMinutes) {
|
|
139
|
+
const localStart = timeFromMinutes(cursor);
|
|
140
|
+
const localEnd = timeFromMinutes(cursor + input.slotMinutes);
|
|
141
|
+
base.set(slotKey(locationReference, date, localStart, localEnd), { localDate: date, localStart, localEnd, locationReference });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const exception of input.exceptions) {
|
|
146
|
+
const [start, end] = validateRange(exception.startTime, exception.endTime, 'availability_exception_invalid');
|
|
147
|
+
if (exception.recurrence.dayOfWeek < 0 || exception.recurrence.dayOfWeek > 6 || (exception.recurrence.frequency === 'monthly' && (!Number.isInteger(exception.recurrence.ordinal) || exception.recurrence.ordinal < 1 || exception.recurrence.ordinal > 5)))
|
|
148
|
+
throw new Error('availability_exception_recurrence_invalid');
|
|
149
|
+
for (const date of allDates) {
|
|
150
|
+
if (!matchesExceptionDate(date, exception.recurrence))
|
|
151
|
+
continue;
|
|
152
|
+
for (const locationReference of exception.locationReferences)
|
|
153
|
+
for (let cursor = start; cursor + input.slotMinutes <= end; cursor += input.slotMinutes) {
|
|
154
|
+
const localStart = timeFromMinutes(cursor);
|
|
155
|
+
const localEnd = timeFromMinutes(cursor + input.slotMinutes);
|
|
156
|
+
const key = slotKey(locationReference, date, localStart, localEnd);
|
|
157
|
+
if (exception.effect === 'restrict')
|
|
158
|
+
base.delete(key);
|
|
159
|
+
else
|
|
160
|
+
base.set(key, { localDate: date, localStart, localEnd, locationReference });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const scheduleReference = `Schedule/${required(input.scheduleId, 'schedule_id_required')}`;
|
|
165
|
+
return Object.freeze([...base.values()].sort((left, right) => slotKey(left.locationReference, left.localDate, left.localStart, left.localEnd).localeCompare(slotKey(right.locationReference, right.localDate, right.localStart, right.localEnd))).map(slot => {
|
|
166
|
+
const start = zonedInstant(slot.localDate, slot.localStart, input.timeZone);
|
|
167
|
+
const end = zonedInstant(slot.localDate, slot.localEnd, input.timeZone);
|
|
168
|
+
const id = `${input.scheduleId}-${slot.localDate}-${slot.localStart.replace(':', '')}-${slot.locationReference.split('/').at(-1)}`;
|
|
169
|
+
return Object.freeze({ ...slot, resource: claimsResource('Slot', id, { 'Slot.identifier': id, 'Slot.schedule': scheduleReference, 'Slot.status': SlotStatuses.Free, 'Slot.start': start, 'Slot.end': end, 'VeterinarySlot.location': slot.locationReference, 'VeterinarySlot.timeZone': input.timeZone }) });
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
/** Builds a claims-first response that confirms, declines or proposes a new appointment time. */
|
|
173
|
+
export function buildVeterinaryAppointmentResponseResource(input) {
|
|
174
|
+
if (!Object.values(AppointmentResponseStatuses).includes(input.participantStatus))
|
|
175
|
+
throw new Error('appointment_response_status_invalid');
|
|
176
|
+
if ((input.start && !input.end) || (!input.start && input.end))
|
|
177
|
+
throw new Error('appointment_response_time_incomplete');
|
|
178
|
+
if (input.start && input.end && Date.parse(input.start) >= Date.parse(input.end))
|
|
179
|
+
throw new Error('appointment_response_time_invalid');
|
|
180
|
+
return claimsResource('AppointmentResponse', input.id, { 'AppointmentResponse.appointment': required(input.appointmentReference, 'appointment_reference_required'), 'AppointmentResponse.actor': required(input.actorReference, 'appointment_response_actor_required'), 'AppointmentResponse.participantStatus': input.participantStatus, ...(input.proposedNewTime === undefined ? {} : { 'AppointmentResponse.proposedNewTime': input.proposedNewTime }), ...(input.start ? { 'AppointmentResponse.start': input.start, 'AppointmentResponse.end': input.end } : {}) });
|
|
181
|
+
}
|
|
182
|
+
/** Builds a searchable claims-first FHIR R5 Appointment with a seven-day confirmation due time. */
|
|
183
|
+
export function buildVeterinaryAppointmentResource(input) {
|
|
184
|
+
if (!Object.values(AppointmentStatuses).includes(input.status))
|
|
185
|
+
throw new Error('appointment_status_invalid');
|
|
186
|
+
const start = Date.parse(input.start);
|
|
187
|
+
const end = Date.parse(input.end);
|
|
188
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end)
|
|
189
|
+
throw new Error('appointment_time_invalid');
|
|
190
|
+
if (!input.slotReferences.length)
|
|
191
|
+
throw new Error('appointment_slot_required');
|
|
192
|
+
if (!input.actorReferences.length)
|
|
193
|
+
throw new Error('appointment_actor_required');
|
|
194
|
+
const confirmationDueAt = input.confirmationDueAt || new Date(start - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
195
|
+
if (!Number.isFinite(Date.parse(confirmationDueAt)) || Date.parse(confirmationDueAt) >= start)
|
|
196
|
+
throw new Error('appointment_confirmation_due_invalid');
|
|
197
|
+
return claimsResource('Appointment', input.id, {
|
|
198
|
+
'Appointment.status': input.status,
|
|
199
|
+
'Appointment.start': new Date(start).toISOString(),
|
|
200
|
+
'Appointment.end': new Date(end).toISOString(),
|
|
201
|
+
'Appointment.slot': Object.freeze([...input.slotReferences]),
|
|
202
|
+
'Appointment.actor': Object.freeze([...input.actorReferences]),
|
|
203
|
+
...(input.locationReference ? { 'Appointment.location': required(input.locationReference, 'appointment_location_required') } : {}),
|
|
204
|
+
'VeterinaryAppointment.confirmationDueAt': new Date(confirmationDueAt).toISOString(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Builds the durable web inbox notification when a schedule change displaces an appointment. */
|
|
208
|
+
export function buildVeterinaryAppointmentNotificationResource(input) {
|
|
209
|
+
const id = required(input.id, 'communication_id_required');
|
|
210
|
+
return claimsResource('Communication', id, {
|
|
211
|
+
'Communication.identifier': id,
|
|
212
|
+
'Communication.status': 'preparation',
|
|
213
|
+
'Communication.category': 'http://terminology.hl7.org/CodeSystem/communication-category|notification',
|
|
214
|
+
'Communication.subject': required(input.subjectReference, 'communication_subject_required'),
|
|
215
|
+
'Communication.content-reference': required(input.appointmentReference, 'communication_appointment_required'),
|
|
216
|
+
'Communication.topic': 'https://vetchain.app/fhir/CodeSystem/communication-topic|appointment-rescheduling-required',
|
|
217
|
+
'Communication.text': required(input.reason, 'communication_reason_required'),
|
|
218
|
+
'Communication.content-code': 'https://vetchain.app/fhir/CodeSystem/appointment-action|reschedule',
|
|
219
|
+
});
|
|
220
|
+
}
|
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.10",
|
|
4
4
|
"description": "Browser-safe governed VetChain data contracts",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Connecting Solution & Applications Ltd",
|
|
@@ -83,6 +83,10 @@
|
|
|
83
83
|
"./assistant": {
|
|
84
84
|
"types": "./dist/assistant.d.ts",
|
|
85
85
|
"default": "./dist/assistant.js"
|
|
86
|
+
},
|
|
87
|
+
"./scheduling": {
|
|
88
|
+
"types": "./dist/scheduling.d.ts",
|
|
89
|
+
"default": "./dist/scheduling.js"
|
|
86
90
|
}
|
|
87
91
|
},
|
|
88
92
|
"files": [
|