@awesomate/sdk 0.15.0 → 0.17.0
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/dist/index.d.ts +275 -1
- package/dist/index.js +299 -2
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* Docs: https://hub.awesomate.ai/docs/sdk/
|
|
25
25
|
*/
|
|
26
26
|
/** This package's version, sent to the hub with every server call. */
|
|
27
|
-
export declare const VERSION = "0.
|
|
27
|
+
export declare const VERSION = "0.17.0";
|
|
28
28
|
/** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
|
|
29
29
|
export interface Kinds {
|
|
30
30
|
}
|
|
@@ -157,6 +157,38 @@ export interface Page<R> {
|
|
|
157
157
|
/** When the hub read the rows. */
|
|
158
158
|
asAt: string;
|
|
159
159
|
}
|
|
160
|
+
/** A file or folder in the account's Files (the folders on its automation account: public/, private/, temp/). */
|
|
161
|
+
export interface FileEntry {
|
|
162
|
+
/** Relative to the files folder, starting with its top folder: public/images/logo.png. */
|
|
163
|
+
path: string;
|
|
164
|
+
name: string;
|
|
165
|
+
type: 'file' | 'dir';
|
|
166
|
+
/** Bytes; 0 for a folder. */
|
|
167
|
+
size: number;
|
|
168
|
+
/** ISO time it last changed. */
|
|
169
|
+
modified: string;
|
|
170
|
+
/** For a file in public/: the address anyone with it can open, with no login. Otherwise null. */
|
|
171
|
+
public_url: string | null;
|
|
172
|
+
}
|
|
173
|
+
/** How much of the plan's Files space is used. */
|
|
174
|
+
export interface FilesUsage {
|
|
175
|
+
used_bytes: number;
|
|
176
|
+
limit_bytes: number;
|
|
177
|
+
files: number;
|
|
178
|
+
/** The measure stopped early on a very large tree: used_bytes is at least this much. */
|
|
179
|
+
partial: boolean;
|
|
180
|
+
measured_at: string;
|
|
181
|
+
}
|
|
182
|
+
/** A file uploaded from an app. Keep `handle` in a record (a kind's file attribute); it opens the file again. */
|
|
183
|
+
export interface AppFile {
|
|
184
|
+
handle: string;
|
|
185
|
+
name: string;
|
|
186
|
+
size: number;
|
|
187
|
+
/** Only when the app's uploads are public. */
|
|
188
|
+
public_url: string | null;
|
|
189
|
+
}
|
|
190
|
+
/** What a file can be sent as. */
|
|
191
|
+
export type FileBody = Blob | ArrayBuffer | Uint8Array | string;
|
|
160
192
|
declare const ERROR_CODES: readonly ["unauthenticated", "forbidden", "not_found", "validation", "consent_blocked", "rate_limited", "conflict", "unavailable"];
|
|
161
193
|
/**
|
|
162
194
|
* Why a call failed, one of a fixed list: unauthenticated, forbidden, not_found, validation,
|
|
@@ -215,6 +247,56 @@ export declare class AwesomateClient {
|
|
|
215
247
|
private readonly appKey;
|
|
216
248
|
constructor(opts: ClientOptions);
|
|
217
249
|
private request;
|
|
250
|
+
/** A call whose body or answer is a file, not JSON. Answers the Response when it succeeded. */
|
|
251
|
+
private raw;
|
|
252
|
+
private filesCall;
|
|
253
|
+
/**
|
|
254
|
+
* The account's Files: the folders on its automation account that its n8n workflows read and
|
|
255
|
+
* write (public/, private/, temp/). A file in public/ has a public_url anyone with it can open;
|
|
256
|
+
* the others never do. Needs the account's token with files:read (reading) or files:write
|
|
257
|
+
* (changing), on a plan with Files, after the owner has switched on "Open your automation
|
|
258
|
+
* account's file folders" in Settings, Privacy.
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* const logo = await db.files.upload('public/brand/logo.png', await readFile('logo.png'));
|
|
262
|
+
* console.log(logo.public_url);
|
|
263
|
+
*/
|
|
264
|
+
readonly files: {
|
|
265
|
+
/** One folder; '' (the default) lists the top folders. */
|
|
266
|
+
list: (path?: string) => Promise<FileEntry[]>;
|
|
267
|
+
/** Files by part of their name (* and ? are wildcards), across the folders or in one. At most 200: `truncated` says there were more. */
|
|
268
|
+
search: (options?: {
|
|
269
|
+
q?: string;
|
|
270
|
+
top?: "public" | "private" | "temp";
|
|
271
|
+
extensions?: string[];
|
|
272
|
+
}) => Promise<{
|
|
273
|
+
results: FileEntry[];
|
|
274
|
+
truncated: boolean;
|
|
275
|
+
}>;
|
|
276
|
+
/** Space used against the plan's Files space. Measured, and up to ten minutes old. */
|
|
277
|
+
usage: () => Promise<FilesUsage>;
|
|
278
|
+
/**
|
|
279
|
+
* Write a file (up to 50 MB). Refuses to replace one already there unless `overwrite`. A full
|
|
280
|
+
* Files space answers serverCode files_storage_full.
|
|
281
|
+
*/
|
|
282
|
+
upload: (path: string, data: FileBody, options?: {
|
|
283
|
+
overwrite?: boolean;
|
|
284
|
+
}) => Promise<FileEntry>;
|
|
285
|
+
/** A file's contents. */
|
|
286
|
+
download: (path: string) => Promise<Blob>;
|
|
287
|
+
/** Make a folder, and any above it. */
|
|
288
|
+
mkdir: (path: string) => Promise<FileEntry>;
|
|
289
|
+
/** Move or rename. Anything using the old path or address needs the new one. */
|
|
290
|
+
move: (from: string, to: string) => Promise<FileEntry>;
|
|
291
|
+
/** Move a file from private/ or temp/ to the same place under public/, and answer its public_url. Anyone with the address can open it. */
|
|
292
|
+
makePublic: (path: string) => Promise<FileEntry>;
|
|
293
|
+
/** Move a file out of public/ into private/. Its public address stops serving it, though Cloudflare can keep showing the last copy for up to 4 hours. */
|
|
294
|
+
makePrivate: (path: string) => Promise<FileEntry>;
|
|
295
|
+
/** Move to the trash. Not erased, and no longer counted toward the Files space. */
|
|
296
|
+
remove: (path: string) => Promise<{
|
|
297
|
+
trashPath: string;
|
|
298
|
+
}>;
|
|
299
|
+
};
|
|
218
300
|
/** The kinds this token can read, their columns, operators and examples. */
|
|
219
301
|
schema(): Promise<{
|
|
220
302
|
kinds: Array<{
|
|
@@ -871,6 +953,34 @@ export declare class AwesomateAppClient {
|
|
|
871
953
|
ids?: string[];
|
|
872
954
|
limit?: number;
|
|
873
955
|
}): Promise<Customer[]>;
|
|
956
|
+
/** Runs a call with the current access token, refreshing it once when the hub says it expired. */
|
|
957
|
+
private authed;
|
|
958
|
+
private rawData;
|
|
959
|
+
/**
|
|
960
|
+
* Files people attach in this app, such as a photo of a job or a signed form, kept in the
|
|
961
|
+
* account's Files. Off until the account switches it on for the app (private, or public with a
|
|
962
|
+
* public address per file): check mode() before showing an upload button. Keep the handle an
|
|
963
|
+
* upload answers in a record's file attribute; anyone signed in to this app who can read that
|
|
964
|
+
* record can open the file with download().
|
|
965
|
+
*
|
|
966
|
+
* @example
|
|
967
|
+
* const file = await app.files.upload(input.files[0]);
|
|
968
|
+
* await app.write('message', { body: 'Photo of the leak', attachment: file.handle }, { links: { job: jobId } });
|
|
969
|
+
*/
|
|
970
|
+
readonly files: {
|
|
971
|
+
/** Whether this app takes uploads: 'off', 'private' or 'public'. */
|
|
972
|
+
mode: () => Promise<"off" | "private" | "public">;
|
|
973
|
+
/**
|
|
974
|
+
* Upload one file, up to 10 MB. The name comes from a File, or pass one. A refusal's
|
|
975
|
+
* serverCode says why: uploads_off, too_large, type_not_allowed (public uploads take no web
|
|
976
|
+
* pages or scripts), files_storage_full; personMessage is the sentence to show.
|
|
977
|
+
*/
|
|
978
|
+
upload: (file: Blob, options?: {
|
|
979
|
+
name?: string;
|
|
980
|
+
}) => Promise<AppFile>;
|
|
981
|
+
/** The file a handle names, for someone signed in to this app. */
|
|
982
|
+
download: (handle: string) => Promise<Blob>;
|
|
983
|
+
};
|
|
874
984
|
/**
|
|
875
985
|
* The agent this app talks with by voice, or null when the account has not picked one. Pass it
|
|
876
986
|
* to AwesomateAgent.mount as agentId; the hub decides which agent answers, never the page.
|
|
@@ -920,4 +1030,168 @@ export declare function createAppClient(options: AppClientOptions): AwesomateApp
|
|
|
920
1030
|
* const { rows } = await db.query('contact', { limit: 10 });
|
|
921
1031
|
*/
|
|
922
1032
|
export declare function createClient(options: ClientOptions): AwesomateClient;
|
|
1033
|
+
/** One question a service asks when someone books. */
|
|
1034
|
+
export interface BookingQuestion {
|
|
1035
|
+
key: string;
|
|
1036
|
+
label: string;
|
|
1037
|
+
type: 'text' | 'textarea' | 'select' | 'multiselect' | 'url';
|
|
1038
|
+
required?: boolean;
|
|
1039
|
+
/** The choices, for select and multiselect. */
|
|
1040
|
+
options?: string[];
|
|
1041
|
+
}
|
|
1042
|
+
/** Something the business offers to book, with the calendars (people, rooms) it is booked with. */
|
|
1043
|
+
export interface BookableService {
|
|
1044
|
+
key: string;
|
|
1045
|
+
name: string;
|
|
1046
|
+
description: string;
|
|
1047
|
+
/** How long it lasts. */
|
|
1048
|
+
minutes: number;
|
|
1049
|
+
/** 1 for one person at a time; more for a class several people join at one start. */
|
|
1050
|
+
capacity: number;
|
|
1051
|
+
/** The price as the business wrote it. Shown, never charged. */
|
|
1052
|
+
price_text: string;
|
|
1053
|
+
location: string;
|
|
1054
|
+
intake: BookingQuestion[];
|
|
1055
|
+
calendars: Array<{
|
|
1056
|
+
key: string;
|
|
1057
|
+
name: string;
|
|
1058
|
+
timezone: string;
|
|
1059
|
+
}>;
|
|
1060
|
+
}
|
|
1061
|
+
/** A time that can be booked. */
|
|
1062
|
+
export interface OpenTime {
|
|
1063
|
+
start: string;
|
|
1064
|
+
end: string;
|
|
1065
|
+
/** The calendar it is with. */
|
|
1066
|
+
calendar: string;
|
|
1067
|
+
calendarName: string;
|
|
1068
|
+
/** Places left: 1 for a one-person service, more while a class has room. */
|
|
1069
|
+
seatsLeft: number;
|
|
1070
|
+
/** True when this start joins a class someone has already booked. */
|
|
1071
|
+
joins: boolean;
|
|
1072
|
+
}
|
|
1073
|
+
/** What book() needs. Take `startsAt` and `calendar` from an OpenTime. */
|
|
1074
|
+
export interface BookingRequest {
|
|
1075
|
+
service: string;
|
|
1076
|
+
calendar: string;
|
|
1077
|
+
startsAt: string;
|
|
1078
|
+
name: string;
|
|
1079
|
+
email: string;
|
|
1080
|
+
phone?: string;
|
|
1081
|
+
/** Places, for a class. Default 1. */
|
|
1082
|
+
seats?: number;
|
|
1083
|
+
/** Answers to the service's questions, by question key. */
|
|
1084
|
+
answers?: Record<string, string | string[]>;
|
|
1085
|
+
/**
|
|
1086
|
+
* One per attempt, so a retry after a dropped connection returns the same booking instead of
|
|
1087
|
+
* making a second. Make a new one for each new booking.
|
|
1088
|
+
*/
|
|
1089
|
+
idempotencyKey?: string;
|
|
1090
|
+
}
|
|
1091
|
+
/** A booking just made. */
|
|
1092
|
+
export interface BookingResult {
|
|
1093
|
+
bookingId: string;
|
|
1094
|
+
/** False when idempotencyKey matched a booking already made. */
|
|
1095
|
+
created: boolean;
|
|
1096
|
+
startsAt: string;
|
|
1097
|
+
endsAt: string;
|
|
1098
|
+
/** The customer's own page to change or cancel, the same link their email carries. */
|
|
1099
|
+
manageUrl: string;
|
|
1100
|
+
}
|
|
1101
|
+
/** One booking, as its customer's link shows it. Names no person. */
|
|
1102
|
+
export interface ManagedBooking {
|
|
1103
|
+
bookingId: string;
|
|
1104
|
+
status: 'confirmed' | 'cancelled' | 'completed' | 'no_show';
|
|
1105
|
+
service: string | null;
|
|
1106
|
+
/** The calendar's name: who or what it is with. */
|
|
1107
|
+
with: string | null;
|
|
1108
|
+
startsAt: string;
|
|
1109
|
+
endsAt: string;
|
|
1110
|
+
/** The calendar's IANA zone, for showing the times. */
|
|
1111
|
+
timezone: string;
|
|
1112
|
+
location: string;
|
|
1113
|
+
seats: number;
|
|
1114
|
+
/** Whether it can still be cancelled or moved online (the service's cutoff has not passed). */
|
|
1115
|
+
canCancel: boolean;
|
|
1116
|
+
canMove: boolean;
|
|
1117
|
+
changesCloseAt: string;
|
|
1118
|
+
}
|
|
1119
|
+
/** Options for createBookingsClient(). */
|
|
1120
|
+
export interface BookingsClientOptions {
|
|
1121
|
+
/** The booking key (bk_...) from Contacts, Bookings, On your website. Public: it works only on the sites it lists. */
|
|
1122
|
+
key: string;
|
|
1123
|
+
/** Default https://hub.awesomate.ai. */
|
|
1124
|
+
baseUrl?: string;
|
|
1125
|
+
/** Your own fetch. Default: the global one. */
|
|
1126
|
+
fetch?: typeof fetch;
|
|
1127
|
+
}
|
|
1128
|
+
/** The token in a booking's manage link (`/booking?t=...`), or null. */
|
|
1129
|
+
export declare function manageTokenFrom(url: string): string | null;
|
|
1130
|
+
/**
|
|
1131
|
+
* The bookings client, from createBookingsClient(): a business's own booking page, for visitors
|
|
1132
|
+
* who are not signed in. Lists what can be booked and when, books, and lets a customer see,
|
|
1133
|
+
* cancel or move their booking from the link in its emails. Safe in a browser: the booking key is
|
|
1134
|
+
* public and locked to the business's own sites.
|
|
1135
|
+
*/
|
|
1136
|
+
export declare class AwesomateBookingsClient {
|
|
1137
|
+
private readonly opts;
|
|
1138
|
+
private readonly base;
|
|
1139
|
+
private readonly doFetch;
|
|
1140
|
+
constructor(opts: BookingsClientOptions);
|
|
1141
|
+
private request;
|
|
1142
|
+
/** The business's name and what it offers to book. */
|
|
1143
|
+
services(): Promise<{
|
|
1144
|
+
business: string;
|
|
1145
|
+
services: BookableService[];
|
|
1146
|
+
}>;
|
|
1147
|
+
/**
|
|
1148
|
+
* The times a service can be booked, soonest first, from now for two weeks unless told
|
|
1149
|
+
* otherwise (at most 62 days at once). Times are UTC; show them in the calendar's own zone.
|
|
1150
|
+
*/
|
|
1151
|
+
openTimes(service: string, options?: {
|
|
1152
|
+
from?: Date | string;
|
|
1153
|
+
to?: Date | string;
|
|
1154
|
+
calendar?: string;
|
|
1155
|
+
seats?: number;
|
|
1156
|
+
}): Promise<OpenTime[]>;
|
|
1157
|
+
/**
|
|
1158
|
+
* Book a time for a visitor. They get an email with an invite and a link to change or cancel;
|
|
1159
|
+
* the business gets a notice. A time taken since you listed it is refused with code `conflict`
|
|
1160
|
+
* and `field` saying why (not_open, slot_taken, session_full, day_full): list the times again.
|
|
1161
|
+
*/
|
|
1162
|
+
book(request: BookingRequest): Promise<BookingResult>;
|
|
1163
|
+
/** One booking, from the token in its manage link (manageTokenFrom()). */
|
|
1164
|
+
booking(manageToken: string): Promise<{
|
|
1165
|
+
business: string;
|
|
1166
|
+
booking: ManagedBooking;
|
|
1167
|
+
}>;
|
|
1168
|
+
/** The times a booking could move to, leaving its own time out. */
|
|
1169
|
+
openTimesToMove(manageToken: string, options?: {
|
|
1170
|
+
from?: Date | string;
|
|
1171
|
+
to?: Date | string;
|
|
1172
|
+
}): Promise<OpenTime[]>;
|
|
1173
|
+
/** Cancel a booking from its manage link. A second cancel answers cancelled: false. */
|
|
1174
|
+
cancel(manageToken: string, reason?: string): Promise<{
|
|
1175
|
+
cancelled: boolean;
|
|
1176
|
+
}>;
|
|
1177
|
+
/** Move a booking to a time from openTimesToMove(). */
|
|
1178
|
+
move(manageToken: string, startsAt: string): Promise<{
|
|
1179
|
+
startsAt: string;
|
|
1180
|
+
endsAt: string;
|
|
1181
|
+
}>;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* The client for a business's own booking page. Use the booking key from Contacts, Bookings, On
|
|
1185
|
+
* your website; it works on every plan, from the sites the key lists.
|
|
1186
|
+
*
|
|
1187
|
+
* @example
|
|
1188
|
+
* ```ts
|
|
1189
|
+
* import { createBookingsClient } from '@awesomate/sdk';
|
|
1190
|
+
*
|
|
1191
|
+
* const bookings = createBookingsClient({ key: 'bk_your_booking_key' });
|
|
1192
|
+
* const { services } = await bookings.services();
|
|
1193
|
+
* const times = await bookings.openTimes(services[0].key);
|
|
1194
|
+
* ```
|
|
1195
|
+
*/
|
|
1196
|
+
export declare function createBookingsClient(options: BookingsClientOptions): AwesomateBookingsClient;
|
|
923
1197
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* Docs: https://hub.awesomate.ai/docs/sdk/
|
|
25
25
|
*/
|
|
26
26
|
/** This package's version, sent to the hub with every server call. */
|
|
27
|
-
export const VERSION = '0.
|
|
27
|
+
export const VERSION = '0.17.0';
|
|
28
28
|
const DEFAULT_BASE = 'https://hub.awesomate.ai';
|
|
29
29
|
const ERROR_CODES = ['unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable'];
|
|
30
30
|
/**
|
|
@@ -57,13 +57,17 @@ export class AwesomateError extends Error {
|
|
|
57
57
|
this.name = 'AwesomateError';
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
const CONSENT_CODES = new Set(['consent_blocked', 'consent_denied', 'files_consent_off']);
|
|
60
61
|
function errorCode(status, server) {
|
|
61
62
|
if (server === 'validation' || server === 'invalid' || status === 422)
|
|
62
63
|
return 'validation';
|
|
64
|
+
// A file the hub will not take as sent: too big, no length, or a type it refuses.
|
|
65
|
+
if (status === 411 || status === 413 || status === 415)
|
|
66
|
+
return 'validation';
|
|
63
67
|
if (status === 401)
|
|
64
68
|
return 'unauthenticated';
|
|
65
69
|
if (status === 403)
|
|
66
|
-
return server
|
|
70
|
+
return server && CONSENT_CODES.has(server) ? 'consent_blocked' : 'forbidden';
|
|
67
71
|
if (status === 429)
|
|
68
72
|
return 'rate_limited';
|
|
69
73
|
if (server === 'conflict')
|
|
@@ -133,6 +137,99 @@ export class AwesomateClient {
|
|
|
133
137
|
const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
|
|
134
138
|
throw new AwesomateError(code, message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
|
|
135
139
|
}
|
|
140
|
+
/** A call whose body or answer is a file, not JSON. Answers the Response when it succeeded. */
|
|
141
|
+
async raw(method, path, body) {
|
|
142
|
+
if (this.appKey) {
|
|
143
|
+
throw new AwesomateError('forbidden', 'Files needs the account\'s token (amt_pat_... with files:read or files:write), not an app key.', 0);
|
|
144
|
+
}
|
|
145
|
+
const res = await this.doFetch(`${this.base}${path}`, {
|
|
146
|
+
method,
|
|
147
|
+
headers: {
|
|
148
|
+
authorization: `Bearer ${this.opts.token}`,
|
|
149
|
+
'user-agent': `@awesomate/sdk/${VERSION}`,
|
|
150
|
+
...(body === undefined ? {} : { 'content-type': 'application/octet-stream' }),
|
|
151
|
+
},
|
|
152
|
+
body: body,
|
|
153
|
+
});
|
|
154
|
+
if (res.ok)
|
|
155
|
+
return res;
|
|
156
|
+
const text = await res.text();
|
|
157
|
+
let json = {};
|
|
158
|
+
try {
|
|
159
|
+
json = text ? JSON.parse(text) : {};
|
|
160
|
+
}
|
|
161
|
+
catch { /* a proxy's HTML error page */ }
|
|
162
|
+
const server = typeof json.code === 'string' ? json.code : undefined;
|
|
163
|
+
const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
|
|
164
|
+
throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server);
|
|
165
|
+
}
|
|
166
|
+
filesCall(method, path, body) {
|
|
167
|
+
if (this.appKey) {
|
|
168
|
+
return Promise.reject(new AwesomateError('forbidden', 'Files needs the account\'s token (amt_pat_... with files:read or files:write), not an app key.', 0));
|
|
169
|
+
}
|
|
170
|
+
return this.request(method, path, body, false);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The account's Files: the folders on its automation account that its n8n workflows read and
|
|
174
|
+
* write (public/, private/, temp/). A file in public/ has a public_url anyone with it can open;
|
|
175
|
+
* the others never do. Needs the account's token with files:read (reading) or files:write
|
|
176
|
+
* (changing), on a plan with Files, after the owner has switched on "Open your automation
|
|
177
|
+
* account's file folders" in Settings, Privacy.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* const logo = await db.files.upload('public/brand/logo.png', await readFile('logo.png'));
|
|
181
|
+
* console.log(logo.public_url);
|
|
182
|
+
*/
|
|
183
|
+
files = {
|
|
184
|
+
/** One folder; '' (the default) lists the top folders. */
|
|
185
|
+
list: async (path = '') => (await this.filesCall('GET', `/api/files?path=${encodeURIComponent(path)}`)).entries,
|
|
186
|
+
/** Files by part of their name (* and ? are wildcards), across the folders or in one. At most 200: `truncated` says there were more. */
|
|
187
|
+
search: async (options = {}) => {
|
|
188
|
+
const qs = new URLSearchParams({ q: options.q ?? '' });
|
|
189
|
+
if (options.top)
|
|
190
|
+
qs.set('top', options.top);
|
|
191
|
+
if (options.extensions?.length)
|
|
192
|
+
qs.set('exts', options.extensions.join(','));
|
|
193
|
+
const r = await this.filesCall('GET', `/api/files/search?${qs}`);
|
|
194
|
+
return { results: r.results, truncated: r.truncated };
|
|
195
|
+
},
|
|
196
|
+
/** Space used against the plan's Files space. Measured, and up to ten minutes old. */
|
|
197
|
+
usage: () => this.filesCall('GET', '/api/files/usage'),
|
|
198
|
+
/**
|
|
199
|
+
* Write a file (up to 50 MB). Refuses to replace one already there unless `overwrite`. A full
|
|
200
|
+
* Files space answers serverCode files_storage_full.
|
|
201
|
+
*/
|
|
202
|
+
upload: async (path, data, options = {}) => {
|
|
203
|
+
const qs = new URLSearchParams({ path });
|
|
204
|
+
if (options.overwrite)
|
|
205
|
+
qs.set('overwrite', '1');
|
|
206
|
+
return (await (await this.raw('POST', `/api/files?${qs}`, data)).json()).entry;
|
|
207
|
+
},
|
|
208
|
+
/** A file's contents. */
|
|
209
|
+
download: async (path) => (await this.raw('GET', `/api/files/stream?${new URLSearchParams({ path, disposition: 'attachment' })}`)).blob(),
|
|
210
|
+
/** Make a folder, and any above it. */
|
|
211
|
+
mkdir: async (path) => (await this.filesCall('POST', '/api/files/mkdir', { path })).entry,
|
|
212
|
+
/** Move or rename. Anything using the old path or address needs the new one. */
|
|
213
|
+
move: async (from, to) => (await this.filesCall('POST', '/api/files/move', { from, to })).entry,
|
|
214
|
+
/** Move a file from private/ or temp/ to the same place under public/, and answer its public_url. Anyone with the address can open it. */
|
|
215
|
+
makePublic: async (path) => {
|
|
216
|
+
const [top, ...rest] = path.replace(/^\/+/, '').split('/');
|
|
217
|
+
if (top === 'public')
|
|
218
|
+
throw new AwesomateError('validation', `${path} is already public.`, 0, 'path');
|
|
219
|
+
if (!rest.length)
|
|
220
|
+
throw new AwesomateError('validation', 'Name a file inside the folder.', 0, 'path');
|
|
221
|
+
return this.files.move(path, `public/${rest.join('/')}`);
|
|
222
|
+
},
|
|
223
|
+
/** Move a file out of public/ into private/. Its public address stops serving it, though Cloudflare can keep showing the last copy for up to 4 hours. */
|
|
224
|
+
makePrivate: async (path) => {
|
|
225
|
+
const [top, ...rest] = path.replace(/^\/+/, '').split('/');
|
|
226
|
+
if (top !== 'public' || !rest.length)
|
|
227
|
+
throw new AwesomateError('validation', `${path} is not a file in public/.`, 0, 'path');
|
|
228
|
+
return this.files.move(path, `private/${rest.join('/')}`);
|
|
229
|
+
},
|
|
230
|
+
/** Move to the trash. Not erased, and no longer counted toward the Files space. */
|
|
231
|
+
remove: async (path) => this.filesCall('DELETE', `/api/files?path=${encodeURIComponent(path)}`),
|
|
232
|
+
};
|
|
136
233
|
/** The kinds this token can read, their columns, operators and examples. */
|
|
137
234
|
schema() {
|
|
138
235
|
return this.request('GET', '/api/my-crm/v1/rows/schema');
|
|
@@ -597,6 +694,71 @@ export class AwesomateAppClient {
|
|
|
597
694
|
async lookupCustomers(options) {
|
|
598
695
|
return (await this.data('POST', '/customers/lookup', options)).customers;
|
|
599
696
|
}
|
|
697
|
+
/** Runs a call with the current access token, refreshing it once when the hub says it expired. */
|
|
698
|
+
async authed(fn) {
|
|
699
|
+
let s = await this.current();
|
|
700
|
+
if (!s)
|
|
701
|
+
throw new AwesomateError('unauthenticated', 'Sign in first.', 401);
|
|
702
|
+
try {
|
|
703
|
+
return await fn(s.access_token);
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
|
|
707
|
+
throw err;
|
|
708
|
+
s = await this.refresh(s);
|
|
709
|
+
if (!s)
|
|
710
|
+
throw err;
|
|
711
|
+
return fn(s.access_token);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
async rawData(method, path, token, body) {
|
|
715
|
+
const res = await this.doFetch(`${this.base}/api/sdk/v1${path}`, {
|
|
716
|
+
method,
|
|
717
|
+
headers: {
|
|
718
|
+
'x-awesomate-key': this.opts.publishableKey,
|
|
719
|
+
authorization: `Bearer ${token}`,
|
|
720
|
+
...(body === undefined ? {} : { 'content-type': 'application/octet-stream' }),
|
|
721
|
+
},
|
|
722
|
+
body: body,
|
|
723
|
+
});
|
|
724
|
+
if (res.ok)
|
|
725
|
+
return res;
|
|
726
|
+
const text = await res.text();
|
|
727
|
+
let json = {};
|
|
728
|
+
try {
|
|
729
|
+
json = text ? JSON.parse(text) : {};
|
|
730
|
+
}
|
|
731
|
+
catch { /* a proxy's error page */ }
|
|
732
|
+
const server = typeof json.code === 'string' ? json.code : undefined;
|
|
733
|
+
const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
|
|
734
|
+
throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server, message);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Files people attach in this app, such as a photo of a job or a signed form, kept in the
|
|
738
|
+
* account's Files. Off until the account switches it on for the app (private, or public with a
|
|
739
|
+
* public address per file): check mode() before showing an upload button. Keep the handle an
|
|
740
|
+
* upload answers in a record's file attribute; anyone signed in to this app who can read that
|
|
741
|
+
* record can open the file with download().
|
|
742
|
+
*
|
|
743
|
+
* @example
|
|
744
|
+
* const file = await app.files.upload(input.files[0]);
|
|
745
|
+
* await app.write('message', { body: 'Photo of the leak', attachment: file.handle }, { links: { job: jobId } });
|
|
746
|
+
*/
|
|
747
|
+
files = {
|
|
748
|
+
/** Whether this app takes uploads: 'off', 'private' or 'public'. */
|
|
749
|
+
mode: async () => (await this.data('GET', '/me')).app.file_uploads ?? 'off',
|
|
750
|
+
/**
|
|
751
|
+
* Upload one file, up to 10 MB. The name comes from a File, or pass one. A refusal's
|
|
752
|
+
* serverCode says why: uploads_off, too_large, type_not_allowed (public uploads take no web
|
|
753
|
+
* pages or scripts), files_storage_full; personMessage is the sentence to show.
|
|
754
|
+
*/
|
|
755
|
+
upload: async (file, options = {}) => {
|
|
756
|
+
const name = options.name ?? file.name ?? 'file';
|
|
757
|
+
return this.authed(async (token) => (await (await this.rawData('POST', `/files?name=${encodeURIComponent(name)}`, token, file)).json()).file);
|
|
758
|
+
},
|
|
759
|
+
/** The file a handle names, for someone signed in to this app. */
|
|
760
|
+
download: async (handle) => this.authed(async (token) => (await this.rawData('GET', `/files/${encodeURIComponent(handle)}`, token)).blob()),
|
|
761
|
+
};
|
|
600
762
|
/**
|
|
601
763
|
* The agent this app talks with by voice, or null when the account has not picked one. Pass it
|
|
602
764
|
* to AwesomateAgent.mount as agentId; the hub decides which agent answers, never the page.
|
|
@@ -948,3 +1110,138 @@ export function createAppClient(options) {
|
|
|
948
1110
|
export function createClient(options) {
|
|
949
1111
|
return new AwesomateClient(options);
|
|
950
1112
|
}
|
|
1113
|
+
/** The token in a booking's manage link (`/booking?t=...`), or null. */
|
|
1114
|
+
export function manageTokenFrom(url) {
|
|
1115
|
+
try {
|
|
1116
|
+
return new URL(url).searchParams.get('t');
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const iso = (d) => (d === undefined ? undefined : typeof d === 'string' ? d : d.toISOString());
|
|
1123
|
+
/**
|
|
1124
|
+
* The bookings client, from createBookingsClient(): a business's own booking page, for visitors
|
|
1125
|
+
* who are not signed in. Lists what can be booked and when, books, and lets a customer see,
|
|
1126
|
+
* cancel or move their booking from the link in its emails. Safe in a browser: the booking key is
|
|
1127
|
+
* public and locked to the business's own sites.
|
|
1128
|
+
*/
|
|
1129
|
+
export class AwesomateBookingsClient {
|
|
1130
|
+
opts;
|
|
1131
|
+
base;
|
|
1132
|
+
doFetch;
|
|
1133
|
+
constructor(opts) {
|
|
1134
|
+
this.opts = opts;
|
|
1135
|
+
if (!opts?.key?.startsWith('bk_'))
|
|
1136
|
+
throw new Error('createBookingsClient needs the booking key (bk_...) from Contacts, Bookings, On your website.');
|
|
1137
|
+
this.base = `${(opts.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '')}/api/sdk/v1/bookings`;
|
|
1138
|
+
const f = opts.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
1139
|
+
if (!f)
|
|
1140
|
+
throw new Error('No fetch available.');
|
|
1141
|
+
this.doFetch = f;
|
|
1142
|
+
}
|
|
1143
|
+
async request(method, path, body, withKey = true) {
|
|
1144
|
+
let res;
|
|
1145
|
+
try {
|
|
1146
|
+
res = await this.doFetch(`${this.base}${path}`, {
|
|
1147
|
+
method,
|
|
1148
|
+
headers: { 'content-type': 'application/json', ...(withKey ? { 'x-awesomate-key': this.opts.key } : {}) },
|
|
1149
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
catch (err) {
|
|
1153
|
+
throw new AwesomateError('unavailable', `The hub could not be reached: ${err.message}`, 0);
|
|
1154
|
+
}
|
|
1155
|
+
const data = (await res.json().catch(() => ({})));
|
|
1156
|
+
if (!res.ok) {
|
|
1157
|
+
const server = typeof data.code === 'string' ? data.code : undefined;
|
|
1158
|
+
const message = typeof data.error === 'string' ? data.error : `Request failed (${res.status})`;
|
|
1159
|
+
throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof data.field === 'string' ? data.field : undefined, server, message);
|
|
1160
|
+
}
|
|
1161
|
+
return data;
|
|
1162
|
+
}
|
|
1163
|
+
/** The business's name and what it offers to book. */
|
|
1164
|
+
services() {
|
|
1165
|
+
return this.request('GET', '/services');
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* The times a service can be booked, soonest first, from now for two weeks unless told
|
|
1169
|
+
* otherwise (at most 62 days at once). Times are UTC; show them in the calendar's own zone.
|
|
1170
|
+
*/
|
|
1171
|
+
async openTimes(service, options = {}) {
|
|
1172
|
+
const q = new URLSearchParams({ service });
|
|
1173
|
+
const from = iso(options.from);
|
|
1174
|
+
const to = iso(options.to);
|
|
1175
|
+
if (from)
|
|
1176
|
+
q.set('from', from);
|
|
1177
|
+
if (to)
|
|
1178
|
+
q.set('to', to);
|
|
1179
|
+
if (options.calendar)
|
|
1180
|
+
q.set('calendar', options.calendar);
|
|
1181
|
+
if (options.seats)
|
|
1182
|
+
q.set('seats', String(options.seats));
|
|
1183
|
+
return (await this.request('GET', `/open-times?${q}`)).times;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Book a time for a visitor. They get an email with an invite and a link to change or cancel;
|
|
1187
|
+
* the business gets a notice. A time taken since you listed it is refused with code `conflict`
|
|
1188
|
+
* and `field` saying why (not_open, slot_taken, session_full, day_full): list the times again.
|
|
1189
|
+
*/
|
|
1190
|
+
async book(request) {
|
|
1191
|
+
const r = await this.request('POST', '/book', {
|
|
1192
|
+
service: request.service, calendar: request.calendar, starts_at: request.startsAt, name: request.name, email: request.email,
|
|
1193
|
+
phone: request.phone, seats: request.seats, answers: request.answers, idempotency_key: request.idempotencyKey,
|
|
1194
|
+
});
|
|
1195
|
+
return { bookingId: r.booking_id, created: r.created, startsAt: r.starts_at, endsAt: r.ends_at, manageUrl: r.manage_url };
|
|
1196
|
+
}
|
|
1197
|
+
/** One booking, from the token in its manage link (manageTokenFrom()). */
|
|
1198
|
+
async booking(manageToken) {
|
|
1199
|
+
const r = await this.request('GET', `/manage?t=${encodeURIComponent(manageToken)}`, undefined, false);
|
|
1200
|
+
const b = r.booking;
|
|
1201
|
+
return {
|
|
1202
|
+
business: r.business,
|
|
1203
|
+
booking: {
|
|
1204
|
+
bookingId: b.booking_id, status: b.status, service: b.service ?? null,
|
|
1205
|
+
with: b.with ?? null, startsAt: b.starts_at, endsAt: b.ends_at, timezone: b.timezone,
|
|
1206
|
+
location: b.location ?? '', seats: b.seats, canCancel: !!b.can_cancel, canMove: !!b.can_move,
|
|
1207
|
+
changesCloseAt: b.changes_close_at,
|
|
1208
|
+
},
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
/** The times a booking could move to, leaving its own time out. */
|
|
1212
|
+
async openTimesToMove(manageToken, options = {}) {
|
|
1213
|
+
const q = new URLSearchParams({ t: manageToken });
|
|
1214
|
+
const from = iso(options.from);
|
|
1215
|
+
const to = iso(options.to);
|
|
1216
|
+
if (from)
|
|
1217
|
+
q.set('from', from);
|
|
1218
|
+
if (to)
|
|
1219
|
+
q.set('to', to);
|
|
1220
|
+
return (await this.request('GET', `/manage/open-times?${q}`, undefined, false)).times;
|
|
1221
|
+
}
|
|
1222
|
+
/** Cancel a booking from its manage link. A second cancel answers cancelled: false. */
|
|
1223
|
+
cancel(manageToken, reason) {
|
|
1224
|
+
return this.request('POST', '/manage/cancel', { t: manageToken, reason }, false);
|
|
1225
|
+
}
|
|
1226
|
+
/** Move a booking to a time from openTimesToMove(). */
|
|
1227
|
+
async move(manageToken, startsAt) {
|
|
1228
|
+
const r = await this.request('POST', '/manage/move', { t: manageToken, starts_at: startsAt }, false);
|
|
1229
|
+
return { startsAt: r.starts_at, endsAt: r.ends_at };
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* The client for a business's own booking page. Use the booking key from Contacts, Bookings, On
|
|
1234
|
+
* your website; it works on every plan, from the sites the key lists.
|
|
1235
|
+
*
|
|
1236
|
+
* @example
|
|
1237
|
+
* ```ts
|
|
1238
|
+
* import { createBookingsClient } from '@awesomate/sdk';
|
|
1239
|
+
*
|
|
1240
|
+
* const bookings = createBookingsClient({ key: 'bk_your_booking_key' });
|
|
1241
|
+
* const { services } = await bookings.services();
|
|
1242
|
+
* const times = await bookings.openTimes(services[0].key);
|
|
1243
|
+
* ```
|
|
1244
|
+
*/
|
|
1245
|
+
export function createBookingsClient(options) {
|
|
1246
|
+
return new AwesomateBookingsClient(options);
|
|
1247
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awesomate/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Your own Awesomate data from Node and the browser: query contacts and app data with generated types, and sign your app's own users in",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|