@astermd-hq/sdk 0.0.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/LICENSE +21 -0
- package/README.md +253 -0
- package/dist/index.cjs +3338 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2825 -0
- package/dist/index.d.ts +2825 -0
- package/dist/index.js +3298 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,3338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var promises = require('fs/promises');
|
|
4
|
+
var path = require('path');
|
|
5
|
+
var crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
// src/auth/in-memory-token-store.ts
|
|
8
|
+
var InMemoryTokenStore = class {
|
|
9
|
+
#token = null;
|
|
10
|
+
/**
|
|
11
|
+
* Returns the token held in memory, or `null` before the first exchange.
|
|
12
|
+
*
|
|
13
|
+
* @returns The cached token, or `null`.
|
|
14
|
+
*/
|
|
15
|
+
get() {
|
|
16
|
+
return Promise.resolve(this.#token);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Replaces the token held in memory.
|
|
20
|
+
*
|
|
21
|
+
* @param token The token to cache.
|
|
22
|
+
*/
|
|
23
|
+
put(token) {
|
|
24
|
+
this.#token = token;
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Drops the token held in memory.
|
|
29
|
+
*/
|
|
30
|
+
clear() {
|
|
31
|
+
this.#token = null;
|
|
32
|
+
return Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/errors/astermd-error.ts
|
|
37
|
+
var AsterMDError = class extends Error {
|
|
38
|
+
constructor(message, options) {
|
|
39
|
+
super(message, options);
|
|
40
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
41
|
+
this.name = "AsterMDError";
|
|
42
|
+
if (typeof Error.captureStackTrace === "function") {
|
|
43
|
+
Error.captureStackTrace(this, new.target);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/errors/api-error.ts
|
|
49
|
+
var ApiError = class extends AsterMDError {
|
|
50
|
+
#statusCode;
|
|
51
|
+
#envelope;
|
|
52
|
+
/**
|
|
53
|
+
* @param message Human-readable message, taken from the envelope's `message`
|
|
54
|
+
* field when the server sent one, otherwise `HTTP {status}`.
|
|
55
|
+
* @param statusCode The HTTP status code the server returned.
|
|
56
|
+
* @param envelope The decoded response body. Copied and frozen, so a later
|
|
57
|
+
* mutation of the caller's object cannot change the error.
|
|
58
|
+
*/
|
|
59
|
+
constructor(message, statusCode, envelope) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "ApiError";
|
|
62
|
+
this.#statusCode = statusCode;
|
|
63
|
+
this.#envelope = Object.freeze({ ...envelope });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Returns the HTTP status code that caused this error.
|
|
67
|
+
*
|
|
68
|
+
* @returns The status code, e.g. `404` or `500`.
|
|
69
|
+
*/
|
|
70
|
+
statusCode() {
|
|
71
|
+
return this.#statusCode;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Returns the decoded response envelope as the server sent it.
|
|
75
|
+
*
|
|
76
|
+
* Useful for surfacing a server-supplied message or an error code the SDK does
|
|
77
|
+
* not model. The object is frozen; treat it as read-only.
|
|
78
|
+
*
|
|
79
|
+
* @returns The decoded body, or an empty object when the response had none.
|
|
80
|
+
*/
|
|
81
|
+
envelope() {
|
|
82
|
+
return this.#envelope;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// src/errors/authentication-error.ts
|
|
87
|
+
var AuthenticationError = class extends ApiError {
|
|
88
|
+
constructor(message, statusCode, envelope) {
|
|
89
|
+
super(message, statusCode, envelope);
|
|
90
|
+
this.name = "AuthenticationError";
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// src/errors/not-found-error.ts
|
|
95
|
+
var NotFoundError = class extends ApiError {
|
|
96
|
+
constructor(message, statusCode, envelope) {
|
|
97
|
+
super(message, statusCode, envelope);
|
|
98
|
+
this.name = "NotFoundError";
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// src/errors/rate-limit-error.ts
|
|
103
|
+
var RateLimitError = class extends ApiError {
|
|
104
|
+
#retryAfter;
|
|
105
|
+
/**
|
|
106
|
+
* @param message Human-readable summary from the envelope.
|
|
107
|
+
* @param statusCode Always 429 in practice.
|
|
108
|
+
* @param envelope The decoded response body.
|
|
109
|
+
* @param retryAfter Seconds to wait, from the `Retry-After` header, or `null`
|
|
110
|
+
* when the header was absent or not a plain integer.
|
|
111
|
+
*/
|
|
112
|
+
constructor(message, statusCode, envelope, retryAfter2) {
|
|
113
|
+
super(message, statusCode, envelope);
|
|
114
|
+
this.name = "RateLimitError";
|
|
115
|
+
this.#retryAfter = retryAfter2;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Returns the server-advised wait in seconds before retrying.
|
|
119
|
+
*
|
|
120
|
+
* @returns Seconds to wait, or `null` when the server did not say.
|
|
121
|
+
*/
|
|
122
|
+
retryAfter() {
|
|
123
|
+
return this.#retryAfter;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/errors/transport-error.ts
|
|
128
|
+
var TransportError = class extends AsterMDError {
|
|
129
|
+
constructor(message, options) {
|
|
130
|
+
super(message, options);
|
|
131
|
+
this.name = "TransportError";
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// src/errors/validation-error.ts
|
|
136
|
+
var ValidationError = class extends ApiError {
|
|
137
|
+
#fieldErrors;
|
|
138
|
+
/**
|
|
139
|
+
* @param message Human-readable summary from the envelope.
|
|
140
|
+
* @param statusCode Always 422 in practice.
|
|
141
|
+
* @param envelope The decoded response body.
|
|
142
|
+
* @param fieldErrors Field name → list of messages, extracted from the
|
|
143
|
+
* envelope's `errors` key by {@link Transport}.
|
|
144
|
+
*/
|
|
145
|
+
constructor(message, statusCode, envelope, fieldErrors2) {
|
|
146
|
+
super(message, statusCode, envelope);
|
|
147
|
+
this.name = "ValidationError";
|
|
148
|
+
this.#fieldErrors = Object.freeze({ ...fieldErrors2 });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Returns the per-field validation messages the server produced.
|
|
152
|
+
*
|
|
153
|
+
* @returns Field name → messages. Empty when the server sent no `errors` key.
|
|
154
|
+
*/
|
|
155
|
+
fieldErrors() {
|
|
156
|
+
return this.#fieldErrors;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// src/response.ts
|
|
161
|
+
var Response2 = class {
|
|
162
|
+
#statusCode;
|
|
163
|
+
#data;
|
|
164
|
+
#meta;
|
|
165
|
+
#message;
|
|
166
|
+
#raw;
|
|
167
|
+
/**
|
|
168
|
+
* @param options The decoded envelope fields.
|
|
169
|
+
*/
|
|
170
|
+
constructor(options) {
|
|
171
|
+
this.#statusCode = options.statusCode;
|
|
172
|
+
this.#data = Object.freeze(Array.isArray(options.data) ? [...options.data] : { ...options.data });
|
|
173
|
+
this.#meta = Object.freeze({ ...options.meta });
|
|
174
|
+
this.#message = options.message;
|
|
175
|
+
this.#raw = options.raw;
|
|
176
|
+
Object.freeze(this);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Returns the HTTP status code, always 200–299 for a `Response` instance.
|
|
180
|
+
*
|
|
181
|
+
* @returns The status code, e.g. `200` or `201`.
|
|
182
|
+
*/
|
|
183
|
+
statusCode() {
|
|
184
|
+
return this.#statusCode;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Returns the primary resource payload from the envelope's `data` key.
|
|
188
|
+
*
|
|
189
|
+
* The shape varies by endpoint - see the TSDoc on the resource method you
|
|
190
|
+
* called. Empty when the server returned no data.
|
|
191
|
+
*
|
|
192
|
+
* @returns The decoded `data` object.
|
|
193
|
+
*/
|
|
194
|
+
data() {
|
|
195
|
+
return this.#data;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Returns the envelope's `meta` key, typically pagination information.
|
|
199
|
+
*
|
|
200
|
+
* Present on paginated list endpoints such as `products/list` and
|
|
201
|
+
* `treatments/list`, where it may carry `total`, `per_page`, `current_page`,
|
|
202
|
+
* and `last_page`. Empty for endpoints that emit no `meta` block.
|
|
203
|
+
*
|
|
204
|
+
* @returns The decoded `meta` object.
|
|
205
|
+
*/
|
|
206
|
+
meta() {
|
|
207
|
+
return this.#meta;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Returns the human-readable `message` string from the envelope.
|
|
211
|
+
*
|
|
212
|
+
* Usually a short confirmation such as `"Session created."`. Empty when the
|
|
213
|
+
* server omitted the field.
|
|
214
|
+
*
|
|
215
|
+
* @returns The envelope message, possibly empty.
|
|
216
|
+
*/
|
|
217
|
+
message() {
|
|
218
|
+
return this.#message;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Returns the raw response body exactly as received, before decoding.
|
|
222
|
+
*
|
|
223
|
+
* Useful for low-level debugging. Do not log this value for endpoints carrying
|
|
224
|
+
* PHI, such as `patients/health-information`.
|
|
225
|
+
*
|
|
226
|
+
* @returns The raw JSON body.
|
|
227
|
+
*/
|
|
228
|
+
raw() {
|
|
229
|
+
return this.#raw;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/http/transport.ts
|
|
234
|
+
var Transport = class {
|
|
235
|
+
#httpClient;
|
|
236
|
+
#urlBuilder;
|
|
237
|
+
#tokenProvider;
|
|
238
|
+
#onUnauthorized;
|
|
239
|
+
/**
|
|
240
|
+
* @param options The collaborators to use for every call.
|
|
241
|
+
*/
|
|
242
|
+
constructor(options) {
|
|
243
|
+
this.#httpClient = options.httpClient;
|
|
244
|
+
this.#urlBuilder = options.urlBuilder;
|
|
245
|
+
this.#tokenProvider = options.tokenProvider;
|
|
246
|
+
this.#onUnauthorized = options.onUnauthorized;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Dispatches one API call and returns the decoded success envelope.
|
|
250
|
+
*
|
|
251
|
+
* On a 401 the token is refreshed and the call is replayed once before
|
|
252
|
+
* {@link AuthenticationError} is raised.
|
|
253
|
+
*
|
|
254
|
+
* @param options The call to make.
|
|
255
|
+
* @returns The decoded envelope; `data()` holds the resource payload.
|
|
256
|
+
* @throws {TypeError} If both `body` and `file` are supplied.
|
|
257
|
+
* @throws {AuthenticationError} On a 401 that a token refresh did not recover.
|
|
258
|
+
* @throws {NotFoundError} On a 404.
|
|
259
|
+
* @throws {ValidationError} On a 422; inspect `fieldErrors()`.
|
|
260
|
+
* @throws {RateLimitError} On a 429; inspect `retryAfter()`.
|
|
261
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
262
|
+
* @throws {TransportError} If the request never completed.
|
|
263
|
+
*/
|
|
264
|
+
async send(options) {
|
|
265
|
+
if (options.body != null && options.file != null) {
|
|
266
|
+
throw new TypeError("A request carries either a JSON body or a file upload, not both.");
|
|
267
|
+
}
|
|
268
|
+
let response = await this.#dispatch(options);
|
|
269
|
+
if (response.status === 401 && await this.#onUnauthorized()) {
|
|
270
|
+
void response.body?.cancel();
|
|
271
|
+
response = await this.#dispatch(options);
|
|
272
|
+
}
|
|
273
|
+
return await this.#handle(response);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Builds and sends one attempt.
|
|
277
|
+
*
|
|
278
|
+
* A fresh `Request` is constructed on every call rather than reused across the
|
|
279
|
+
* 401 replay. A request body is a single-use stream: sending an already-sent
|
|
280
|
+
* `Request` again transmits an empty body, silently. Keeping the description
|
|
281
|
+
* and materialising the request per attempt is what makes the replay correct.
|
|
282
|
+
*/
|
|
283
|
+
async #dispatch(options) {
|
|
284
|
+
const url = this.#urlBuilder.build(options.service, options.path, options.pathParams ?? {}, options.query ?? {});
|
|
285
|
+
const headers = new Headers({ Accept: "application/json" });
|
|
286
|
+
const token = await this.#tokenProvider();
|
|
287
|
+
if (token !== null) {
|
|
288
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
289
|
+
}
|
|
290
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
291
|
+
headers.set(name, value);
|
|
292
|
+
}
|
|
293
|
+
let body = null;
|
|
294
|
+
if (options.body != null) {
|
|
295
|
+
headers.set("Content-Type", "application/json");
|
|
296
|
+
body = JSON.stringify(options.body);
|
|
297
|
+
} else if (options.file != null) {
|
|
298
|
+
const form = new FormData();
|
|
299
|
+
form.append(
|
|
300
|
+
"file",
|
|
301
|
+
new Blob([options.file.contents()], { type: options.file.mimeType() }),
|
|
302
|
+
options.file.fileName()
|
|
303
|
+
);
|
|
304
|
+
body = form;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
return await this.#httpClient.sendRequest(new Request(url, { method: options.method, headers, body }));
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof AsterMDError) {
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
313
|
+
throw new TransportError(`Request to ${url} failed: ${detail}`, {
|
|
314
|
+
cause: error
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async #handle(response) {
|
|
319
|
+
let raw;
|
|
320
|
+
try {
|
|
321
|
+
raw = await response.text();
|
|
322
|
+
} catch (error) {
|
|
323
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
324
|
+
throw new TransportError(`Reading the response body failed: ${detail}`, {
|
|
325
|
+
cause: error
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const envelope = decode(raw);
|
|
329
|
+
const status = response.status;
|
|
330
|
+
if (status >= 200 && status < 300) {
|
|
331
|
+
return new Response2({
|
|
332
|
+
statusCode: status,
|
|
333
|
+
data: objectField(envelope, "data"),
|
|
334
|
+
meta: objectField(envelope, "meta"),
|
|
335
|
+
message: typeof envelope.message === "string" ? envelope.message : "",
|
|
336
|
+
raw
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const message = typeof envelope.message === "string" ? envelope.message : `HTTP ${status}`;
|
|
340
|
+
switch (status) {
|
|
341
|
+
case 401:
|
|
342
|
+
throw new AuthenticationError(message, status, envelope);
|
|
343
|
+
case 404:
|
|
344
|
+
throw new NotFoundError(message, status, envelope);
|
|
345
|
+
case 422:
|
|
346
|
+
throw new ValidationError(message, status, envelope, fieldErrors(envelope));
|
|
347
|
+
case 429:
|
|
348
|
+
throw new RateLimitError(message, status, envelope, retryAfter(response));
|
|
349
|
+
default:
|
|
350
|
+
throw new ApiError(message, status, envelope);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
function decode(raw) {
|
|
355
|
+
if (raw === "") {
|
|
356
|
+
return {};
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const decoded = JSON.parse(raw);
|
|
360
|
+
return typeof decoded === "object" && decoded !== null ? decoded : {};
|
|
361
|
+
} catch {
|
|
362
|
+
return {};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function objectField(envelope, key) {
|
|
366
|
+
const value = envelope[key];
|
|
367
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
368
|
+
}
|
|
369
|
+
function fieldErrors(envelope) {
|
|
370
|
+
const errors = envelope.errors;
|
|
371
|
+
if (typeof errors !== "object" || errors === null || Array.isArray(errors)) {
|
|
372
|
+
return {};
|
|
373
|
+
}
|
|
374
|
+
const out = {};
|
|
375
|
+
for (const [field, messages] of Object.entries(errors)) {
|
|
376
|
+
if (Array.isArray(messages)) {
|
|
377
|
+
out[field] = messages.filter((message) => typeof message === "string");
|
|
378
|
+
} else if (typeof messages === "string") {
|
|
379
|
+
out[field] = [messages];
|
|
380
|
+
} else if (typeof messages === "object" && messages !== null) {
|
|
381
|
+
out[field] = Object.values(messages).filter((message) => typeof message === "string");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
function retryAfter(response) {
|
|
387
|
+
const header = response.headers.get("Retry-After");
|
|
388
|
+
return header !== null && /^\d+$/.test(header) ? Number(header) : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/auth/token.ts
|
|
392
|
+
var Token = class {
|
|
393
|
+
#value;
|
|
394
|
+
#expiresAtMs;
|
|
395
|
+
/**
|
|
396
|
+
* @param value The raw JWT string, to be sent as `Authorization: Bearer <value>`.
|
|
397
|
+
* @param expiresAt Expiry as declared by the token response's
|
|
398
|
+
* `access_token_expiry` field. Copied, so a later mutation of
|
|
399
|
+
* the caller's `Date` cannot change the token.
|
|
400
|
+
*/
|
|
401
|
+
constructor(value, expiresAt) {
|
|
402
|
+
this.#value = value;
|
|
403
|
+
this.#expiresAtMs = expiresAt.getTime();
|
|
404
|
+
Object.freeze(this);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Returns the raw JWT string.
|
|
408
|
+
*
|
|
409
|
+
* @returns The JWT, ready for use as a bearer credential.
|
|
410
|
+
*/
|
|
411
|
+
value() {
|
|
412
|
+
return this.#value;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Returns the server-stated expiry.
|
|
416
|
+
*
|
|
417
|
+
* A fresh `Date` each call, so callers cannot mutate the token's state. Note
|
|
418
|
+
* that the SDK treats the token as expired before this moment - see
|
|
419
|
+
* {@link Token.isExpired}.
|
|
420
|
+
*
|
|
421
|
+
* @returns The expiry as declared in the token response.
|
|
422
|
+
*/
|
|
423
|
+
expiresAt() {
|
|
424
|
+
return new Date(this.#expiresAtMs);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Reports whether this token should be considered expired and replaced.
|
|
428
|
+
*
|
|
429
|
+
* The pre-buffer avoids a race where the token is valid when checked but
|
|
430
|
+
* expires in transit before the server processes the request. The default 30
|
|
431
|
+
* seconds means the SDK only trusts a token with at least half a minute of
|
|
432
|
+
* validity left. Pass a custom value only when you know something specific
|
|
433
|
+
* about clock skew in your environment.
|
|
434
|
+
*
|
|
435
|
+
* @param preBufferSeconds Seconds to subtract from the stated expiry when
|
|
436
|
+
* computing the effective cutoff.
|
|
437
|
+
* @returns `true` when the token is at or past its effective expiry.
|
|
438
|
+
*/
|
|
439
|
+
isExpired(preBufferSeconds = 30) {
|
|
440
|
+
return Date.now() >= this.#expiresAtMs - preBufferSeconds * 1e3;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// src/auth/token-manager.ts
|
|
445
|
+
var TokenManager = class {
|
|
446
|
+
#config;
|
|
447
|
+
#store;
|
|
448
|
+
#expiryPreBufferSeconds;
|
|
449
|
+
#tokenTransport;
|
|
450
|
+
#inFlight = null;
|
|
451
|
+
/**
|
|
452
|
+
* @param options The collaborators and tuning to use.
|
|
453
|
+
*/
|
|
454
|
+
constructor(options) {
|
|
455
|
+
this.#config = options.config;
|
|
456
|
+
this.#store = options.store ?? new InMemoryTokenStore();
|
|
457
|
+
this.#expiryPreBufferSeconds = options.expiryPreBufferSeconds ?? 30;
|
|
458
|
+
this.#tokenTransport = new Transport({
|
|
459
|
+
httpClient: options.httpClient,
|
|
460
|
+
urlBuilder: options.urlBuilder,
|
|
461
|
+
tokenProvider: () => Promise.resolve(null),
|
|
462
|
+
onUnauthorized: () => Promise.resolve(false)
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Returns a valid bearer token, acquiring one if the cache cannot serve it.
|
|
467
|
+
*
|
|
468
|
+
* A cached, unexpired token is returned with no HTTP call. Otherwise the
|
|
469
|
+
* credential exchange runs and its result is stored before being returned. Two
|
|
470
|
+
* concurrent calls that both miss the cache share a single exchange.
|
|
471
|
+
*
|
|
472
|
+
* @returns The raw JWT, ready for `Authorization: Bearer <value>`.
|
|
473
|
+
* @throws {AuthenticationError} If the credentials are refused, or if the token
|
|
474
|
+
* response omits `access_token` or `access_token_expiry`.
|
|
475
|
+
* @throws {TransportError} If the exchange never completed.
|
|
476
|
+
*/
|
|
477
|
+
async bearerToken() {
|
|
478
|
+
const cached = await this.#store.get();
|
|
479
|
+
if (cached !== null && !cached.isExpired(this.#expiryPreBufferSeconds)) {
|
|
480
|
+
return cached.value();
|
|
481
|
+
}
|
|
482
|
+
return (await this.#acquire()).value();
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Discards the cached token, acquires a fresh one, and reports `true`.
|
|
486
|
+
*
|
|
487
|
+
* Always resolves `true` on success so it can be wired straight to
|
|
488
|
+
* {@link Transport}'s `onUnauthorized` hook, where `true` means "token
|
|
489
|
+
* refreshed, replay the request once". A second 401 on that replay raises
|
|
490
|
+
* {@link AuthenticationError} without calling this again.
|
|
491
|
+
*
|
|
492
|
+
* @returns `true`.
|
|
493
|
+
* @throws {AuthenticationError} If the fresh acquisition fails.
|
|
494
|
+
* @throws {TransportError} If the exchange never completed.
|
|
495
|
+
*/
|
|
496
|
+
async refresh() {
|
|
497
|
+
await this.#store.clear();
|
|
498
|
+
await this.#acquire();
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Runs the credential exchange, sharing one in-flight attempt across callers.
|
|
503
|
+
*
|
|
504
|
+
* The in-flight promise is cleared in a `finally`, so a failed exchange is
|
|
505
|
+
* never cached - the next caller gets a fresh attempt rather than the stored
|
|
506
|
+
* rejection.
|
|
507
|
+
*/
|
|
508
|
+
async #acquire() {
|
|
509
|
+
this.#inFlight ??= this.#exchange().finally(() => {
|
|
510
|
+
this.#inFlight = null;
|
|
511
|
+
});
|
|
512
|
+
return await this.#inFlight;
|
|
513
|
+
}
|
|
514
|
+
async #exchange() {
|
|
515
|
+
const response = await this.#tokenTransport.send({
|
|
516
|
+
service: "auth",
|
|
517
|
+
method: "POST",
|
|
518
|
+
path: "/api-credentials/token",
|
|
519
|
+
body: {
|
|
520
|
+
client_id: this.#config.clientId(),
|
|
521
|
+
client_secret: this.#config.clientSecret()
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
const data = response.data();
|
|
525
|
+
const value = data.access_token;
|
|
526
|
+
const expiry = data.access_token_expiry;
|
|
527
|
+
if (typeof value !== "string" || typeof expiry !== "string") {
|
|
528
|
+
throw new AuthenticationError(
|
|
529
|
+
"Token response missing access_token / access_token_expiry.",
|
|
530
|
+
response.statusCode(),
|
|
531
|
+
{ data }
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
const expiresAt = new Date(expiry);
|
|
535
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
536
|
+
throw new AuthenticationError(
|
|
537
|
+
`Token response carried an unparseable access_token_expiry: ${expiry}`,
|
|
538
|
+
response.statusCode(),
|
|
539
|
+
{ data }
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const token = new Token(value, expiresAt);
|
|
543
|
+
await this.#store.put(token);
|
|
544
|
+
return token;
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// src/config.ts
|
|
549
|
+
var ASSET_BASE_URL = "https://cdn.astermd.com/";
|
|
550
|
+
var DEFAULT_BASE_HOST = "api.astermd.com";
|
|
551
|
+
var Config = class {
|
|
552
|
+
#clientId;
|
|
553
|
+
#clientSecret;
|
|
554
|
+
#baseHost;
|
|
555
|
+
#timeoutSeconds;
|
|
556
|
+
/**
|
|
557
|
+
* @param options Credentials and connection settings.
|
|
558
|
+
* @throws {TypeError} If `clientId` or `clientSecret` is empty, if `baseHost`
|
|
559
|
+
* contains a scheme or a path, or if `timeoutSeconds` is below 1.
|
|
560
|
+
*/
|
|
561
|
+
constructor(options) {
|
|
562
|
+
const { clientId, clientSecret, baseHost = DEFAULT_BASE_HOST, timeoutSeconds = 10 } = options;
|
|
563
|
+
if (clientId === "" || clientSecret === "") {
|
|
564
|
+
throw new TypeError("clientId and clientSecret must be non-empty.");
|
|
565
|
+
}
|
|
566
|
+
if (baseHost.includes("://") || baseHost.includes("/")) {
|
|
567
|
+
throw new TypeError(`baseHost must be a bare host (no scheme, no path). Got: ${baseHost}`);
|
|
568
|
+
}
|
|
569
|
+
if (timeoutSeconds < 1) {
|
|
570
|
+
throw new TypeError("timeoutSeconds must be >= 1.");
|
|
571
|
+
}
|
|
572
|
+
this.#clientId = clientId;
|
|
573
|
+
this.#clientSecret = clientSecret;
|
|
574
|
+
this.#baseHost = baseHost;
|
|
575
|
+
this.#timeoutSeconds = timeoutSeconds;
|
|
576
|
+
Object.freeze(this);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Returns the OAuth2 client ID used to acquire JWT bearer tokens.
|
|
580
|
+
*
|
|
581
|
+
* @returns The client ID, guaranteed non-empty.
|
|
582
|
+
*/
|
|
583
|
+
clientId() {
|
|
584
|
+
return this.#clientId;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Returns the OAuth2 client secret used to acquire JWT bearer tokens.
|
|
588
|
+
*
|
|
589
|
+
* @returns The client secret, guaranteed non-empty.
|
|
590
|
+
*/
|
|
591
|
+
clientSecret() {
|
|
592
|
+
return this.#clientSecret;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Returns the bare API hostname used to build request URLs.
|
|
596
|
+
*
|
|
597
|
+
* Never contains a scheme or a path. {@link UrlBuilder} prepends `https://`
|
|
598
|
+
* and appends `/v1/{service}/{path}` at request time.
|
|
599
|
+
*
|
|
600
|
+
* @returns The hostname, e.g. `api.astermd.com`.
|
|
601
|
+
*/
|
|
602
|
+
baseHost() {
|
|
603
|
+
return this.#baseHost;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Returns the per-request HTTP timeout in seconds.
|
|
607
|
+
*
|
|
608
|
+
* Applied by {@link FetchHttpClient} as an `AbortSignal` deadline covering the
|
|
609
|
+
* whole request. A custom {@link HttpClient} may use it at its own discretion.
|
|
610
|
+
*
|
|
611
|
+
* @returns The timeout in seconds, always >= 1.
|
|
612
|
+
*/
|
|
613
|
+
timeoutSeconds() {
|
|
614
|
+
return this.#timeoutSeconds;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Builds the fully-qualified CDN URL for a media asset path returned by the API.
|
|
618
|
+
*
|
|
619
|
+
* API responses carry relative asset paths such as
|
|
620
|
+
* `{org}/{channel}/products/{file}.png`. Pass one here to resolve it.
|
|
621
|
+
*
|
|
622
|
+
* Treat the result as a *fetch* URL, not a *serve* URL: download the asset
|
|
623
|
+
* once, store it on your own filesystem or CDN, and serve your copy to end
|
|
624
|
+
* users. Hot-linking the AsterMD CDN from a storefront is not supported and is
|
|
625
|
+
* subject to rate limiting.
|
|
626
|
+
*
|
|
627
|
+
* @param path Relative asset path as returned by the API; a leading slash is
|
|
628
|
+
* optional and is trimmed.
|
|
629
|
+
* @returns The fully-qualified URL.
|
|
630
|
+
*/
|
|
631
|
+
assetUrl(path) {
|
|
632
|
+
return ASSET_BASE_URL + path.replace(/^\/+/, "");
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// src/http/fetch-http-client.ts
|
|
637
|
+
var FetchHttpClient = class {
|
|
638
|
+
#timeoutSeconds;
|
|
639
|
+
#fetchImpl;
|
|
640
|
+
/**
|
|
641
|
+
* @param timeoutSeconds Per-request deadline in seconds. Must be >= 1.
|
|
642
|
+
* @param fetchImpl The `fetch` implementation to call. Defaults to the global
|
|
643
|
+
* one; override it to supply an instrumented or stubbed
|
|
644
|
+
* `fetch` without implementing the whole interface.
|
|
645
|
+
* @throws {TypeError} If `timeoutSeconds` is below 1.
|
|
646
|
+
*/
|
|
647
|
+
constructor(timeoutSeconds = 10, fetchImpl = globalThis.fetch) {
|
|
648
|
+
if (timeoutSeconds < 1) {
|
|
649
|
+
throw new TypeError("timeoutSeconds must be >= 1.");
|
|
650
|
+
}
|
|
651
|
+
this.#timeoutSeconds = timeoutSeconds;
|
|
652
|
+
this.#fetchImpl = fetchImpl;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Sends the request through `fetch` under the configured deadline.
|
|
656
|
+
*
|
|
657
|
+
* @param request The request to send.
|
|
658
|
+
* @returns The response as received, whatever its status.
|
|
659
|
+
* @throws {TransportError} If the request aborts on the deadline or fails at
|
|
660
|
+
* the network layer.
|
|
661
|
+
*/
|
|
662
|
+
async sendRequest(request) {
|
|
663
|
+
try {
|
|
664
|
+
return await this.#fetchImpl(request, {
|
|
665
|
+
signal: AbortSignal.timeout(this.#timeoutSeconds * 1e3)
|
|
666
|
+
});
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
669
|
+
throw new TransportError(`Request to ${request.url} timed out after ${this.#timeoutSeconds}s.`, {
|
|
670
|
+
cause: error
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
674
|
+
throw new TransportError(`Request to ${request.url} failed: ${detail}`, { cause: error });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// src/http/log-redactor.ts
|
|
680
|
+
var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "x-phi-verification-token"]);
|
|
681
|
+
var SENSITIVE_FIELDS = ["client_secret", "access_token", "refresh_token"];
|
|
682
|
+
var PHI_PATH_FRAGMENTS = ["/patients", "/extensions/identity-verify"];
|
|
683
|
+
var BINARY_UPLOAD_FRAGMENTS = ["/intake-submissions/upload-file/", "/upload-file-multipart/part/"];
|
|
684
|
+
function escapeRegExp(value) {
|
|
685
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
686
|
+
}
|
|
687
|
+
var LogRedactor = class _LogRedactor {
|
|
688
|
+
/** Replacement text substituted for every redacted value. */
|
|
689
|
+
static PLACEHOLDER = "[REDACTED]";
|
|
690
|
+
/** Marker written in place of a body dropped wholesale. */
|
|
691
|
+
static PHI_PLACEHOLDER = "[REDACTED - PHI endpoint]";
|
|
692
|
+
/** Marker written in place of the raw file bytes of an upload. */
|
|
693
|
+
static UPLOAD_PLACEHOLDER = "[REDACTED - binary upload]";
|
|
694
|
+
/**
|
|
695
|
+
* Returns the loggable form of one request header value.
|
|
696
|
+
*
|
|
697
|
+
* A sensitive header keeps its name, so the log still shows the header was
|
|
698
|
+
* sent, but its value is replaced. For `Authorization` the scheme survives -
|
|
699
|
+
* `Bearer [REDACTED]` - because knowing the scheme helps when debugging and
|
|
700
|
+
* carries no secret.
|
|
701
|
+
*
|
|
702
|
+
* @param name The header name as it appears on the request.
|
|
703
|
+
* @param value The raw header value.
|
|
704
|
+
* @returns The value to write to the log.
|
|
705
|
+
*/
|
|
706
|
+
headerValue(name, value) {
|
|
707
|
+
if (!SENSITIVE_HEADERS.has(name.toLowerCase())) {
|
|
708
|
+
return value;
|
|
709
|
+
}
|
|
710
|
+
const scheme = /^(\S+)\s+\S/.exec(value);
|
|
711
|
+
return scheme === null ? _LogRedactor.PLACEHOLDER : `${scheme[1]} ${_LogRedactor.PLACEHOLDER}`;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Returns the loggable form of a request or response body.
|
|
715
|
+
*
|
|
716
|
+
* A body belonging to a PHI-carrying path, and the raw bytes sent to an upload
|
|
717
|
+
* endpoint, are replaced entirely. Every other body has its sensitive JSON
|
|
718
|
+
* fields rewritten in place, leaving the surrounding structure intact so the
|
|
719
|
+
* entry stays readable and replayable apart from the redacted values.
|
|
720
|
+
*
|
|
721
|
+
* @param body The raw body as it would otherwise be logged.
|
|
722
|
+
* @param path The request path, used to detect PHI and upload endpoints.
|
|
723
|
+
* @returns The body to write to the log.
|
|
724
|
+
*/
|
|
725
|
+
body(body, path) {
|
|
726
|
+
if (body === "") {
|
|
727
|
+
return body;
|
|
728
|
+
}
|
|
729
|
+
if (this.isPhiPath(path)) {
|
|
730
|
+
return _LogRedactor.PHI_PLACEHOLDER;
|
|
731
|
+
}
|
|
732
|
+
if (this.isBinaryUploadPath(path)) {
|
|
733
|
+
return _LogRedactor.UPLOAD_PLACEHOLDER;
|
|
734
|
+
}
|
|
735
|
+
return this.#redactFields(body);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Reports whether a path carries PII or PHI and must have its body dropped.
|
|
739
|
+
*
|
|
740
|
+
* @param path The request path, with or without the `/v1/{service}` prefix.
|
|
741
|
+
* @returns `true` for a patient endpoint or the identity-verification endpoint.
|
|
742
|
+
*/
|
|
743
|
+
isPhiPath(path) {
|
|
744
|
+
const lower = path.toLowerCase();
|
|
745
|
+
return PHI_PATH_FRAGMENTS.some((fragment) => lower.includes(fragment));
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Reports whether a path carries raw file bytes and must have its body dropped.
|
|
749
|
+
*
|
|
750
|
+
* True for the single-shot intake-submission upload endpoint and the multipart
|
|
751
|
+
* `part` endpoint. The multipart `initiate`, `finish`, and `abort` endpoints
|
|
752
|
+
* carry small JSON control messages, so they are excluded and their bodies stay
|
|
753
|
+
* in the log where they help.
|
|
754
|
+
*
|
|
755
|
+
* @param path The request path, with or without the `/v1/{service}` prefix.
|
|
756
|
+
* @returns `true` when the body is file contents rather than JSON.
|
|
757
|
+
*/
|
|
758
|
+
isBinaryUploadPath(path) {
|
|
759
|
+
const lower = path.toLowerCase();
|
|
760
|
+
return BINARY_UPLOAD_FRAGMENTS.some((fragment) => lower.includes(fragment));
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Extracts the path from a request, for use with the methods above.
|
|
764
|
+
*
|
|
765
|
+
* @param request The request to inspect.
|
|
766
|
+
* @returns The URL path, without the query string.
|
|
767
|
+
*/
|
|
768
|
+
pathOf(request) {
|
|
769
|
+
return new URL(request.url).pathname;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Replaces the value of every sensitive JSON field found in the given text.
|
|
773
|
+
*
|
|
774
|
+
* Operates on the serialised form rather than decoding, so it works for
|
|
775
|
+
* pretty-printed and compact JSON alike and never reorders or reformats a body
|
|
776
|
+
* that is only partly JSON.
|
|
777
|
+
*/
|
|
778
|
+
#redactFields(body) {
|
|
779
|
+
let out = body;
|
|
780
|
+
for (const field of SENSITIVE_FIELDS) {
|
|
781
|
+
const pattern = new RegExp(`("${escapeRegExp(field)}"\\s*:\\s*)"(?:[^"\\\\]|\\\\.)*"`, "gi");
|
|
782
|
+
out = out.replace(pattern, `$1"${_LogRedactor.PLACEHOLDER}"`);
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
// src/http/curl-formatter.ts
|
|
789
|
+
var CurlFormatter = class {
|
|
790
|
+
/**
|
|
791
|
+
* Renders the given request as a multi-line curl invocation.
|
|
792
|
+
*
|
|
793
|
+
* Each header becomes its own `--header '...'` line. A body is appended as
|
|
794
|
+
* `--data '...'` with single quotes shell-escaped through the `'\''` idiom.
|
|
795
|
+
* With no body, the trailing backslash continuation is stripped from the last
|
|
796
|
+
* line so the command pastes cleanly.
|
|
797
|
+
*
|
|
798
|
+
* The request's body is read from a clone, so the request itself stays unread
|
|
799
|
+
* and can still be sent. Reading the original would leave the real request with
|
|
800
|
+
* an empty body.
|
|
801
|
+
*
|
|
802
|
+
* @param request The request to format.
|
|
803
|
+
* @param redactor Applied to header values and the body before rendering. Pass
|
|
804
|
+
* `null` or omit it to render verbatim.
|
|
805
|
+
* @returns The multi-line curl command.
|
|
806
|
+
*/
|
|
807
|
+
static async format(request, redactor = null) {
|
|
808
|
+
const lines = [`curl --location --request ${request.method} '${request.url}' \\`];
|
|
809
|
+
const headerLines = [];
|
|
810
|
+
for (const [name, value] of request.headers.entries()) {
|
|
811
|
+
const shown = redactor?.headerValue(name, value) ?? value;
|
|
812
|
+
headerLines.push(` --header '${name}: ${shown}' \\`);
|
|
813
|
+
}
|
|
814
|
+
let body = await readBody(request);
|
|
815
|
+
if (redactor !== null) {
|
|
816
|
+
body = redactor.body(body, redactor.pathOf(request));
|
|
817
|
+
}
|
|
818
|
+
if (body !== "") {
|
|
819
|
+
lines.push(...headerLines);
|
|
820
|
+
lines.push(` --data '${body.replaceAll("'", String.raw`'\''`)}'`);
|
|
821
|
+
} else {
|
|
822
|
+
headerLines.forEach((line, index) => {
|
|
823
|
+
lines.push(index === headerLines.length - 1 ? line.replace(/ \\$/, "") : line);
|
|
824
|
+
});
|
|
825
|
+
if (headerLines.length === 0) {
|
|
826
|
+
lines[0] = lines[0].replace(/ \\$/, "");
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return lines.join("\n");
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
async function readBody(request) {
|
|
833
|
+
if (request.body === null) {
|
|
834
|
+
return "";
|
|
835
|
+
}
|
|
836
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
837
|
+
if (contentType.startsWith("multipart/form-data")) {
|
|
838
|
+
return "[multipart/form-data]";
|
|
839
|
+
}
|
|
840
|
+
return await request.clone().text();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/http/logging-http-client.ts
|
|
844
|
+
var LoggingHttpClient = class {
|
|
845
|
+
#inner;
|
|
846
|
+
#sink;
|
|
847
|
+
#redactor;
|
|
848
|
+
#formatter;
|
|
849
|
+
/**
|
|
850
|
+
* @param options The client to wrap and where to log.
|
|
851
|
+
*/
|
|
852
|
+
constructor(options) {
|
|
853
|
+
this.#inner = options.inner;
|
|
854
|
+
this.#sink = options.sink;
|
|
855
|
+
this.#redactor = options.redactor === void 0 ? new LogRedactor() : options.redactor;
|
|
856
|
+
this.#formatter = new Intl.DateTimeFormat("en-CA", {
|
|
857
|
+
timeZone: options.timeZone ?? "UTC",
|
|
858
|
+
year: "numeric",
|
|
859
|
+
month: "2-digit",
|
|
860
|
+
day: "2-digit",
|
|
861
|
+
hour: "2-digit",
|
|
862
|
+
minute: "2-digit",
|
|
863
|
+
second: "2-digit",
|
|
864
|
+
hour12: false,
|
|
865
|
+
hourCycle: "h23",
|
|
866
|
+
timeZoneName: "short"
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Forwards the request to the inner client, then logs the exchange.
|
|
871
|
+
*
|
|
872
|
+
* @param request The request to send and log.
|
|
873
|
+
* @returns The response, with its body still readable.
|
|
874
|
+
* @throws The inner client's error, rethrown after being logged.
|
|
875
|
+
*/
|
|
876
|
+
async sendRequest(request) {
|
|
877
|
+
const timestamp = this.#timestamp();
|
|
878
|
+
const curl = await CurlFormatter.format(request, this.#redactor);
|
|
879
|
+
let response;
|
|
880
|
+
try {
|
|
881
|
+
response = await this.#inner.sendRequest(request);
|
|
882
|
+
} catch (error) {
|
|
883
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
884
|
+
await this.#write(`[${timestamp}]
|
|
885
|
+
${curl}
|
|
886
|
+
|
|
887
|
+
# Transport error: ${detail}
|
|
888
|
+
|
|
889
|
+
`);
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
const raw = await response.clone().text();
|
|
893
|
+
const shown = this.#redactor?.body(raw, this.#redactor.pathOf(request)) ?? raw;
|
|
894
|
+
await this.#write(`[${timestamp}]
|
|
895
|
+
${curl}
|
|
896
|
+
|
|
897
|
+
# Response: HTTP ${response.status}
|
|
898
|
+
${shown}
|
|
899
|
+
|
|
900
|
+
`);
|
|
901
|
+
const nullBody = response.status === 204 || response.status === 205 || response.status === 304;
|
|
902
|
+
return new Response(nullBody ? null : raw, {
|
|
903
|
+
status: response.status,
|
|
904
|
+
statusText: response.statusText,
|
|
905
|
+
headers: response.headers
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
async #write(entry) {
|
|
909
|
+
try {
|
|
910
|
+
await this.#sink(entry);
|
|
911
|
+
} catch {
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Formats the current instant as `YYYY-MM-DD HH:MM:SS.ffffff ZONE`.
|
|
916
|
+
*
|
|
917
|
+
* PHP logs microseconds; `Date` only resolves to milliseconds, so the
|
|
918
|
+
* millisecond value is zero-padded to six digits to keep the column width and
|
|
919
|
+
* the shape of the PHP entries.
|
|
920
|
+
*/
|
|
921
|
+
#timestamp() {
|
|
922
|
+
const now = /* @__PURE__ */ new Date();
|
|
923
|
+
const parts = new Map(this.#formatter.formatToParts(now).map(({ type, value }) => [type, value]));
|
|
924
|
+
const micros = String(now.getMilliseconds()).padStart(3, "0").padEnd(6, "0");
|
|
925
|
+
return `${parts.get("year")}-${parts.get("month")}-${parts.get("day")} ${parts.get("hour")}:${parts.get("minute")}:${parts.get("second")}.${micros} ${parts.get("timeZoneName")}`;
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
// src/http/url-builder.ts
|
|
930
|
+
function rawUrlEncode(value) {
|
|
931
|
+
return encodeURIComponent(value).replace(
|
|
932
|
+
/[!'()*]/g,
|
|
933
|
+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
function buildQuery(query) {
|
|
937
|
+
return Object.entries(query).map(([key, value]) => {
|
|
938
|
+
const raw = typeof value === "boolean" ? value ? "1" : "0" : String(value);
|
|
939
|
+
return `${urlEncode(key)}=${urlEncode(raw)}`;
|
|
940
|
+
}).join("&");
|
|
941
|
+
}
|
|
942
|
+
function urlEncode(value) {
|
|
943
|
+
return rawUrlEncode(value).replace(/%20/g, "+").replace(/~/g, "%7E");
|
|
944
|
+
}
|
|
945
|
+
var UrlBuilder = class {
|
|
946
|
+
#baseHost;
|
|
947
|
+
/**
|
|
948
|
+
* @param baseHost Bare hostname to prepend, e.g. `api.astermd.com`.
|
|
949
|
+
*/
|
|
950
|
+
constructor(baseHost) {
|
|
951
|
+
this.#baseHost = baseHost;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Constructs the fully-qualified URL for a service and path.
|
|
955
|
+
*
|
|
956
|
+
* Placeholder tokens in `path` - `{id}`, `{session}`, `{form_json_identifier}`
|
|
957
|
+
* - are replaced with the matching values from `pathParams`, percent-encoded.
|
|
958
|
+
* A placeholder with no matching entry is left untouched rather than guessed
|
|
959
|
+
* at, which surfaces the programming error in the request URL. Non-empty
|
|
960
|
+
* `query` entries are serialised and appended after `?`.
|
|
961
|
+
*
|
|
962
|
+
* @param service Service segment, e.g. `sales`, `auth`, `platform`.
|
|
963
|
+
* @param path Path relative to `/v1/{service}`, with optional `{placeholder}`
|
|
964
|
+
* tokens.
|
|
965
|
+
* @param pathParams Placeholder name → replacement value.
|
|
966
|
+
* @param query Query string parameters.
|
|
967
|
+
* @returns The fully-qualified URL.
|
|
968
|
+
*/
|
|
969
|
+
build(service, path, pathParams = {}, query = {}) {
|
|
970
|
+
let resolved = path;
|
|
971
|
+
for (const [key, value] of Object.entries(pathParams)) {
|
|
972
|
+
resolved = resolved.split(`{${key}}`).join(rawUrlEncode(String(value)));
|
|
973
|
+
}
|
|
974
|
+
const url = `https://${this.#baseHost}/v1/${service}${resolved}`;
|
|
975
|
+
const queryString = Object.keys(query).length > 0 ? buildQuery(query) : "";
|
|
976
|
+
return queryString === "" ? url : `${url}?${queryString}`;
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
var DailyFileLogSink = class {
|
|
980
|
+
#directory;
|
|
981
|
+
#stem;
|
|
982
|
+
#extension;
|
|
983
|
+
#retentionDays;
|
|
984
|
+
#dateFormatter;
|
|
985
|
+
#pruned = false;
|
|
986
|
+
#tail = Promise.resolve();
|
|
987
|
+
/**
|
|
988
|
+
* @param options Base path, retention window, and time zone.
|
|
989
|
+
* @throws {TypeError} If `basePath` is empty or carries no file name, or if
|
|
990
|
+
* `retentionDays` is negative.
|
|
991
|
+
*/
|
|
992
|
+
constructor(options) {
|
|
993
|
+
const { basePath, retentionDays = 7, timeZone = "UTC" } = options;
|
|
994
|
+
if (basePath.trim() === "") {
|
|
995
|
+
throw new TypeError("basePath must be a non-empty file path.");
|
|
996
|
+
}
|
|
997
|
+
if (retentionDays < 0) {
|
|
998
|
+
throw new TypeError("retentionDays must be >= 0 (0 disables pruning).");
|
|
999
|
+
}
|
|
1000
|
+
const extension = path.extname(basePath);
|
|
1001
|
+
const stem = path.basename(basePath, extension);
|
|
1002
|
+
if (stem === "") {
|
|
1003
|
+
throw new TypeError(`basePath must include a file name. Got: ${basePath}`);
|
|
1004
|
+
}
|
|
1005
|
+
this.#directory = path.dirname(basePath);
|
|
1006
|
+
this.#stem = stem;
|
|
1007
|
+
this.#extension = extension === "" ? ".log" : extension;
|
|
1008
|
+
this.#retentionDays = retentionDays;
|
|
1009
|
+
this.#dateFormatter = new Intl.DateTimeFormat("en-CA", {
|
|
1010
|
+
timeZone,
|
|
1011
|
+
year: "numeric",
|
|
1012
|
+
month: "2-digit",
|
|
1013
|
+
day: "2-digit"
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Appends one entry to today's log file, pruning aged-out files on the first
|
|
1018
|
+
* call.
|
|
1019
|
+
*
|
|
1020
|
+
* Resolves once the entry is on disk. Writes are chained, so entries land whole
|
|
1021
|
+
* and in call order even under concurrency. Never rejects: an I/O failure is
|
|
1022
|
+
* swallowed rather than propagated into the API call being logged.
|
|
1023
|
+
*
|
|
1024
|
+
* @param entry The formatted log entry to append.
|
|
1025
|
+
*/
|
|
1026
|
+
async write(entry) {
|
|
1027
|
+
this.#tail = this.#tail.then(() => this.#append(entry)).catch(() => void 0);
|
|
1028
|
+
await this.#tail;
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Returns the absolute path entries are currently written to.
|
|
1032
|
+
*
|
|
1033
|
+
* Useful in tests and for a "where are my logs" diagnostic. The path changes at
|
|
1034
|
+
* midnight in the configured time zone.
|
|
1035
|
+
*
|
|
1036
|
+
* @returns The dated file path, e.g. `/var/log/astermd/sdk-2026-09-08.log`.
|
|
1037
|
+
*/
|
|
1038
|
+
currentFile() {
|
|
1039
|
+
return path.join(this.#directory, `${this.#stem}-${this.#today()}${this.#extension}`);
|
|
1040
|
+
}
|
|
1041
|
+
async #append(entry) {
|
|
1042
|
+
try {
|
|
1043
|
+
await promises.mkdir(this.#directory, { recursive: true, mode: 448 });
|
|
1044
|
+
await promises.appendFile(this.currentFile(), entry, { mode: 384 });
|
|
1045
|
+
if (!this.#pruned) {
|
|
1046
|
+
this.#pruned = true;
|
|
1047
|
+
await this.#prune();
|
|
1048
|
+
}
|
|
1049
|
+
} catch {
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Deletes dated log files older than the retention window.
|
|
1054
|
+
*
|
|
1055
|
+
* Only files matching this sink's own `{stem}-YYYY-MM-DD{ext}` pattern are
|
|
1056
|
+
* considered, and the date comes from the filename rather than filesystem
|
|
1057
|
+
* metadata. A file that cannot be deleted is skipped silently.
|
|
1058
|
+
*/
|
|
1059
|
+
async #prune() {
|
|
1060
|
+
if (this.#retentionDays === 0) {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const cutoff = this.#cutoff();
|
|
1064
|
+
const pattern = new RegExp(`^${escapeRegExp2(this.#stem)}-(\\d{4}-\\d{2}-\\d{2})${escapeRegExp2(this.#extension)}$`);
|
|
1065
|
+
let names;
|
|
1066
|
+
try {
|
|
1067
|
+
names = await promises.readdir(this.#directory);
|
|
1068
|
+
} catch {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
for (const name of names) {
|
|
1072
|
+
const matched = pattern.exec(name);
|
|
1073
|
+
if (matched !== null && matched[1] < cutoff) {
|
|
1074
|
+
try {
|
|
1075
|
+
await promises.rm(path.join(this.#directory, name), { force: true });
|
|
1076
|
+
} catch {
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
#today() {
|
|
1082
|
+
return this.#dateFormatter.format(/* @__PURE__ */ new Date());
|
|
1083
|
+
}
|
|
1084
|
+
/** The oldest date still kept, as `YYYY-MM-DD`. */
|
|
1085
|
+
#cutoff() {
|
|
1086
|
+
const today = /* @__PURE__ */ new Date(`${this.#today()}T00:00:00.000Z`);
|
|
1087
|
+
today.setUTCDate(today.getUTCDate() - this.#retentionDays);
|
|
1088
|
+
return today.toISOString().slice(0, 10);
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
function escapeRegExp2(value) {
|
|
1092
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// src/resource/abstract-resource.ts
|
|
1096
|
+
var AbstractResource = class {
|
|
1097
|
+
/** The shared transport used for all outbound API calls. */
|
|
1098
|
+
transport;
|
|
1099
|
+
/**
|
|
1100
|
+
* @param transport The transport every call from this resource goes through.
|
|
1101
|
+
*/
|
|
1102
|
+
constructor(transport) {
|
|
1103
|
+
this.transport = transport;
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
// src/resource/carts.ts
|
|
1108
|
+
var Carts = class extends AbstractResource {
|
|
1109
|
+
/**
|
|
1110
|
+
* @param transport The shared transport.
|
|
1111
|
+
*/
|
|
1112
|
+
constructor(transport) {
|
|
1113
|
+
super(transport);
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Creates the cart for a session.
|
|
1117
|
+
*
|
|
1118
|
+
* Call this the first time the visitor puts something in their cart. The
|
|
1119
|
+
* session must already exist. If a cart is already recorded for the session,
|
|
1120
|
+
* use {@link Carts.update} instead.
|
|
1121
|
+
*
|
|
1122
|
+
* @param options The session and the cart's contents.
|
|
1123
|
+
* @returns The created cart.
|
|
1124
|
+
* @throws {ValidationError} If the session or an item is rejected.
|
|
1125
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1126
|
+
* @throws {TransportError} If the request never completed.
|
|
1127
|
+
*/
|
|
1128
|
+
async create(options) {
|
|
1129
|
+
return await this.transport.send({
|
|
1130
|
+
service: "sales",
|
|
1131
|
+
method: "POST",
|
|
1132
|
+
path: "/carts/create",
|
|
1133
|
+
body: { session: options.session, items: options.items }
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Replaces the contents of a session's cart.
|
|
1138
|
+
*
|
|
1139
|
+
* The item list is authoritative: whatever you send becomes the cart. Send the
|
|
1140
|
+
* full list on every change rather than only what moved, and pass an empty list
|
|
1141
|
+
* to record that the visitor emptied their cart.
|
|
1142
|
+
*
|
|
1143
|
+
* @param options The session and the cart's new contents.
|
|
1144
|
+
* @returns The updated cart.
|
|
1145
|
+
* @throws {NotFoundError} If no cart exists for the session.
|
|
1146
|
+
* @throws {ValidationError} If an item is rejected.
|
|
1147
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1148
|
+
* @throws {TransportError} If the request never completed.
|
|
1149
|
+
*/
|
|
1150
|
+
async update(options) {
|
|
1151
|
+
return await this.transport.send({
|
|
1152
|
+
service: "sales",
|
|
1153
|
+
method: "PUT",
|
|
1154
|
+
path: "/carts/update/{session}",
|
|
1155
|
+
pathParams: { session: options.session },
|
|
1156
|
+
body: { items: options.items }
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
// src/resource/categories.ts
|
|
1162
|
+
var Categories = class extends AbstractResource {
|
|
1163
|
+
/**
|
|
1164
|
+
* @param transport The shared transport.
|
|
1165
|
+
*/
|
|
1166
|
+
constructor(transport) {
|
|
1167
|
+
super(transport);
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Lists categories, filtered and paginated.
|
|
1171
|
+
*
|
|
1172
|
+
* Pagination details come back in `meta()`. The accepted filters are in the
|
|
1173
|
+
* API reference in your AsterMD dashboard.
|
|
1174
|
+
*
|
|
1175
|
+
* @param query Filters and pagination parameters.
|
|
1176
|
+
* @returns The matching categories, with pagination in `meta()`.
|
|
1177
|
+
* @throws {ValidationError} If a filter is rejected.
|
|
1178
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1179
|
+
* @throws {TransportError} If the request never completed.
|
|
1180
|
+
*/
|
|
1181
|
+
async list(query = {}) {
|
|
1182
|
+
return await this.transport.send({
|
|
1183
|
+
service: "sales",
|
|
1184
|
+
method: "GET",
|
|
1185
|
+
path: "/categories/list",
|
|
1186
|
+
query
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Fetches one category.
|
|
1191
|
+
*
|
|
1192
|
+
* @param id The category's id.
|
|
1193
|
+
* @returns The category record.
|
|
1194
|
+
* @throws {NotFoundError} If the category does not exist or is out of scope.
|
|
1195
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1196
|
+
* @throws {TransportError} If the request never completed.
|
|
1197
|
+
*/
|
|
1198
|
+
async view(id) {
|
|
1199
|
+
return await this.transport.send({
|
|
1200
|
+
service: "sales",
|
|
1201
|
+
method: "GET",
|
|
1202
|
+
path: "/categories/view/{id}",
|
|
1203
|
+
pathParams: { id }
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
// src/resource/channels.ts
|
|
1209
|
+
var Channels = class extends AbstractResource {
|
|
1210
|
+
/**
|
|
1211
|
+
* @param transport The shared transport.
|
|
1212
|
+
*/
|
|
1213
|
+
constructor(transport) {
|
|
1214
|
+
super(transport);
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Fetches a channel's own configuration.
|
|
1218
|
+
*
|
|
1219
|
+
* The channel record itself - name, type, status - without its product
|
|
1220
|
+
* catalogue.
|
|
1221
|
+
*
|
|
1222
|
+
* @param id The channel's id.
|
|
1223
|
+
* @returns The channel record.
|
|
1224
|
+
* @throws {NotFoundError} If the channel does not exist or is out of scope.
|
|
1225
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1226
|
+
* @throws {TransportError} If the request never completed.
|
|
1227
|
+
*/
|
|
1228
|
+
async view(id) {
|
|
1229
|
+
return await this.transport.send({
|
|
1230
|
+
service: "sales",
|
|
1231
|
+
method: "GET",
|
|
1232
|
+
path: "/channels/view/{id}",
|
|
1233
|
+
pathParams: { id }
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Fetches the products assigned to a channel.
|
|
1238
|
+
*
|
|
1239
|
+
* What this selling surface is permitted to sell. Use it to build a catalogue
|
|
1240
|
+
* page without pulling the full detail tree.
|
|
1241
|
+
*
|
|
1242
|
+
* @param id The channel's id.
|
|
1243
|
+
* @returns The assigned products.
|
|
1244
|
+
* @throws {NotFoundError} If the channel does not exist.
|
|
1245
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1246
|
+
* @throws {TransportError} If the request never completed.
|
|
1247
|
+
*/
|
|
1248
|
+
async assignedProducts(id) {
|
|
1249
|
+
return await this.transport.send({
|
|
1250
|
+
service: "sales",
|
|
1251
|
+
method: "GET",
|
|
1252
|
+
path: "/channels/assigned-products/{id}",
|
|
1253
|
+
pathParams: { id }
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Fetches a channel's complete configuration tree.
|
|
1258
|
+
*
|
|
1259
|
+
* Channel, payment processor, products, variants, prices, and per-integration
|
|
1260
|
+
* mappings in one response - everything a storefront needs to render its
|
|
1261
|
+
* catalogue without further calls. The envelope is deeply nested and carries
|
|
1262
|
+
* server-side field names; see the API reference in your AsterMD dashboard for
|
|
1263
|
+
* its shape. Pass `data()` through {@link ChannelDetail.from} to flatten it.
|
|
1264
|
+
*
|
|
1265
|
+
* @param id The channel's id.
|
|
1266
|
+
* @returns The full configuration tree.
|
|
1267
|
+
* @throws {NotFoundError} If the channel does not exist.
|
|
1268
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1269
|
+
* @throws {TransportError} If the request never completed.
|
|
1270
|
+
*/
|
|
1271
|
+
async details(id) {
|
|
1272
|
+
return await this.transport.send({
|
|
1273
|
+
service: "sales",
|
|
1274
|
+
method: "GET",
|
|
1275
|
+
path: "/channels/detail/{id}",
|
|
1276
|
+
pathParams: { id }
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
// src/enum/checkout-event.ts
|
|
1282
|
+
var CheckoutEvent = Object.freeze({
|
|
1283
|
+
/** The prospect reached the checkout page. Recorded implicitly by `create()`. */
|
|
1284
|
+
CheckoutVisited: "checkout_visited",
|
|
1285
|
+
/** An upsell was presented to the prospect. */
|
|
1286
|
+
UpsellOffered: "upsell_offered",
|
|
1287
|
+
/** The prospect accepted a presented upsell. */
|
|
1288
|
+
UpsellAccepted: "upsell_accepted",
|
|
1289
|
+
/** The prospect declined a presented upsell. */
|
|
1290
|
+
UpsellDeclined: "upsell_declined",
|
|
1291
|
+
/** The order was placed successfully. */
|
|
1292
|
+
OrderPlaced: "order_placed",
|
|
1293
|
+
/** The order was declined, typically by the payment processor. */
|
|
1294
|
+
OrderDeclined: "order_declined"
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
// src/resource/checkout-events.ts
|
|
1298
|
+
var DEFAULT_CURRENCY = "USD";
|
|
1299
|
+
var CheckoutEvents = class extends AbstractResource {
|
|
1300
|
+
/**
|
|
1301
|
+
* @param transport The shared transport.
|
|
1302
|
+
*/
|
|
1303
|
+
constructor(transport) {
|
|
1304
|
+
super(transport);
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Opens the checkout record for a session, registering `checkout_visited`.
|
|
1308
|
+
*
|
|
1309
|
+
* Call this once, when the visitor reaches the checkout page. The event is
|
|
1310
|
+
* fixed - this is the call that starts the funnel record - and `currency`
|
|
1311
|
+
* defaults to `USD` when you do not supply one, since the server requires a
|
|
1312
|
+
* currency to interpret any amounts in the payload.
|
|
1313
|
+
*
|
|
1314
|
+
* @param session The session reaching checkout.
|
|
1315
|
+
* @param data Additional context, e.g. `cart_total`, `items_count`,
|
|
1316
|
+
* `currency`. Cannot override `session` or `event`.
|
|
1317
|
+
* @returns The created checkout-event record.
|
|
1318
|
+
* @throws {ValidationError} If the session or payload is rejected.
|
|
1319
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1320
|
+
* @throws {TransportError} If the request never completed.
|
|
1321
|
+
*/
|
|
1322
|
+
async create(session, data = {}) {
|
|
1323
|
+
const body = {
|
|
1324
|
+
...data,
|
|
1325
|
+
session,
|
|
1326
|
+
event: CheckoutEvent.CheckoutVisited
|
|
1327
|
+
};
|
|
1328
|
+
if (body.currency === void 0 || body.currency === "") {
|
|
1329
|
+
body.currency = DEFAULT_CURRENCY;
|
|
1330
|
+
}
|
|
1331
|
+
return await this.transport.send({
|
|
1332
|
+
service: "sales",
|
|
1333
|
+
method: "POST",
|
|
1334
|
+
path: "/checkout-events/create",
|
|
1335
|
+
body
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Advances a session's checkout record to a new funnel state.
|
|
1340
|
+
*
|
|
1341
|
+
* Send one call per state the visitor reaches: an upsell offered, then accepted
|
|
1342
|
+
* or declined, then the order placed or declined. The payload carries whatever
|
|
1343
|
+
* context that state needs, but it can never override `event` - the state
|
|
1344
|
+
* recorded is always the one you passed explicitly.
|
|
1345
|
+
*
|
|
1346
|
+
* @param options The session, the new state, and any context for it.
|
|
1347
|
+
* @returns The updated checkout-event record.
|
|
1348
|
+
* @throws {NotFoundError} If no checkout record exists for the session.
|
|
1349
|
+
* @throws {ValidationError} If the event or payload is rejected.
|
|
1350
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1351
|
+
* @throws {TransportError} If the request never completed.
|
|
1352
|
+
*/
|
|
1353
|
+
async update(options) {
|
|
1354
|
+
return await this.transport.send({
|
|
1355
|
+
service: "sales",
|
|
1356
|
+
method: "PUT",
|
|
1357
|
+
path: "/checkout-events/update/{session_id}",
|
|
1358
|
+
pathParams: { session_id: options.session },
|
|
1359
|
+
body: { ...options.data ?? {}, event: options.event }
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
// src/resource/doctors-networks.ts
|
|
1365
|
+
var DoctorsNetworks = class extends AbstractResource {
|
|
1366
|
+
/**
|
|
1367
|
+
* @param transport The shared transport.
|
|
1368
|
+
*/
|
|
1369
|
+
constructor(transport) {
|
|
1370
|
+
super(transport);
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Pushes a case to the configured doctors' network.
|
|
1374
|
+
*
|
|
1375
|
+
* The payload must identify who the case is about, one of two ways: an
|
|
1376
|
+
* `opportunity_id` for a prospect already recorded, or a `user_info` object
|
|
1377
|
+
* carrying at least `first_name` and `email` for one who is not. Everything
|
|
1378
|
+
* else is forwarded unmodified.
|
|
1379
|
+
*
|
|
1380
|
+
* An `emit_opportunity` key is removed before sending - that flag is the
|
|
1381
|
+
* server's to set, not a caller's. Your object is not modified; the SDK copies
|
|
1382
|
+
* it first.
|
|
1383
|
+
*
|
|
1384
|
+
* @param payload The case to push. Requires either a non-empty
|
|
1385
|
+
* `opportunity_id` string, or a `user_info` object with non-empty
|
|
1386
|
+
* `first_name` and `email` strings.
|
|
1387
|
+
* @returns The sync result.
|
|
1388
|
+
* @throws {TypeError} If the payload identifies neither an opportunity nor a
|
|
1389
|
+
* person.
|
|
1390
|
+
* @throws {ValidationError} If the server rejects a field.
|
|
1391
|
+
* @throws {NotFoundError} If the referenced opportunity does not exist.
|
|
1392
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1393
|
+
* @throws {TransportError} If the request never completed.
|
|
1394
|
+
*/
|
|
1395
|
+
async sync(payload) {
|
|
1396
|
+
const body = { ...payload };
|
|
1397
|
+
delete body.emit_opportunity;
|
|
1398
|
+
validate(body);
|
|
1399
|
+
return await this.transport.send({
|
|
1400
|
+
service: "sales",
|
|
1401
|
+
method: "POST",
|
|
1402
|
+
path: "/doctors-networks/sync",
|
|
1403
|
+
body
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
function isNonEmptyString(value) {
|
|
1408
|
+
return typeof value === "string" && value !== "";
|
|
1409
|
+
}
|
|
1410
|
+
function validate(payload) {
|
|
1411
|
+
const hasOpportunity = isNonEmptyString(payload.opportunity_id);
|
|
1412
|
+
const userInfo = payload.user_info;
|
|
1413
|
+
const hasMinimalUserInfo = typeof userInfo === "object" && userInfo !== null && isNonEmptyString(userInfo.first_name) && isNonEmptyString(userInfo.email);
|
|
1414
|
+
if (!hasOpportunity && !hasMinimalUserInfo) {
|
|
1415
|
+
throw new TypeError("doctors-networks/sync requires either opportunity_id or user_info with first_name + email.");
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// src/resource/geo.ts
|
|
1420
|
+
var Geo = class extends AbstractResource {
|
|
1421
|
+
/**
|
|
1422
|
+
* @param transport The shared transport.
|
|
1423
|
+
*/
|
|
1424
|
+
constructor(transport) {
|
|
1425
|
+
super(transport);
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Resolves an IP address to a geographic location.
|
|
1429
|
+
*
|
|
1430
|
+
* Returns the configured provider's answer, normalised. Treat it as a strong
|
|
1431
|
+
* hint rather than proof - a VPN or a mobile carrier's routing can place a
|
|
1432
|
+
* visitor a long way from where they are. For a licensing decision, prefer
|
|
1433
|
+
* {@link Geo.blocklist} and the state the visitor declares.
|
|
1434
|
+
*
|
|
1435
|
+
* @param ip The visitor's IP address, v4 or v6.
|
|
1436
|
+
* @returns The resolved location.
|
|
1437
|
+
* @throws {ValidationError} If the address is malformed.
|
|
1438
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1439
|
+
* @throws {TransportError} If the request never completed.
|
|
1440
|
+
*/
|
|
1441
|
+
async info(ip) {
|
|
1442
|
+
return await this.transport.send({
|
|
1443
|
+
service: "platform",
|
|
1444
|
+
method: "GET",
|
|
1445
|
+
path: "/extensions/geo-info",
|
|
1446
|
+
query: { ip }
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Reports whether an IP address is barred from starting an order flow.
|
|
1451
|
+
*
|
|
1452
|
+
* Covers jurisdictions your organisation is not licensed for, along with known
|
|
1453
|
+
* proxies and anonymisers. Call it as early as the landing page: refusing a
|
|
1454
|
+
* visitor before they invest time in a questionnaire is better for them and
|
|
1455
|
+
* cheaper for you.
|
|
1456
|
+
*
|
|
1457
|
+
* @param ip The visitor's IP address, v4 or v6.
|
|
1458
|
+
* @returns The blocklist verdict.
|
|
1459
|
+
* @throws {ValidationError} If the address is malformed.
|
|
1460
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1461
|
+
* @throws {TransportError} If the request never completed.
|
|
1462
|
+
*/
|
|
1463
|
+
async blocklist(ip) {
|
|
1464
|
+
return await this.transport.send({
|
|
1465
|
+
service: "platform",
|
|
1466
|
+
method: "GET",
|
|
1467
|
+
path: "/extensions/geo-blocklist",
|
|
1468
|
+
query: { ip }
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
var MIME_TYPES = Object.freeze({
|
|
1473
|
+
jpg: "image/jpeg",
|
|
1474
|
+
jpeg: "image/jpeg",
|
|
1475
|
+
png: "image/png",
|
|
1476
|
+
webp: "image/webp",
|
|
1477
|
+
gif: "image/gif",
|
|
1478
|
+
pdf: "application/pdf",
|
|
1479
|
+
doc: "application/msword",
|
|
1480
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1481
|
+
txt: "text/plain",
|
|
1482
|
+
mp4: "video/mp4",
|
|
1483
|
+
mov: "video/quicktime",
|
|
1484
|
+
webm: "video/webm"
|
|
1485
|
+
});
|
|
1486
|
+
var FALLBACK_MIME_TYPE = "application/octet-stream";
|
|
1487
|
+
var FileUpload = class _FileUpload {
|
|
1488
|
+
#contents;
|
|
1489
|
+
#fileName;
|
|
1490
|
+
#mimeType;
|
|
1491
|
+
constructor(contents, fileName, mimeType) {
|
|
1492
|
+
this.#contents = contents;
|
|
1493
|
+
this.#fileName = fileName;
|
|
1494
|
+
this.#mimeType = mimeType;
|
|
1495
|
+
Object.freeze(this);
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Reads a file from local disk into an upload.
|
|
1499
|
+
*
|
|
1500
|
+
* The file is read in full and held in memory, so keep this for the sizes the
|
|
1501
|
+
* single-shot endpoints allow. Without `options.fileName` the path's base name
|
|
1502
|
+
* is used, and without `options.mimeType` the type is derived from the
|
|
1503
|
+
* extension, falling back to `application/octet-stream`.
|
|
1504
|
+
*
|
|
1505
|
+
* @param path Path to an existing, readable file.
|
|
1506
|
+
* @param options Optional name and MIME type overrides.
|
|
1507
|
+
* @returns An upload carrying the file's bytes, name, and MIME type.
|
|
1508
|
+
* @throws {TypeError} If `path` is not a readable file, or if the resulting
|
|
1509
|
+
* file name normalises to nothing usable.
|
|
1510
|
+
*/
|
|
1511
|
+
static async fromPath(path$1, options = {}) {
|
|
1512
|
+
let isFile;
|
|
1513
|
+
try {
|
|
1514
|
+
isFile = (await promises.stat(path$1)).isFile();
|
|
1515
|
+
} catch {
|
|
1516
|
+
isFile = false;
|
|
1517
|
+
}
|
|
1518
|
+
if (!isFile) {
|
|
1519
|
+
throw new TypeError(`File is not readable: ${path$1}`);
|
|
1520
|
+
}
|
|
1521
|
+
let contents;
|
|
1522
|
+
try {
|
|
1523
|
+
contents = await promises.readFile(path$1);
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
throw new TypeError(`Failed to read file: ${path$1}`, { cause: error });
|
|
1526
|
+
}
|
|
1527
|
+
const name = normaliseFileName(options.fileName ?? path.basename(path$1));
|
|
1528
|
+
return new _FileUpload(new Uint8Array(contents), name, options.mimeType ?? guessMimeType(name));
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Wraps bytes already held in memory into an upload.
|
|
1532
|
+
*
|
|
1533
|
+
* Use this when the bytes never came from a file on disk: a slice of a larger
|
|
1534
|
+
* file being sent through the multipart flow, a payload received over the
|
|
1535
|
+
* wire, or content generated at runtime. `fileName` is required because there
|
|
1536
|
+
* is no path to derive one from.
|
|
1537
|
+
*
|
|
1538
|
+
* @param contents The raw bytes to send as the part body.
|
|
1539
|
+
* @param fileName Name to send in the part header.
|
|
1540
|
+
* @param options Optional MIME type override.
|
|
1541
|
+
* @returns An upload carrying the given bytes, name, and MIME type.
|
|
1542
|
+
* @throws {TypeError} If `fileName` normalises to nothing usable.
|
|
1543
|
+
*/
|
|
1544
|
+
static fromContents(contents, fileName, options = {}) {
|
|
1545
|
+
const name = normaliseFileName(fileName);
|
|
1546
|
+
return new _FileUpload(contents, name, options.mimeType ?? guessMimeType(name));
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Returns the raw bytes that will form the body of the multipart part.
|
|
1550
|
+
*
|
|
1551
|
+
* @returns The file's contents.
|
|
1552
|
+
*/
|
|
1553
|
+
contents() {
|
|
1554
|
+
return this.#contents;
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Returns the normalised file name sent in the part header.
|
|
1558
|
+
*
|
|
1559
|
+
* The server uses this to derive the stored object name, inserting a unique
|
|
1560
|
+
* identifier before the extension so repeat uploads of the same name never
|
|
1561
|
+
* collide.
|
|
1562
|
+
*
|
|
1563
|
+
* @returns The file name, stripped of directory components and
|
|
1564
|
+
* header-breaking characters.
|
|
1565
|
+
*/
|
|
1566
|
+
fileName() {
|
|
1567
|
+
return this.#fileName;
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Returns the MIME type sent for the part.
|
|
1571
|
+
*
|
|
1572
|
+
* @returns The MIME type, e.g. `image/jpeg`.
|
|
1573
|
+
*/
|
|
1574
|
+
mimeType() {
|
|
1575
|
+
return this.#mimeType;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Returns the size of the upload in bytes.
|
|
1579
|
+
*
|
|
1580
|
+
* Useful for checking a file against an endpoint's documented limit before
|
|
1581
|
+
* spending a round trip on it, and for the `fileSize` value the multipart
|
|
1582
|
+
* initiate call requires.
|
|
1583
|
+
*
|
|
1584
|
+
* @returns The number of bytes in {@link FileUpload.contents}.
|
|
1585
|
+
*/
|
|
1586
|
+
size() {
|
|
1587
|
+
return this.#contents.byteLength;
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
function normaliseFileName(fileName) {
|
|
1591
|
+
const stripped = fileName.trim().replace(/[\r\n"\\]/g, "");
|
|
1592
|
+
const name = path.basename(stripped).trim();
|
|
1593
|
+
if (name === "" || name === "." || name === "..") {
|
|
1594
|
+
throw new TypeError("File name must not be empty.");
|
|
1595
|
+
}
|
|
1596
|
+
return name;
|
|
1597
|
+
}
|
|
1598
|
+
function guessMimeType(fileName) {
|
|
1599
|
+
const extension = path.extname(fileName).replace(/^\./, "").toLowerCase();
|
|
1600
|
+
return MIME_TYPES[extension] ?? FALLBACK_MIME_TYPE;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// src/resource/intake-submissions.ts
|
|
1604
|
+
var IntakeSubmissions = class _IntakeSubmissions extends AbstractResource {
|
|
1605
|
+
/**
|
|
1606
|
+
* @param transport The shared transport.
|
|
1607
|
+
*/
|
|
1608
|
+
constructor(transport) {
|
|
1609
|
+
super(transport);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Fetches one intake submission and its recorded answers.
|
|
1613
|
+
*
|
|
1614
|
+
* The teleform id is required alongside the submission id because a submission
|
|
1615
|
+
* is only meaningful against the form it answers - the server uses it to
|
|
1616
|
+
* resolve field definitions for the stored values.
|
|
1617
|
+
*
|
|
1618
|
+
* @param options The submission id and the teleform it answers.
|
|
1619
|
+
* @returns The submission and its answers.
|
|
1620
|
+
* @throws {NotFoundError} If the submission does not exist.
|
|
1621
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1622
|
+
* @throws {TransportError} If the request never completed.
|
|
1623
|
+
*/
|
|
1624
|
+
async view(options) {
|
|
1625
|
+
return await this.transport.send({
|
|
1626
|
+
service: "sales",
|
|
1627
|
+
method: "GET",
|
|
1628
|
+
path: "/intake-submissions/view/{id}",
|
|
1629
|
+
pathParams: { id: options.id },
|
|
1630
|
+
query: { teleform_id: options.teleformId }
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Creates the intake-submission record for a session and records the first
|
|
1635
|
+
* answers.
|
|
1636
|
+
*
|
|
1637
|
+
* This is the call that opens the record, so it takes an `*Initiated` event -
|
|
1638
|
+
* `Event.PreQualifyingInitiated` or `Event.IntakeInitiated`. Every later step
|
|
1639
|
+
* goes to {@link IntakeSubmissions.update}. The server advances its order-flow
|
|
1640
|
+
* state machine on the event, so passing the wrong one puts the flow in the
|
|
1641
|
+
* wrong state rather than failing loudly.
|
|
1642
|
+
*
|
|
1643
|
+
* @param options The session, event, teleform, answers, and optional progress.
|
|
1644
|
+
* @returns The created submission.
|
|
1645
|
+
* @throws {ValidationError} If the answers or the event are rejected.
|
|
1646
|
+
* @throws {NotFoundError} If the session or teleform does not exist.
|
|
1647
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1648
|
+
* @throws {TransportError} If the request never completed.
|
|
1649
|
+
*/
|
|
1650
|
+
async create(options) {
|
|
1651
|
+
return await this.transport.send({
|
|
1652
|
+
service: "sales",
|
|
1653
|
+
method: "POST",
|
|
1654
|
+
path: "/intake-submissions/create",
|
|
1655
|
+
body: intakeBody(options)
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Records further answers against an existing intake submission.
|
|
1660
|
+
*
|
|
1661
|
+
* Takes an `*InProgress` event for an intermediate step or a `*Completed` event
|
|
1662
|
+
* for the final submission. `data` is cumulative - send every answer collected
|
|
1663
|
+
* so far, since the server treats the payload as the current state of the form
|
|
1664
|
+
* rather than a patch.
|
|
1665
|
+
*
|
|
1666
|
+
* @param options The session, event, teleform, answers, and optional progress.
|
|
1667
|
+
* @returns The updated submission.
|
|
1668
|
+
* @throws {ValidationError} If the answers or the event are rejected.
|
|
1669
|
+
* @throws {NotFoundError} If no submission exists for the session.
|
|
1670
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1671
|
+
* @throws {TransportError} If the request never completed.
|
|
1672
|
+
*/
|
|
1673
|
+
async update(options) {
|
|
1674
|
+
return await this.transport.send({
|
|
1675
|
+
service: "sales",
|
|
1676
|
+
method: "PUT",
|
|
1677
|
+
path: "/intake-submissions/update/{session}",
|
|
1678
|
+
pathParams: { session: options.session },
|
|
1679
|
+
body: intakeBody(options)
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
/** Smallest part the multipart endpoint accepts: 5 MB. */
|
|
1683
|
+
static MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
1684
|
+
/** Largest part the multipart endpoint accepts: 25 MB. */
|
|
1685
|
+
static MAX_PART_SIZE = 25 * 1024 * 1024;
|
|
1686
|
+
/** Part size {@link IntakeSubmissions.uploadLargeFile} uses by default: 10 MB. */
|
|
1687
|
+
static DEFAULT_PART_SIZE = 10 * 1024 * 1024;
|
|
1688
|
+
/**
|
|
1689
|
+
* Uploads one file in a single request, for a file-type answer.
|
|
1690
|
+
*
|
|
1691
|
+
* Use this for the sizes a single request can carry - a photograph of an ID, a
|
|
1692
|
+
* PDF. The whole file is held in memory and sent as one `multipart/form-data`
|
|
1693
|
+
* body. For anything large enough to risk a timeout, use
|
|
1694
|
+
* {@link IntakeSubmissions.uploadLargeFile} instead.
|
|
1695
|
+
*
|
|
1696
|
+
* The response carries the stored file's reference, which is what you place in
|
|
1697
|
+
* the `value` of the corresponding field when you next call
|
|
1698
|
+
* {@link IntakeSubmissions.update}.
|
|
1699
|
+
*
|
|
1700
|
+
* @param options The session and the file.
|
|
1701
|
+
* @returns The stored file's reference.
|
|
1702
|
+
* @throws {ValidationError} If the file type or size is rejected.
|
|
1703
|
+
* @throws {NotFoundError} If the session does not exist.
|
|
1704
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1705
|
+
* @throws {TransportError} If the request never completed.
|
|
1706
|
+
*/
|
|
1707
|
+
async uploadFile(options) {
|
|
1708
|
+
return await this.transport.send({
|
|
1709
|
+
service: "sales",
|
|
1710
|
+
method: "POST",
|
|
1711
|
+
path: "/intake-submissions/upload-file/{session_id}",
|
|
1712
|
+
pathParams: { session_id: options.sessionId },
|
|
1713
|
+
file: options.file
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Opens a multipart upload and returns the id the parts will be sent under.
|
|
1718
|
+
*
|
|
1719
|
+
* The first of the four low-level multipart calls. Declare the *whole* file's
|
|
1720
|
+
* name, type, and size here - the server uses the size to validate the parts it
|
|
1721
|
+
* later receives. Prefer {@link IntakeSubmissions.uploadLargeFile}, which
|
|
1722
|
+
* drives all four calls; reach for these directly only when you need to
|
|
1723
|
+
* control the loop, for instance to upload parts from several workers.
|
|
1724
|
+
*
|
|
1725
|
+
* @param options The session and the whole file's metadata.
|
|
1726
|
+
* @returns The opened upload; `data().upload_id` carries the id.
|
|
1727
|
+
* @throws {ValidationError} If the metadata is rejected.
|
|
1728
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1729
|
+
* @throws {TransportError} If the request never completed.
|
|
1730
|
+
*/
|
|
1731
|
+
async initiateMultipartUpload(options) {
|
|
1732
|
+
return await this.transport.send({
|
|
1733
|
+
service: "sales",
|
|
1734
|
+
method: "POST",
|
|
1735
|
+
path: "/intake-submissions/upload-file-multipart/initiate/{session_id}",
|
|
1736
|
+
pathParams: { session_id: options.sessionId },
|
|
1737
|
+
body: {
|
|
1738
|
+
file_name: options.fileName,
|
|
1739
|
+
mime_type: options.mimeType,
|
|
1740
|
+
file_size: options.fileSize
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Uploads one part of an open multipart upload.
|
|
1746
|
+
*
|
|
1747
|
+
* Part numbers are 1-based and must arrive with the part they label; the
|
|
1748
|
+
* response carries an etag you must keep, because
|
|
1749
|
+
* {@link IntakeSubmissions.finishMultipartUpload} needs every part's etag to
|
|
1750
|
+
* assemble the file. Parts may be uploaded in any order.
|
|
1751
|
+
*
|
|
1752
|
+
* @param options The upload id, the part number, and the part's bytes.
|
|
1753
|
+
* @returns The stored part; `data().etag` carries the etag.
|
|
1754
|
+
* @throws {TypeError} If `partNumber` is outside 1–10000.
|
|
1755
|
+
* @throws {NotFoundError} If the upload id is unknown or already finished.
|
|
1756
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1757
|
+
* @throws {TransportError} If the request never completed.
|
|
1758
|
+
*/
|
|
1759
|
+
async uploadMultipartPart(options) {
|
|
1760
|
+
if (options.partNumber < 1 || options.partNumber > 1e4) {
|
|
1761
|
+
throw new TypeError(`Part number must be between 1 and 10000, got ${options.partNumber}.`);
|
|
1762
|
+
}
|
|
1763
|
+
return await this.transport.send({
|
|
1764
|
+
service: "sales",
|
|
1765
|
+
method: "POST",
|
|
1766
|
+
path: "/intake-submissions/upload-file-multipart/part/{upload_id}",
|
|
1767
|
+
pathParams: { upload_id: options.uploadId },
|
|
1768
|
+
query: { part_number: options.partNumber },
|
|
1769
|
+
file: options.part
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Closes a multipart upload, assembling the parts into one stored file.
|
|
1774
|
+
*
|
|
1775
|
+
* Send every part with the etag its own upload returned. The server verifies
|
|
1776
|
+
* the set against what it received, so a missing or mismatched part fails here
|
|
1777
|
+
* rather than producing a corrupt file.
|
|
1778
|
+
*
|
|
1779
|
+
* @param options The upload id and every part with its etag.
|
|
1780
|
+
* @returns The stored file's reference.
|
|
1781
|
+
* @throws {TypeError} If `parts` is empty.
|
|
1782
|
+
* @throws {ValidationError} If the parts do not match what the server holds.
|
|
1783
|
+
* @throws {NotFoundError} If the upload id is unknown.
|
|
1784
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1785
|
+
* @throws {TransportError} If the request never completed.
|
|
1786
|
+
*/
|
|
1787
|
+
async finishMultipartUpload(options) {
|
|
1788
|
+
if (options.parts.length === 0) {
|
|
1789
|
+
throw new TypeError("At least one part is required to finish a multipart upload.");
|
|
1790
|
+
}
|
|
1791
|
+
return await this.transport.send({
|
|
1792
|
+
service: "sales",
|
|
1793
|
+
method: "POST",
|
|
1794
|
+
path: "/intake-submissions/upload-file-multipart/finish/{upload_id}",
|
|
1795
|
+
pathParams: { upload_id: options.uploadId },
|
|
1796
|
+
body: { parts: options.parts }
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Abandons a multipart upload and discards the parts already stored.
|
|
1801
|
+
*
|
|
1802
|
+
* Call this when an upload cannot be completed, so the parts do not linger.
|
|
1803
|
+
* {@link IntakeSubmissions.uploadLargeFile} does it for you on failure.
|
|
1804
|
+
*
|
|
1805
|
+
* @param uploadId The upload id to abandon.
|
|
1806
|
+
* @returns The abort confirmation.
|
|
1807
|
+
* @throws {NotFoundError} If the upload id is unknown or already finished.
|
|
1808
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1809
|
+
* @throws {TransportError} If the request never completed.
|
|
1810
|
+
*/
|
|
1811
|
+
async abortMultipartUpload(uploadId) {
|
|
1812
|
+
return await this.transport.send({
|
|
1813
|
+
service: "sales",
|
|
1814
|
+
method: "POST",
|
|
1815
|
+
path: "/intake-submissions/upload-file-multipart/abort/{upload_id}",
|
|
1816
|
+
pathParams: { upload_id: uploadId }
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Uploads a large file from disk, driving the whole multipart flow.
|
|
1821
|
+
*
|
|
1822
|
+
* This is the method to reach for when a file is too big for one request. It
|
|
1823
|
+
* streams the file one part at a time, so a 200 MB video costs one part of
|
|
1824
|
+
* memory rather than 200 MB: initiate, then read a part, upload it, keep its
|
|
1825
|
+
* etag, repeat, then finish. If any step fails the upload is aborted so no
|
|
1826
|
+
* parts are left behind, and the failure that broke the upload is what
|
|
1827
|
+
* surfaces - not the abort's own outcome.
|
|
1828
|
+
*
|
|
1829
|
+
* The first part fixes the name and MIME type for the whole upload, so
|
|
1830
|
+
* `initiate` and every part agree.
|
|
1831
|
+
*
|
|
1832
|
+
* ```ts
|
|
1833
|
+
* const stored = await client.intakeSubmissions().uploadLargeFile({
|
|
1834
|
+
* sessionId,
|
|
1835
|
+
* filePath: '/var/uploads/consult-video.mp4',
|
|
1836
|
+
* });
|
|
1837
|
+
* ```
|
|
1838
|
+
*
|
|
1839
|
+
* @param options The session, the file's path, and optional name, MIME type,
|
|
1840
|
+
* and part size.
|
|
1841
|
+
* @returns The finished upload's response - the same one
|
|
1842
|
+
* {@link IntakeSubmissions.finishMultipartUpload} returns.
|
|
1843
|
+
* @throws {TypeError} If `partSize` is outside the allowed range, or if the
|
|
1844
|
+
* file is missing, unreadable, or empty.
|
|
1845
|
+
* @throws {ApiError} If the server response omits a usable `upload_id` or
|
|
1846
|
+
* `etag`, or on any other non-2xx status.
|
|
1847
|
+
* @throws {TransportError} If a request never completed.
|
|
1848
|
+
*/
|
|
1849
|
+
async uploadLargeFile(options) {
|
|
1850
|
+
const partSize = options.partSize ?? _IntakeSubmissions.DEFAULT_PART_SIZE;
|
|
1851
|
+
if (partSize < _IntakeSubmissions.MIN_PART_SIZE || partSize > _IntakeSubmissions.MAX_PART_SIZE) {
|
|
1852
|
+
throw new TypeError(
|
|
1853
|
+
`Part size must be between ${_IntakeSubmissions.MIN_PART_SIZE} and ${_IntakeSubmissions.MAX_PART_SIZE} bytes, got ${partSize}.`
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
let fileSize;
|
|
1857
|
+
try {
|
|
1858
|
+
const stats = await promises.stat(options.filePath);
|
|
1859
|
+
if (!stats.isFile()) {
|
|
1860
|
+
throw new TypeError(`File is not readable: ${options.filePath}`);
|
|
1861
|
+
}
|
|
1862
|
+
fileSize = stats.size;
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
if (error instanceof TypeError) {
|
|
1865
|
+
throw error;
|
|
1866
|
+
}
|
|
1867
|
+
throw new TypeError(`File is not readable: ${options.filePath}`, {
|
|
1868
|
+
cause: error
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
if (fileSize === 0) {
|
|
1872
|
+
throw new TypeError(`File is empty: ${options.filePath}`);
|
|
1873
|
+
}
|
|
1874
|
+
const handle = await promises.open(options.filePath, "r");
|
|
1875
|
+
try {
|
|
1876
|
+
const buffer = Buffer.alloc(partSize);
|
|
1877
|
+
const firstBytesRead = await readFull(handle, buffer, partSize);
|
|
1878
|
+
if (firstBytesRead === 0) {
|
|
1879
|
+
throw new TypeError(`File is empty: ${options.filePath}`);
|
|
1880
|
+
}
|
|
1881
|
+
let part = FileUpload.fromContents(
|
|
1882
|
+
new Uint8Array(buffer.subarray(0, firstBytesRead)),
|
|
1883
|
+
options.fileName ?? path.basename(options.filePath),
|
|
1884
|
+
options.mimeType === void 0 ? {} : { mimeType: options.mimeType }
|
|
1885
|
+
);
|
|
1886
|
+
const uploadId = stringField(
|
|
1887
|
+
await this.initiateMultipartUpload({
|
|
1888
|
+
sessionId: options.sessionId,
|
|
1889
|
+
fileName: part.fileName(),
|
|
1890
|
+
mimeType: part.mimeType(),
|
|
1891
|
+
fileSize
|
|
1892
|
+
}),
|
|
1893
|
+
"upload_id"
|
|
1894
|
+
);
|
|
1895
|
+
try {
|
|
1896
|
+
const parts = [];
|
|
1897
|
+
let partNumber = 1;
|
|
1898
|
+
for (; ; ) {
|
|
1899
|
+
const result = await this.uploadMultipartPart({
|
|
1900
|
+
uploadId,
|
|
1901
|
+
partNumber,
|
|
1902
|
+
part
|
|
1903
|
+
});
|
|
1904
|
+
parts.push({
|
|
1905
|
+
part_number: partNumber,
|
|
1906
|
+
etag: stringField(result, "etag")
|
|
1907
|
+
});
|
|
1908
|
+
const nextBytesRead = await readFull(handle, buffer, partSize);
|
|
1909
|
+
if (nextBytesRead === 0) {
|
|
1910
|
+
break;
|
|
1911
|
+
}
|
|
1912
|
+
partNumber += 1;
|
|
1913
|
+
part = FileUpload.fromContents(new Uint8Array(buffer.subarray(0, nextBytesRead)), part.fileName(), {
|
|
1914
|
+
mimeType: part.mimeType()
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
return await this.finishMultipartUpload({ uploadId, parts });
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
try {
|
|
1920
|
+
await this.abortMultipartUpload(uploadId);
|
|
1921
|
+
} catch {
|
|
1922
|
+
}
|
|
1923
|
+
throw error;
|
|
1924
|
+
}
|
|
1925
|
+
} finally {
|
|
1926
|
+
await handle.close().catch(() => void 0);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1930
|
+
async function readFull(handle, buffer, length) {
|
|
1931
|
+
let filled = 0;
|
|
1932
|
+
while (filled < length) {
|
|
1933
|
+
const { bytesRead } = await handle.read(buffer, filled, length - filled, null);
|
|
1934
|
+
if (bytesRead === 0) {
|
|
1935
|
+
break;
|
|
1936
|
+
}
|
|
1937
|
+
filled += bytesRead;
|
|
1938
|
+
}
|
|
1939
|
+
return filled;
|
|
1940
|
+
}
|
|
1941
|
+
function intakeBody(options) {
|
|
1942
|
+
const body = {
|
|
1943
|
+
session: options.session,
|
|
1944
|
+
event: options.event,
|
|
1945
|
+
teleform_id: options.teleformId,
|
|
1946
|
+
data: options.data
|
|
1947
|
+
};
|
|
1948
|
+
if (options.progress !== void 0) {
|
|
1949
|
+
body.progress = options.progress;
|
|
1950
|
+
}
|
|
1951
|
+
return body;
|
|
1952
|
+
}
|
|
1953
|
+
function stringField(response, key) {
|
|
1954
|
+
const value = response.data()[key];
|
|
1955
|
+
if (typeof value !== "string" || value === "") {
|
|
1956
|
+
throw new ApiError(`The server response did not include a usable ${key}.`, response.statusCode(), {});
|
|
1957
|
+
}
|
|
1958
|
+
return value;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// src/resource/lab-tests.ts
|
|
1962
|
+
var LabTests = class extends AbstractResource {
|
|
1963
|
+
/**
|
|
1964
|
+
* @param transport The shared transport.
|
|
1965
|
+
*/
|
|
1966
|
+
constructor(transport) {
|
|
1967
|
+
super(transport);
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Lists lab tests, filtered and paginated.
|
|
1971
|
+
*
|
|
1972
|
+
* Pagination details come back in `meta()`. The accepted filters are in the
|
|
1973
|
+
* API reference in your AsterMD dashboard.
|
|
1974
|
+
*
|
|
1975
|
+
* @param query Filters and pagination parameters.
|
|
1976
|
+
* @returns The matching lab tests, with pagination in `meta()`.
|
|
1977
|
+
* @throws {ValidationError} If a filter is rejected.
|
|
1978
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1979
|
+
* @throws {TransportError} If the request never completed.
|
|
1980
|
+
*/
|
|
1981
|
+
async list(query = {}) {
|
|
1982
|
+
return await this.transport.send({
|
|
1983
|
+
service: "sales",
|
|
1984
|
+
method: "GET",
|
|
1985
|
+
path: "/lab-tests/list",
|
|
1986
|
+
query
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Fetches one lab test.
|
|
1991
|
+
*
|
|
1992
|
+
* @param id The lab test's id.
|
|
1993
|
+
* @returns The lab test record.
|
|
1994
|
+
* @throws {NotFoundError} If the lab test does not exist or is out of scope.
|
|
1995
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
1996
|
+
* @throws {TransportError} If the request never completed.
|
|
1997
|
+
*/
|
|
1998
|
+
async view(id) {
|
|
1999
|
+
return await this.transport.send({
|
|
2000
|
+
service: "sales",
|
|
2001
|
+
method: "GET",
|
|
2002
|
+
path: "/lab-tests/view/{id}",
|
|
2003
|
+
pathParams: { id }
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
// src/resource/medications.ts
|
|
2009
|
+
var Medications = class extends AbstractResource {
|
|
2010
|
+
/**
|
|
2011
|
+
* @param transport The shared transport.
|
|
2012
|
+
*/
|
|
2013
|
+
constructor(transport) {
|
|
2014
|
+
super(transport);
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* Lists medications, filtered and paginated.
|
|
2018
|
+
*
|
|
2019
|
+
* Intended to back a search or type-ahead on an intake form. Pagination comes
|
|
2020
|
+
* back in `meta()`; the accepted filters are in the API reference in your
|
|
2021
|
+
* AsterMD dashboard.
|
|
2022
|
+
*
|
|
2023
|
+
* @param query Filters and pagination parameters.
|
|
2024
|
+
* @returns The matching medications, with pagination in `meta()`.
|
|
2025
|
+
* @throws {ValidationError} If a filter is rejected.
|
|
2026
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2027
|
+
* @throws {TransportError} If the request never completed.
|
|
2028
|
+
*/
|
|
2029
|
+
async list(query = {}) {
|
|
2030
|
+
return await this.transport.send({
|
|
2031
|
+
service: "sales",
|
|
2032
|
+
method: "GET",
|
|
2033
|
+
path: "/medications/list",
|
|
2034
|
+
query
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
// src/resource/opportunities.ts
|
|
2040
|
+
var Opportunities = class extends AbstractResource {
|
|
2041
|
+
/**
|
|
2042
|
+
* @param transport The shared transport.
|
|
2043
|
+
*/
|
|
2044
|
+
constructor(transport) {
|
|
2045
|
+
super(transport);
|
|
2046
|
+
}
|
|
2047
|
+
/**
|
|
2048
|
+
* Creates an opportunity.
|
|
2049
|
+
*
|
|
2050
|
+
* Include a `sessions` array of the session identifiers that led here, so the
|
|
2051
|
+
* server can attribute the lead. The rest of the payload is forwarded
|
|
2052
|
+
* unmodified.
|
|
2053
|
+
*
|
|
2054
|
+
* @param data The prospect's details and the sessions to attach.
|
|
2055
|
+
* @returns The created opportunity, including its id.
|
|
2056
|
+
* @throws {ValidationError} If a field is missing or rejected.
|
|
2057
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2058
|
+
* @throws {TransportError} If the request never completed.
|
|
2059
|
+
*/
|
|
2060
|
+
async create(data) {
|
|
2061
|
+
return await this.transport.send({
|
|
2062
|
+
service: "sales",
|
|
2063
|
+
method: "POST",
|
|
2064
|
+
path: "/opportunities/create",
|
|
2065
|
+
body: data
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Fetches one opportunity.
|
|
2070
|
+
*
|
|
2071
|
+
* @param id The opportunity's id.
|
|
2072
|
+
* @returns The opportunity record.
|
|
2073
|
+
* @throws {NotFoundError} If the opportunity does not exist or is out of scope.
|
|
2074
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2075
|
+
* @throws {TransportError} If the request never completed.
|
|
2076
|
+
*/
|
|
2077
|
+
async view(id) {
|
|
2078
|
+
return await this.transport.send({
|
|
2079
|
+
service: "sales",
|
|
2080
|
+
method: "GET",
|
|
2081
|
+
path: "/opportunities/view/{id}",
|
|
2082
|
+
pathParams: { id }
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Updates an opportunity.
|
|
2087
|
+
*
|
|
2088
|
+
* Send only the fields you are changing. Use this to enrich a lead as more
|
|
2089
|
+
* information arrives - a phone number, a later session to attach.
|
|
2090
|
+
*
|
|
2091
|
+
* @param id The opportunity's id.
|
|
2092
|
+
* @param data The fields to change.
|
|
2093
|
+
* @returns The updated opportunity.
|
|
2094
|
+
* @throws {NotFoundError} If the opportunity does not exist.
|
|
2095
|
+
* @throws {ValidationError} If a field is rejected.
|
|
2096
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2097
|
+
* @throws {TransportError} If the request never completed.
|
|
2098
|
+
*/
|
|
2099
|
+
async update(id, data) {
|
|
2100
|
+
return await this.transport.send({
|
|
2101
|
+
service: "sales",
|
|
2102
|
+
method: "PUT",
|
|
2103
|
+
path: "/opportunities/update/{id}",
|
|
2104
|
+
pathParams: { id },
|
|
2105
|
+
body: data
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Changes an opportunity's status.
|
|
2110
|
+
*
|
|
2111
|
+
* Kept separate from {@link Opportunities.update} so a pipeline transition is
|
|
2112
|
+
* explicit in your code and in the server's audit trail. The accepted values
|
|
2113
|
+
* are configured for your organisation; see the API reference in your AsterMD
|
|
2114
|
+
* dashboard.
|
|
2115
|
+
*
|
|
2116
|
+
* @param id The opportunity's id.
|
|
2117
|
+
* @param status The new status value, sent as `opportunity_status`.
|
|
2118
|
+
* @returns The updated opportunity.
|
|
2119
|
+
* @throws {NotFoundError} If the opportunity does not exist.
|
|
2120
|
+
* @throws {ValidationError} If the status is not an accepted value.
|
|
2121
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2122
|
+
* @throws {TransportError} If the request never completed.
|
|
2123
|
+
*/
|
|
2124
|
+
async status(id, status) {
|
|
2125
|
+
return await this.transport.send({
|
|
2126
|
+
service: "sales",
|
|
2127
|
+
method: "PATCH",
|
|
2128
|
+
path: "/opportunities/status/{id}",
|
|
2129
|
+
pathParams: { id },
|
|
2130
|
+
body: { opportunity_status: status }
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
};
|
|
2134
|
+
|
|
2135
|
+
// src/resource/patients.ts
|
|
2136
|
+
var Patients = class extends AbstractResource {
|
|
2137
|
+
/**
|
|
2138
|
+
* @param transport The shared transport.
|
|
2139
|
+
*/
|
|
2140
|
+
constructor(transport) {
|
|
2141
|
+
super(transport);
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Creates a patient record.
|
|
2145
|
+
*
|
|
2146
|
+
* Call this once the prospect has qualified and is proceeding. The payload is
|
|
2147
|
+
* forwarded unmodified, so it carries whatever demographic and contact fields
|
|
2148
|
+
* your organisation's configuration requires.
|
|
2149
|
+
*
|
|
2150
|
+
* @param data The patient's details. See the API reference in your AsterMD
|
|
2151
|
+
* dashboard for the accepted fields.
|
|
2152
|
+
* @returns The created patient; `data()` carries the new record including its id.
|
|
2153
|
+
* @throws {ValidationError} If a field is missing or rejected.
|
|
2154
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2155
|
+
* @throws {TransportError} If the request never completed.
|
|
2156
|
+
*/
|
|
2157
|
+
async create(data) {
|
|
2158
|
+
return await this.transport.send({
|
|
2159
|
+
service: "sales",
|
|
2160
|
+
method: "POST",
|
|
2161
|
+
path: "/patients/create",
|
|
2162
|
+
body: data
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Fetches one patient record.
|
|
2167
|
+
*
|
|
2168
|
+
* Returns the record as your credentials are permitted to see it. Because the
|
|
2169
|
+
* organisation scope lives inside the JWT, an id belonging to another
|
|
2170
|
+
* organisation is indistinguishable from one that never existed - both raise
|
|
2171
|
+
* {@link NotFoundError}.
|
|
2172
|
+
*
|
|
2173
|
+
* @param id The patient's id.
|
|
2174
|
+
* @returns The patient record.
|
|
2175
|
+
* @throws {NotFoundError} If the patient does not exist or is out of scope.
|
|
2176
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2177
|
+
* @throws {TransportError} If the request never completed.
|
|
2178
|
+
*/
|
|
2179
|
+
async view(id) {
|
|
2180
|
+
return await this.transport.send({
|
|
2181
|
+
service: "sales",
|
|
2182
|
+
method: "GET",
|
|
2183
|
+
path: "/patients/view/{id}",
|
|
2184
|
+
pathParams: { id }
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Updates a patient record.
|
|
2189
|
+
*
|
|
2190
|
+
* Send only the fields you are changing; the server merges them into the
|
|
2191
|
+
* existing record rather than replacing it.
|
|
2192
|
+
*
|
|
2193
|
+
* @param id The patient's id.
|
|
2194
|
+
* @param data The fields to change.
|
|
2195
|
+
* @returns The updated patient record.
|
|
2196
|
+
* @throws {NotFoundError} If the patient does not exist.
|
|
2197
|
+
* @throws {ValidationError} If a field is rejected.
|
|
2198
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2199
|
+
* @throws {TransportError} If the request never completed.
|
|
2200
|
+
*/
|
|
2201
|
+
async update(id, data) {
|
|
2202
|
+
return await this.transport.send({
|
|
2203
|
+
service: "sales",
|
|
2204
|
+
method: "PUT",
|
|
2205
|
+
path: "/patients/update/{id}",
|
|
2206
|
+
pathParams: { id },
|
|
2207
|
+
body: data
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Changes a patient's status.
|
|
2212
|
+
*
|
|
2213
|
+
* A narrow call for the one field that drives workflow state, kept separate
|
|
2214
|
+
* from {@link Patients.update} so a status transition is explicit in your code
|
|
2215
|
+
* and in the server's audit trail. The accepted values are configured for your
|
|
2216
|
+
* organisation; see the API reference in your AsterMD dashboard.
|
|
2217
|
+
*
|
|
2218
|
+
* @param id The patient's id.
|
|
2219
|
+
* @param status The new status value.
|
|
2220
|
+
* @returns The updated patient record.
|
|
2221
|
+
* @throws {NotFoundError} If the patient does not exist.
|
|
2222
|
+
* @throws {ValidationError} If the status is not an accepted value.
|
|
2223
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2224
|
+
* @throws {TransportError} If the request never completed.
|
|
2225
|
+
*/
|
|
2226
|
+
async status(id, status) {
|
|
2227
|
+
return await this.transport.send({
|
|
2228
|
+
service: "sales",
|
|
2229
|
+
method: "PATCH",
|
|
2230
|
+
path: "/patients/status/{id}",
|
|
2231
|
+
pathParams: { id },
|
|
2232
|
+
body: { status }
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Submits a patient's health information.
|
|
2237
|
+
*
|
|
2238
|
+
* This is the clinical questionnaire result a clinician reviews, and the most
|
|
2239
|
+
* sensitive payload the SDK carries. Where the server requires explicit patient
|
|
2240
|
+
* confirmation, obtain a token through
|
|
2241
|
+
* {@link Patients.verifyHealthInformationOtp} first and pass it as
|
|
2242
|
+
* `phiVerificationToken`.
|
|
2243
|
+
*
|
|
2244
|
+
* Neither the request nor the response body is ever written to a debug log.
|
|
2245
|
+
*
|
|
2246
|
+
* @param options The health information, and the verification token where one
|
|
2247
|
+
* is required.
|
|
2248
|
+
* @returns The stored submission.
|
|
2249
|
+
* @throws {AuthenticationError} If the verification token is missing or invalid.
|
|
2250
|
+
* @throws {ValidationError} If the payload is rejected.
|
|
2251
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2252
|
+
* @throws {TransportError} If the request never completed.
|
|
2253
|
+
*/
|
|
2254
|
+
async submitHealthInformation(options) {
|
|
2255
|
+
const headers = options.phiVerificationToken !== void 0 ? { "x-phi-verification-token": options.phiVerificationToken } : {};
|
|
2256
|
+
return await this.transport.send({
|
|
2257
|
+
service: "sales",
|
|
2258
|
+
method: "POST",
|
|
2259
|
+
path: "/patients/health-information",
|
|
2260
|
+
body: options.data,
|
|
2261
|
+
headers
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* Verifies the one-time code a patient was sent, before health information is
|
|
2266
|
+
* submitted.
|
|
2267
|
+
*
|
|
2268
|
+
* The confirming half of the two-step health-information flow. On success the
|
|
2269
|
+
* response carries the verification token to pass to
|
|
2270
|
+
* {@link Patients.submitHealthInformation}.
|
|
2271
|
+
*
|
|
2272
|
+
* The one-time code is a credential: it is never written to a debug log, and it
|
|
2273
|
+
* should not be written to yours either.
|
|
2274
|
+
*
|
|
2275
|
+
* @param data The patient identifier and the one-time code. See the API
|
|
2276
|
+
* reference in your AsterMD dashboard for the exact fields.
|
|
2277
|
+
* @returns The verification result, carrying the token on success.
|
|
2278
|
+
* @throws {ValidationError} If the code is wrong, expired, or malformed.
|
|
2279
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2280
|
+
* @throws {TransportError} If the request never completed.
|
|
2281
|
+
*/
|
|
2282
|
+
async verifyHealthInformationOtp(data) {
|
|
2283
|
+
return await this.transport.send({
|
|
2284
|
+
service: "sales",
|
|
2285
|
+
method: "POST",
|
|
2286
|
+
path: "/patients/health-information/otp-verification",
|
|
2287
|
+
body: data
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
};
|
|
2291
|
+
|
|
2292
|
+
// src/resource/products.ts
|
|
2293
|
+
var Products = class extends AbstractResource {
|
|
2294
|
+
/**
|
|
2295
|
+
* @param transport The shared transport.
|
|
2296
|
+
*/
|
|
2297
|
+
constructor(transport) {
|
|
2298
|
+
super(transport);
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Lists products, filtered and paginated.
|
|
2302
|
+
*
|
|
2303
|
+
* Pagination details come back in `meta()`. The accepted filters are in the
|
|
2304
|
+
* API reference in your AsterMD dashboard.
|
|
2305
|
+
*
|
|
2306
|
+
* @param query Filters and pagination parameters.
|
|
2307
|
+
* @returns The matching products, with pagination in `meta()`.
|
|
2308
|
+
* @throws {ValidationError} If a filter is rejected.
|
|
2309
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2310
|
+
* @throws {TransportError} If the request never completed.
|
|
2311
|
+
*/
|
|
2312
|
+
async list(query = {}) {
|
|
2313
|
+
return await this.transport.send({
|
|
2314
|
+
service: "sales",
|
|
2315
|
+
method: "GET",
|
|
2316
|
+
path: "/products/list",
|
|
2317
|
+
query
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2321
|
+
* Fetches one product, including its variants and prices.
|
|
2322
|
+
*
|
|
2323
|
+
* @param id The product's id.
|
|
2324
|
+
* @returns The product record.
|
|
2325
|
+
* @throws {NotFoundError} If the product does not exist or is out of scope.
|
|
2326
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2327
|
+
* @throws {TransportError} If the request never completed.
|
|
2328
|
+
*/
|
|
2329
|
+
async view(id) {
|
|
2330
|
+
return await this.transport.send({
|
|
2331
|
+
service: "sales",
|
|
2332
|
+
method: "GET",
|
|
2333
|
+
path: "/products/view/{id}",
|
|
2334
|
+
pathParams: { id }
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
};
|
|
2338
|
+
|
|
2339
|
+
// src/resource/sessions.ts
|
|
2340
|
+
var Sessions = class extends AbstractResource {
|
|
2341
|
+
/**
|
|
2342
|
+
* @param transport The shared transport.
|
|
2343
|
+
*/
|
|
2344
|
+
constructor(transport) {
|
|
2345
|
+
super(transport);
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Creates a new session and returns its identifier.
|
|
2349
|
+
*
|
|
2350
|
+
* This is the first call of an order flow. The server records an implicit
|
|
2351
|
+
* `visit_page` event alongside the new session, so the funnel begins here.
|
|
2352
|
+
* Inspect `data().session` on the response for the identifier to thread through
|
|
2353
|
+
* every later call.
|
|
2354
|
+
*
|
|
2355
|
+
* @param options Attribution payload and visitor headers. All optional; with
|
|
2356
|
+
* none, an empty object body is sent.
|
|
2357
|
+
* @returns The created session; `data()` holds `{ session }`.
|
|
2358
|
+
* @throws {ValidationError} If the attribution payload is rejected.
|
|
2359
|
+
* @throws {AuthenticationError} If the credentials are refused.
|
|
2360
|
+
* @throws {RateLimitError} If the organisation's request allowance is exceeded.
|
|
2361
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2362
|
+
* @throws {TransportError} If the request never completed.
|
|
2363
|
+
*/
|
|
2364
|
+
async create(options = {}) {
|
|
2365
|
+
const headers = {};
|
|
2366
|
+
if (options.userAgent !== void 0 && options.userAgent !== "") {
|
|
2367
|
+
headers["User-Agent"] = options.userAgent;
|
|
2368
|
+
}
|
|
2369
|
+
if (options.clientIp !== void 0 && options.clientIp !== "") {
|
|
2370
|
+
headers["X-Original-Client-Ip"] = options.clientIp;
|
|
2371
|
+
}
|
|
2372
|
+
return await this.transport.send({
|
|
2373
|
+
service: "sales",
|
|
2374
|
+
method: "POST",
|
|
2375
|
+
path: "/sessions/create",
|
|
2376
|
+
body: options.data ?? {},
|
|
2377
|
+
headers
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* Fetches one or more sessions and the events recorded against them.
|
|
2382
|
+
*
|
|
2383
|
+
* Useful for reconstructing a visitor's journey server-side - which forms they
|
|
2384
|
+
* touched, where they stopped. Several identifiers can be fetched in one call,
|
|
2385
|
+
* which is cheaper than looping. Timestamps come back in `tz` when you supply
|
|
2386
|
+
* one, and in the organisation's default zone otherwise.
|
|
2387
|
+
*
|
|
2388
|
+
* @param options The session identifiers to fetch, and an optional time zone.
|
|
2389
|
+
* @returns The sessions and their recorded events.
|
|
2390
|
+
* @throws {NotFoundError} If none of the identifiers resolve.
|
|
2391
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2392
|
+
* @throws {TransportError} If the request never completed.
|
|
2393
|
+
*/
|
|
2394
|
+
async view(options) {
|
|
2395
|
+
const query = {
|
|
2396
|
+
session_ids: options.sessions.join(",")
|
|
2397
|
+
};
|
|
2398
|
+
if (options.tz !== void 0 && options.tz !== "") {
|
|
2399
|
+
query.tz = options.tz;
|
|
2400
|
+
}
|
|
2401
|
+
return await this.transport.send({
|
|
2402
|
+
service: "sales",
|
|
2403
|
+
method: "GET",
|
|
2404
|
+
path: "/sessions/view",
|
|
2405
|
+
query
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* Updates the attribution and context recorded against an existing session.
|
|
2410
|
+
*
|
|
2411
|
+
* Call this when information arrives after the session was created - a UTM
|
|
2412
|
+
* parameter only present on a later page, a referrer resolved asynchronously.
|
|
2413
|
+
* The payload is merged server-side rather than replacing what is already
|
|
2414
|
+
* recorded.
|
|
2415
|
+
*
|
|
2416
|
+
* @param session The session identifier to update.
|
|
2417
|
+
* @param data Fields to record. Forwarded unmodified; see the API reference in
|
|
2418
|
+
* your AsterMD dashboard for the recognised keys.
|
|
2419
|
+
* @returns The updated session.
|
|
2420
|
+
* @throws {NotFoundError} If the session does not exist.
|
|
2421
|
+
* @throws {ValidationError} If the payload is rejected.
|
|
2422
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2423
|
+
* @throws {TransportError} If the request never completed.
|
|
2424
|
+
*/
|
|
2425
|
+
async update(session, data = {}) {
|
|
2426
|
+
return await this.transport.send({
|
|
2427
|
+
service: "sales",
|
|
2428
|
+
method: "PUT",
|
|
2429
|
+
path: "/sessions/update/{id}",
|
|
2430
|
+
pathParams: { id: session },
|
|
2431
|
+
body: data
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Deletes a session and the events recorded against it.
|
|
2436
|
+
*
|
|
2437
|
+
* Intended for honouring a visitor's erasure request, not for ordinary
|
|
2438
|
+
* cleanup - sessions age out server-side on their own. Deleting one removes the
|
|
2439
|
+
* attribution trail for anything that referenced it.
|
|
2440
|
+
*
|
|
2441
|
+
* @param session The session identifier to delete.
|
|
2442
|
+
* @returns The deletion confirmation.
|
|
2443
|
+
* @throws {NotFoundError} If the session does not exist.
|
|
2444
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2445
|
+
* @throws {TransportError} If the request never completed.
|
|
2446
|
+
*/
|
|
2447
|
+
async delete(session) {
|
|
2448
|
+
return await this.transport.send({
|
|
2449
|
+
service: "sales",
|
|
2450
|
+
method: "DELETE",
|
|
2451
|
+
path: "/sessions/delete/{id}",
|
|
2452
|
+
pathParams: { id: session }
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2457
|
+
// src/resource/shippings.ts
|
|
2458
|
+
var Shippings = class extends AbstractResource {
|
|
2459
|
+
/**
|
|
2460
|
+
* @param transport The shared transport.
|
|
2461
|
+
*/
|
|
2462
|
+
constructor(transport) {
|
|
2463
|
+
super(transport);
|
|
2464
|
+
}
|
|
2465
|
+
/**
|
|
2466
|
+
* Lists shipping options, filtered and paginated.
|
|
2467
|
+
*
|
|
2468
|
+
* Pagination details come back in `meta()`. The accepted filters are in the
|
|
2469
|
+
* API reference in your AsterMD dashboard.
|
|
2470
|
+
*
|
|
2471
|
+
* @param query Filters and pagination parameters.
|
|
2472
|
+
* @returns The matching shipping options, with pagination in `meta()`.
|
|
2473
|
+
* @throws {ValidationError} If a filter is rejected.
|
|
2474
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2475
|
+
* @throws {TransportError} If the request never completed.
|
|
2476
|
+
*/
|
|
2477
|
+
async list(query = {}) {
|
|
2478
|
+
return await this.transport.send({
|
|
2479
|
+
service: "sales",
|
|
2480
|
+
method: "GET",
|
|
2481
|
+
path: "/shippings/list",
|
|
2482
|
+
query
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Fetches one shipping option.
|
|
2487
|
+
*
|
|
2488
|
+
* @param id The shipping option's id.
|
|
2489
|
+
* @returns The shipping option record.
|
|
2490
|
+
* @throws {NotFoundError} If the shipping option does not exist or is out of scope.
|
|
2491
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2492
|
+
* @throws {TransportError} If the request never completed.
|
|
2493
|
+
*/
|
|
2494
|
+
async view(id) {
|
|
2495
|
+
return await this.transport.send({
|
|
2496
|
+
service: "sales",
|
|
2497
|
+
method: "GET",
|
|
2498
|
+
path: "/shippings/view/{id}",
|
|
2499
|
+
pathParams: { id }
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
|
|
2504
|
+
// src/resource/teleforms.ts
|
|
2505
|
+
var Teleforms = class extends AbstractResource {
|
|
2506
|
+
/**
|
|
2507
|
+
* @param transport The shared transport.
|
|
2508
|
+
*/
|
|
2509
|
+
constructor(transport) {
|
|
2510
|
+
super(transport);
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Fetches a teleform definition by its configured identifier.
|
|
2514
|
+
*
|
|
2515
|
+
* This is the call a storefront usually wants: the identifier is the stable
|
|
2516
|
+
* name you chose in your AsterMD dashboard, so it can live in your
|
|
2517
|
+
* configuration and survive the form being edited.
|
|
2518
|
+
*
|
|
2519
|
+
* @param identifier The teleform's configured identifier.
|
|
2520
|
+
* @returns The teleform definition.
|
|
2521
|
+
* @throws {NotFoundError} If no teleform carries that identifier.
|
|
2522
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2523
|
+
* @throws {TransportError} If the request never completed.
|
|
2524
|
+
*/
|
|
2525
|
+
async viewByIdentifier(identifier) {
|
|
2526
|
+
return await this.transport.send({
|
|
2527
|
+
service: "sales",
|
|
2528
|
+
method: "GET",
|
|
2529
|
+
path: "/teleforms/view-url/{form_json_identifier}",
|
|
2530
|
+
pathParams: { form_json_identifier: identifier }
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Fetches a teleform definition by its generated id.
|
|
2535
|
+
*
|
|
2536
|
+
* Use this when you already hold an id from another API response - a product's
|
|
2537
|
+
* assigned teleform, for instance - rather than a configured identifier.
|
|
2538
|
+
*
|
|
2539
|
+
* @param id The teleform's generated id.
|
|
2540
|
+
* @returns The teleform definition.
|
|
2541
|
+
* @throws {NotFoundError} If the teleform does not exist.
|
|
2542
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2543
|
+
* @throws {TransportError} If the request never completed.
|
|
2544
|
+
*/
|
|
2545
|
+
async view(id) {
|
|
2546
|
+
return await this.transport.send({
|
|
2547
|
+
service: "sales",
|
|
2548
|
+
method: "GET",
|
|
2549
|
+
path: "/teleforms/view/{id}",
|
|
2550
|
+
pathParams: { id }
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
};
|
|
2554
|
+
|
|
2555
|
+
// src/resource/treatments.ts
|
|
2556
|
+
var Treatments = class extends AbstractResource {
|
|
2557
|
+
/**
|
|
2558
|
+
* @param transport The shared transport.
|
|
2559
|
+
*/
|
|
2560
|
+
constructor(transport) {
|
|
2561
|
+
super(transport);
|
|
2562
|
+
}
|
|
2563
|
+
/**
|
|
2564
|
+
* Records a treatment - an order that has been paid for.
|
|
2565
|
+
*
|
|
2566
|
+
* Call this only after your payment flow has settled. Include your own order
|
|
2567
|
+
* identifier as `external_order_id` so the record can be reconciled against
|
|
2568
|
+
* your commerce system later.
|
|
2569
|
+
*
|
|
2570
|
+
* @param data The order: patient, product, your order identifier, and whatever
|
|
2571
|
+
* else your configuration requires.
|
|
2572
|
+
* @returns The created treatment.
|
|
2573
|
+
* @throws {ValidationError} If a field is missing or rejected.
|
|
2574
|
+
* @throws {NotFoundError} If the referenced patient or product does not exist.
|
|
2575
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2576
|
+
* @throws {TransportError} If the request never completed.
|
|
2577
|
+
*/
|
|
2578
|
+
async create(data) {
|
|
2579
|
+
return await this.transport.send({
|
|
2580
|
+
service: "sales",
|
|
2581
|
+
method: "POST",
|
|
2582
|
+
path: "/treatments/create",
|
|
2583
|
+
body: data
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Fetches one treatment, including its clinical status.
|
|
2588
|
+
*
|
|
2589
|
+
* This is how a storefront shows an order's progress - whether a clinician has
|
|
2590
|
+
* reviewed it, whether it has shipped.
|
|
2591
|
+
*
|
|
2592
|
+
* @param id The treatment's id.
|
|
2593
|
+
* @returns The treatment record.
|
|
2594
|
+
* @throws {NotFoundError} If the treatment does not exist or is out of scope.
|
|
2595
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2596
|
+
* @throws {TransportError} If the request never completed.
|
|
2597
|
+
*/
|
|
2598
|
+
async view(id) {
|
|
2599
|
+
return await this.transport.send({
|
|
2600
|
+
service: "sales",
|
|
2601
|
+
method: "GET",
|
|
2602
|
+
path: "/treatments/view/{id}",
|
|
2603
|
+
pathParams: { id }
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* Lists treatments, filtered and paginated.
|
|
2608
|
+
*
|
|
2609
|
+
* Useful for an account area showing a patient's order history. Pagination
|
|
2610
|
+
* details come back in `meta()`; the accepted filters are in the API reference
|
|
2611
|
+
* in your AsterMD dashboard.
|
|
2612
|
+
*
|
|
2613
|
+
* @param query Filters and pagination parameters.
|
|
2614
|
+
* @returns The matching treatments, with pagination in `meta()`.
|
|
2615
|
+
* @throws {ValidationError} If a filter is rejected.
|
|
2616
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2617
|
+
* @throws {TransportError} If the request never completed.
|
|
2618
|
+
*/
|
|
2619
|
+
async list(query = {}) {
|
|
2620
|
+
return await this.transport.send({
|
|
2621
|
+
service: "sales",
|
|
2622
|
+
method: "GET",
|
|
2623
|
+
path: "/treatments/list",
|
|
2624
|
+
query
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Reconciles orders placed in your own commerce system against a session.
|
|
2629
|
+
*
|
|
2630
|
+
* The alternative to {@link Treatments.create} for flows where checkout happens
|
|
2631
|
+
* outside your storefront: hand over the session and your order identifiers and
|
|
2632
|
+
* the server creates the treatments and attributes them to that session's
|
|
2633
|
+
* journey. Several orders can be reconciled in one call.
|
|
2634
|
+
*
|
|
2635
|
+
* @param options The session, your order identifiers, and optional attribution.
|
|
2636
|
+
* @returns The created treatments.
|
|
2637
|
+
* @throws {ValidationError} If the session or an order identifier is rejected.
|
|
2638
|
+
* @throws {NotFoundError} If the session does not exist.
|
|
2639
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2640
|
+
* @throws {TransportError} If the request never completed.
|
|
2641
|
+
*/
|
|
2642
|
+
async sync(options) {
|
|
2643
|
+
const body = {
|
|
2644
|
+
session_id: options.session,
|
|
2645
|
+
order_ids: options.orderIds
|
|
2646
|
+
};
|
|
2647
|
+
if (options.utmSource !== void 0) {
|
|
2648
|
+
body.utm_source = options.utmSource;
|
|
2649
|
+
}
|
|
2650
|
+
const headers = options.userAgent !== void 0 && options.userAgent !== "" ? { "User-Agent": options.userAgent } : {};
|
|
2651
|
+
return await this.transport.send({
|
|
2652
|
+
service: "sales",
|
|
2653
|
+
method: "POST",
|
|
2654
|
+
path: "/treatments/sync",
|
|
2655
|
+
body,
|
|
2656
|
+
headers
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
};
|
|
2660
|
+
|
|
2661
|
+
// src/resource/verification.ts
|
|
2662
|
+
var Verification = class extends AbstractResource {
|
|
2663
|
+
/**
|
|
2664
|
+
* @param transport The shared transport.
|
|
2665
|
+
*/
|
|
2666
|
+
constructor(transport) {
|
|
2667
|
+
super(transport);
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Suggests complete addresses for a partial one.
|
|
2671
|
+
*
|
|
2672
|
+
* Built for a type-ahead: call it as the buyer types and render the
|
|
2673
|
+
* suggestions. Debouncing is your side's job - every keystroke here is a round
|
|
2674
|
+
* trip and counts against your request allowance.
|
|
2675
|
+
*
|
|
2676
|
+
* @param search The partial address typed so far.
|
|
2677
|
+
* @returns Candidate addresses from the configured provider.
|
|
2678
|
+
* @throws {ValidationError} If the search term is rejected.
|
|
2679
|
+
* @throws {RateLimitError} If the request allowance is exceeded.
|
|
2680
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2681
|
+
* @throws {TransportError} If the request never completed.
|
|
2682
|
+
*/
|
|
2683
|
+
async autofillAddress(search) {
|
|
2684
|
+
return await this.transport.send({
|
|
2685
|
+
service: "platform",
|
|
2686
|
+
method: "GET",
|
|
2687
|
+
path: "/extensions/address-autofill",
|
|
2688
|
+
query: { search }
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Verifies that an address exists and is deliverable.
|
|
2693
|
+
*
|
|
2694
|
+
* Call this before checkout completes rather than after: a prescription sent to
|
|
2695
|
+
* an undeliverable address is a clinical problem, not just a logistics one. The
|
|
2696
|
+
* response carries the provider's normalised form of the address, which is what
|
|
2697
|
+
* you should store.
|
|
2698
|
+
*
|
|
2699
|
+
* @param address The full address to verify.
|
|
2700
|
+
* @returns The verification result and the normalised address.
|
|
2701
|
+
* @throws {ValidationError} If the address is rejected.
|
|
2702
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2703
|
+
* @throws {TransportError} If the request never completed.
|
|
2704
|
+
*/
|
|
2705
|
+
async verifyAddress(address) {
|
|
2706
|
+
return await this.transport.send({
|
|
2707
|
+
service: "platform",
|
|
2708
|
+
method: "GET",
|
|
2709
|
+
path: "/extensions/address-verify",
|
|
2710
|
+
query: { address }
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
/**
|
|
2714
|
+
* Verifies that an email address is real and reachable.
|
|
2715
|
+
*
|
|
2716
|
+
* Catches typos and disposable addresses at the point of entry. Since every
|
|
2717
|
+
* later notification - intake reminders, clinician messages, shipping updates -
|
|
2718
|
+
* goes to this address, a bad one silently costs you the patient.
|
|
2719
|
+
*
|
|
2720
|
+
* @param email The email address to verify.
|
|
2721
|
+
* @returns The verification result.
|
|
2722
|
+
* @throws {ValidationError} If the address is malformed.
|
|
2723
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2724
|
+
* @throws {TransportError} If the request never completed.
|
|
2725
|
+
*/
|
|
2726
|
+
async verifyEmail(email) {
|
|
2727
|
+
return await this.transport.send({
|
|
2728
|
+
service: "platform",
|
|
2729
|
+
method: "GET",
|
|
2730
|
+
path: "/extensions/email-verify",
|
|
2731
|
+
query: { email }
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Runs an identity check against the configured provider.
|
|
2736
|
+
*
|
|
2737
|
+
* The strongest check available, and usually a precondition for clinical
|
|
2738
|
+
* review. Which variant runs is carried by `check`, and each expects a
|
|
2739
|
+
* different payload - see the API reference in your AsterMD dashboard for the
|
|
2740
|
+
* fields each one needs.
|
|
2741
|
+
*
|
|
2742
|
+
* The payload carries PII, and a Social Security Number in the `SsnVerify`
|
|
2743
|
+
* case. It is never written to a debug log, and it should not be written to
|
|
2744
|
+
* yours. The payload cannot override the check you selected.
|
|
2745
|
+
*
|
|
2746
|
+
* @param check Which identity check to run.
|
|
2747
|
+
* @param data The fields that check requires.
|
|
2748
|
+
* @returns The verification result.
|
|
2749
|
+
* @throws {ValidationError} If the payload does not satisfy the chosen check.
|
|
2750
|
+
* @throws {ApiError} On any other non-2xx status.
|
|
2751
|
+
* @throws {TransportError} If the request never completed.
|
|
2752
|
+
*/
|
|
2753
|
+
async verifyIdentity(check, data) {
|
|
2754
|
+
return await this.transport.send({
|
|
2755
|
+
service: "platform",
|
|
2756
|
+
method: "POST",
|
|
2757
|
+
path: "/extensions/identity-verify",
|
|
2758
|
+
// Data first, slug last: the caller's payload must not rewrite the check.
|
|
2759
|
+
body: { ...data, slug: check }
|
|
2760
|
+
});
|
|
2761
|
+
}
|
|
2762
|
+
};
|
|
2763
|
+
|
|
2764
|
+
// src/astermd-client.ts
|
|
2765
|
+
var AsterMDClient = class {
|
|
2766
|
+
#config;
|
|
2767
|
+
#transport;
|
|
2768
|
+
#sessions;
|
|
2769
|
+
#intakeSubmissions;
|
|
2770
|
+
#carts;
|
|
2771
|
+
#checkoutEvents;
|
|
2772
|
+
#teleforms;
|
|
2773
|
+
#patients;
|
|
2774
|
+
#opportunities;
|
|
2775
|
+
#treatments;
|
|
2776
|
+
#doctorsNetworks;
|
|
2777
|
+
#channels;
|
|
2778
|
+
#products;
|
|
2779
|
+
#categories;
|
|
2780
|
+
#labTests;
|
|
2781
|
+
#medications;
|
|
2782
|
+
#shippings;
|
|
2783
|
+
#verification;
|
|
2784
|
+
#geo;
|
|
2785
|
+
/**
|
|
2786
|
+
* @param options Credentials, connection settings, and debug configuration.
|
|
2787
|
+
* @throws {TypeError} If the credentials are empty, if `baseHost` carries a
|
|
2788
|
+
* scheme or path, if `timeoutSeconds` is below 1, or if `debug` is on with
|
|
2789
|
+
* neither `debugFile` nor `debugSink`.
|
|
2790
|
+
*/
|
|
2791
|
+
constructor(options) {
|
|
2792
|
+
const timeoutSeconds = options.timeoutSeconds ?? 10;
|
|
2793
|
+
this.#config = new Config({
|
|
2794
|
+
clientId: options.clientId,
|
|
2795
|
+
clientSecret: options.clientSecret,
|
|
2796
|
+
baseHost: options.baseHost ?? "api.astermd.com",
|
|
2797
|
+
timeoutSeconds
|
|
2798
|
+
});
|
|
2799
|
+
let http = options.httpClient ?? new FetchHttpClient(timeoutSeconds);
|
|
2800
|
+
if (options.debug === true) {
|
|
2801
|
+
if (options.debugSink === void 0 && options.debugFile === void 0) {
|
|
2802
|
+
throw new TypeError("debug mode requires debugFile or debugSink.");
|
|
2803
|
+
}
|
|
2804
|
+
const timeZone = options.debugTimezone ?? "UTC";
|
|
2805
|
+
const sink = options.debugSink ?? (() => {
|
|
2806
|
+
const fileSink = new DailyFileLogSink({
|
|
2807
|
+
basePath: options.debugFile,
|
|
2808
|
+
retentionDays: options.debugRetentionDays ?? 7,
|
|
2809
|
+
timeZone
|
|
2810
|
+
});
|
|
2811
|
+
return async (entry) => {
|
|
2812
|
+
await fileSink.write(entry);
|
|
2813
|
+
};
|
|
2814
|
+
})();
|
|
2815
|
+
http = new LoggingHttpClient({
|
|
2816
|
+
inner: http,
|
|
2817
|
+
sink,
|
|
2818
|
+
timeZone,
|
|
2819
|
+
redactor: options.debugRedact === false ? null : new LogRedactor()
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
const urlBuilder = new UrlBuilder(this.#config.baseHost());
|
|
2823
|
+
const tokenManager = new TokenManager({
|
|
2824
|
+
config: this.#config,
|
|
2825
|
+
httpClient: http,
|
|
2826
|
+
urlBuilder,
|
|
2827
|
+
store: options.tokenStore ?? new InMemoryTokenStore()
|
|
2828
|
+
});
|
|
2829
|
+
this.#transport = new Transport({
|
|
2830
|
+
httpClient: http,
|
|
2831
|
+
urlBuilder,
|
|
2832
|
+
tokenProvider: async () => await tokenManager.bearerToken(),
|
|
2833
|
+
onUnauthorized: async () => await tokenManager.refresh()
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
/**
|
|
2837
|
+
* Returns the immutable configuration this client was built with.
|
|
2838
|
+
*
|
|
2839
|
+
* @returns The configuration, including {@link Config.assetUrl}.
|
|
2840
|
+
*/
|
|
2841
|
+
config() {
|
|
2842
|
+
return this.#config;
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* Resolves a relative asset path returned by the API against the AsterMD CDN.
|
|
2846
|
+
*
|
|
2847
|
+
* A convenience for `client.config().assetUrl(path)`. Treat the result as a
|
|
2848
|
+
* fetch URL: download the asset once and serve your own copy rather than
|
|
2849
|
+
* hot-linking.
|
|
2850
|
+
*
|
|
2851
|
+
* @param path Relative asset path as returned by the API.
|
|
2852
|
+
* @returns The fully-qualified CDN URL.
|
|
2853
|
+
*/
|
|
2854
|
+
assetUrl(path) {
|
|
2855
|
+
return this.#config.assetUrl(path);
|
|
2856
|
+
}
|
|
2857
|
+
/** Sessions - the thread tying one visitor's journey together. */
|
|
2858
|
+
sessions() {
|
|
2859
|
+
return this.#sessions ??= new Sessions(this.#transport);
|
|
2860
|
+
}
|
|
2861
|
+
/** Intake submissions - pre-qualifying and intake questionnaire answers. */
|
|
2862
|
+
intakeSubmissions() {
|
|
2863
|
+
return this.#intakeSubmissions ??= new IntakeSubmissions(this.#transport);
|
|
2864
|
+
}
|
|
2865
|
+
/** Carts - what the visitor intends to buy. */
|
|
2866
|
+
carts() {
|
|
2867
|
+
return this.#carts ??= new Carts(this.#transport);
|
|
2868
|
+
}
|
|
2869
|
+
/** Checkout events - progress through the checkout funnel. */
|
|
2870
|
+
checkoutEvents() {
|
|
2871
|
+
return this.#checkoutEvents ??= new CheckoutEvents(this.#transport);
|
|
2872
|
+
}
|
|
2873
|
+
/** Teleforms - the questionnaire definitions your storefront renders. */
|
|
2874
|
+
teleforms() {
|
|
2875
|
+
return this.#teleforms ??= new Teleforms(this.#transport);
|
|
2876
|
+
}
|
|
2877
|
+
/** Patients - the person receiving care. Carries PHI. */
|
|
2878
|
+
patients() {
|
|
2879
|
+
return this.#patients ??= new Patients(this.#transport);
|
|
2880
|
+
}
|
|
2881
|
+
/** Opportunities - a named prospect worth following up. */
|
|
2882
|
+
opportunities() {
|
|
2883
|
+
return this.#opportunities ??= new Opportunities(this.#transport);
|
|
2884
|
+
}
|
|
2885
|
+
/** Treatments - the order, once payment has settled. */
|
|
2886
|
+
treatments() {
|
|
2887
|
+
return this.#treatments ??= new Treatments(this.#transport);
|
|
2888
|
+
}
|
|
2889
|
+
/** Doctors' networks - handing a case to the clinicians who review it. */
|
|
2890
|
+
doctorsNetworks() {
|
|
2891
|
+
return this.#doctorsNetworks ??= new DoctorsNetworks(this.#transport);
|
|
2892
|
+
}
|
|
2893
|
+
/** Channels - the storefront's own server-side configuration. */
|
|
2894
|
+
channels() {
|
|
2895
|
+
return this.#channels ??= new Channels(this.#transport);
|
|
2896
|
+
}
|
|
2897
|
+
/** Products - the read-only catalogue. */
|
|
2898
|
+
products() {
|
|
2899
|
+
return this.#products ??= new Products(this.#transport);
|
|
2900
|
+
}
|
|
2901
|
+
/** Categories - how the catalogue is grouped for browsing. */
|
|
2902
|
+
categories() {
|
|
2903
|
+
return this.#categories ??= new Categories(this.#transport);
|
|
2904
|
+
}
|
|
2905
|
+
/** Lab tests - diagnostic panels a product may require. */
|
|
2906
|
+
labTests() {
|
|
2907
|
+
return this.#labTests ??= new LabTests(this.#transport);
|
|
2908
|
+
}
|
|
2909
|
+
/** Medications - the reference list backing "what are you currently taking?". */
|
|
2910
|
+
medications() {
|
|
2911
|
+
return this.#medications ??= new Medications(this.#transport);
|
|
2912
|
+
}
|
|
2913
|
+
/** Shipping options - delivery methods available for an order. */
|
|
2914
|
+
shippings() {
|
|
2915
|
+
return this.#shippings ??= new Shippings(this.#transport);
|
|
2916
|
+
}
|
|
2917
|
+
/** Verification - address, email, and identity checks. */
|
|
2918
|
+
verification() {
|
|
2919
|
+
return this.#verification ??= new Verification(this.#transport);
|
|
2920
|
+
}
|
|
2921
|
+
/** Geo - where a visitor is, and whether you may serve them. */
|
|
2922
|
+
geo() {
|
|
2923
|
+
return this.#geo ??= new Geo(this.#transport);
|
|
2924
|
+
}
|
|
2925
|
+
};
|
|
2926
|
+
|
|
2927
|
+
// src/enum/event.ts
|
|
2928
|
+
var Event = Object.freeze({
|
|
2929
|
+
/** The prospect started the pre-qualifying questionnaire. Pass to `create()`. */
|
|
2930
|
+
PreQualifyingInitiated: "pre_qualifying_initiated",
|
|
2931
|
+
/** The prospect advanced through an intermediate pre-qualifying step. Pass to `update()`. */
|
|
2932
|
+
PreQualifyingInProgress: "pre_qualifying_inprogress",
|
|
2933
|
+
/** The prospect submitted the pre-qualifying form. Pass to `update()`. */
|
|
2934
|
+
PreQualifyingCompleted: "pre_qualifying_completed",
|
|
2935
|
+
/** The prospect started the intake questionnaire. Pass to `create()`. */
|
|
2936
|
+
IntakeInitiated: "intake_initiated",
|
|
2937
|
+
/** The prospect advanced through an intermediate intake step. Pass to `update()`. */
|
|
2938
|
+
IntakeInProgress: "intake_inprogress",
|
|
2939
|
+
/** The prospect submitted the intake form. Pass to `update()`. */
|
|
2940
|
+
IntakeCompleted: "intake_completed"
|
|
2941
|
+
});
|
|
2942
|
+
|
|
2943
|
+
// src/enum/identity-check.ts
|
|
2944
|
+
var IdentityCheck = Object.freeze({
|
|
2945
|
+
/** Cross-check the supplied identity details against the provider's records. */
|
|
2946
|
+
Crosscheck: "crosscheck",
|
|
2947
|
+
/** Confirm the supplied date of birth. */
|
|
2948
|
+
DobVerify: "dob_verify",
|
|
2949
|
+
/** Confirm the supplied Social Security Number. */
|
|
2950
|
+
SsnVerify: "ssn_verify"
|
|
2951
|
+
});
|
|
2952
|
+
var FileTokenStore = class {
|
|
2953
|
+
#path;
|
|
2954
|
+
/**
|
|
2955
|
+
* @param path Path to the JSON file used for token storage. The parent
|
|
2956
|
+
* directory is created on first write if it does not exist.
|
|
2957
|
+
*/
|
|
2958
|
+
constructor(path) {
|
|
2959
|
+
this.#path = path;
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2962
|
+
* Reads the cached token from the file.
|
|
2963
|
+
*
|
|
2964
|
+
* @returns The stored token, or `null` when the file is absent, unreadable, or
|
|
2965
|
+
* does not hold a usable token.
|
|
2966
|
+
*/
|
|
2967
|
+
async get() {
|
|
2968
|
+
let contents;
|
|
2969
|
+
try {
|
|
2970
|
+
contents = await promises.readFile(this.#path, "utf8");
|
|
2971
|
+
} catch {
|
|
2972
|
+
return null;
|
|
2973
|
+
}
|
|
2974
|
+
if (contents === "") {
|
|
2975
|
+
return null;
|
|
2976
|
+
}
|
|
2977
|
+
let decoded;
|
|
2978
|
+
try {
|
|
2979
|
+
decoded = JSON.parse(contents);
|
|
2980
|
+
} catch {
|
|
2981
|
+
return null;
|
|
2982
|
+
}
|
|
2983
|
+
if (typeof decoded !== "object" || decoded === null) {
|
|
2984
|
+
return null;
|
|
2985
|
+
}
|
|
2986
|
+
const { value, expires_at: expiresAt } = decoded;
|
|
2987
|
+
if (typeof value !== "string" || typeof expiresAt !== "string") {
|
|
2988
|
+
return null;
|
|
2989
|
+
}
|
|
2990
|
+
const parsed = new Date(expiresAt);
|
|
2991
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
2992
|
+
return null;
|
|
2993
|
+
}
|
|
2994
|
+
return new Token(value, parsed);
|
|
2995
|
+
}
|
|
2996
|
+
/**
|
|
2997
|
+
* Writes the token to the backing file, replacing any existing content.
|
|
2998
|
+
*
|
|
2999
|
+
* Persistence is best-effort: a filesystem failure - a read-only or
|
|
3000
|
+
* misconfigured cache directory, a permissions problem - is swallowed rather
|
|
3001
|
+
* than thrown, matching the PHP SDK's `put()`, which returns early on a
|
|
3002
|
+
* `fopen` failure. This method runs on every token acquisition, so failing it
|
|
3003
|
+
* would fail the API call it was only meant to optimise; the cost of a
|
|
3004
|
+
* degraded cache is an extra credential exchange next time, not a broken
|
|
3005
|
+
* request.
|
|
3006
|
+
*
|
|
3007
|
+
* @param token The freshly acquired token to persist.
|
|
3008
|
+
*/
|
|
3009
|
+
async put(token) {
|
|
3010
|
+
const directory = path.dirname(this.#path);
|
|
3011
|
+
const temporary = path.join(directory, `.${crypto.randomUUID()}.tmp`);
|
|
3012
|
+
try {
|
|
3013
|
+
await promises.mkdir(directory, { recursive: true, mode: 493 });
|
|
3014
|
+
const contents = JSON.stringify({
|
|
3015
|
+
value: token.value(),
|
|
3016
|
+
expires_at: token.expiresAt().toISOString()
|
|
3017
|
+
});
|
|
3018
|
+
await promises.writeFile(temporary, contents, { mode: 384 });
|
|
3019
|
+
await promises.rename(temporary, this.#path);
|
|
3020
|
+
} catch {
|
|
3021
|
+
await promises.rm(temporary, { force: true }).catch(() => void 0);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Deletes the backing file so the next {@link FileTokenStore.get} returns
|
|
3026
|
+
* `null`.
|
|
3027
|
+
*
|
|
3028
|
+
* A no-op when the file does not exist, including when another process
|
|
3029
|
+
* removed it first. Best-effort otherwise: any other filesystem failure (for
|
|
3030
|
+
* example a permissions problem) is swallowed rather than thrown, since a
|
|
3031
|
+
* cache that fails to clear only costs a stale-token read on the next call,
|
|
3032
|
+
* which {@link FileTokenStore.get} already tolerates.
|
|
3033
|
+
*/
|
|
3034
|
+
async clear() {
|
|
3035
|
+
await promises.rm(this.#path, { force: true }).catch(() => void 0);
|
|
3036
|
+
}
|
|
3037
|
+
};
|
|
3038
|
+
|
|
3039
|
+
// src/presenter/channel-detail.ts
|
|
3040
|
+
var ChannelDetail = class {
|
|
3041
|
+
constructor() {
|
|
3042
|
+
}
|
|
3043
|
+
/**
|
|
3044
|
+
* Flattens a channel-detail payload into a shape a view can render directly.
|
|
3045
|
+
*
|
|
3046
|
+
* Pass `response.data()` from {@link Channels.details}. Malformed or missing
|
|
3047
|
+
* branches degrade to `null` and `[]` rather than raising, because a channel is
|
|
3048
|
+
* configuration a storefront needs at boot: failing the page over one absent
|
|
3049
|
+
* optional field would be the wrong trade.
|
|
3050
|
+
*
|
|
3051
|
+
* @param data The raw `data` object from the channel-detail response.
|
|
3052
|
+
* @returns The flattened view.
|
|
3053
|
+
*/
|
|
3054
|
+
static from(data) {
|
|
3055
|
+
return {
|
|
3056
|
+
id: data._id ?? null,
|
|
3057
|
+
name: data.name ?? null,
|
|
3058
|
+
description: data.description ?? null,
|
|
3059
|
+
status: data.status ?? null,
|
|
3060
|
+
type: data.type ?? null,
|
|
3061
|
+
auto_inc_id: data.auto_inc_id ?? null,
|
|
3062
|
+
payment_processor: processor(data.payment_processor),
|
|
3063
|
+
products: toList(data.products).map((entry) => product(toObject(entry)))
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
};
|
|
3067
|
+
function processor(value) {
|
|
3068
|
+
if (typeof value !== "object" || value === null) {
|
|
3069
|
+
return null;
|
|
3070
|
+
}
|
|
3071
|
+
const pp = value;
|
|
3072
|
+
return {
|
|
3073
|
+
id: pp._id ?? null,
|
|
3074
|
+
name: pp.name ?? null,
|
|
3075
|
+
provider: pp.provider_category ?? null,
|
|
3076
|
+
type: pp.type ?? null,
|
|
3077
|
+
config: pp.config ?? null
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
function product(entry) {
|
|
3081
|
+
const byVariant = /* @__PURE__ */ new Map();
|
|
3082
|
+
for (const raw of toList(entry.product_mappings)) {
|
|
3083
|
+
const pm = toObject(raw);
|
|
3084
|
+
const variantId = pm.variant_id;
|
|
3085
|
+
if (typeof variantId === "string") {
|
|
3086
|
+
byVariant.set(variantId, mapping(pm));
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
return coreProduct(toObject(entry.product), byVariant, toList(entry.add_ons));
|
|
3090
|
+
}
|
|
3091
|
+
function coreProduct(p, byVariant, addOns) {
|
|
3092
|
+
const id = typeof p._id === "string" ? p._id : null;
|
|
3093
|
+
const variants = toList(p.variants).map((raw) => {
|
|
3094
|
+
const v = toObject(raw);
|
|
3095
|
+
const variantId = typeof v._id === "string" ? v._id : null;
|
|
3096
|
+
return variant(v, lookup(byVariant, variantId));
|
|
3097
|
+
});
|
|
3098
|
+
const singleRows = toList(p.single);
|
|
3099
|
+
const single = singleRows.length === 0 ? null : singlePricing(toObject(singleRows[0]), lookup(byVariant, id));
|
|
3100
|
+
return {
|
|
3101
|
+
id: p._id ?? null,
|
|
3102
|
+
name: p.name ?? null,
|
|
3103
|
+
type: p.type ?? null,
|
|
3104
|
+
sku: p.sku ?? null,
|
|
3105
|
+
status: p.status ?? null,
|
|
3106
|
+
visibility: p.visibility ?? null,
|
|
3107
|
+
image: p.image ?? null,
|
|
3108
|
+
description_long: p.description_long ?? null,
|
|
3109
|
+
description_short: p.description_short ?? null,
|
|
3110
|
+
restrict_multiple: p.restrict_multiple ?? null,
|
|
3111
|
+
min_buy_qty: p.min_buy_qty ?? null,
|
|
3112
|
+
max_buy_qty: p.max_buy_qty ?? null,
|
|
3113
|
+
stock: p.stock ?? null,
|
|
3114
|
+
categories: refs(p.categories),
|
|
3115
|
+
condition_treated: refs(p.condition_treated),
|
|
3116
|
+
teleforms: refs(p.teleforms)[0] ?? null,
|
|
3117
|
+
labtest: refs(p.labtest),
|
|
3118
|
+
add_ons: addOns.map((addOn) => coreProduct(toObject(addOn), /* @__PURE__ */ new Map(), [])),
|
|
3119
|
+
single,
|
|
3120
|
+
variants
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
function variant(v, productMapping) {
|
|
3124
|
+
return {
|
|
3125
|
+
id: v._id ?? null,
|
|
3126
|
+
name: v.name ?? null,
|
|
3127
|
+
sku: v.sku ?? null,
|
|
3128
|
+
price: v.default_price ?? null,
|
|
3129
|
+
intro_price: v.intro_price ?? null,
|
|
3130
|
+
sale_price: v.sale_price ?? null,
|
|
3131
|
+
sale_start: v.sale_start ?? null,
|
|
3132
|
+
sale_end: v.sale_end ?? null,
|
|
3133
|
+
mapping: productMapping
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
function singlePricing(row, productMapping) {
|
|
3137
|
+
return {
|
|
3138
|
+
price: row.default_price ?? null,
|
|
3139
|
+
intro_price: row.intro_price ?? null,
|
|
3140
|
+
sale_price: row.sale_price ?? null,
|
|
3141
|
+
sale_start: row.sale_start ?? null,
|
|
3142
|
+
sale_end: row.sale_end ?? null,
|
|
3143
|
+
mapping: productMapping
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
function mapping(pm) {
|
|
3147
|
+
const integration = toObject(pm.integration);
|
|
3148
|
+
const out = { type: integration.type ?? null };
|
|
3149
|
+
for (const [key, value] of Object.entries(toObject(pm.mapping))) {
|
|
3150
|
+
if (key !== "type") {
|
|
3151
|
+
out[key] = value;
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
return out;
|
|
3155
|
+
}
|
|
3156
|
+
function refs(list) {
|
|
3157
|
+
return toList(list).map((item) => ref(item));
|
|
3158
|
+
}
|
|
3159
|
+
function ref(r) {
|
|
3160
|
+
if (typeof r !== "object" || r === null) {
|
|
3161
|
+
return r;
|
|
3162
|
+
}
|
|
3163
|
+
const raw = r;
|
|
3164
|
+
const out = { id: raw._id ?? null };
|
|
3165
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
3166
|
+
if (key !== "_id") {
|
|
3167
|
+
out[key] = value;
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
return out;
|
|
3171
|
+
}
|
|
3172
|
+
function lookup(map, key) {
|
|
3173
|
+
return key === null ? null : map.get(key) ?? null;
|
|
3174
|
+
}
|
|
3175
|
+
function toList(value) {
|
|
3176
|
+
if (Array.isArray(value)) {
|
|
3177
|
+
return value;
|
|
3178
|
+
}
|
|
3179
|
+
return typeof value === "object" && value !== null ? Object.values(value) : [];
|
|
3180
|
+
}
|
|
3181
|
+
function toObject(value) {
|
|
3182
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
3183
|
+
}
|
|
3184
|
+
var ALGORITHM = "aes-128-cbc";
|
|
3185
|
+
var IV_LENGTH = 16;
|
|
3186
|
+
var QueryParamCipher = class {
|
|
3187
|
+
constructor() {
|
|
3188
|
+
}
|
|
3189
|
+
/**
|
|
3190
|
+
* Decrypts a query-parameter token into tracking parameters.
|
|
3191
|
+
*
|
|
3192
|
+
* Call this at your storefront's entry point with the raw token from the URL
|
|
3193
|
+
* and the key from your own configuration. Any failure along the way - bad
|
|
3194
|
+
* encoding, wrong or malformed key, decryption error, non-list payload -
|
|
3195
|
+
* returns an empty object so the caller can fall back to plain parameters.
|
|
3196
|
+
*
|
|
3197
|
+
* @param token The base64url token, `iv(16) || ciphertext`.
|
|
3198
|
+
* @param keyHex 32 hex characters - a 16-byte AES-128 key. Supplied by you;
|
|
3199
|
+
* the SDK ships no default.
|
|
3200
|
+
* @returns The decrypted parameters, or `{}` on any failure.
|
|
3201
|
+
*/
|
|
3202
|
+
static decrypt(token, keyHex) {
|
|
3203
|
+
if (token === "" || keyHex === "") {
|
|
3204
|
+
return {};
|
|
3205
|
+
}
|
|
3206
|
+
const key = decodeKey(keyHex);
|
|
3207
|
+
if (key === null) {
|
|
3208
|
+
return {};
|
|
3209
|
+
}
|
|
3210
|
+
let blob;
|
|
3211
|
+
try {
|
|
3212
|
+
blob = Buffer.from(base64UrlToBase64(token), "base64");
|
|
3213
|
+
} catch {
|
|
3214
|
+
return {};
|
|
3215
|
+
}
|
|
3216
|
+
if (blob.byteLength <= IV_LENGTH) {
|
|
3217
|
+
return {};
|
|
3218
|
+
}
|
|
3219
|
+
let json;
|
|
3220
|
+
try {
|
|
3221
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, blob.subarray(0, IV_LENGTH));
|
|
3222
|
+
json = Buffer.concat([decipher.update(blob.subarray(IV_LENGTH)), decipher.final()]).toString("utf8");
|
|
3223
|
+
} catch {
|
|
3224
|
+
return {};
|
|
3225
|
+
}
|
|
3226
|
+
let decoded;
|
|
3227
|
+
try {
|
|
3228
|
+
decoded = JSON.parse(json);
|
|
3229
|
+
} catch {
|
|
3230
|
+
return {};
|
|
3231
|
+
}
|
|
3232
|
+
if (!Array.isArray(decoded)) {
|
|
3233
|
+
return {};
|
|
3234
|
+
}
|
|
3235
|
+
const out = {};
|
|
3236
|
+
for (const entry of decoded) {
|
|
3237
|
+
if (typeof entry !== "object" || entry === null) {
|
|
3238
|
+
continue;
|
|
3239
|
+
}
|
|
3240
|
+
const { key: name, value } = entry;
|
|
3241
|
+
if (!isScalar(name)) {
|
|
3242
|
+
continue;
|
|
3243
|
+
}
|
|
3244
|
+
out[phpString(name)] = isScalar(value) ? phpString(value) : "";
|
|
3245
|
+
}
|
|
3246
|
+
return out;
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Encrypts tracking parameters into a token.
|
|
3250
|
+
*
|
|
3251
|
+
* Provided mainly for round-trip tests and local tooling. A fresh random IV is
|
|
3252
|
+
* generated on every call and prepended to the ciphertext before encoding, so
|
|
3253
|
+
* the same parameters never produce the same token twice.
|
|
3254
|
+
*
|
|
3255
|
+
* Unlike {@link QueryParamCipher.decrypt}, this is strict: a bad key raises
|
|
3256
|
+
* rather than being swallowed, because a failed encrypt is a programming error
|
|
3257
|
+
* and not untrusted input.
|
|
3258
|
+
*
|
|
3259
|
+
* @param params The parameters to encrypt. Written to the wire as the
|
|
3260
|
+
* `[{ key, value }, …]` list the PHP SDK also reads.
|
|
3261
|
+
* @param keyHex 32 hex characters - a 16-byte AES-128 key.
|
|
3262
|
+
* @returns The base64url token, `iv(16) || ciphertext`.
|
|
3263
|
+
* @throws {TypeError} If `keyHex` is not a valid 16-byte hex key.
|
|
3264
|
+
*/
|
|
3265
|
+
static encrypt(params, keyHex) {
|
|
3266
|
+
const key = decodeKey(keyHex);
|
|
3267
|
+
if (key === null) {
|
|
3268
|
+
throw new TypeError("keyHex must be 32 hex chars (a 16-byte AES-128 key).");
|
|
3269
|
+
}
|
|
3270
|
+
const payload = JSON.stringify(Object.entries(params).map(([name, value]) => ({ key: name, value })));
|
|
3271
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
3272
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
3273
|
+
const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
|
|
3274
|
+
return Buffer.concat([iv, ciphertext]).toString("base64url");
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
function decodeKey(keyHex) {
|
|
3278
|
+
if (!/^[0-9a-fA-F]{32}$/.test(keyHex)) {
|
|
3279
|
+
return null;
|
|
3280
|
+
}
|
|
3281
|
+
return Buffer.from(keyHex, "hex");
|
|
3282
|
+
}
|
|
3283
|
+
function base64UrlToBase64(value) {
|
|
3284
|
+
const standard = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
3285
|
+
const remainder = standard.length % 4;
|
|
3286
|
+
return remainder === 0 ? standard : standard + "=".repeat(4 - remainder);
|
|
3287
|
+
}
|
|
3288
|
+
function isScalar(value) {
|
|
3289
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
3290
|
+
}
|
|
3291
|
+
function phpString(value) {
|
|
3292
|
+
if (typeof value === "boolean") {
|
|
3293
|
+
return value ? "1" : "";
|
|
3294
|
+
}
|
|
3295
|
+
return String(value);
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
exports.AbstractResource = AbstractResource;
|
|
3299
|
+
exports.ApiError = ApiError;
|
|
3300
|
+
exports.AsterMDClient = AsterMDClient;
|
|
3301
|
+
exports.AsterMDError = AsterMDError;
|
|
3302
|
+
exports.AuthenticationError = AuthenticationError;
|
|
3303
|
+
exports.Carts = Carts;
|
|
3304
|
+
exports.Categories = Categories;
|
|
3305
|
+
exports.ChannelDetail = ChannelDetail;
|
|
3306
|
+
exports.Channels = Channels;
|
|
3307
|
+
exports.CheckoutEvent = CheckoutEvent;
|
|
3308
|
+
exports.CheckoutEvents = CheckoutEvents;
|
|
3309
|
+
exports.Config = Config;
|
|
3310
|
+
exports.DailyFileLogSink = DailyFileLogSink;
|
|
3311
|
+
exports.DoctorsNetworks = DoctorsNetworks;
|
|
3312
|
+
exports.Event = Event;
|
|
3313
|
+
exports.FetchHttpClient = FetchHttpClient;
|
|
3314
|
+
exports.FileTokenStore = FileTokenStore;
|
|
3315
|
+
exports.FileUpload = FileUpload;
|
|
3316
|
+
exports.Geo = Geo;
|
|
3317
|
+
exports.IdentityCheck = IdentityCheck;
|
|
3318
|
+
exports.InMemoryTokenStore = InMemoryTokenStore;
|
|
3319
|
+
exports.IntakeSubmissions = IntakeSubmissions;
|
|
3320
|
+
exports.LabTests = LabTests;
|
|
3321
|
+
exports.Medications = Medications;
|
|
3322
|
+
exports.NotFoundError = NotFoundError;
|
|
3323
|
+
exports.Opportunities = Opportunities;
|
|
3324
|
+
exports.Patients = Patients;
|
|
3325
|
+
exports.Products = Products;
|
|
3326
|
+
exports.QueryParamCipher = QueryParamCipher;
|
|
3327
|
+
exports.RateLimitError = RateLimitError;
|
|
3328
|
+
exports.Response = Response2;
|
|
3329
|
+
exports.Sessions = Sessions;
|
|
3330
|
+
exports.Shippings = Shippings;
|
|
3331
|
+
exports.Teleforms = Teleforms;
|
|
3332
|
+
exports.Token = Token;
|
|
3333
|
+
exports.TransportError = TransportError;
|
|
3334
|
+
exports.Treatments = Treatments;
|
|
3335
|
+
exports.ValidationError = ValidationError;
|
|
3336
|
+
exports.Verification = Verification;
|
|
3337
|
+
//# sourceMappingURL=index.cjs.map
|
|
3338
|
+
//# sourceMappingURL=index.cjs.map
|