vet-data-utils-ts 0.5.9 → 0.5.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
@@ -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
@@ -16,3 +16,4 @@ export * from './research-study.js';
16
16
  export * from './sectors.js';
17
17
  export * from './shc.js';
18
18
  export * from './veterinary-sections.js';
19
+ export * from './scheduling.js';
package/dist/index.js CHANGED
@@ -16,3 +16,4 @@ export * from './research-study.js';
16
16
  export * from './sectors.js';
17
17
  export * from './shc.js';
18
18
  export * from './veterinary-sections.js';
19
+ export * from './scheduling.js';
@@ -23,6 +23,7 @@ export type ReusableOrganizationApplication = Readonly<{
23
23
  regionalId: boolean;
24
24
  subdivisionCode?: string;
25
25
  officialLicense?: string;
26
+ officialPhone?: string;
26
27
  legalRepresentative: Readonly<{
27
28
  name: string;
28
29
  email: string;
@@ -49,3 +50,5 @@ export declare function organizationServiceTypeForParticipationRoles(roles: read
49
50
  * object and remain inside the BFF/high-level Node runtime.
50
51
  */
51
52
  export declare function parseReusableOrganizationApplication(input: unknown): ReusableOrganizationApplication;
53
+ /** Normalizes an optional official organization telephone to E.164 using the selected jurisdiction calling code. */
54
+ export declare function normalizeOptionalOrganizationOfficialPhone(value: unknown, defaultCallingCode: unknown): string | undefined;
@@ -91,6 +91,7 @@ export function parseReusableOrganizationApplication(input) {
91
91
  regionalId,
92
92
  ...(subdivisionCode ? { subdivisionCode } : {}),
93
93
  ...(optional(value.officialLicense) ? { officialLicense: optional(value.officialLicense) } : {}),
94
+ ...(optional(value.officialPhone) ? { officialPhone: normalizeOptionalOrganizationOfficialPhone(value.officialPhone, value.defaultPhoneCallingCode) } : {}),
94
95
  legalRepresentative: Object.freeze({
95
96
  name: required(legalRepresentative.name, 'organization_legal_representative_name_required'),
96
97
  email: legalEmail,
@@ -99,6 +100,24 @@ export function parseReusableOrganizationApplication(input) {
99
100
  participationRoles: Object.freeze([...new Set(selectedRoles)]),
100
101
  });
101
102
  }
103
+ /** Normalizes an optional official organization telephone to E.164 using the selected jurisdiction calling code. */
104
+ export function normalizeOptionalOrganizationOfficialPhone(value, defaultCallingCode) {
105
+ const raw = optional(value);
106
+ if (!raw)
107
+ return undefined;
108
+ let compact = raw.replace(/^tel:/i, '').replace(/[\s().-]/g, '');
109
+ if (compact.startsWith('00'))
110
+ compact = `+${compact.slice(2)}`;
111
+ if (!compact.startsWith('+')) {
112
+ const callingCode = String(defaultCallingCode ?? '').trim().replace(/^00/, '+');
113
+ if (!/^\+[1-9]\d{0,2}$/.test(callingCode))
114
+ throw new TypeError('organization_official_phone_calling_code_required');
115
+ compact = `${callingCode}${compact.replace(/^0/, '')}`;
116
+ }
117
+ if (!/^\+[1-9]\d{7,14}$/.test(compact))
118
+ throw new TypeError('organization_official_phone_invalid');
119
+ return compact;
120
+ }
102
121
  function record(value, error) {
103
122
  if (!value || typeof value !== 'object' || Array.isArray(value))
104
123
  throw new TypeError(error);
@@ -0,0 +1,154 @@
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
+ respondedAt?: string;
119
+ proposedNewTime?: boolean;
120
+ start?: string;
121
+ end?: string;
122
+ notificationRecipientReference?: string;
123
+ notificationRecipientEmail?: string;
124
+ }>): ClaimsFirstResource;
125
+ /** Builds the FHIR-like Communication that an adapter may render as the additional English clinic email. */
126
+ export declare function buildVeterinaryAppointmentResponseNotificationResource(input: Readonly<{
127
+ id: string;
128
+ responseReference: string;
129
+ appointmentReference: string;
130
+ senderReference: string;
131
+ recipientReference: string;
132
+ participantStatus: string;
133
+ sentAt: string;
134
+ language: string;
135
+ }>): ClaimsFirstResource;
136
+ /** Builds a searchable claims-first FHIR R5 Appointment with a seven-day confirmation due time. */
137
+ export declare function buildVeterinaryAppointmentResource(input: Readonly<{
138
+ id: string;
139
+ status: string;
140
+ start: string;
141
+ end: string;
142
+ slotReferences: readonly string[];
143
+ actorReferences: readonly string[];
144
+ locationReference?: string;
145
+ confirmationDueAt?: string;
146
+ userSelected?: boolean;
147
+ }>): ClaimsFirstResource;
148
+ /** Builds the durable web inbox notification when a schedule change displaces an appointment. */
149
+ export declare function buildVeterinaryAppointmentNotificationResource(input: Readonly<{
150
+ id: string;
151
+ appointmentReference: string;
152
+ subjectReference: string;
153
+ reason: string;
154
+ }>): ClaimsFirstResource;
@@ -0,0 +1,252 @@
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', 'Appointment.user-selected', 'VeterinaryAppointment.confirmationDueAt', 'VeterinaryAppointment.reschedulingReason']),
18
+ AppointmentResponse: Object.freeze(['AppointmentResponse.identifier', 'AppointmentResponse.appointment', 'AppointmentResponse.actor', 'AppointmentResponse.participantStatus', 'AppointmentResponse.proposedNewTime', 'AppointmentResponse.start', 'AppointmentResponse.end', 'VeterinaryAppointmentResponse.respondedAt', 'VeterinaryAppointmentResponse.recipient', 'VeterinaryAppointmentResponse.recipientEmail']),
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
+ const respondedAt = input.respondedAt || new Date().toISOString();
181
+ if (!Number.isFinite(Date.parse(respondedAt)))
182
+ throw new Error('appointment_response_responded_at_invalid');
183
+ const hasRecipientReference = Boolean(input.notificationRecipientReference?.trim());
184
+ const hasRecipientEmail = Boolean(input.notificationRecipientEmail?.trim());
185
+ if (hasRecipientReference !== hasRecipientEmail)
186
+ throw new Error('appointment_response_notification_recipient_incomplete');
187
+ if (hasRecipientEmail && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input.notificationRecipientEmail.trim()))
188
+ throw new Error('appointment_response_notification_email_invalid');
189
+ 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, 'VeterinaryAppointmentResponse.respondedAt': new Date(respondedAt).toISOString(), ...(hasRecipientReference ? { 'VeterinaryAppointmentResponse.recipient': input.notificationRecipientReference.trim(), 'VeterinaryAppointmentResponse.recipientEmail': input.notificationRecipientEmail.trim().toLowerCase() } : {}), ...(input.proposedNewTime === undefined ? {} : { 'AppointmentResponse.proposedNewTime': input.proposedNewTime }), ...(input.start ? { 'AppointmentResponse.start': input.start, 'AppointmentResponse.end': input.end } : {}) });
190
+ }
191
+ /** Builds the FHIR-like Communication that an adapter may render as the additional English clinic email. */
192
+ export function buildVeterinaryAppointmentResponseNotificationResource(input) {
193
+ if (input.language !== 'en')
194
+ throw new Error('appointment_response_notification_language_unsupported');
195
+ if (!Object.values(AppointmentResponseStatuses).includes(input.participantStatus))
196
+ throw new Error('appointment_response_status_invalid');
197
+ if (!Number.isFinite(Date.parse(input.sentAt)))
198
+ throw new Error('appointment_response_notification_sent_at_invalid');
199
+ const statusText = Object.freeze({ accepted: 'accepted', declined: 'declined', tentative: 'tentatively changed', 'needs-action': 'left awaiting action', 'entered-in-error': 'marked as entered in error' });
200
+ return claimsResource('Communication', input.id, {
201
+ 'Communication.identifier': input.id,
202
+ 'Communication.status': 'completed',
203
+ 'Communication.category': 'http://terminology.hl7.org/CodeSystem/communication-category|notification',
204
+ 'Communication.recipient': required(input.recipientReference, 'appointment_response_notification_recipient_required'),
205
+ 'Communication.sender': required(input.senderReference, 'appointment_response_notification_sender_required'),
206
+ 'Communication.sent': new Date(input.sentAt).toISOString(),
207
+ 'Communication.topic': 'https://vetchain.app/fhir/CodeSystem/communication-topic|appointment-response',
208
+ 'Communication.content-reference': Object.freeze([required(input.responseReference, 'appointment_response_reference_required'), required(input.appointmentReference, 'appointment_reference_required')]),
209
+ 'Communication.text': `The appointment was ${statusText[input.participantStatus]}.`,
210
+ });
211
+ }
212
+ /** Builds a searchable claims-first FHIR R5 Appointment with a seven-day confirmation due time. */
213
+ export function buildVeterinaryAppointmentResource(input) {
214
+ if (!Object.values(AppointmentStatuses).includes(input.status))
215
+ throw new Error('appointment_status_invalid');
216
+ const start = Date.parse(input.start);
217
+ const end = Date.parse(input.end);
218
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end)
219
+ throw new Error('appointment_time_invalid');
220
+ const userSelected = input.userSelected ?? false;
221
+ if (!input.slotReferences.length && !userSelected)
222
+ throw new Error('appointment_slot_required');
223
+ if (!input.actorReferences.length)
224
+ throw new Error('appointment_actor_required');
225
+ const confirmationDueAt = input.confirmationDueAt || new Date(start - 7 * 24 * 60 * 60 * 1000).toISOString();
226
+ if (!Number.isFinite(Date.parse(confirmationDueAt)) || Date.parse(confirmationDueAt) >= start)
227
+ throw new Error('appointment_confirmation_due_invalid');
228
+ return claimsResource('Appointment', input.id, {
229
+ 'Appointment.status': input.status,
230
+ 'Appointment.start': new Date(start).toISOString(),
231
+ 'Appointment.end': new Date(end).toISOString(),
232
+ ...(input.slotReferences.length ? { 'Appointment.slot': Object.freeze([...input.slotReferences]) } : {}),
233
+ 'Appointment.actor': Object.freeze([...input.actorReferences]),
234
+ 'Appointment.user-selected': userSelected,
235
+ ...(input.locationReference ? { 'Appointment.location': required(input.locationReference, 'appointment_location_required') } : {}),
236
+ 'VeterinaryAppointment.confirmationDueAt': new Date(confirmationDueAt).toISOString(),
237
+ });
238
+ }
239
+ /** Builds the durable web inbox notification when a schedule change displaces an appointment. */
240
+ export function buildVeterinaryAppointmentNotificationResource(input) {
241
+ const id = required(input.id, 'communication_id_required');
242
+ return claimsResource('Communication', id, {
243
+ 'Communication.identifier': id,
244
+ 'Communication.status': 'preparation',
245
+ 'Communication.category': 'http://terminology.hl7.org/CodeSystem/communication-category|notification',
246
+ 'Communication.subject': required(input.subjectReference, 'communication_subject_required'),
247
+ 'Communication.content-reference': required(input.appointmentReference, 'communication_appointment_required'),
248
+ 'Communication.topic': 'https://vetchain.app/fhir/CodeSystem/communication-topic|appointment-rescheduling-required',
249
+ 'Communication.text': required(input.reason, 'communication_reason_required'),
250
+ 'Communication.content-code': 'https://vetchain.app/fhir/CodeSystem/appointment-action|reschedule',
251
+ });
252
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-data-utils-ts",
3
- "version": "0.5.9",
3
+ "version": "0.5.13",
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": [