@crvouga/mockingbird-service-junction 0.1.0 → 0.1.2
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 +23 -11
- package/dist/index.d.ts +492 -21
- package/dist/index.js +6086 -107
- package/dist/index.js.map +7 -1
- package/package.json +11 -10
- package/dist/catalog.d.ts +0 -63
- package/dist/catalog.d.ts.map +0 -1
- package/dist/catalog.js +0 -736
- package/dist/catalog.js.map +0 -1
- package/dist/coverage-corpus.d.ts +0 -76
- package/dist/coverage-corpus.d.ts.map +0 -1
- package/dist/coverage-corpus.js +0 -166
- package/dist/coverage-corpus.js.map +0 -1
- package/dist/generated/openapi.d.ts +0 -7
- package/dist/generated/openapi.d.ts.map +0 -1
- package/dist/generated/openapi.js +0 -5
- package/dist/generated/openapi.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/lab-accounts.d.ts +0 -38
- package/dist/lab-accounts.d.ts.map +0 -1
- package/dist/lab-accounts.js +0 -213
- package/dist/lab-accounts.js.map +0 -1
- package/dist/orders.d.ts +0 -18
- package/dist/orders.d.ts.map +0 -1
- package/dist/orders.js +0 -1247
- package/dist/orders.js.map +0 -1
- package/dist/prefetch.d.ts +0 -43
- package/dist/prefetch.d.ts.map +0 -1
- package/dist/prefetch.js +0 -131
- package/dist/prefetch.js.map +0 -1
- package/dist/reshape.d.ts +0 -22
- package/dist/reshape.d.ts.map +0 -1
- package/dist/reshape.js +0 -190
- package/dist/reshape.js.map +0 -1
- package/dist/results.d.ts +0 -19
- package/dist/results.d.ts.map +0 -1
- package/dist/results.js +0 -221
- package/dist/results.js.map +0 -1
- package/dist/scheduling.d.ts +0 -113
- package/dist/scheduling.d.ts.map +0 -1
- package/dist/scheduling.js +0 -1508
- package/dist/scheduling.js.map +0 -1
- package/dist/sealed-corpus.d.ts +0 -28
- package/dist/sealed-corpus.d.ts.map +0 -1
- package/dist/sealed-corpus.js +0 -17
- package/dist/sealed-corpus.js.map +0 -1
- package/dist/seed-from.d.ts +0 -27
- package/dist/seed-from.d.ts.map +0 -1
- package/dist/seed-from.js +0 -523
- package/dist/seed-from.js.map +0 -1
- package/dist/seeds.d.ts +0 -9
- package/dist/seeds.d.ts.map +0 -1
- package/dist/seeds.js +0 -9
- package/dist/seeds.js.map +0 -1
- package/dist/state.d.ts +0 -270
- package/dist/state.d.ts.map +0 -1
- package/dist/state.js +0 -448
- package/dist/state.js.map +0 -1
- package/dist/users.d.ts +0 -13
- package/dist/users.d.ts.map +0 -1
- package/dist/users.js +0 -669
- package/dist/users.js.map +0 -1
package/dist/scheduling.js
DELETED
|
@@ -1,1508 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Appointment scheduling surface: phlebotomy + PSC availability/booking lifecycle,
|
|
3
|
-
* cancellation reasons, area serviceability, and PSC site info.
|
|
4
|
-
*
|
|
5
|
-
* Slot data is generated deterministically (seeded by zip/site/date), so walks are
|
|
6
|
-
* reproducible without any provider-side state.
|
|
7
|
-
*/
|
|
8
|
-
import { HttpError, jsonRes, opaqueToken, } from "@crvouga/mockingbird-service";
|
|
9
|
-
import { labAccountById } from "./lab-accounts.js";
|
|
10
|
-
import { MOCK_TEAM_ID } from "./state.js";
|
|
11
|
-
const cachedResponse = (state, context) => {
|
|
12
|
-
const body = context.request.method.toUpperCase() === "GET" ||
|
|
13
|
-
context.request.method.toUpperCase() === "HEAD"
|
|
14
|
-
? undefined
|
|
15
|
-
: context.body.kind === "json"
|
|
16
|
-
? context.body.value
|
|
17
|
-
: context.body.kind === "form"
|
|
18
|
-
? context.body.value
|
|
19
|
-
: undefined;
|
|
20
|
-
const key = state.cacheKeyForRequest(context.request.method, context.url.pathname, context.url.searchParams, body);
|
|
21
|
-
const cached = state.getGetCache(key);
|
|
22
|
-
if (!cached)
|
|
23
|
-
return undefined;
|
|
24
|
-
if (key.toLowerCase().includes("availability")) {
|
|
25
|
-
materializeAvailabilityBookingKeys(state, context, cached.body);
|
|
26
|
-
rotateAvailabilityBookingKeys(state, cached.body, context.now());
|
|
27
|
-
}
|
|
28
|
-
const headers = new Headers(cached.headers);
|
|
29
|
-
// The recorded body is served uncompressed, so drop transfer/encoding headers captured
|
|
30
|
-
// from the sandbox response or a client would try to decompress plain JSON.
|
|
31
|
-
headers.delete("content-encoding");
|
|
32
|
-
headers.delete("content-length");
|
|
33
|
-
headers.delete("transfer-encoding");
|
|
34
|
-
if (!headers.has("content-type"))
|
|
35
|
-
headers.set("content-type", "application/json");
|
|
36
|
-
return new Response(JSON.stringify(cached.body), { status: cached.status, headers });
|
|
37
|
-
};
|
|
38
|
-
/**
|
|
39
|
-
* Ensure booking_key records exist for a cached availability response.
|
|
40
|
-
* Prefer deterministic slot generation (rich address/provider), then fill any remaining
|
|
41
|
-
* oracle keys from the cached body using the request zip.
|
|
42
|
-
*/
|
|
43
|
-
const materializeAvailabilityBookingKeys = (state, context, cachedBody) => {
|
|
44
|
-
const requestBody = context.body.kind === "json" &&
|
|
45
|
-
typeof context.body.value === "object" &&
|
|
46
|
-
context.body.value !== null &&
|
|
47
|
-
!Array.isArray(context.body.value)
|
|
48
|
-
? context.body.value
|
|
49
|
-
: {};
|
|
50
|
-
const zip = typeof requestBody.zip_code === "string" && /^\d{5}/.test(requestBody.zip_code)
|
|
51
|
-
? requestBody.zip_code.slice(0, 5)
|
|
52
|
-
: "85004";
|
|
53
|
-
const path = context.url.pathname;
|
|
54
|
-
const modalityHint = path.includes("psc")
|
|
55
|
-
? "patient_service_center"
|
|
56
|
-
: path.includes("phlebotomy")
|
|
57
|
-
? "phlebotomy"
|
|
58
|
-
: undefined;
|
|
59
|
-
state.hydrateBookingKeysFromAvailability(cachedBody, context.now(), zip, modalityHint);
|
|
60
|
-
// Normalize address/provider/modality on oracle keys so book/reschedule match generated shape.
|
|
61
|
-
if (typeof cachedBody === "object" && cachedBody !== null && !Array.isArray(cachedBody)) {
|
|
62
|
-
const root = cachedBody;
|
|
63
|
-
const dayBuckets = [];
|
|
64
|
-
if (Array.isArray(root.days))
|
|
65
|
-
dayBuckets.push(...root.days);
|
|
66
|
-
if (Array.isArray(root.slots))
|
|
67
|
-
dayBuckets.push(...root.slots);
|
|
68
|
-
for (const day of dayBuckets) {
|
|
69
|
-
if (typeof day !== "object" || day === null || Array.isArray(day))
|
|
70
|
-
continue;
|
|
71
|
-
const daySlots = day.slots;
|
|
72
|
-
const slots = Array.isArray(daySlots) ? daySlots : [day];
|
|
73
|
-
for (const entry of slots) {
|
|
74
|
-
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
75
|
-
continue;
|
|
76
|
-
const slot = entry;
|
|
77
|
-
const bookingKey = typeof slot.booking_key === "string" ? slot.booking_key : "";
|
|
78
|
-
if (!bookingKey)
|
|
79
|
-
continue;
|
|
80
|
-
const record = state.bookingKeys.get(bookingKey);
|
|
81
|
-
if (!record)
|
|
82
|
-
continue;
|
|
83
|
-
if (modalityHint)
|
|
84
|
-
record.modality = modalityHint;
|
|
85
|
-
record.zip_code = zip;
|
|
86
|
-
record.provider = record.modality === "patient_service_center" ? "quest" : "getlabs";
|
|
87
|
-
record.address = addressFromAvailabilityRequest(requestBody, zip);
|
|
88
|
-
record.location = zipLocation(zip);
|
|
89
|
-
state.bookingKeys.update(bookingKey, record);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
/**
|
|
95
|
-
* Vital mints fresh booking_key values on every availability response while the slot
|
|
96
|
-
* skeleton stays stable, and previously issued keys remain bookable. Mirror that: each
|
|
97
|
-
* serve registers a new record per slot position (deriving slot facts from the current
|
|
98
|
-
* record for that position) and rewrites the served body, leaving stale keys bookable
|
|
99
|
-
* until consumed/expired.
|
|
100
|
-
*/
|
|
101
|
-
const rotateAvailabilityBookingKeys = (state, cachedBody, nowMs) => {
|
|
102
|
-
if (typeof cachedBody !== "object" || cachedBody === null || Array.isArray(cachedBody))
|
|
103
|
-
return;
|
|
104
|
-
const root = cachedBody;
|
|
105
|
-
const dayBuckets = [];
|
|
106
|
-
if (Array.isArray(root.days))
|
|
107
|
-
dayBuckets.push(...root.days);
|
|
108
|
-
if (Array.isArray(root.slots))
|
|
109
|
-
dayBuckets.push(...root.slots);
|
|
110
|
-
for (const day of dayBuckets) {
|
|
111
|
-
if (typeof day !== "object" || day === null || Array.isArray(day))
|
|
112
|
-
continue;
|
|
113
|
-
const daySlots = day.slots;
|
|
114
|
-
const slots = Array.isArray(daySlots) ? daySlots : [day];
|
|
115
|
-
for (let index = 0; index < slots.length; index += 1) {
|
|
116
|
-
const slot = slots[index];
|
|
117
|
-
if (typeof slot !== "object" || slot === null || Array.isArray(slot))
|
|
118
|
-
continue;
|
|
119
|
-
const entry = slot;
|
|
120
|
-
const sealedKey = typeof entry.booking_key === "string" ? entry.booking_key : "";
|
|
121
|
-
if (!sealedKey)
|
|
122
|
-
continue;
|
|
123
|
-
const sealed = state.bookingKeys.get(sealedKey);
|
|
124
|
-
if (!sealed)
|
|
125
|
-
continue;
|
|
126
|
-
const freshKey = bookingKeyRotationFor(state, sealedKey, nowMs, index);
|
|
127
|
-
const record = {
|
|
128
|
-
...sealed,
|
|
129
|
-
key: freshKey,
|
|
130
|
-
consumed_by_order_id: null,
|
|
131
|
-
created_at: new Date(nowMs).toISOString(),
|
|
132
|
-
};
|
|
133
|
-
state.bookingKeys.insert(freshKey, record);
|
|
134
|
-
entry.booking_key = freshKey;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
};
|
|
138
|
-
/**
|
|
139
|
-
* Derive a fresh, unused booking key for a rotated slot. Mixing the sealed key, clock,
|
|
140
|
-
* slot index, and an attempt counter keeps re-serves unique even under frozen test
|
|
141
|
-
* clocks where consecutive serves share the same millisecond.
|
|
142
|
-
*/
|
|
143
|
-
const bookingKeyRotationFor = (state, sealedKey, nowMs, index) => {
|
|
144
|
-
for (let attempt = 0; attempt < 64; attempt += 1) {
|
|
145
|
-
const candidate = state.bookingKeyFor(`rotation:${sealedKey}:${nowMs}:${index}:${attempt}`);
|
|
146
|
-
if (!state.bookingKeys.has(candidate))
|
|
147
|
-
return candidate;
|
|
148
|
-
}
|
|
149
|
-
return state.bookingKeyFor(`rotation:${sealedKey}:${nowMs}:${index}:fallback:${state.bookingKeys.list().length}`);
|
|
150
|
-
};
|
|
151
|
-
/**
|
|
152
|
-
* Rotation for the generated (cache-miss) availability paths: newly created slot
|
|
153
|
-
* records are replaced with fresh records on every serve so repeated reads mint
|
|
154
|
-
* fresh keys like the oracle, and the served body carries only the fresh keys.
|
|
155
|
-
* When `override` is given (phlebotomy — Vital echoes the availability request
|
|
156
|
-
* address on book), the fresh record's address/location/zip are re-aligned.
|
|
157
|
-
*/
|
|
158
|
-
const rotateGeneratedAvailabilityKeys = (state, days, nowMs, override) => {
|
|
159
|
-
for (const day of days) {
|
|
160
|
-
const slots = Array.isArray(day.slots) ? day.slots : [];
|
|
161
|
-
for (let index = 0; index < slots.length; index += 1) {
|
|
162
|
-
const slot = slots[index];
|
|
163
|
-
if (typeof slot !== "object" || slot === null || Array.isArray(slot))
|
|
164
|
-
continue;
|
|
165
|
-
const entry = slot;
|
|
166
|
-
const currentKey = typeof entry.booking_key === "string" ? entry.booking_key : "";
|
|
167
|
-
if (!currentKey)
|
|
168
|
-
continue;
|
|
169
|
-
const current = state.bookingKeys.get(currentKey);
|
|
170
|
-
if (!current)
|
|
171
|
-
continue;
|
|
172
|
-
const freshKey = bookingKeyRotationFor(state, currentKey, nowMs, index);
|
|
173
|
-
const record = {
|
|
174
|
-
...current,
|
|
175
|
-
key: freshKey,
|
|
176
|
-
...(override ?? {}),
|
|
177
|
-
consumed_by_order_id: null,
|
|
178
|
-
created_at: new Date(nowMs).toISOString(),
|
|
179
|
-
};
|
|
180
|
-
state.bookingKeys.insert(freshKey, record);
|
|
181
|
-
entry.booking_key = freshKey;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
const PHLEBOTOMY_PROVIDERS = ["getlabs", "phlebfinders"];
|
|
186
|
-
const zipTimezone = (zip) => {
|
|
187
|
-
const prefix = Number(zip.slice(0, 3));
|
|
188
|
-
if (!Number.isFinite(prefix) || prefix === 0)
|
|
189
|
-
return "America/New_York";
|
|
190
|
-
if (prefix < 500)
|
|
191
|
-
return "America/New_York";
|
|
192
|
-
if (prefix < 800)
|
|
193
|
-
return "America/Chicago";
|
|
194
|
-
// Arizona does not observe DST — Vital uses America/Phoenix for 850–865.
|
|
195
|
-
if (prefix >= 850 && prefix <= 865)
|
|
196
|
-
return "America/Phoenix";
|
|
197
|
-
if (prefix < 900)
|
|
198
|
-
return "America/Denver";
|
|
199
|
-
return "America/Los_Angeles";
|
|
200
|
-
};
|
|
201
|
-
/** Known Vital sandbox centroids for booking location parity. */
|
|
202
|
-
const ZIP_LOCATION = {
|
|
203
|
-
"85004": { lat: 33.6242904, lng: -111.9283407 },
|
|
204
|
-
};
|
|
205
|
-
const zipLocation = (zip) => {
|
|
206
|
-
const known = ZIP_LOCATION[zip.slice(0, 5)];
|
|
207
|
-
if (known)
|
|
208
|
-
return { ...known };
|
|
209
|
-
// Deterministic fallback centroid so unknown zips stay stable across runs.
|
|
210
|
-
const prefix = Number(zip.slice(0, 3)) || 0;
|
|
211
|
-
return {
|
|
212
|
-
lat: 30 + (prefix % 20) * 0.4,
|
|
213
|
-
lng: -120 + (prefix % 30) * 0.5,
|
|
214
|
-
};
|
|
215
|
-
};
|
|
216
|
-
const cityStateForZip = (zip) => {
|
|
217
|
-
const tz = zipTimezone(zip);
|
|
218
|
-
if (tz === "America/Phoenix")
|
|
219
|
-
return { city: "Phoenix", state: "AZ" };
|
|
220
|
-
if (tz === "America/Los_Angeles")
|
|
221
|
-
return { city: "Los Angeles", state: "CA" };
|
|
222
|
-
if (tz === "America/Denver")
|
|
223
|
-
return { city: "Denver", state: "CO" };
|
|
224
|
-
if (tz === "America/Chicago")
|
|
225
|
-
return { city: "Chicago", state: "IL" };
|
|
226
|
-
return { city: "New York", state: "NY" };
|
|
227
|
-
};
|
|
228
|
-
export const addressFromAvailabilityRequest = (requestBody, zip) => {
|
|
229
|
-
const { city, state } = cityStateForZip(zip);
|
|
230
|
-
const firstLine = typeof requestBody.first_line === "string" && requestBody.first_line.length > 0
|
|
231
|
-
? requestBody.first_line
|
|
232
|
-
: "1 Main St";
|
|
233
|
-
const secondLine = typeof requestBody.second_line === "string"
|
|
234
|
-
? requestBody.second_line
|
|
235
|
-
: requestBody.second_line === null
|
|
236
|
-
? ""
|
|
237
|
-
: "";
|
|
238
|
-
return {
|
|
239
|
-
first_line: firstLine,
|
|
240
|
-
second_line: secondLine,
|
|
241
|
-
city: typeof requestBody.city === "string" && requestBody.city.length > 0 ? requestBody.city : city,
|
|
242
|
-
state: typeof requestBody.state === "string" && requestBody.state.length > 0
|
|
243
|
-
? requestBody.state
|
|
244
|
-
: state,
|
|
245
|
-
zip_code: typeof requestBody.zip_code === "string" && /^\d{5}/.test(requestBody.zip_code)
|
|
246
|
-
? requestBody.zip_code.slice(0, 5)
|
|
247
|
-
: zip,
|
|
248
|
-
unit: typeof requestBody.unit === "string" ? requestBody.unit : null,
|
|
249
|
-
access_notes: null,
|
|
250
|
-
};
|
|
251
|
-
};
|
|
252
|
-
const seededRandom = (seed) => {
|
|
253
|
-
let value = seed >>> 0 || 1;
|
|
254
|
-
return () => {
|
|
255
|
-
value = (value * 1664525 + 1013904223) >>> 0;
|
|
256
|
-
return value / 0x1_0000_0000;
|
|
257
|
-
};
|
|
258
|
-
};
|
|
259
|
-
const seedFor = (parts) => {
|
|
260
|
-
let hash = 0x811c9dc5;
|
|
261
|
-
for (const part of parts) {
|
|
262
|
-
const text = String(part);
|
|
263
|
-
for (let i = 0; i < text.length; i++) {
|
|
264
|
-
hash ^= text.charCodeAt(i);
|
|
265
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return hash >>> 0;
|
|
269
|
-
};
|
|
270
|
-
const isoDate = (date) => date.toISOString().slice(0, 10);
|
|
271
|
-
const addDays = (iso, days) => {
|
|
272
|
-
const date = new Date(`${iso}T00:00:00Z`);
|
|
273
|
-
date.setUTCDate(date.getUTCDate() + days);
|
|
274
|
-
return isoDate(date);
|
|
275
|
-
};
|
|
276
|
-
const SCHEDULED_ORDER_EVENT = {
|
|
277
|
-
phlebotomy: "collecting_sample.at_home_phlebotomy.appointment_scheduled",
|
|
278
|
-
patient_service_center: "collecting_sample.walk_in_test.appointment_scheduled",
|
|
279
|
-
};
|
|
280
|
-
const CANCELLED_ORDER_EVENT = {
|
|
281
|
-
phlebotomy: "collecting_sample.at_home_phlebotomy.appointment_cancelled",
|
|
282
|
-
patient_service_center: "collecting_sample.walk_in_test.appointment_cancelled",
|
|
283
|
-
};
|
|
284
|
-
export const CANCELLATION_REASONS = [
|
|
285
|
-
{
|
|
286
|
-
id: "5c0257ef-6fea-4a22-b20a-3ddab573d5c9",
|
|
287
|
-
name: "Did not fast for appointment",
|
|
288
|
-
is_refundable: true,
|
|
289
|
-
},
|
|
290
|
-
{ id: "448c519c-64b4-4497-ae73-622fa93371b3", name: "Do not trust company", is_refundable: true },
|
|
291
|
-
{
|
|
292
|
-
id: "d378e152-12d1-433e-9dd1-e0410f9331dc",
|
|
293
|
-
name: "Getlabs cannot deliver to my preferred lab",
|
|
294
|
-
is_refundable: true,
|
|
295
|
-
},
|
|
296
|
-
{ id: "7be75f90-303d-4656-8eed-9bd3e20cb2cc", name: "Getlabs - late", is_refundable: true },
|
|
297
|
-
{
|
|
298
|
-
id: "ae3ec4dc-387c-42ac-835a-bf88fc4e411b",
|
|
299
|
-
name: "Getlabs -Phleb Out Sick",
|
|
300
|
-
is_refundable: true,
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
id: "e2396e00-dd49-4f38-a395-51d6304c3312",
|
|
304
|
-
name: "Getlabs rescheduled time does not work for patient",
|
|
305
|
-
is_refundable: true,
|
|
306
|
-
},
|
|
307
|
-
{ id: "5330c863-ac80-4316-901b-d305d0df74d5", name: "No longer interested", is_refundable: true },
|
|
308
|
-
{ id: "2b9f23fd-163e-4483-bb10-90c74a67e0dc", name: "Other", is_refundable: true },
|
|
309
|
-
{
|
|
310
|
-
id: "98f861dd-fe61-4817-a9d6-1b99b19cd0fb",
|
|
311
|
-
name: "Provider asked me to cancel",
|
|
312
|
-
is_refundable: true,
|
|
313
|
-
},
|
|
314
|
-
{
|
|
315
|
-
id: "7dfd7da5-ed6e-40bb-a7e4-c8003f0c10a9",
|
|
316
|
-
name: "Scheduled for wrong patient",
|
|
317
|
-
is_refundable: true,
|
|
318
|
-
},
|
|
319
|
-
{
|
|
320
|
-
id: "796da8c8-e654-4347-8ded-1026410c1976",
|
|
321
|
-
name: "Scheduled time no longer works",
|
|
322
|
-
is_refundable: true,
|
|
323
|
-
},
|
|
324
|
-
{
|
|
325
|
-
id: "ba02af35-a34f-4a7a-abe5-5f766e8f6cd1",
|
|
326
|
-
name: "Unable to get lab order from provider",
|
|
327
|
-
is_refundable: true,
|
|
328
|
-
},
|
|
329
|
-
{
|
|
330
|
-
id: "0c9425db-f11e-49c5-b976-3c0d1d4a4ea8",
|
|
331
|
-
name: "Wanted to book in-person appointment",
|
|
332
|
-
is_refundable: true,
|
|
333
|
-
},
|
|
334
|
-
{
|
|
335
|
-
id: "2599a0ea-0b4a-42fe-8df6-d8f7c68182e7",
|
|
336
|
-
name: "Went to lab for appointment",
|
|
337
|
-
is_refundable: true,
|
|
338
|
-
},
|
|
339
|
-
];
|
|
340
|
-
/** PSC offers a single cancellation reason in the sandbox. */
|
|
341
|
-
export const PSC_CANCELLATION_REASONS = [
|
|
342
|
-
{ id: "226a6520-667c-495f-8500-c20722d231d0", name: "Other", is_refundable: true },
|
|
343
|
-
];
|
|
344
|
-
/** The phlebotomy reason named "Other" requires explanatory notes. */
|
|
345
|
-
export const OTHER_CANCELLATION_REASON_ID = "2b9f23fd-163e-4483-bb10-90c74a67e0dc";
|
|
346
|
-
const PSC_LABS = [
|
|
347
|
-
{
|
|
348
|
-
lab_id: 25,
|
|
349
|
-
slug: "sonora_quest",
|
|
350
|
-
supported_bill_types: ["client_bill"],
|
|
351
|
-
capabilities: ["appointment_scheduling_via_junction"],
|
|
352
|
-
},
|
|
353
|
-
{
|
|
354
|
-
lab_id: 6,
|
|
355
|
-
slug: "labcorp",
|
|
356
|
-
supported_bill_types: ["client_bill", "commercial_insurance", "patient_bill"],
|
|
357
|
-
capabilities: [],
|
|
358
|
-
},
|
|
359
|
-
{
|
|
360
|
-
lab_id: 13,
|
|
361
|
-
slug: "bioreference",
|
|
362
|
-
supported_bill_types: ["patient_bill_passthrough"],
|
|
363
|
-
capabilities: [],
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
lab_id: 4,
|
|
367
|
-
slug: "quest",
|
|
368
|
-
supported_bill_types: ["client_bill", "commercial_insurance", "patient_bill"],
|
|
369
|
-
capabilities: ["appointment_scheduling_via_junction"],
|
|
370
|
-
},
|
|
371
|
-
];
|
|
372
|
-
/** Lab 3 (USSL) exists in the catalog but is excluded from PSC info in the sandbox. */
|
|
373
|
-
export const UNSUPPORTED_PSC_LAB_ID = 3;
|
|
374
|
-
const AREA_LAB_BILLS = {
|
|
375
|
-
sonora_quest: ["client_bill"],
|
|
376
|
-
labcorp: ["client_bill", "commercial_insurance", "patient_bill"],
|
|
377
|
-
bioreference: ["patient_bill_passthrough"],
|
|
378
|
-
quest: ["client_bill", "commercial_insurance", "patient_bill"],
|
|
379
|
-
};
|
|
380
|
-
const AREA_LABS = [
|
|
381
|
-
{ slug: "sonora_quest", lab_id: 25, appointment_with_vital: true, capabilities: [] },
|
|
382
|
-
{ slug: "labcorp", lab_id: 6, appointment_with_vital: false, capabilities: [] },
|
|
383
|
-
{ slug: "bioreference", lab_id: 13, appointment_with_vital: false, capabilities: [] },
|
|
384
|
-
{
|
|
385
|
-
slug: "quest",
|
|
386
|
-
lab_id: 4,
|
|
387
|
-
appointment_with_vital: true,
|
|
388
|
-
capabilities: ["appointment_scheduling_via_junction"],
|
|
389
|
-
},
|
|
390
|
-
];
|
|
391
|
-
/** Zips where getlabs phlebotomy is offered (mirrors sandbox coverage). */
|
|
392
|
-
const PHLEBOTOMY_SERVED_PREFIXES = [850, 900, 303, 917, 891];
|
|
393
|
-
const phlebotomyServed = (zip) => {
|
|
394
|
-
const prefix = Number(zip.slice(0, 3));
|
|
395
|
-
return PHLEBOTOMY_SERVED_PREFIXES.includes(prefix);
|
|
396
|
-
};
|
|
397
|
-
/**
|
|
398
|
-
* Deterministic PSC inventory model: the true number of sites within the radius is a
|
|
399
|
-
* seeded function of zip + lab scaled by radius (the sandbox derives it from real geo
|
|
400
|
-
* data); `psc/info` returns the nearest sites capped at 30. Unserviced zip prefixes
|
|
401
|
-
* (000, 96x territories with no national-lab coverage) have no inventory.
|
|
402
|
-
*/
|
|
403
|
-
const UNSERVICED_PSC_PREFIXES = [0];
|
|
404
|
-
const withinRadiusFor = (zip, labSlug, radius) => {
|
|
405
|
-
if (UNSERVICED_PSC_PREFIXES.includes(Number(zip.slice(0, 3))))
|
|
406
|
-
return 0;
|
|
407
|
-
const seed = seedFor(["psc-count", zip, labSlug]);
|
|
408
|
-
const fraction = seededRandom(seed)();
|
|
409
|
-
return Math.round(fraction * radius * 1.6);
|
|
410
|
-
};
|
|
411
|
-
const PSC_SITE_NAMES = ["Downtown", "Midtown", "Harbor", "Central", "University", "Parkside"];
|
|
412
|
-
const PSC_SITE_HOURS = {
|
|
413
|
-
monday: "08:00-17:00",
|
|
414
|
-
tuesday: "08:00-17:00",
|
|
415
|
-
wednesday: "08:00-17:00",
|
|
416
|
-
thursday: "08:00-17:00",
|
|
417
|
-
friday: "08:00-14:00",
|
|
418
|
-
};
|
|
419
|
-
const pscSiteFor = (zip, labSlug, index, radius) => {
|
|
420
|
-
const random = seededRandom(seedFor(["psc-site", zip, labSlug, index]));
|
|
421
|
-
const prefix = Number(zip.slice(0, 3));
|
|
422
|
-
const state = prefix >= 900 ? "CA" : prefix >= 600 ? "CO" : prefix >= 300 ? "GA" : "NY";
|
|
423
|
-
const city = PSC_SITE_NAMES[index % PSC_SITE_NAMES.length];
|
|
424
|
-
const siteCodeSeed = seededRandom(seedFor(["psc-code", labSlug, index]));
|
|
425
|
-
const siteCode = labSlug === "quest" || labSlug === "sonora_quest"
|
|
426
|
-
? Array.from({ length: 3 }, () => String.fromCharCode(65 + Math.floor(siteCodeSeed() * 26))).join("")
|
|
427
|
-
: String(10_000 + Math.floor(siteCodeSeed() * 89_999));
|
|
428
|
-
return {
|
|
429
|
-
name: `${labSlug.replace("_", " ")} - ${city}`,
|
|
430
|
-
state,
|
|
431
|
-
city,
|
|
432
|
-
zip_code: zip,
|
|
433
|
-
first_line: `${100 + Math.floor(random() * 800)} Main St`,
|
|
434
|
-
phone_number: `+1${200 + Math.floor(random() * 700)}555${String(1000 + Math.floor(random() * 8999))}`,
|
|
435
|
-
hours: PSC_SITE_HOURS,
|
|
436
|
-
distance: Math.round((0.5 + random() * radius) * 10) / 10,
|
|
437
|
-
site_code: siteCode,
|
|
438
|
-
location: {
|
|
439
|
-
lng: -122.4 + (random() - 0.5) * 0.2,
|
|
440
|
-
lat: 37.77 + (random() - 0.5) * 0.2,
|
|
441
|
-
},
|
|
442
|
-
};
|
|
443
|
-
};
|
|
444
|
-
/**
|
|
445
|
-
* Deterministic area model: `within_radius` mirrors the PSC inventory count per lab, the
|
|
446
|
-
* phlebotomy section mirrors getlabs market coverage, and central_labs always lists the
|
|
447
|
-
* four national labs with their billing/capability profiles.
|
|
448
|
-
*
|
|
449
|
-
* With a lab account, the availability check is scoped to that account: `central_labs`
|
|
450
|
-
* contains only the account's lab (empty when the account's lab has no area profile) and
|
|
451
|
-
* `supported_bill_types` comes from the account's `allowed_billing` keys.
|
|
452
|
-
*/
|
|
453
|
-
const areaInfoFor = (zip, radius, account = null) => {
|
|
454
|
-
const served = phlebotomyServed(zip);
|
|
455
|
-
const centralLabs = {};
|
|
456
|
-
const labs = account === null ? AREA_LABS : AREA_LABS.filter((lab) => lab.slug === account.lab);
|
|
457
|
-
for (const lab of labs) {
|
|
458
|
-
centralLabs[lab.slug] = {
|
|
459
|
-
patient_service_centers: {
|
|
460
|
-
appointment_with_vital: lab.appointment_with_vital,
|
|
461
|
-
within_radius: withinRadiusFor(zip, lab.slug, radius),
|
|
462
|
-
radius: String(radius),
|
|
463
|
-
capabilities: [...lab.capabilities],
|
|
464
|
-
},
|
|
465
|
-
supported_bill_types: account === null
|
|
466
|
-
? [...(AREA_LAB_BILLS[lab.slug] ?? [])]
|
|
467
|
-
: Object.keys(account.allowed_billing),
|
|
468
|
-
lab_id: lab.lab_id,
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
return {
|
|
472
|
-
zip_code: zip,
|
|
473
|
-
phlebotomy: {
|
|
474
|
-
is_served: served,
|
|
475
|
-
providers: served ? [{ name: "getlabs", service_types: ["appointment-ready"] }] : [],
|
|
476
|
-
},
|
|
477
|
-
central_labs: centralLabs,
|
|
478
|
-
};
|
|
479
|
-
};
|
|
480
|
-
const jsonObject = (context) => {
|
|
481
|
-
const body = context.body;
|
|
482
|
-
const value = body.kind === "json" ? body.value : undefined;
|
|
483
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
484
|
-
throw new HttpError(422, {
|
|
485
|
-
detail: [
|
|
486
|
-
{
|
|
487
|
-
type: "model_attributes_type",
|
|
488
|
-
loc: ["body"],
|
|
489
|
-
msg: "Input should be a valid dictionary or object to extract fields from",
|
|
490
|
-
input: value,
|
|
491
|
-
},
|
|
492
|
-
],
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
return value;
|
|
496
|
-
};
|
|
497
|
-
const isUuid = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
498
|
-
const uuidError = (value, loc) => {
|
|
499
|
-
const characters = Array.from(value);
|
|
500
|
-
const invalidIndex = characters.findIndex((character) => !/^[0-9a-f-]$/i.test(character));
|
|
501
|
-
const error = invalidIndex >= 0
|
|
502
|
-
? `invalid character: expected an optional prefix of \`urn:uuid:\` followed by [0-9a-fA-F-], found \`${characters[invalidIndex]}\` at ${invalidIndex + 1}`
|
|
503
|
-
: `invalid length: expected length 32 for simple format, found ${value.replaceAll("-", "").length}`;
|
|
504
|
-
return {
|
|
505
|
-
type: "uuid_parsing",
|
|
506
|
-
loc,
|
|
507
|
-
msg: `Input should be a valid UUID, ${error}`,
|
|
508
|
-
input: value,
|
|
509
|
-
ctx: { error },
|
|
510
|
-
};
|
|
511
|
-
};
|
|
512
|
-
const ALLOWED_RADII = [10, 20, 25, 50, 100];
|
|
513
|
-
const DEFAULT_RADIUS = 25;
|
|
514
|
-
const radiusOf = (context) => {
|
|
515
|
-
const raw = context.query.radius;
|
|
516
|
-
if (raw === undefined)
|
|
517
|
-
return DEFAULT_RADIUS;
|
|
518
|
-
const value = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
|
519
|
-
if (!ALLOWED_RADII.includes(value)) {
|
|
520
|
-
throw new HttpError(422, {
|
|
521
|
-
detail: [
|
|
522
|
-
{
|
|
523
|
-
type: "enum",
|
|
524
|
-
loc: ["query", "radius"],
|
|
525
|
-
msg: "Input should be '10', '20', '25', '50' or '100'",
|
|
526
|
-
input: raw,
|
|
527
|
-
ctx: { expected: "'10', '20', '25', '50' or '100'" },
|
|
528
|
-
},
|
|
529
|
-
],
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
return value;
|
|
533
|
-
};
|
|
534
|
-
const zipCodeOf = (context, required) => {
|
|
535
|
-
const raw = context.query.zip_code;
|
|
536
|
-
if (raw === undefined) {
|
|
537
|
-
if (required) {
|
|
538
|
-
throw new HttpError(422, {
|
|
539
|
-
detail: [
|
|
540
|
-
{
|
|
541
|
-
type: "missing",
|
|
542
|
-
loc: ["query", "zip_code"],
|
|
543
|
-
msg: "Field required",
|
|
544
|
-
input: undefined,
|
|
545
|
-
},
|
|
546
|
-
],
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
return undefined;
|
|
550
|
-
}
|
|
551
|
-
if (typeof raw !== "string")
|
|
552
|
-
throw new HttpError(422, { detail: "zip_code must be a string" });
|
|
553
|
-
return raw;
|
|
554
|
-
};
|
|
555
|
-
/**
|
|
556
|
-
* Optional `lab_account_id` for an availability check: it must be a UUID, exist for this
|
|
557
|
-
* team, and be active — the same account rules Create Order applies.
|
|
558
|
-
*/
|
|
559
|
-
const labAccountOf = (context) => {
|
|
560
|
-
const raw = context.query.lab_account_id;
|
|
561
|
-
if (raw === undefined)
|
|
562
|
-
return null;
|
|
563
|
-
if (typeof raw !== "string" || !isUuid(raw)) {
|
|
564
|
-
throw new HttpError(422, {
|
|
565
|
-
detail: [uuidError(typeof raw === "string" ? raw : String(raw), ["query", "lab_account_id"])],
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
const account = labAccountById(raw);
|
|
569
|
-
if (!account?.team_id_allowlist.includes(MOCK_TEAM_ID))
|
|
570
|
-
throw new HttpError(404, { detail: "Lab account does not exist" });
|
|
571
|
-
if (account.status !== "active")
|
|
572
|
-
throw new HttpError(400, { detail: "Lab account is not active" });
|
|
573
|
-
return account;
|
|
574
|
-
};
|
|
575
|
-
const startDateOf = (context, nowMs) => {
|
|
576
|
-
const raw = context.query.start_date;
|
|
577
|
-
if (raw === undefined)
|
|
578
|
-
return addDays(isoDate(new Date(nowMs)), 1);
|
|
579
|
-
if (typeof raw !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
580
|
-
throw new HttpError(422, {
|
|
581
|
-
detail: [
|
|
582
|
-
{
|
|
583
|
-
type: "date_from_datetime_parsing",
|
|
584
|
-
loc: ["query", "start_date"],
|
|
585
|
-
msg: "Input should be a valid date or datetime, invalid character in year",
|
|
586
|
-
input: raw,
|
|
587
|
-
ctx: { error: "invalid character in year" },
|
|
588
|
-
},
|
|
589
|
-
],
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
return raw;
|
|
593
|
-
};
|
|
594
|
-
const appointmentOfOrder = (state, orderId) => {
|
|
595
|
-
const binding = state.appointmentsByOrder.get(orderId);
|
|
596
|
-
if (!binding)
|
|
597
|
-
return undefined;
|
|
598
|
-
return state.appointments.get(binding.appointment_id);
|
|
599
|
-
};
|
|
600
|
-
const renderAppointment = (appointment) => ({
|
|
601
|
-
id: appointment.id,
|
|
602
|
-
user_id: appointment.user_id,
|
|
603
|
-
order_id: appointment.order_id,
|
|
604
|
-
address: appointment.address,
|
|
605
|
-
location: appointment.location,
|
|
606
|
-
start_at: appointment.start_at,
|
|
607
|
-
end_at: appointment.end_at,
|
|
608
|
-
iana_timezone: appointment.iana_timezone,
|
|
609
|
-
type: appointment.type,
|
|
610
|
-
provider: appointment.provider,
|
|
611
|
-
status: appointment.status,
|
|
612
|
-
provider_id: appointment.provider_id,
|
|
613
|
-
external_id: appointment.external_id,
|
|
614
|
-
can_reschedule: appointment.can_reschedule,
|
|
615
|
-
event_status: appointment.event_status,
|
|
616
|
-
event_data: appointment.event_data,
|
|
617
|
-
order_transaction_id: appointment.order_transaction_id,
|
|
618
|
-
appointment_notes: appointment.appointment_notes,
|
|
619
|
-
events: appointment.events,
|
|
620
|
-
});
|
|
621
|
-
const phlebotomyAddress = (zip) => {
|
|
622
|
-
const { city, state } = cityStateForZip(zip);
|
|
623
|
-
return {
|
|
624
|
-
first_line: "1 Main St",
|
|
625
|
-
second_line: "",
|
|
626
|
-
city,
|
|
627
|
-
state,
|
|
628
|
-
zip_code: zip,
|
|
629
|
-
unit: null,
|
|
630
|
-
access_notes: null,
|
|
631
|
-
};
|
|
632
|
-
};
|
|
633
|
-
const persistSlot = (state, seed, nowMs) => {
|
|
634
|
-
const record = {
|
|
635
|
-
key: seed.bookingKey,
|
|
636
|
-
start: seed.start,
|
|
637
|
-
end: seed.end,
|
|
638
|
-
expires_at: seed.expiresAt,
|
|
639
|
-
price: seed.price,
|
|
640
|
-
is_priority: seed.isPriority,
|
|
641
|
-
num_appointments_available: seed.numAppointmentsAvailable,
|
|
642
|
-
modality: seed.modality,
|
|
643
|
-
provider: seed.provider,
|
|
644
|
-
site_code: seed.siteCode,
|
|
645
|
-
zip_code: seed.zipCode,
|
|
646
|
-
address: seed.address,
|
|
647
|
-
location: seed.location,
|
|
648
|
-
consumed_by_order_id: null,
|
|
649
|
-
created_at: new Date(nowMs).toISOString(),
|
|
650
|
-
};
|
|
651
|
-
state.bookingKeys.insert(seed.bookingKey, record);
|
|
652
|
-
return record;
|
|
653
|
-
};
|
|
654
|
-
const generatePhlebotomySlots = (state, zip, startDate, provider, nowMs) => {
|
|
655
|
-
const random = seededRandom(seedFor(["phlebotomy", provider, zip, startDate]));
|
|
656
|
-
const timezone = zipTimezone(zip);
|
|
657
|
-
const days = [];
|
|
658
|
-
for (let day = 0; day < 3; day++) {
|
|
659
|
-
const date = addDays(startDate, day);
|
|
660
|
-
const slots = [];
|
|
661
|
-
const slotCount = 2 + Math.floor(random() * 3);
|
|
662
|
-
for (let slot = 0; slot < slotCount; slot++) {
|
|
663
|
-
const hour = 8 + Math.floor(random() * 8);
|
|
664
|
-
const minute = random() < 0.5 ? 0 : 30;
|
|
665
|
-
const start = new Date(`${date}T00:00:00Z`);
|
|
666
|
-
start.setUTCHours(hour, minute, 0, 0);
|
|
667
|
-
if (start.getTime() <= nowMs)
|
|
668
|
-
continue;
|
|
669
|
-
const end = new Date(start.getTime() + 45 * 60_000);
|
|
670
|
-
const salt = `${provider}:${zip}:${date}:${start.toISOString()}`;
|
|
671
|
-
const seed = {
|
|
672
|
-
bookingKey: state.bookingKeyFor(salt),
|
|
673
|
-
start: start.toISOString(),
|
|
674
|
-
end: end.toISOString(),
|
|
675
|
-
expiresAt: new Date(start.getTime() - 60 * 60_000).toISOString(),
|
|
676
|
-
price: random() < 0.5 ? 0 : 30,
|
|
677
|
-
isPriority: random() < 0.3,
|
|
678
|
-
numAppointmentsAvailable: 1 + Math.floor(random() * 4),
|
|
679
|
-
modality: "phlebotomy",
|
|
680
|
-
provider,
|
|
681
|
-
siteCode: null,
|
|
682
|
-
zipCode: zip,
|
|
683
|
-
address: phlebotomyAddress(zip),
|
|
684
|
-
location: zipLocation(zip),
|
|
685
|
-
};
|
|
686
|
-
const record = persistSlot(state, seed, nowMs);
|
|
687
|
-
slots.push({
|
|
688
|
-
booking_key: record.key,
|
|
689
|
-
start: record.start,
|
|
690
|
-
end: record.end,
|
|
691
|
-
expires_at: record.expires_at,
|
|
692
|
-
price: record.price,
|
|
693
|
-
is_priority: record.is_priority,
|
|
694
|
-
num_appointments_available: record.num_appointments_available,
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
if (slots.length > 0)
|
|
698
|
-
days.push({ date, slots });
|
|
699
|
-
}
|
|
700
|
-
return { timezone, days };
|
|
701
|
-
};
|
|
702
|
-
const generatePscSlots = (state, zip, startDate, siteCodes, nowMs) => {
|
|
703
|
-
const siteCount = Math.max(withinRadiusFor(zip, "quest", 25), 1);
|
|
704
|
-
const sites = Array.from({ length: Math.min(siteCount, 30) }, (_, index) => pscSiteFor(zip, "quest", index, 25)).filter((site) => siteCodes === null || siteCodes.includes(site.site_code));
|
|
705
|
-
const random = seededRandom(seedFor(["psc", zip, startDate, (siteCodes ?? []).sort().join("|")]));
|
|
706
|
-
const timezone = zipTimezone(zip);
|
|
707
|
-
const days = [];
|
|
708
|
-
for (let day = 0; day < 3; day++) {
|
|
709
|
-
const date = addDays(startDate, day);
|
|
710
|
-
const slots = [];
|
|
711
|
-
const slotCount = 2 + Math.floor(random() * 3);
|
|
712
|
-
for (let slot = 0; slot < slotCount; slot++) {
|
|
713
|
-
const hour = 7 + Math.floor(random() * 9);
|
|
714
|
-
const minute = random() < 0.5 ? 0 : 30;
|
|
715
|
-
const start = new Date(`${date}T00:00:00Z`);
|
|
716
|
-
start.setUTCHours(hour, minute, 0, 0);
|
|
717
|
-
if (start.getTime() <= nowMs)
|
|
718
|
-
continue;
|
|
719
|
-
const end = new Date(start.getTime() + 15 * 60_000);
|
|
720
|
-
const site = sites.length > 0 ? sites[slot % sites.length] : undefined;
|
|
721
|
-
const salt = `psc:${zip}:${site?.site_code ?? "none"}:${date}:${start.toISOString()}`;
|
|
722
|
-
const seed = {
|
|
723
|
-
bookingKey: state.bookingKeyFor(salt),
|
|
724
|
-
start: start.toISOString(),
|
|
725
|
-
end: end.toISOString(),
|
|
726
|
-
expiresAt: new Date(start.getTime() - 60 * 60_000).toISOString(),
|
|
727
|
-
price: 0,
|
|
728
|
-
isPriority: false,
|
|
729
|
-
numAppointmentsAvailable: 1 + Math.floor(random() * 6),
|
|
730
|
-
modality: "patient_service_center",
|
|
731
|
-
provider: "quest",
|
|
732
|
-
siteCode: site?.site_code ?? null,
|
|
733
|
-
zipCode: zip,
|
|
734
|
-
address: site
|
|
735
|
-
? {
|
|
736
|
-
first_line: site.first_line,
|
|
737
|
-
second_line: null,
|
|
738
|
-
city: site.city,
|
|
739
|
-
state: site.state,
|
|
740
|
-
zip_code: site.zip_code,
|
|
741
|
-
unit: null,
|
|
742
|
-
}
|
|
743
|
-
: phlebotomyAddress(zip),
|
|
744
|
-
location: site?.location ?? zipLocation(zip),
|
|
745
|
-
};
|
|
746
|
-
const record = persistSlot(state, seed, nowMs);
|
|
747
|
-
slots.push({
|
|
748
|
-
booking_key: record.key,
|
|
749
|
-
start: record.start,
|
|
750
|
-
end: record.end,
|
|
751
|
-
expires_at: record.expires_at,
|
|
752
|
-
price: record.price,
|
|
753
|
-
is_priority: record.is_priority,
|
|
754
|
-
num_appointments_available: record.num_appointments_available,
|
|
755
|
-
site_code: record.site_code,
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
if (slots.length > 0)
|
|
759
|
-
days.push({ date, slots });
|
|
760
|
-
}
|
|
761
|
-
return { timezone, days };
|
|
762
|
-
};
|
|
763
|
-
function notFound(message) {
|
|
764
|
-
throw new HttpError(404, { detail: message });
|
|
765
|
-
}
|
|
766
|
-
function invalidBookingKey() {
|
|
767
|
-
throw new HttpError(400, { detail: "Invalid or expired booking key" });
|
|
768
|
-
}
|
|
769
|
-
const requireOrder = (state, orderId, context, missingMessage = "This order doesn't exist.") => {
|
|
770
|
-
if (!isUuid(orderId)) {
|
|
771
|
-
throw new HttpError(422, { detail: [uuidError(orderId, ["path", "order_id"])] });
|
|
772
|
-
}
|
|
773
|
-
const order = state.orders.get(orderId);
|
|
774
|
-
if (order === undefined)
|
|
775
|
-
notFound(missingMessage);
|
|
776
|
-
const loaded = order;
|
|
777
|
-
state.applyDueSimulateTransitions(context.now(), (_due, finalStatus, flags) => {
|
|
778
|
-
applySimulateTransition(state, loaded, finalStatus, flags, context);
|
|
779
|
-
});
|
|
780
|
-
const fresh = state.orders.get(orderId);
|
|
781
|
-
if (fresh === undefined)
|
|
782
|
-
notFound(missingMessage);
|
|
783
|
-
return fresh;
|
|
784
|
-
};
|
|
785
|
-
const orderCollectionMethod = (order) => {
|
|
786
|
-
if (typeof order.lab_test.method === "string" && order.lab_test.method.length > 0) {
|
|
787
|
-
return order.lab_test.method;
|
|
788
|
-
}
|
|
789
|
-
if (typeof order.details?.type === "string" && order.details.type.length > 0) {
|
|
790
|
-
return order.details.type;
|
|
791
|
-
}
|
|
792
|
-
return "";
|
|
793
|
-
};
|
|
794
|
-
const requirePhlebotomyCapableOrder = (order) => {
|
|
795
|
-
if (orderCollectionMethod(order) !== "at_home_phlebotomy") {
|
|
796
|
-
notFound("This order doesn't have a phlebotomy order.");
|
|
797
|
-
}
|
|
798
|
-
};
|
|
799
|
-
const requirePscCapableOrder = (order) => {
|
|
800
|
-
const method = orderCollectionMethod(order);
|
|
801
|
-
if (method !== "walk_in_test" && method !== "on_site_collection") {
|
|
802
|
-
notFound("This order is not a walk-in phlebotomy order.");
|
|
803
|
-
}
|
|
804
|
-
};
|
|
805
|
-
const requireOrderHasRequisition = (order) => {
|
|
806
|
-
const hasRequisition = order.events.some((entry) => typeof entry.status === "string" && entry.status.endsWith(".requisition_created"));
|
|
807
|
-
if (hasRequisition)
|
|
808
|
-
return;
|
|
809
|
-
const lowLevel = typeof order.last_event?.status === "string"
|
|
810
|
-
? (order.last_event.status.split(".").at(-1) ?? order.last_event.status)
|
|
811
|
-
: typeof order.status === "string" && order.status.length > 0
|
|
812
|
-
? order.status
|
|
813
|
-
: "ordered";
|
|
814
|
-
throw new HttpError(400, {
|
|
815
|
-
detail: `This order does not have a requisition, it's still in state ${lowLevel}.`,
|
|
816
|
-
});
|
|
817
|
-
};
|
|
818
|
-
const appendOrderStatusEvent = (state, order, status, context) => {
|
|
819
|
-
const now = state.isoNow(context.now);
|
|
820
|
-
const eventId = Number.parseInt(opaqueToken(`junction:order-event:${order.id}:${order.events.length}`, 8), 16) %
|
|
821
|
-
1_000_000_000 || order.events.length + 1;
|
|
822
|
-
const event = {
|
|
823
|
-
id: eventId,
|
|
824
|
-
created_at: now,
|
|
825
|
-
status,
|
|
826
|
-
status_detail: null,
|
|
827
|
-
};
|
|
828
|
-
order.status = status.split(".")[0] ?? order.status;
|
|
829
|
-
order.updated_at = now;
|
|
830
|
-
order.events.push(event);
|
|
831
|
-
order.last_event = event;
|
|
832
|
-
if (status.endsWith(".requisition_created")) {
|
|
833
|
-
if (order.sample_id === null) {
|
|
834
|
-
order.sample_id = opaqueToken(`junction:sample:${order.id}`, 12).toUpperCase();
|
|
835
|
-
}
|
|
836
|
-
if (order.requisition_form_url === null) {
|
|
837
|
-
order.requisition_form_url = `https://storage.googleapis.com/vital_labs_sandbox/mock/requisition_forms/${order.id}_${order.sample_id}.pdf`;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
const transactionOrder = order.order_transaction.orders.find((entry) => entry.id === order.id);
|
|
841
|
-
if (transactionOrder) {
|
|
842
|
-
transactionOrder.low_level_status = status.split(".").at(-1) ?? status;
|
|
843
|
-
transactionOrder.low_level_status_created_at = new Date(context.now()).toISOString();
|
|
844
|
-
transactionOrder.updated_at = new Date(context.now()).toISOString();
|
|
845
|
-
}
|
|
846
|
-
if (status.startsWith("completed"))
|
|
847
|
-
order.order_transaction.status = "completed";
|
|
848
|
-
if (status.startsWith("cancelled"))
|
|
849
|
-
order.order_transaction.status = "cancelled";
|
|
850
|
-
};
|
|
851
|
-
const applySimulationFlags = (order, flags) => {
|
|
852
|
-
if (!flags)
|
|
853
|
-
return false;
|
|
854
|
-
let changed = false;
|
|
855
|
-
if (typeof flags.interpretation === "string") {
|
|
856
|
-
order.interpretation = flags.interpretation;
|
|
857
|
-
changed = true;
|
|
858
|
-
}
|
|
859
|
-
if (typeof flags.has_missing_results === "boolean") {
|
|
860
|
-
order.has_missing_results = flags.has_missing_results;
|
|
861
|
-
changed = true;
|
|
862
|
-
}
|
|
863
|
-
if (Array.isArray(flags.result_types)) {
|
|
864
|
-
order.result_types = flags.result_types;
|
|
865
|
-
changed = true;
|
|
866
|
-
}
|
|
867
|
-
return changed;
|
|
868
|
-
};
|
|
869
|
-
/**
|
|
870
|
-
* Mirror api.sandbox.tryvital.io `/v3/order/{id}/test` semantics (probed 2026-09):
|
|
871
|
-
* - First call on a fresh order always creates `received.{method}.requisition_created`.
|
|
872
|
-
* - `at_home_phlebotomy` (and other non-walk-in methods): further `/test` calls are no-ops.
|
|
873
|
-
* - `walk_in_test` after requisition:
|
|
874
|
-
* - matching `appointment_*` / `requisition_created` → no-op
|
|
875
|
-
* - matching `partial_results` → append partial only (default interpretation normal)
|
|
876
|
-
* - matching `ordered` / `completed` / `cancelled`, or any mismatched-method status →
|
|
877
|
-
* jump through partial then completed (+ result date fields)
|
|
878
|
-
* - `failed.*` → append the failed status
|
|
879
|
-
*/
|
|
880
|
-
const applySimulateTransition = (state, order, finalStatus, flags, context) => {
|
|
881
|
-
const orderMethod = typeof order.lab_test.method === "string" && order.lab_test.method.length > 0
|
|
882
|
-
? order.lab_test.method
|
|
883
|
-
: typeof order.details?.type === "string" && order.details.type.length > 0
|
|
884
|
-
? order.details.type
|
|
885
|
-
: "at_home_phlebotomy";
|
|
886
|
-
const hasCancelled = order.events.some((entry) => typeof entry.status === "string" ? entry.status.startsWith("cancelled.") : false);
|
|
887
|
-
if (hasCancelled)
|
|
888
|
-
return;
|
|
889
|
-
const hasRequisition = order.events.some((entry) => typeof entry.status === "string" ? entry.status.endsWith(".requisition_created") : false);
|
|
890
|
-
const hasPartial = order.events.some((entry) => typeof entry.status === "string" ? entry.status.endsWith(".partial_results") : false);
|
|
891
|
-
const hasCompleted = order.events.some((entry) => typeof entry.status === "string" ? entry.status.endsWith(".completed") : false);
|
|
892
|
-
const requestedParts = finalStatus.split(".");
|
|
893
|
-
const requestedMethod = requestedParts.length >= 3 ? (requestedParts[1] ?? orderMethod) : orderMethod;
|
|
894
|
-
const methodMismatch = requestedMethod !== orderMethod;
|
|
895
|
-
const normalizedTarget = (() => {
|
|
896
|
-
if (requestedParts.length >= 3)
|
|
897
|
-
return `${requestedParts[0]}.${orderMethod}.${requestedParts.slice(2).join(".")}`;
|
|
898
|
-
return finalStatus;
|
|
899
|
-
})();
|
|
900
|
-
const targetTail = normalizedTarget.split(".").at(-1) ?? normalizedTarget;
|
|
901
|
-
let statusesToApply = [];
|
|
902
|
-
let applyFlags = false;
|
|
903
|
-
let markCompleteDates = false;
|
|
904
|
-
if (!hasRequisition) {
|
|
905
|
-
statusesToApply = [`received.${orderMethod}.requisition_created`];
|
|
906
|
-
}
|
|
907
|
-
else if (orderMethod === "walk_in_test") {
|
|
908
|
-
if (hasCompleted) {
|
|
909
|
-
statusesToApply = [];
|
|
910
|
-
}
|
|
911
|
-
else if (normalizedTarget.startsWith("failed.")) {
|
|
912
|
-
statusesToApply = [normalizedTarget];
|
|
913
|
-
}
|
|
914
|
-
else if (!methodMismatch &&
|
|
915
|
-
(targetTail === "appointment_pending" ||
|
|
916
|
-
targetTail === "appointment_scheduled" ||
|
|
917
|
-
targetTail === "appointment_cancelled" ||
|
|
918
|
-
targetTail === "redraw_available" ||
|
|
919
|
-
targetTail === "requisition_created" ||
|
|
920
|
-
targetTail === "requisition_bypassed")) {
|
|
921
|
-
statusesToApply = [];
|
|
922
|
-
}
|
|
923
|
-
else if (!methodMismatch && targetTail === "partial_results") {
|
|
924
|
-
if (!hasPartial)
|
|
925
|
-
statusesToApply = [`sample_with_lab.${orderMethod}.partial_results`];
|
|
926
|
-
applyFlags = true;
|
|
927
|
-
if (!flags || typeof flags.interpretation !== "string") {
|
|
928
|
-
order.interpretation = order.interpretation ?? "normal";
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
else {
|
|
932
|
-
// ordered / completed / cancelled / mismatched-method → complete jump
|
|
933
|
-
statusesToApply = [];
|
|
934
|
-
if (!hasPartial)
|
|
935
|
-
statusesToApply.push(`sample_with_lab.${orderMethod}.partial_results`);
|
|
936
|
-
statusesToApply.push(`completed.${orderMethod}.completed`);
|
|
937
|
-
applyFlags = true;
|
|
938
|
-
markCompleteDates = true;
|
|
939
|
-
if (!flags || typeof flags.interpretation !== "string") {
|
|
940
|
-
order.interpretation = order.interpretation ?? "normal";
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
if (statusesToApply.length === 0 && !applyFlags && !markCompleteDates)
|
|
945
|
-
return;
|
|
946
|
-
for (const status of statusesToApply) {
|
|
947
|
-
appendOrderStatusEvent(state, order, status, context);
|
|
948
|
-
}
|
|
949
|
-
if (applyFlags)
|
|
950
|
-
applySimulationFlags(order, flags);
|
|
951
|
-
if (markCompleteDates) {
|
|
952
|
-
const now = new Date(context.now());
|
|
953
|
-
const commonDays = typeof order.lab_test.common_tat_days === "number" && order.lab_test.common_tat_days > 0
|
|
954
|
-
? order.lab_test.common_tat_days
|
|
955
|
-
: 3;
|
|
956
|
-
const worstDays = typeof order.lab_test.worst_case_tat_days === "number" &&
|
|
957
|
-
order.lab_test.worst_case_tat_days > 0
|
|
958
|
-
? order.lab_test.worst_case_tat_days
|
|
959
|
-
: 5;
|
|
960
|
-
const expected = new Date(now);
|
|
961
|
-
expected.setUTCDate(expected.getUTCDate() + commonDays);
|
|
962
|
-
const worst = new Date(now);
|
|
963
|
-
worst.setUTCDate(worst.getUTCDate() + worstDays);
|
|
964
|
-
order.expected_result_by_date = expected.toISOString().slice(0, 10);
|
|
965
|
-
order.worst_case_result_by_date = worst.toISOString().slice(0, 10);
|
|
966
|
-
}
|
|
967
|
-
state.orders.update(order.id, order);
|
|
968
|
-
state.publishOrderWebhook(order, "labtest.order.updated", context.now());
|
|
969
|
-
};
|
|
970
|
-
const linkAppointmentToOrder = (state, order, appointment, status, nowIso) => {
|
|
971
|
-
const data = order.details.data;
|
|
972
|
-
if (data && typeof data === "object") {
|
|
973
|
-
data.appointment_id = appointment.id;
|
|
974
|
-
data.updated_at = nowIso;
|
|
975
|
-
}
|
|
976
|
-
const event = {
|
|
977
|
-
id: order.events.length + 1,
|
|
978
|
-
created_at: nowIso,
|
|
979
|
-
status,
|
|
980
|
-
status_detail: null,
|
|
981
|
-
};
|
|
982
|
-
order.status = status.split(".")[0] ?? order.status;
|
|
983
|
-
order.updated_at = nowIso;
|
|
984
|
-
order.events.push(event);
|
|
985
|
-
order.last_event = event;
|
|
986
|
-
const transactionOrder = order.order_transaction.orders.find((entry) => entry.id === order.id);
|
|
987
|
-
if (transactionOrder) {
|
|
988
|
-
transactionOrder.low_level_status = status.split(".").at(-1) ?? status;
|
|
989
|
-
transactionOrder.low_level_status_created_at = nowIso;
|
|
990
|
-
transactionOrder.updated_at = nowIso;
|
|
991
|
-
}
|
|
992
|
-
state.orders.update(order.id, order);
|
|
993
|
-
state.publishOrderWebhook(order, "labtest.order.updated");
|
|
994
|
-
};
|
|
995
|
-
const appendAppointmentEvent = (appointment, status, at) => {
|
|
996
|
-
const event = { created_at: at, status, data: null };
|
|
997
|
-
appointment.events.push(event);
|
|
998
|
-
appointment.event_status = status;
|
|
999
|
-
return event;
|
|
1000
|
-
};
|
|
1001
|
-
const cancelAppointment = (state, appointment, order, nowMs) => {
|
|
1002
|
-
const nowIso = state.isoNow(() => nowMs);
|
|
1003
|
-
appointment.status = "cancelled";
|
|
1004
|
-
appendAppointmentEvent(appointment, "cancelled", nowIso);
|
|
1005
|
-
state.appointments.update(appointment.id, appointment);
|
|
1006
|
-
const eventStatus = CANCELLED_ORDER_EVENT[appointment.type];
|
|
1007
|
-
const event = {
|
|
1008
|
-
id: order.events.length + 1,
|
|
1009
|
-
created_at: nowIso,
|
|
1010
|
-
status: eventStatus,
|
|
1011
|
-
status_detail: null,
|
|
1012
|
-
};
|
|
1013
|
-
order.status = "collecting_sample";
|
|
1014
|
-
order.updated_at = nowIso;
|
|
1015
|
-
order.events.push(event);
|
|
1016
|
-
order.last_event = event;
|
|
1017
|
-
state.orders.update(order.id, order);
|
|
1018
|
-
state.publishAppointmentWebhook(appointment, order.team_id, nowMs);
|
|
1019
|
-
state.publishOrderWebhook(order, "labtest.order.updated", nowMs);
|
|
1020
|
-
return appointment;
|
|
1021
|
-
};
|
|
1022
|
-
export const schedulingHandlers = (state) => ({
|
|
1023
|
-
get_phlebotomy_appointment_cancellation_reason_v3_order_phlebotomy_appointment_cancellation_reasons_get: async () => jsonRes(200, CANCELLATION_REASONS),
|
|
1024
|
-
get_psc_appointment_cancellation_reason_v3_order_psc_appointment_cancellation_reasons_get: async () => jsonRes(200, PSC_CANCELLATION_REASONS),
|
|
1025
|
-
get_area_info_v3_order_area_info_get: async (context) => {
|
|
1026
|
-
const hit = cachedResponse(state, context);
|
|
1027
|
-
if (hit)
|
|
1028
|
-
return hit;
|
|
1029
|
-
const rawZip = zipCodeOf(context, true) ?? "";
|
|
1030
|
-
const radius = radiusOf(context);
|
|
1031
|
-
if (!/^\d{5}(?:-?\d{4})?$/.test(rawZip)) {
|
|
1032
|
-
throw new HttpError(422, {
|
|
1033
|
-
detail: [
|
|
1034
|
-
{
|
|
1035
|
-
type: "string_pattern_mismatch",
|
|
1036
|
-
loc: ["query", "zip_code"],
|
|
1037
|
-
msg: "String should match pattern '^\\d{5}(?:-?\\d{4})?$'",
|
|
1038
|
-
input: rawZip,
|
|
1039
|
-
ctx: { pattern: "^\\d{5}(?:-?\\d{4})?$" },
|
|
1040
|
-
},
|
|
1041
|
-
],
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1044
|
-
const zip = rawZip.slice(0, 5);
|
|
1045
|
-
return jsonRes(200, areaInfoFor(zip, radius, labAccountOf(context)));
|
|
1046
|
-
},
|
|
1047
|
-
get_psc_info_v3_order_psc_info_get: async (context) => {
|
|
1048
|
-
const hit = cachedResponse(state, context);
|
|
1049
|
-
if (hit)
|
|
1050
|
-
return hit;
|
|
1051
|
-
const zip = zipCodeOf(context, true) ?? "";
|
|
1052
|
-
const labIdRaw = context.query.lab_id;
|
|
1053
|
-
if (labIdRaw === undefined) {
|
|
1054
|
-
throw new HttpError(422, {
|
|
1055
|
-
detail: [
|
|
1056
|
-
{
|
|
1057
|
-
type: "missing",
|
|
1058
|
-
loc: ["query", "lab_id"],
|
|
1059
|
-
msg: "Field required",
|
|
1060
|
-
input: undefined,
|
|
1061
|
-
},
|
|
1062
|
-
],
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
if (typeof labIdRaw !== "string" || !/^-?\d+$/.test(labIdRaw)) {
|
|
1066
|
-
throw new HttpError(422, {
|
|
1067
|
-
detail: [
|
|
1068
|
-
{
|
|
1069
|
-
type: "int_parsing",
|
|
1070
|
-
loc: ["query", "lab_id"],
|
|
1071
|
-
msg: "Input should be a valid integer, unable to parse string as an integer",
|
|
1072
|
-
input: labIdRaw,
|
|
1073
|
-
ctx: { error: {} },
|
|
1074
|
-
},
|
|
1075
|
-
],
|
|
1076
|
-
});
|
|
1077
|
-
}
|
|
1078
|
-
if (!/^\d{5}$/.test(zip)) {
|
|
1079
|
-
throw new HttpError(422, {
|
|
1080
|
-
detail: [
|
|
1081
|
-
{
|
|
1082
|
-
type: "string_pattern_mismatch",
|
|
1083
|
-
loc: ["query", "zip_code"],
|
|
1084
|
-
msg: "String should match pattern '^\\d{5}$'",
|
|
1085
|
-
input: zip,
|
|
1086
|
-
ctx: { pattern: "^\\d{5}$" },
|
|
1087
|
-
},
|
|
1088
|
-
],
|
|
1089
|
-
});
|
|
1090
|
-
}
|
|
1091
|
-
const labId = Number(labIdRaw);
|
|
1092
|
-
const radius = radiusOf(context);
|
|
1093
|
-
const capabilitiesRaw = context.query.capabilities;
|
|
1094
|
-
const capabilityList = Array.isArray(capabilitiesRaw)
|
|
1095
|
-
? capabilitiesRaw.map(String)
|
|
1096
|
-
: capabilitiesRaw !== undefined
|
|
1097
|
-
? [String(capabilitiesRaw)]
|
|
1098
|
-
: [];
|
|
1099
|
-
const knownCapabilities = [
|
|
1100
|
-
"stat",
|
|
1101
|
-
"appointment_scheduling_via_junction",
|
|
1102
|
-
"appointment_scheduling_with_lab",
|
|
1103
|
-
];
|
|
1104
|
-
if (capabilityList.some((capability) => !knownCapabilities.includes(capability))) {
|
|
1105
|
-
throw new HttpError(422, {
|
|
1106
|
-
detail: [
|
|
1107
|
-
{
|
|
1108
|
-
type: "enum",
|
|
1109
|
-
loc: ["query", "capabilities", 0],
|
|
1110
|
-
msg: "Input should be 'stat', 'appointment_scheduling_via_junction' or 'appointment_scheduling_with_lab'",
|
|
1111
|
-
input: capabilityList,
|
|
1112
|
-
ctx: {
|
|
1113
|
-
expected: "'stat', 'appointment_scheduling_via_junction' or 'appointment_scheduling_with_lab'",
|
|
1114
|
-
},
|
|
1115
|
-
},
|
|
1116
|
-
],
|
|
1117
|
-
});
|
|
1118
|
-
}
|
|
1119
|
-
const lab = PSC_LABS.find((entry) => entry.lab_id === labId);
|
|
1120
|
-
if (labId === UNSUPPORTED_PSC_LAB_ID) {
|
|
1121
|
-
throw new HttpError(404, { detail: "Lab not supported for PSC info." });
|
|
1122
|
-
}
|
|
1123
|
-
if (!lab) {
|
|
1124
|
-
throw new HttpError(404, { detail: "Lab not found." });
|
|
1125
|
-
}
|
|
1126
|
-
// The sandbox returns the nearest sites within the radius, capped at 30 entries; the
|
|
1127
|
-
// mock synthesizes a deterministic inventory of the same contract.
|
|
1128
|
-
const within = withinRadiusFor(zip, lab.slug, radius);
|
|
1129
|
-
const centerCount = Math.min(within, 30);
|
|
1130
|
-
const centers = Array.from({ length: centerCount }, (_, index) => {
|
|
1131
|
-
const site = pscSiteFor(zip, lab.slug, index, radius);
|
|
1132
|
-
return {
|
|
1133
|
-
metadata: {
|
|
1134
|
-
name: site.name,
|
|
1135
|
-
state: site.state,
|
|
1136
|
-
city: site.city,
|
|
1137
|
-
zip_code: site.zip_code,
|
|
1138
|
-
first_line: site.first_line,
|
|
1139
|
-
second_line: null,
|
|
1140
|
-
phone_number: site.phone_number,
|
|
1141
|
-
fax_number: null,
|
|
1142
|
-
hours: site.hours,
|
|
1143
|
-
},
|
|
1144
|
-
distance: site.distance,
|
|
1145
|
-
site_code: site.site_code,
|
|
1146
|
-
supported_bill_types: [...lab.supported_bill_types],
|
|
1147
|
-
location: site.location,
|
|
1148
|
-
capabilities: [...lab.capabilities],
|
|
1149
|
-
};
|
|
1150
|
-
});
|
|
1151
|
-
return jsonRes(200, {
|
|
1152
|
-
lab_id: labId,
|
|
1153
|
-
slug: lab.slug,
|
|
1154
|
-
patient_service_centers: centers,
|
|
1155
|
-
});
|
|
1156
|
-
},
|
|
1157
|
-
get_phlebotomy_appointment_availability_v3_order_phlebotomy_appointment_availability_post: async (context) => {
|
|
1158
|
-
const hit = cachedResponse(state, context);
|
|
1159
|
-
if (hit)
|
|
1160
|
-
return hit;
|
|
1161
|
-
const body = jsonObject(context);
|
|
1162
|
-
const zip = typeof body.zip_code === "string" ? body.zip_code : "";
|
|
1163
|
-
if (!/^\d{5}$/.test(zip)) {
|
|
1164
|
-
throw new HttpError(422, {
|
|
1165
|
-
detail: [
|
|
1166
|
-
{
|
|
1167
|
-
type: "value_error",
|
|
1168
|
-
loc: ["body", "zip_code"],
|
|
1169
|
-
msg: `Value error, Invalid zip code: ${zip}`,
|
|
1170
|
-
input: body.zip_code,
|
|
1171
|
-
ctx: { error: {} },
|
|
1172
|
-
},
|
|
1173
|
-
],
|
|
1174
|
-
});
|
|
1175
|
-
}
|
|
1176
|
-
if (typeof body.first_line !== "string" || typeof body.city !== "string") {
|
|
1177
|
-
throw new HttpError(422, {
|
|
1178
|
-
detail: [
|
|
1179
|
-
{
|
|
1180
|
-
type: "missing",
|
|
1181
|
-
loc: ["body", "first_line"],
|
|
1182
|
-
msg: "Field required",
|
|
1183
|
-
input: body,
|
|
1184
|
-
},
|
|
1185
|
-
],
|
|
1186
|
-
});
|
|
1187
|
-
}
|
|
1188
|
-
const startDate = startDateOf(context, context.now());
|
|
1189
|
-
const { timezone, days } = generatePhlebotomySlots(state, zip, startDate, "getlabs", context.now());
|
|
1190
|
-
if (days.length === 0)
|
|
1191
|
-
notFound("No availability found");
|
|
1192
|
-
// Align booking-key address/location with the availability request (Vital echoes it on book).
|
|
1193
|
-
rotateGeneratedAvailabilityKeys(state, days, context.now(), {
|
|
1194
|
-
address: addressFromAvailabilityRequest(body, zip),
|
|
1195
|
-
location: zipLocation(zip),
|
|
1196
|
-
zip,
|
|
1197
|
-
});
|
|
1198
|
-
return jsonRes(200, { timezone, slots: days });
|
|
1199
|
-
},
|
|
1200
|
-
get_psc_appointment_availability_v3_order_psc_appointment_availability_post: async (context) => {
|
|
1201
|
-
const hit = cachedResponse(state, context);
|
|
1202
|
-
if (hit)
|
|
1203
|
-
return hit;
|
|
1204
|
-
const lab = context.query.lab;
|
|
1205
|
-
if (lab !== "quest") {
|
|
1206
|
-
throw new HttpError(422, {
|
|
1207
|
-
detail: [
|
|
1208
|
-
{
|
|
1209
|
-
type: "enum",
|
|
1210
|
-
loc: ["query", "lab"],
|
|
1211
|
-
msg: "Input should be 'quest'",
|
|
1212
|
-
input: lab,
|
|
1213
|
-
ctx: { expected: "'quest'" },
|
|
1214
|
-
},
|
|
1215
|
-
],
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
const zip = zipCodeOf(context, false);
|
|
1219
|
-
const startDate = startDateOf(context, context.now());
|
|
1220
|
-
const siteCodesRaw = context.query.site_codes;
|
|
1221
|
-
let siteCodes = null;
|
|
1222
|
-
if (siteCodesRaw !== undefined) {
|
|
1223
|
-
if (typeof siteCodesRaw !== "string") {
|
|
1224
|
-
throw new HttpError(422, { detail: "site_codes must be a JSON array string" });
|
|
1225
|
-
}
|
|
1226
|
-
try {
|
|
1227
|
-
const parsed = JSON.parse(siteCodesRaw);
|
|
1228
|
-
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
|
|
1229
|
-
throw new Error("not an array of strings");
|
|
1230
|
-
}
|
|
1231
|
-
siteCodes = parsed;
|
|
1232
|
-
}
|
|
1233
|
-
catch {
|
|
1234
|
-
throw new HttpError(422, { detail: "site_codes must be a JSON array of strings" });
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
const { timezone, days } = generatePscSlots(state, zip ?? "", startDate, siteCodes, context.now());
|
|
1238
|
-
if (days.length === 0)
|
|
1239
|
-
notFound("No slots found");
|
|
1240
|
-
rotateGeneratedAvailabilityKeys(state, days, context.now());
|
|
1241
|
-
return jsonRes(200, { timezone, slots: days });
|
|
1242
|
-
},
|
|
1243
|
-
get_phlebotomy_appointment_v3_order__order_id__phlebotomy_appointment_get: async (context) => {
|
|
1244
|
-
const orderId = context.params.order_id ?? "";
|
|
1245
|
-
const order = requireOrder(state, orderId, context);
|
|
1246
|
-
const appointment = appointmentOfOrder(state, order.id);
|
|
1247
|
-
if (appointment?.type !== "phlebotomy")
|
|
1248
|
-
notFound("No appointment for this order");
|
|
1249
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1250
|
-
},
|
|
1251
|
-
get_psc_appointment_v3_order__order_id__psc_appointment_get: async (context) => {
|
|
1252
|
-
const orderId = context.params.order_id ?? "";
|
|
1253
|
-
const order = requireOrder(state, orderId, context);
|
|
1254
|
-
const appointment = appointmentOfOrder(state, order.id);
|
|
1255
|
-
if (appointment?.type !== "patient_service_center")
|
|
1256
|
-
notFound("No appointment for this order");
|
|
1257
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1258
|
-
},
|
|
1259
|
-
book_phlebotomy_appointment_v3_order__order_id__phlebotomy_appointment_book_post: async (context) => {
|
|
1260
|
-
const orderId = context.params.order_id ?? "";
|
|
1261
|
-
const order = requireOrder(state, orderId, context);
|
|
1262
|
-
requirePhlebotomyCapableOrder(order);
|
|
1263
|
-
requireOrderHasRequisition(order);
|
|
1264
|
-
const existing = appointmentOfOrder(state, order.id);
|
|
1265
|
-
if (existing && existing.status !== "cancelled") {
|
|
1266
|
-
throw new HttpError(400, { detail: "Appointment already booked for this order" });
|
|
1267
|
-
}
|
|
1268
|
-
const body = jsonObject(context);
|
|
1269
|
-
const bookingKey = typeof body.booking_key === "string" ? body.booking_key : "";
|
|
1270
|
-
const record = bookingKey === "" ? undefined : state.bookingKeys.get(bookingKey);
|
|
1271
|
-
if (record?.modality !== "phlebotomy" ||
|
|
1272
|
-
record.consumed_by_order_id !== null ||
|
|
1273
|
-
(record.expires_at !== null && new Date(record.expires_at).getTime() <= context.now())) {
|
|
1274
|
-
invalidBookingKey();
|
|
1275
|
-
}
|
|
1276
|
-
const nowIso = state.isoNow(context.now);
|
|
1277
|
-
const appointmentId = state.nextAppointmentId();
|
|
1278
|
-
const appointment = {
|
|
1279
|
-
id: appointmentId,
|
|
1280
|
-
order_id: order.id,
|
|
1281
|
-
user_id: order.user_id,
|
|
1282
|
-
provider_id: state.appointmentProviderIdFor(`${order.id}:${bookingKey}`),
|
|
1283
|
-
external_id: `getlabs-${state.appointmentProviderIdFor(orderId)}`,
|
|
1284
|
-
type: "phlebotomy",
|
|
1285
|
-
provider: record.provider,
|
|
1286
|
-
// Vital quirk: top-level status + events[0].status are pending, but event_status is scheduled.
|
|
1287
|
-
status: "pending",
|
|
1288
|
-
event_status: "scheduled",
|
|
1289
|
-
start_at: record.start,
|
|
1290
|
-
end_at: record.end,
|
|
1291
|
-
iana_timezone: zipTimezone(record.zip_code),
|
|
1292
|
-
address: record.address,
|
|
1293
|
-
location: record.location,
|
|
1294
|
-
can_reschedule: true,
|
|
1295
|
-
booking_key: record.key,
|
|
1296
|
-
site_code: null,
|
|
1297
|
-
appointment_notes: null,
|
|
1298
|
-
order_transaction_id: order.order_transaction.id,
|
|
1299
|
-
event_data: null,
|
|
1300
|
-
events: [],
|
|
1301
|
-
created_at: nowIso,
|
|
1302
|
-
updated_at: nowIso,
|
|
1303
|
-
};
|
|
1304
|
-
appendAppointmentEvent(appointment, "pending", nowIso);
|
|
1305
|
-
appointment.event_status = "scheduled";
|
|
1306
|
-
state.appointments.insert(appointmentId, appointment);
|
|
1307
|
-
state.appointmentsByOrder.insert(order.id, { appointment_id: appointmentId });
|
|
1308
|
-
record.consumed_by_order_id = order.id;
|
|
1309
|
-
state.bookingKeys.update(record.key, record);
|
|
1310
|
-
linkAppointmentToOrder(state, order, appointment, SCHEDULED_ORDER_EVENT.phlebotomy, nowIso);
|
|
1311
|
-
state.publishAppointmentWebhook(appointment, order.team_id, context.now());
|
|
1312
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1313
|
-
},
|
|
1314
|
-
book_psc_appointment_v3_order__order_id__psc_appointment_book_post: async (context) => {
|
|
1315
|
-
const orderId = context.params.order_id ?? "";
|
|
1316
|
-
const order = requireOrder(state, orderId, context);
|
|
1317
|
-
requirePscCapableOrder(order);
|
|
1318
|
-
requireOrderHasRequisition(order);
|
|
1319
|
-
const idempotencyKey = context.request.headers.get("x-idempotency-key");
|
|
1320
|
-
if (idempotencyKey !== null && idempotencyKey !== "") {
|
|
1321
|
-
const replay = state.cancelIdempotency.get(`psc-book:${idempotencyKey}`);
|
|
1322
|
-
if (replay)
|
|
1323
|
-
return jsonRes(200, replay.response);
|
|
1324
|
-
}
|
|
1325
|
-
const existing = appointmentOfOrder(state, order.id);
|
|
1326
|
-
if (existing && existing.status !== "cancelled") {
|
|
1327
|
-
throw new HttpError(400, { detail: "Appointment already booked for this order" });
|
|
1328
|
-
}
|
|
1329
|
-
const body = jsonObject(context);
|
|
1330
|
-
const bookingKey = typeof body.booking_key === "string" ? body.booking_key : "";
|
|
1331
|
-
const siteCode = typeof body.site_code === "string" ? body.site_code : "";
|
|
1332
|
-
if (siteCode === "") {
|
|
1333
|
-
throw new HttpError(400, { detail: "site_code is required for PSC appointments" });
|
|
1334
|
-
}
|
|
1335
|
-
const record = bookingKey === "" ? undefined : state.bookingKeys.get(bookingKey);
|
|
1336
|
-
if (record === undefined ||
|
|
1337
|
-
(record.modality === "patient_service_center" &&
|
|
1338
|
-
record.consumed_by_order_id !== null &&
|
|
1339
|
-
record.consumed_by_order_id !== order.id) ||
|
|
1340
|
-
(record.expires_at !== null && new Date(record.expires_at).getTime() <= context.now())) {
|
|
1341
|
-
invalidBookingKey();
|
|
1342
|
-
}
|
|
1343
|
-
if (record.modality !== "patient_service_center") {
|
|
1344
|
-
throw new HttpError(400, { detail: "Booking key is not a patient service center slot" });
|
|
1345
|
-
}
|
|
1346
|
-
if (record.site_code !== null && record.site_code !== siteCode) {
|
|
1347
|
-
throw new HttpError(400, { detail: "Booking key does not match the requested site" });
|
|
1348
|
-
}
|
|
1349
|
-
const nowIso = state.isoNow(context.now);
|
|
1350
|
-
const appointmentId = state.nextAppointmentId();
|
|
1351
|
-
const appointment = {
|
|
1352
|
-
id: appointmentId,
|
|
1353
|
-
order_id: order.id,
|
|
1354
|
-
user_id: order.user_id,
|
|
1355
|
-
provider_id: state.appointmentProviderIdFor(`${order.id}:${record.key}`),
|
|
1356
|
-
external_id: `quest-${state.appointmentProviderIdFor(orderId)}`,
|
|
1357
|
-
type: "patient_service_center",
|
|
1358
|
-
provider: "quest",
|
|
1359
|
-
status: "confirmed",
|
|
1360
|
-
event_status: "scheduled",
|
|
1361
|
-
start_at: record.start,
|
|
1362
|
-
end_at: record.end,
|
|
1363
|
-
iana_timezone: zipTimezone(record.zip_code),
|
|
1364
|
-
address: record.address,
|
|
1365
|
-
location: record.location,
|
|
1366
|
-
can_reschedule: true,
|
|
1367
|
-
booking_key: record.key,
|
|
1368
|
-
site_code: siteCode,
|
|
1369
|
-
appointment_notes: null,
|
|
1370
|
-
order_transaction_id: order.order_transaction.id,
|
|
1371
|
-
event_data: null,
|
|
1372
|
-
events: [],
|
|
1373
|
-
created_at: nowIso,
|
|
1374
|
-
updated_at: nowIso,
|
|
1375
|
-
};
|
|
1376
|
-
appendAppointmentEvent(appointment, "scheduled", nowIso);
|
|
1377
|
-
state.appointments.insert(appointmentId, appointment);
|
|
1378
|
-
state.appointmentsByOrder.insert(order.id, { appointment_id: appointmentId });
|
|
1379
|
-
record.consumed_by_order_id = order.id;
|
|
1380
|
-
state.bookingKeys.update(record.key, record);
|
|
1381
|
-
linkAppointmentToOrder(state, order, appointment, SCHEDULED_ORDER_EVENT.patient_service_center, nowIso);
|
|
1382
|
-
state.publishAppointmentWebhook(appointment, order.team_id, context.now());
|
|
1383
|
-
const response = renderAppointment(appointment);
|
|
1384
|
-
if (idempotencyKey !== null && idempotencyKey !== "") {
|
|
1385
|
-
state.cancelIdempotency.insert(`psc-book:${idempotencyKey}`, {
|
|
1386
|
-
order_id: order.id,
|
|
1387
|
-
response,
|
|
1388
|
-
});
|
|
1389
|
-
}
|
|
1390
|
-
return jsonRes(200, response);
|
|
1391
|
-
},
|
|
1392
|
-
reschedule_phlebotomy_appointment_v3_order__order_id__phlebotomy_appointment_reschedule_patch: async (context) => {
|
|
1393
|
-
const orderId = context.params.order_id ?? "";
|
|
1394
|
-
const order = requireOrder(state, orderId, context);
|
|
1395
|
-
const body = jsonObject(context);
|
|
1396
|
-
const bookingKey = typeof body.booking_key === "string" ? body.booking_key : "";
|
|
1397
|
-
const appointment = appointmentOfOrder(state, order.id);
|
|
1398
|
-
if (appointment?.type !== "phlebotomy")
|
|
1399
|
-
notFound("No appointment for this order");
|
|
1400
|
-
if (appointment.status === "cancelled")
|
|
1401
|
-
throw new HttpError(400, { detail: "Cannot reschedule a cancelled appointment" });
|
|
1402
|
-
const record = bookingKey === "" ? undefined : state.bookingKeys.get(bookingKey);
|
|
1403
|
-
if (record === undefined ||
|
|
1404
|
-
record.modality !== "phlebotomy" ||
|
|
1405
|
-
(record.consumed_by_order_id !== null && record.consumed_by_order_id !== order.id) ||
|
|
1406
|
-
(record.expires_at !== null && new Date(record.expires_at).getTime() <= context.now())) {
|
|
1407
|
-
invalidBookingKey();
|
|
1408
|
-
}
|
|
1409
|
-
const nowIso = state.isoNow(context.now);
|
|
1410
|
-
appointment.start_at = record.start;
|
|
1411
|
-
appointment.end_at = record.end;
|
|
1412
|
-
appointment.iana_timezone = zipTimezone(record.zip_code);
|
|
1413
|
-
appointment.address = record.address;
|
|
1414
|
-
appointment.location = record.location;
|
|
1415
|
-
appointment.booking_key = record.key;
|
|
1416
|
-
appendAppointmentEvent(appointment, "scheduled", nowIso);
|
|
1417
|
-
state.appointments.update(appointment.id, appointment);
|
|
1418
|
-
record.consumed_by_order_id = order.id;
|
|
1419
|
-
state.bookingKeys.update(record.key, record);
|
|
1420
|
-
state.publishAppointmentWebhook(appointment, order.team_id, context.now());
|
|
1421
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1422
|
-
},
|
|
1423
|
-
reschedule_psc_appointment_v3_order__order_id__psc_appointment_reschedule_patch: async (context) => {
|
|
1424
|
-
const orderId = context.params.order_id ?? "";
|
|
1425
|
-
const order = requireOrder(state, orderId, context);
|
|
1426
|
-
const body = jsonObject(context);
|
|
1427
|
-
const bookingKey = typeof body.booking_key === "string" ? body.booking_key : "";
|
|
1428
|
-
const appointment = appointmentOfOrder(state, order.id);
|
|
1429
|
-
if (appointment?.type !== "patient_service_center")
|
|
1430
|
-
notFound("No appointment for this order");
|
|
1431
|
-
if (appointment.status === "cancelled")
|
|
1432
|
-
throw new HttpError(400, { detail: "Cannot reschedule a cancelled appointment" });
|
|
1433
|
-
const record = bookingKey === "" ? undefined : state.bookingKeys.get(bookingKey);
|
|
1434
|
-
if (record === undefined ||
|
|
1435
|
-
record.modality !== "patient_service_center" ||
|
|
1436
|
-
(record.consumed_by_order_id !== null && record.consumed_by_order_id !== order.id) ||
|
|
1437
|
-
(record.expires_at !== null && new Date(record.expires_at).getTime() <= context.now())) {
|
|
1438
|
-
invalidBookingKey();
|
|
1439
|
-
}
|
|
1440
|
-
if (record.site_code !== null && appointment.site_code !== record.site_code) {
|
|
1441
|
-
throw new HttpError(400, { detail: "Booking key does not match the appointment site" });
|
|
1442
|
-
}
|
|
1443
|
-
const nowIso = state.isoNow(context.now);
|
|
1444
|
-
appointment.start_at = record.start;
|
|
1445
|
-
appointment.end_at = record.end;
|
|
1446
|
-
appointment.iana_timezone = zipTimezone(record.zip_code);
|
|
1447
|
-
appointment.address = record.address;
|
|
1448
|
-
appointment.location = record.location;
|
|
1449
|
-
appointment.booking_key = record.key;
|
|
1450
|
-
appendAppointmentEvent(appointment, "scheduled", nowIso);
|
|
1451
|
-
state.appointments.update(appointment.id, appointment);
|
|
1452
|
-
record.consumed_by_order_id = order.id;
|
|
1453
|
-
state.bookingKeys.update(record.key, record);
|
|
1454
|
-
state.publishAppointmentWebhook(appointment, order.team_id, context.now());
|
|
1455
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1456
|
-
},
|
|
1457
|
-
cancel_phlebotomy_appointment_v3_order__order_id__phlebotomy_appointment_cancel_patch: async (context) => {
|
|
1458
|
-
const orderId = context.params.order_id ?? "";
|
|
1459
|
-
const order = requireOrder(state, orderId, context);
|
|
1460
|
-
const body = jsonObject(context);
|
|
1461
|
-
const reasonId = typeof body.cancellation_reason_id === "string" ? body.cancellation_reason_id : "";
|
|
1462
|
-
const appointment = appointmentOfOrder(state, order.id);
|
|
1463
|
-
if (appointment?.type !== "phlebotomy")
|
|
1464
|
-
notFound("No appointment for this order");
|
|
1465
|
-
const reason = CANCELLATION_REASONS.find((entry) => entry.id === reasonId);
|
|
1466
|
-
if (!reason) {
|
|
1467
|
-
throw new HttpError(400, { detail: "Invalid cancellation reason id" });
|
|
1468
|
-
}
|
|
1469
|
-
if (reasonId === OTHER_CANCELLATION_REASON_ID && typeof body.notes !== "string") {
|
|
1470
|
-
throw new HttpError(400, { detail: "notes is required for reason 'Other'" });
|
|
1471
|
-
}
|
|
1472
|
-
if (appointment.status === "cancelled")
|
|
1473
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1474
|
-
appointment.appointment_notes = typeof body.notes === "string" ? body.notes : null;
|
|
1475
|
-
cancelAppointment(state, appointment, order, context.now());
|
|
1476
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1477
|
-
},
|
|
1478
|
-
cancel_psc_appointment_v3_order__order_id__psc_appointment_cancel_patch: async (context) => {
|
|
1479
|
-
const orderId = context.params.order_id ?? "";
|
|
1480
|
-
const order = requireOrder(state, orderId, context);
|
|
1481
|
-
const body = jsonObject(context);
|
|
1482
|
-
const reasonId = typeof body.cancellationReasonId === "string" ? body.cancellationReasonId : "";
|
|
1483
|
-
const appointment = appointmentOfOrder(state, order.id);
|
|
1484
|
-
if (appointment?.type !== "patient_service_center")
|
|
1485
|
-
notFound("No appointment for this order");
|
|
1486
|
-
const reason = PSC_CANCELLATION_REASONS.find((entry) => entry.id === reasonId);
|
|
1487
|
-
if (!reason) {
|
|
1488
|
-
throw new HttpError(400, { detail: "Invalid cancellation reason id" });
|
|
1489
|
-
}
|
|
1490
|
-
if (appointment.status === "cancelled")
|
|
1491
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1492
|
-
appointment.appointment_notes = typeof body.note === "string" ? body.note : null;
|
|
1493
|
-
cancelAppointment(state, appointment, order, context.now());
|
|
1494
|
-
return jsonRes(200, renderAppointment(appointment));
|
|
1495
|
-
},
|
|
1496
|
-
});
|
|
1497
|
-
/** Cancels any active appointment linked to `orderId`; used by order cancellation cascade. */
|
|
1498
|
-
export const cascadeCancelAppointments = (state, orderId, nowMs) => {
|
|
1499
|
-
const appointment = appointmentOfOrder(state, orderId);
|
|
1500
|
-
if (!appointment || appointment.status === "cancelled")
|
|
1501
|
-
return;
|
|
1502
|
-
const order = state.orders.get(orderId);
|
|
1503
|
-
if (!order)
|
|
1504
|
-
return;
|
|
1505
|
-
cancelAppointment(state, appointment, order, nowMs);
|
|
1506
|
-
};
|
|
1507
|
-
export { applySimulateTransition, appointmentOfOrder, renderAppointment, requireOrder };
|
|
1508
|
-
//# sourceMappingURL=scheduling.js.map
|