@awesomate/sdk 0.25.0 → 0.27.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 +1009 -63
- package/dist/index.js +572 -71
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @awesomate/sdk:
|
|
2
|
+
* @awesomate/sdk: a client's own Awesomate data from Node and the browser: contacts and app
|
|
3
|
+
* records, saved queries and recipes, the business, bookings, files, tasks, Knowledge and the
|
|
4
|
+
* people who sign in to the account's own apps.
|
|
3
5
|
*
|
|
4
6
|
* import { createClient } from '@awesomate/sdk';
|
|
5
7
|
* import './awesomate'; // the file `npx @awesomate/sdk types` wrote: types every query below
|
|
@@ -24,14 +26,19 @@
|
|
|
24
26
|
* Docs: https://hub.awesomate.ai/docs/sdk/
|
|
25
27
|
*/
|
|
26
28
|
/** This package's version, sent to the hub with every server call. */
|
|
27
|
-
export const VERSION = '0.
|
|
29
|
+
export const VERSION = '0.27.0';
|
|
28
30
|
const DEFAULT_BASE = 'https://hub.awesomate.ai';
|
|
29
31
|
/** Every record and contact id is a UUID; anything else can name no row. */
|
|
30
32
|
const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
31
|
-
const ERROR_CODES = [
|
|
33
|
+
const ERROR_CODES = [
|
|
34
|
+
'unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable',
|
|
35
|
+
'feature_unavailable', 'upgrade_required', 'scope_missing',
|
|
36
|
+
];
|
|
32
37
|
/**
|
|
33
38
|
* Every failure from the hub. `message` is for you, the developer; `personMessage`, when the hub
|
|
34
|
-
* sent one, is the sentence to show the person using your app.
|
|
39
|
+
* sent one, is the sentence to show the person using your app. A refusal over a feature, a plan
|
|
40
|
+
* or a token's scopes says what it is about in `reason`, `feature`, `missingScopes`, `upgrade`
|
|
41
|
+
* and `requiredPlan`.
|
|
35
42
|
*/
|
|
36
43
|
export class AwesomateError extends Error {
|
|
37
44
|
code;
|
|
@@ -39,6 +46,16 @@ export class AwesomateError extends Error {
|
|
|
39
46
|
field;
|
|
40
47
|
serverCode;
|
|
41
48
|
personMessage;
|
|
49
|
+
/** With feature_unavailable: why the account does not have the feature (not_released, turned_off_by_awesomate, not_on_plan, switched_off_by_you). */
|
|
50
|
+
reason;
|
|
51
|
+
/** With feature_unavailable: the feature's key. */
|
|
52
|
+
feature;
|
|
53
|
+
/** With scope_missing: the scopes the token lacks. Make a token with them in the hub. */
|
|
54
|
+
missingScopes;
|
|
55
|
+
/** The plan that includes it and the hub page to upgrade on, when the hub said. */
|
|
56
|
+
upgrade;
|
|
57
|
+
/** The plan this needs, when the hub named one. */
|
|
58
|
+
requiredPlan;
|
|
42
59
|
constructor(
|
|
43
60
|
/** Why it failed. */
|
|
44
61
|
code, message,
|
|
@@ -49,7 +66,9 @@ export class AwesomateError extends Error {
|
|
|
49
66
|
/** The hub's own code, unmapped. */
|
|
50
67
|
serverCode,
|
|
51
68
|
/** A sentence for the person using the app, when the hub sent one (a refused voice call: "You've used today's voice time"). */
|
|
52
|
-
personMessage
|
|
69
|
+
personMessage,
|
|
70
|
+
/** The refusal's typed fields. */
|
|
71
|
+
details = {}) {
|
|
53
72
|
super(message);
|
|
54
73
|
this.code = code;
|
|
55
74
|
this.status = status;
|
|
@@ -57,10 +76,24 @@ export class AwesomateError extends Error {
|
|
|
57
76
|
this.serverCode = serverCode;
|
|
58
77
|
this.personMessage = personMessage;
|
|
59
78
|
this.name = 'AwesomateError';
|
|
79
|
+
if (details.reason !== undefined)
|
|
80
|
+
this.reason = details.reason;
|
|
81
|
+
if (details.feature !== undefined)
|
|
82
|
+
this.feature = details.feature;
|
|
83
|
+
if (details.missingScopes !== undefined)
|
|
84
|
+
this.missingScopes = details.missingScopes;
|
|
85
|
+
if (details.upgrade !== undefined)
|
|
86
|
+
this.upgrade = details.upgrade;
|
|
87
|
+
if (details.requiredPlan !== undefined)
|
|
88
|
+
this.requiredPlan = details.requiredPlan;
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
|
-
|
|
63
|
-
|
|
91
|
+
/** Every code the hub uses for "the account has switched this off in its privacy settings". */
|
|
92
|
+
const CONSENT_CODES = new Set(['consent_blocked', 'consent_denied', 'consent_required', 'consent_off', 'consent_missing', 'files_consent_off']);
|
|
93
|
+
const UPGRADE_CODES = new Set(['upgrade_required', 'plan_required']);
|
|
94
|
+
/** A code-like word: some hub areas send their code in `error` and the sentence in `message`. */
|
|
95
|
+
const CODE_LIKE = /^[a-z][a-z0-9_]*$/;
|
|
96
|
+
function errorCode(status, server, body = {}) {
|
|
64
97
|
if (server === 'validation' || server === 'invalid' || status === 422)
|
|
65
98
|
return 'validation';
|
|
66
99
|
// A file the hub will not take as sent: too big, no length, or a type it refuses.
|
|
@@ -68,8 +101,17 @@ function errorCode(status, server) {
|
|
|
68
101
|
return 'validation';
|
|
69
102
|
if (status === 401)
|
|
70
103
|
return 'unauthenticated';
|
|
71
|
-
if (
|
|
72
|
-
return
|
|
104
|
+
if ((server && CONSENT_CODES.has(server)) || body.consent_required === true)
|
|
105
|
+
return 'consent_blocked';
|
|
106
|
+
if (status === 403) {
|
|
107
|
+
if (server === 'feature_unavailable')
|
|
108
|
+
return 'feature_unavailable';
|
|
109
|
+
if (Array.isArray(body.missingScopes))
|
|
110
|
+
return 'scope_missing';
|
|
111
|
+
if ((server && UPGRADE_CODES.has(server)) || Array.isArray(body.requiredPlans) || body.upgrade_required === true)
|
|
112
|
+
return 'upgrade_required';
|
|
113
|
+
return 'forbidden';
|
|
114
|
+
}
|
|
73
115
|
if (status === 429)
|
|
74
116
|
return 'rate_limited';
|
|
75
117
|
if (server === 'conflict')
|
|
@@ -78,6 +120,43 @@ function errorCode(status, server) {
|
|
|
78
120
|
return 'not_found';
|
|
79
121
|
return 'unavailable';
|
|
80
122
|
}
|
|
123
|
+
const nonEmpty = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
124
|
+
/** A refusal's parts from the hub's JSON body, whichever of its shapes the area uses. */
|
|
125
|
+
function refusal(status, json) {
|
|
126
|
+
const error = nonEmpty(json.error);
|
|
127
|
+
const sentence = nonEmpty(json.message);
|
|
128
|
+
// {error: 'invalid_request', message: 'A sentence.'}: the code is in error, the sentence in message.
|
|
129
|
+
const codeInError = !nonEmpty(json.code) && error && sentence && CODE_LIKE.test(error) ? error : undefined;
|
|
130
|
+
const serverCode = nonEmpty(json.code) ?? codeInError;
|
|
131
|
+
const details = {};
|
|
132
|
+
const reason = nonEmpty(json.reason);
|
|
133
|
+
if (reason)
|
|
134
|
+
details.reason = reason;
|
|
135
|
+
const feature = nonEmpty(json.feature);
|
|
136
|
+
if (feature)
|
|
137
|
+
details.feature = feature;
|
|
138
|
+
if (Array.isArray(json.missingScopes))
|
|
139
|
+
details.missingScopes = json.missingScopes.map(String);
|
|
140
|
+
const up = json.upgrade;
|
|
141
|
+
const upPlan = up && typeof up === 'object' ? nonEmpty(up.plan) : undefined;
|
|
142
|
+
if (upPlan)
|
|
143
|
+
details.upgrade = { plan: upPlan, url: nonEmpty(up.url) ?? '/billing' };
|
|
144
|
+
const required = nonEmpty(json.required_plan) ?? nonEmpty(json.requiredPlan) ?? upPlan
|
|
145
|
+
?? (Array.isArray(json.requiredPlans) ? nonEmpty(json.requiredPlans[0]) : undefined);
|
|
146
|
+
if (required)
|
|
147
|
+
details.requiredPlan = required;
|
|
148
|
+
return {
|
|
149
|
+
code: errorCode(status, serverCode, json),
|
|
150
|
+
message: (codeInError ? sentence : error ?? sentence) ?? `The hub answered ${status}.`,
|
|
151
|
+
serverCode,
|
|
152
|
+
field: nonEmpty(json.field),
|
|
153
|
+
details,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function hubError(status, json, personMessage) {
|
|
157
|
+
const r = refusal(status, json);
|
|
158
|
+
return new AwesomateError(r.code, r.message, status, r.field, r.serverCode, personMessage, r.details);
|
|
159
|
+
}
|
|
81
160
|
const ACCOUNT = '/api/my-crm/v1';
|
|
82
161
|
const KEY_ROUTES = [
|
|
83
162
|
['GET', /^\/rows\/schema$/], ['POST', /^\/rows\/query$/], ['GET', /^\/rows\/[^/]+\/[^/?]+(\?.*)?$/], ['POST', /^\/call$/],
|
|
@@ -111,13 +190,14 @@ export class AwesomateClient {
|
|
|
111
190
|
throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
|
|
112
191
|
}
|
|
113
192
|
async request(method, path, body, retry = true) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const res = await this.doFetch(`${this.base}${path}`, {
|
|
193
|
+
const sent = this.appKey ? keyPath(method, path) : path;
|
|
194
|
+
const res = await this.doFetch(`${this.base}${sent}`, {
|
|
117
195
|
method,
|
|
118
196
|
headers: {
|
|
119
197
|
authorization: `Bearer ${this.opts.token}`,
|
|
120
198
|
'user-agent': `@awesomate/sdk/${VERSION}`,
|
|
199
|
+
// Knowledge answers stream to a browser; asked for JSON, the hub sends the whole answer.
|
|
200
|
+
accept: 'application/json',
|
|
121
201
|
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
122
202
|
},
|
|
123
203
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -130,14 +210,12 @@ export class AwesomateClient {
|
|
|
130
210
|
catch { /* a proxy's HTML error page */ }
|
|
131
211
|
if (res.ok)
|
|
132
212
|
return json;
|
|
133
|
-
const
|
|
134
|
-
const code = errorCode(res.status, server);
|
|
213
|
+
const err = hubError(res.status, json);
|
|
135
214
|
// The field list changed while the read ran (the view is rebuilt on every field edit): a read
|
|
136
215
|
// is safe to run again, once.
|
|
137
|
-
if (code === 'conflict' && retry)
|
|
216
|
+
if (err.code === 'conflict' && retry)
|
|
138
217
|
return this.request(method, path, body, false);
|
|
139
|
-
|
|
140
|
-
throw new AwesomateError(code, message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
|
|
218
|
+
throw err;
|
|
141
219
|
}
|
|
142
220
|
/** A call whose body or answer is a file, not JSON. Answers the Response when it succeeded. */
|
|
143
221
|
async raw(method, path, body) {
|
|
@@ -161,9 +239,7 @@ export class AwesomateClient {
|
|
|
161
239
|
json = text ? JSON.parse(text) : {};
|
|
162
240
|
}
|
|
163
241
|
catch { /* a proxy's HTML error page */ }
|
|
164
|
-
|
|
165
|
-
const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
|
|
166
|
-
throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server);
|
|
242
|
+
throw hubError(res.status, json);
|
|
167
243
|
}
|
|
168
244
|
filesCall(method, path, body) {
|
|
169
245
|
if (this.appKey) {
|
|
@@ -249,6 +325,20 @@ export class AwesomateClient {
|
|
|
249
325
|
const r = await this.filesCall('POST', '/api/files/trash/empty', days === undefined ? {} : { older_than_days: days });
|
|
250
326
|
return { removed: r.removed, bytes: r.bytes, files: r.files };
|
|
251
327
|
},
|
|
328
|
+
/**
|
|
329
|
+
* Unpack a .zip in Files into a folder beside it, named after the archive (skills.zip unpacks
|
|
330
|
+
* to skills/). At most 4,000 entries and 250 MB unpacked. Needs files:write.
|
|
331
|
+
*/
|
|
332
|
+
extract: async (path) => {
|
|
333
|
+
const r = await this.filesCall('POST', '/api/files/extract', { path });
|
|
334
|
+
return { extractedTo: r.extractedTo, fileCount: r.fileCount };
|
|
335
|
+
},
|
|
336
|
+
/**
|
|
337
|
+
* Whether Files works for this account now, and why not: the plan, the automation account's
|
|
338
|
+
* version, and the owner's privacy switch, with the largest upload and the plan's space. Works
|
|
339
|
+
* with any account token, so check it before offering Files.
|
|
340
|
+
*/
|
|
341
|
+
capabilities: () => this.filesCall('GET', '/api/files/capabilities'),
|
|
252
342
|
};
|
|
253
343
|
tasksCall(method, path, body) {
|
|
254
344
|
if (this.appKey) {
|
|
@@ -259,35 +349,50 @@ export class AwesomateClient {
|
|
|
259
349
|
/**
|
|
260
350
|
* The account's Tasks: what is waiting on its people, and tasks they give each other. An app's
|
|
261
351
|
* server or an n8n workflow can give someone a task ("call this customer back") and finish it.
|
|
262
|
-
* Needs the account's token
|
|
263
|
-
* when it is not).
|
|
352
|
+
* Needs the account's token (never an app key) and Tasks on the account (a feature_unavailable
|
|
353
|
+
* refusal says when it is not), on every plan. Reading (board, record, decisions) needs
|
|
354
|
+
* hosting:read; every change needs hosting:manage.
|
|
264
355
|
*
|
|
265
356
|
* @example
|
|
266
357
|
* await db.tasks.give({ title: 'Call Sam back about the leak', forEmail: 'jo@brightwater.example', dueAt: '2026-10-06T09:00:00+11:00' });
|
|
267
358
|
*/
|
|
268
359
|
tasks = {
|
|
269
|
-
/** The board: waiting now, put off, with someone else, recently decided. `all` shows everyone's, not only yours. */
|
|
360
|
+
/** The board: waiting now, put off, with someone else, recently decided. `all` shows everyone's, not only yours. hosting:read. */
|
|
270
361
|
board: (options = {}) => this.tasksCall('GET', `/api/my-tasks${options.scope === 'all' ? '?scope=all' : ''}`),
|
|
271
|
-
/**
|
|
362
|
+
/**
|
|
363
|
+
* The decisions on the Tasks page: waiting now, put off, said yes to and gone further up, and
|
|
364
|
+
* decided in the last 14 days. A token is no one person, so `mine` shows only decisions waiting
|
|
365
|
+
* on the owners; pass `scope: 'all'` for the rest, including what was decided. Read only: deciding is a
|
|
366
|
+
* person's tap in the hub, never a token's, so every card's `actions` is empty here.
|
|
367
|
+
* hosting:read; refused with serverCode `not_live` where decisions are not switched on yet.
|
|
368
|
+
*/
|
|
369
|
+
decisions: (options = {}) => this.tasksCall('GET', `/api/my-tasks/decisions${options.scope === 'all' ? '?scope=all' : ''}`),
|
|
370
|
+
/**
|
|
371
|
+
* What happened to a decision, in order, with how long each person had it. A decision this
|
|
372
|
+
* token may not see reads as not found (serverCode `gone`). hosting:read.
|
|
373
|
+
*/
|
|
374
|
+
decisionRecord: (taskId) => this.tasksCall('GET', `/api/my-tasks/decisions/${encodeURIComponent(String(taskId))}/record`),
|
|
375
|
+
/** Give a task. The person gets it on their board, and an email when they are not the one giving it. hosting:manage. */
|
|
272
376
|
give: async (task) => {
|
|
273
377
|
const r = await this.tasksCall('POST', '/api/my-tasks/tasks', task);
|
|
274
378
|
return { key: r.key, id: r.id, holder: r.holder, emailed: r.emailed };
|
|
275
379
|
},
|
|
276
|
-
/** Change a given task: whoever wrote it, or the owner. */
|
|
380
|
+
/** Change a given task: whoever wrote it, or the owner. hosting:manage. */
|
|
277
381
|
update: async (id, patch) => {
|
|
278
382
|
await this.tasksCall('PATCH', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}`, patch);
|
|
279
383
|
},
|
|
280
|
-
/** Mark a given task done: the person who has it, whoever wrote it, or the owner. */
|
|
384
|
+
/** Mark a given task done: the person who has it, whoever wrote it, or the owner. hosting:manage. */
|
|
281
385
|
done: async (id) => {
|
|
282
386
|
await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/done`);
|
|
283
387
|
},
|
|
284
|
-
/** Take a given task off the board without doing it: whoever wrote it, or the owner. */
|
|
388
|
+
/** Take a given task off the board without doing it: whoever wrote it, or the owner. hosting:manage. */
|
|
285
389
|
drop: async (id) => {
|
|
286
390
|
await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/drop`);
|
|
287
391
|
},
|
|
288
392
|
/**
|
|
289
393
|
* Open a done or dropped task again, with the person who had it. They get an email unless
|
|
290
394
|
* they are the one bringing it back. Who may: see `decided[].canBringBack` on the board.
|
|
395
|
+
* hosting:manage.
|
|
291
396
|
*/
|
|
292
397
|
bringBack: async (id) => {
|
|
293
398
|
const r = await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/reopen`);
|
|
@@ -296,21 +401,21 @@ export class AwesomateClient {
|
|
|
296
401
|
/**
|
|
297
402
|
* What happened to a given task, in order: who gave it, who passed it on, who closed it and
|
|
298
403
|
* brought it back, and how long each person had it. The owner reads any task's record; anyone
|
|
299
|
-
* else only one they were part of.
|
|
404
|
+
* else only one they were part of. hosting:read.
|
|
300
405
|
*/
|
|
301
406
|
record: (id) => this.tasksCall('GET', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/record`),
|
|
302
|
-
/** Put a card off until a time (at most 90 days), for this person only. */
|
|
407
|
+
/** Put a card off until a time (at most 90 days), for this person only. hosting:manage. */
|
|
303
408
|
notNow: async (key, until) => {
|
|
304
409
|
const r = await this.tasksCall('POST', `/api/my-tasks/items/${encodeURIComponent(key)}/not-now`, {
|
|
305
410
|
until: until instanceof Date ? until.toISOString() : until,
|
|
306
411
|
});
|
|
307
412
|
return { until: r.until };
|
|
308
413
|
},
|
|
309
|
-
/** Bring a card put off with notNow() back now, for this person. */
|
|
414
|
+
/** Bring a card put off with notNow() back now, for this person. hosting:manage. */
|
|
310
415
|
cancelNotNow: async (key) => {
|
|
311
416
|
await this.tasksCall('DELETE', `/api/my-tasks/items/${encodeURIComponent(key)}/not-now`);
|
|
312
417
|
},
|
|
313
|
-
/** Hand a card to someone on the account who can act on it. */
|
|
418
|
+
/** Hand a card to someone on the account who can act on it. hosting:manage. */
|
|
314
419
|
passOn: async (key, toEmail, reason) => {
|
|
315
420
|
const r = await this.tasksCall('POST', `/api/my-tasks/items/${encodeURIComponent(key)}/pass-on`, {
|
|
316
421
|
toEmail, ...(reason ? { reason } : {}),
|
|
@@ -331,6 +436,12 @@ export class AwesomateClient {
|
|
|
331
436
|
* const today = await db.bookings.list({ from: '2026-10-08T00:00:00+11:00', to: '2026-10-09T00:00:00+11:00', status: 'confirmed' });
|
|
332
437
|
*/
|
|
333
438
|
bookings = {
|
|
439
|
+
/**
|
|
440
|
+
* How bookings are set up: whether they are on, the calendars (people, rooms) with their
|
|
441
|
+
* hours, the services with the calendars that take them, and this month's online bookings
|
|
442
|
+
* against the plan's. The `key`s here are what openTimes() and book() take. crm:read.
|
|
443
|
+
*/
|
|
444
|
+
setup: () => this.request('GET', '/api/my-crm/v1/bookings/setup', undefined, false),
|
|
334
445
|
/**
|
|
335
446
|
* Bookings that start in a window, soonest first, each with its customer. The window is from a
|
|
336
447
|
* day ago for 31 days unless told otherwise; `limit` is 1 to 500 (default 200).
|
|
@@ -419,6 +530,164 @@ export class AwesomateClient {
|
|
|
419
530
|
await this.request('POST', `/api/my-crm/v1/bookings/${encodeURIComponent(String(id))}/outcome`, { status }, false);
|
|
420
531
|
},
|
|
421
532
|
};
|
|
533
|
+
/**
|
|
534
|
+
* The people who sign in to the account's own apps (createAppClient()), for a staff screen or
|
|
535
|
+
* your own sign-up flow. One list for the account: a person signs in to any of its apps with
|
|
536
|
+
* the same email and role. Needs the account's token, never an app key. Reading needs crm:read
|
|
537
|
+
* on every plan; adding and changing people needs crm:write and Pro and above (signing people
|
|
538
|
+
* in to your own apps is part of Pro).
|
|
539
|
+
*
|
|
540
|
+
* @example
|
|
541
|
+
* const person = await db.appUsers.invite('sam@example.com', { role: 'customer' });
|
|
542
|
+
* await db.appUsers.update(person.id, { disabled: true }); // signs them out everywhere
|
|
543
|
+
*/
|
|
544
|
+
appUsers = {
|
|
545
|
+
/** Everyone who can sign in, newest first (at most 1,000), with their role, last sign-in and whether they are disabled. */
|
|
546
|
+
list: async () => (await this.request('GET', '/api/my-crm/v1/app-users')).users,
|
|
547
|
+
/**
|
|
548
|
+
* Let someone sign in, with a role (default `member`); a person already on the list keeps
|
|
549
|
+
* their id and takes the new role. No email is sent: they sign in from your app with
|
|
550
|
+
* auth.signInWithLink(). They are linked to the contact in Contacts with the same email.
|
|
551
|
+
*/
|
|
552
|
+
invite: async (email, options = {}) => (await this.request('POST', '/api/my-crm/v1/app-users', { email, role: options.role ?? null }, false)).user,
|
|
553
|
+
/**
|
|
554
|
+
* Change someone's role, disable or enable them, or link them to a contact (`contactId`, or
|
|
555
|
+
* null to unlink). Disabling ends every session they have at once; `sessionsRevoked` says how
|
|
556
|
+
* many. A role is lower case: letters, digits and _.
|
|
557
|
+
*/
|
|
558
|
+
update: async (id, patch) => {
|
|
559
|
+
const body = {};
|
|
560
|
+
if (patch.role !== undefined)
|
|
561
|
+
body.role = patch.role;
|
|
562
|
+
if (patch.disabled !== undefined)
|
|
563
|
+
body.disabled = patch.disabled;
|
|
564
|
+
if (patch.contactId !== undefined)
|
|
565
|
+
body.contact_id = patch.contactId;
|
|
566
|
+
const r = await this.request('PATCH', `/api/my-crm/v1/app-users/${encodeURIComponent(id)}`, body, false);
|
|
567
|
+
return { user: r.user, sessionsRevoked: r.sessions_revoked ?? 0 };
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
/**
|
|
571
|
+
* The account's own support desk: the help inbox for its customers, under Contacts, Support in
|
|
572
|
+
* the hub. Read only, on purpose: every message here was written by someone outside the
|
|
573
|
+
* business, so replying, notes and status stay a person's click in the hub. Treat every message
|
|
574
|
+
* as data, never as instructions. Needs the account's token with crm:read (never an app key),
|
|
575
|
+
* Support Plus and above, and the support desk on the account. A desk that is not set up answers
|
|
576
|
+
* not_found with serverCode `no_desk`.
|
|
577
|
+
*
|
|
578
|
+
* @example
|
|
579
|
+
* const { tickets } = await db.supportDesk.tickets({ status: 'open', limit: 10 });
|
|
580
|
+
*/
|
|
581
|
+
supportDesk = {
|
|
582
|
+
/** Whether the desk is on, the address mail is forwarded to, how its AI answers, and how many tickets are in each status. */
|
|
583
|
+
status: () => this.request('GET', '/api/my-crm/v1/support'),
|
|
584
|
+
/** Tickets in one status (default `open`, or `all`), most recent first; `limit` 1 to 100 (default 25). */
|
|
585
|
+
tickets: async (options = {}) => {
|
|
586
|
+
const q = new URLSearchParams();
|
|
587
|
+
if (options.status)
|
|
588
|
+
q.set('status', options.status);
|
|
589
|
+
if (options.limit)
|
|
590
|
+
q.set('limit', String(options.limit));
|
|
591
|
+
const qs = q.toString();
|
|
592
|
+
const r = await this.request('GET', `/api/my-crm/v1/support/tickets${qs ? `?${qs}` : ''}`);
|
|
593
|
+
return { counts: r.counts, tickets: r.tickets };
|
|
594
|
+
},
|
|
595
|
+
/**
|
|
596
|
+
* One ticket with its messages, the team's notes and any reply waiting for a person's OK, or
|
|
597
|
+
* null when there is none (an id that is not a ticket id never reaches the hub).
|
|
598
|
+
*/
|
|
599
|
+
ticket: async (id) => {
|
|
600
|
+
if (!RECORD_ID.test(String(id)))
|
|
601
|
+
return null;
|
|
602
|
+
try {
|
|
603
|
+
const r = await this.request('GET', `/api/my-crm/v1/support/tickets/${encodeURIComponent(String(id))}`);
|
|
604
|
+
return { ticket: r.ticket, messages: r.messages, team_notes: r.team_notes, held_replies: r.held_replies, answer_in_hub: r.answer_in_hub };
|
|
605
|
+
}
|
|
606
|
+
catch (err) {
|
|
607
|
+
if (err instanceof AwesomateError && err.code === 'not_found' && err.serverCode !== 'no_desk')
|
|
608
|
+
return null;
|
|
609
|
+
throw err;
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
/**
|
|
614
|
+
* The account's Knowledge Base, from a server: a cited answer, instant search, and Your numbers
|
|
615
|
+
* (the business's own figures: imported tables, Google Analytics and Search Console). Needs the
|
|
616
|
+
* account's token with knowledge:read (never an app key), Knowledge on the account and active,
|
|
617
|
+
* and the owner's "Use your content for Knowledge" switch on (consent_blocked when it is off).
|
|
618
|
+
* Every plan. Answers count towards the account's Knowledge answers; these calls share the hub's
|
|
619
|
+
* limit of 30 a minute. For a signed-in person in an app, use app.ask() instead.
|
|
620
|
+
*
|
|
621
|
+
* @example
|
|
622
|
+
* const a = await db.knowledge.ask('What is our refund policy?');
|
|
623
|
+
* if (a.grounded) console.log(a.answer, a.sources);
|
|
624
|
+
*/
|
|
625
|
+
knowledge = {
|
|
626
|
+
/**
|
|
627
|
+
* A cited answer from the account's own content. `grounded` is true only when the answer came
|
|
628
|
+
* with sources: an answer with none came from the model, not the content, so never present it
|
|
629
|
+
* as the business's. `session` keeps a conversation going.
|
|
630
|
+
*/
|
|
631
|
+
ask: async (question, options = {}) => {
|
|
632
|
+
const r = await this.request('POST', '/api/knowledge/chat', {
|
|
633
|
+
question, ...(options.session ? { session: options.session } : {}), ...(options.filters ? { filters: options.filters } : {}),
|
|
634
|
+
}, false);
|
|
635
|
+
const status = typeof r.status === 'string' ? r.status : 'error';
|
|
636
|
+
const sources = (Array.isArray(r.sources) ? r.sources : []).map((s, i) => ({
|
|
637
|
+
ref: typeof s.ref === 'number' ? s.ref : i + 1,
|
|
638
|
+
title: typeof s.title === 'string' ? s.title : null,
|
|
639
|
+
kind: typeof s.kind === 'string' ? s.kind : null,
|
|
640
|
+
locator: (typeof s.locator === 'string' ? s.locator : typeof s.section_path === 'string' ? s.section_path : null),
|
|
641
|
+
url: typeof s.url === 'string' ? s.url : null,
|
|
642
|
+
}));
|
|
643
|
+
const text = typeof r.answer_plain === 'string' && r.answer_plain ? r.answer_plain : typeof r.answer === 'string' ? r.answer : null;
|
|
644
|
+
return {
|
|
645
|
+
status,
|
|
646
|
+
answer: status === 'ok' ? text : null,
|
|
647
|
+
grounded: status === 'ok' && sources.length > 0,
|
|
648
|
+
sources,
|
|
649
|
+
noAnswerMessage: typeof r.no_answer_message === 'string' ? r.no_answer_message : null,
|
|
650
|
+
session: typeof r.session === 'string' ? r.session : null,
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
/**
|
|
654
|
+
* Instant search over the library: hits with highlighted snippets and facet counts for the
|
|
655
|
+
* current filters. A facet given several values matches any of them. Snippets mark the
|
|
656
|
+
* matched words between `highlight.pre` and `highlight.post`. `limit` is 1 to 50.
|
|
657
|
+
*/
|
|
658
|
+
search: (options = {}) => {
|
|
659
|
+
const q = new URLSearchParams();
|
|
660
|
+
const add = (name, v) => {
|
|
661
|
+
if (v === undefined || v === '')
|
|
662
|
+
return;
|
|
663
|
+
for (const item of Array.isArray(v) ? v : [v])
|
|
664
|
+
q.append(name, String(item));
|
|
665
|
+
};
|
|
666
|
+
add('q', options.q);
|
|
667
|
+
add('kind', options.kind);
|
|
668
|
+
add('category', options.category);
|
|
669
|
+
add('person', options.person);
|
|
670
|
+
add('place', options.place);
|
|
671
|
+
add('topic', options.topic);
|
|
672
|
+
add('author', options.author);
|
|
673
|
+
add('doc', options.doc);
|
|
674
|
+
add('year', options.year);
|
|
675
|
+
add('limit', options.limit);
|
|
676
|
+
add('offset', options.offset);
|
|
677
|
+
add('sort', options.sort);
|
|
678
|
+
if (options.includeMedia)
|
|
679
|
+
q.set('include', 'media');
|
|
680
|
+
const qs = q.toString();
|
|
681
|
+
return this.request('GET', `/api/knowledge/explore${qs ? `?${qs}` : ''}`);
|
|
682
|
+
},
|
|
683
|
+
/**
|
|
684
|
+
* Grouped numbers from Your numbers: a dataset by its id (the Data tab lists them; built-in
|
|
685
|
+
* ones are `ds-builtin-<slug>`, such as `ds-builtin-google-analytics-daily`), one to eight
|
|
686
|
+
* measures, and optional dimensions, filters and time. The answer is the platform's own shape:
|
|
687
|
+
* columns and rows, plus what it assumed.
|
|
688
|
+
*/
|
|
689
|
+
data: (query) => this.request('POST', '/api/knowledge/data/query', query, false),
|
|
690
|
+
};
|
|
422
691
|
/**
|
|
423
692
|
* Tell Contacts something happened to someone ("job_paid", "quote_sent"), so any email series
|
|
424
693
|
* waiting for that event starts or stops for them. The person is found by email, or added; it
|
|
@@ -438,16 +707,44 @@ export class AwesomateClient {
|
|
|
438
707
|
}, false);
|
|
439
708
|
return { duplicate: !!r.duplicate };
|
|
440
709
|
}
|
|
441
|
-
/**
|
|
710
|
+
/**
|
|
711
|
+
* Who this token is: the account, its plan, the token's scopes and when it expires, and which
|
|
712
|
+
* features the account has (each with the sentence to say when it does not), and whose key it
|
|
713
|
+
* is: the account owner's, which acts as the owner, or a team member's own, which does only what
|
|
714
|
+
* that person can do in the hub. Needs hosting:read; an app key cannot ask (it reads and writes
|
|
715
|
+
* its own kinds only).
|
|
716
|
+
*
|
|
717
|
+
* @example
|
|
718
|
+
* const me = await db.whoami();
|
|
719
|
+
* if (!me.features?.bookings?.available) console.log(me.features?.bookings?.message);
|
|
720
|
+
*/
|
|
721
|
+
async whoami() {
|
|
722
|
+
const r = await this.request('GET', '/api/hosting-access/context');
|
|
723
|
+
return {
|
|
724
|
+
account: { contactId: r.contactId, slug: r.slug ?? null, email: r.email ?? null },
|
|
725
|
+
plan: r.plan ?? null,
|
|
726
|
+
scopes: Array.isArray(r.scopes) ? r.scopes.map(String) : [],
|
|
727
|
+
tokenExpiresAt: r.patExpiresAt ?? null,
|
|
728
|
+
features: r.features ?? null,
|
|
729
|
+
key: r.key ?? null,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* The kinds this token can read: each kind's columns with their types, operators, choices and
|
|
734
|
+
* the owner's descriptions and sections, how many fields are hidden and why, the query grammar
|
|
735
|
+
* and examples. Works with the account's token (crm:read, every plan, Contacts on the account)
|
|
736
|
+
* and with an app key (its own kinds, and `access` says whether it can write).
|
|
737
|
+
*/
|
|
442
738
|
schema() {
|
|
443
739
|
return this.request('GET', '/api/my-crm/v1/rows/schema');
|
|
444
740
|
}
|
|
445
|
-
/** The readable kinds as a TypeScript file (what the types command writes). */
|
|
741
|
+
/** The readable kinds as a TypeScript file (what the types command writes). The account's token, crm:read. */
|
|
446
742
|
types() {
|
|
447
743
|
return this.request('GET', '/api/my-crm/v1/rows/types');
|
|
448
744
|
}
|
|
449
745
|
/**
|
|
450
|
-
* Rows of one kind that this token can read, a page at a time.
|
|
746
|
+
* Rows of one kind that this token can read, a page at a time. The account's token (crm:read,
|
|
747
|
+
* every plan, Contacts on the account) or an app key, for the kinds it was made for.
|
|
451
748
|
*
|
|
452
749
|
* @example
|
|
453
750
|
* const { rows, next } = await db.query('contact', {
|
|
@@ -460,7 +757,7 @@ export class AwesomateClient {
|
|
|
460
757
|
const r = await this.request('POST', '/api/my-crm/v1/rows/query', { kind, ...options });
|
|
461
758
|
return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
|
|
462
759
|
}
|
|
463
|
-
/** Every matching row, a page at a time. maxRows guards against reading a whole list by accident. */
|
|
760
|
+
/** Every matching row, a page at a time. maxRows guards against reading a whole list by accident. Needs what query() needs. */
|
|
464
761
|
async *queryAll(kind, options = {}) {
|
|
465
762
|
const { maxRows = 10_000, ...rest } = options;
|
|
466
763
|
let after;
|
|
@@ -477,7 +774,7 @@ export class AwesomateClient {
|
|
|
477
774
|
}
|
|
478
775
|
/**
|
|
479
776
|
* One row by id, or null when there is none this token can read. Ids are UUIDs: anything else
|
|
480
|
-
* names no row, so it answers null without asking the hub.
|
|
777
|
+
* names no row, so it answers null without asking the hub. Needs what query() needs.
|
|
481
778
|
*/
|
|
482
779
|
async get(kind, id, options = {}) {
|
|
483
780
|
if (!RECORD_ID.test(String(id)))
|
|
@@ -493,64 +790,88 @@ export class AwesomateClient {
|
|
|
493
790
|
throw err;
|
|
494
791
|
}
|
|
495
792
|
}
|
|
496
|
-
/** The app kinds this account has defined. */
|
|
793
|
+
/** The app kinds this account has defined. The account's token, crm:read, every plan; never an app key. */
|
|
497
794
|
async kinds() {
|
|
498
795
|
return (await this.request('GET', '/api/my-crm/v1/kinds')).kinds;
|
|
499
796
|
}
|
|
500
|
-
/**
|
|
797
|
+
/**
|
|
798
|
+
* Define an app kind (a table an app keeps). No migration: registry rows and generated views.
|
|
799
|
+
* The account's token, crm:write, Support Plus and above; never an app key.
|
|
800
|
+
*/
|
|
501
801
|
async defineKind(spec) {
|
|
502
802
|
return (await this.request('POST', '/api/my-crm/v1/kinds', spec)).kind;
|
|
503
803
|
}
|
|
504
|
-
/**
|
|
804
|
+
/**
|
|
805
|
+
* Add an attribute to a kind you defined; records already there have no value for it. Answers
|
|
806
|
+
* the kind as it now is. The account's token, crm:write, Support Plus and above.
|
|
807
|
+
*/
|
|
808
|
+
async addAttribute(kind, attribute) {
|
|
809
|
+
return (await this.request('POST', `/api/my-crm/v1/kinds/${encodeURIComponent(kind)}/attributes`, { attribute }, false)).kind;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Archive an attribute: it leaves every read and the generated types, and its values are kept.
|
|
813
|
+
* Answers the kind as it now is. The account's token, crm:write, Support Plus and above.
|
|
814
|
+
*/
|
|
815
|
+
async archiveAttribute(kind, key) {
|
|
816
|
+
return (await this.request('DELETE', `/api/my-crm/v1/kinds/${encodeURIComponent(kind)}/attributes/${encodeURIComponent(key)}`, undefined, false)).kind;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Create a record (returns its id), or change one with options.id. Every value is checked
|
|
820
|
+
* against the kind. crm:write, Support Plus and above: the account's token, or an app key made
|
|
821
|
+
* with write access for this kind.
|
|
822
|
+
*/
|
|
505
823
|
async write(kind, data, options = {}) {
|
|
506
824
|
const r = await this.request('POST', '/api/my-crm/v1/call', {
|
|
507
825
|
fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
|
|
508
826
|
}, false);
|
|
509
827
|
return r.result.id;
|
|
510
828
|
}
|
|
511
|
-
/** Archive a record: it leaves every read and is kept for a restore. */
|
|
829
|
+
/** Archive a record: it leaves every read and is kept for a restore. Needs what write() needs. */
|
|
512
830
|
async archive(kind, id) {
|
|
513
831
|
await this.request('POST', '/api/my-crm/v1/call', { fn: 'archive_record', args: { kind, id } }, false);
|
|
514
832
|
}
|
|
515
|
-
/** The saved queries on this account. */
|
|
833
|
+
/** The saved queries on this account. crm:read, every plan: the account's token or an app key. */
|
|
516
834
|
async queries() {
|
|
517
835
|
return (await this.request('GET', '/api/my-crm/v1/queries')).queries;
|
|
518
836
|
}
|
|
519
837
|
/**
|
|
520
838
|
* Save a query by name (saving a name again replaces it). The spec is the query() grammar on one
|
|
521
839
|
* kind, with {"$param": "<name>"} where a caller's value goes; it is compiled before it is
|
|
522
|
-
* stored, so a column it cannot read is refused now rather than when it runs.
|
|
840
|
+
* stored, so a column it cannot read is refused now rather than when it runs. The account's
|
|
841
|
+
* token, crm:write, Support Plus and above; never an app key.
|
|
523
842
|
*/
|
|
524
843
|
async saveQuery(spec) {
|
|
525
844
|
return (await this.request('POST', '/api/my-crm/v1/queries', spec, false)).query;
|
|
526
845
|
}
|
|
527
|
-
/** Archive a saved query by its key. */
|
|
846
|
+
/** Archive a saved query by its key. The account's token, crm:write, Support Plus and above. */
|
|
528
847
|
async archiveQuery(key) {
|
|
529
848
|
await this.request('DELETE', `/api/my-crm/v1/queries/${encodeURIComponent(key)}`, undefined, false);
|
|
530
849
|
}
|
|
531
|
-
/** Run a saved query with its params. An optional param left out drops its condition. */
|
|
850
|
+
/** Run a saved query with its params. An optional param left out drops its condition. crm:read, every plan: the account's token or an app key. */
|
|
532
851
|
async run(query, params = {}, options = {}) {
|
|
533
852
|
const r = await this.request('POST', `/api/my-crm/v1/queries/${encodeURIComponent(query)}/run`, { params, ...options });
|
|
534
853
|
return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
|
|
535
854
|
}
|
|
536
|
-
/** The write recipes on this account. */
|
|
855
|
+
/** The write recipes on this account. crm:read, every plan: the account's token or an app key. */
|
|
537
856
|
async recipes() {
|
|
538
857
|
return (await this.request('GET', '/api/my-crm/v1/recipes')).recipes;
|
|
539
858
|
}
|
|
540
859
|
/**
|
|
541
860
|
* Save a write recipe: up to 10 write_record / archive_record steps run in one transaction.
|
|
542
861
|
* {"$param": "<name>"} takes a caller's value, {"$step": "<as>"} the id an earlier step wrote.
|
|
862
|
+
* The account's token, crm:write, Support Plus and above; never an app key.
|
|
543
863
|
*/
|
|
544
864
|
async saveRecipe(spec) {
|
|
545
865
|
return (await this.request('POST', '/api/my-crm/v1/recipes', spec, false)).recipe;
|
|
546
866
|
}
|
|
547
|
-
/** Archive a write recipe by its key. */
|
|
867
|
+
/** Archive a write recipe by its key. The account's token, crm:write, Support Plus and above. */
|
|
548
868
|
async archiveRecipe(key) {
|
|
549
869
|
await this.request('DELETE', `/api/my-crm/v1/recipes/${encodeURIComponent(key)}`, undefined, false);
|
|
550
870
|
}
|
|
551
871
|
/**
|
|
552
872
|
* Run a write recipe. Every step commits or none does; a refusal names the step and the field.
|
|
553
|
-
* Never retried: a write that may have landed is not safe to send twice.
|
|
873
|
+
* Never retried: a write that may have landed is not safe to send twice. crm:write, Support
|
|
874
|
+
* Plus and above: the account's token, or an app key with write access to every kind it writes.
|
|
554
875
|
*/
|
|
555
876
|
async call(recipe, args = {}) {
|
|
556
877
|
return (await this.request('POST', '/api/my-crm/v1/call', { fn: recipe, args }, false)).result;
|
|
@@ -574,20 +895,21 @@ export class AwesomateClient {
|
|
|
574
895
|
async businessMap() {
|
|
575
896
|
return (await this.request('GET', '/api/my-business/v2/map?format=json')).map;
|
|
576
897
|
}
|
|
577
|
-
/** Every role on the map, one line each: slug, title, department and who holds it. */
|
|
898
|
+
/** Every role on the map, one line each: slug, title, department and who holds it. hosting:read, the business map on the account. */
|
|
578
899
|
async businessMapRoles() {
|
|
579
900
|
return (await this.request('GET', '/api/my-business/v2/map/roles')).roles;
|
|
580
901
|
}
|
|
581
902
|
/**
|
|
582
903
|
* One role by its slug, with the line each helper's instructions carry (`helpers[].instructionLine`):
|
|
583
|
-
* which role it helps, for whom, how far it may go and where its procedures are.
|
|
904
|
+
* which role it helps, for whom, how far it may go and where its procedures are. hosting:read,
|
|
905
|
+
* the business map on the account.
|
|
584
906
|
*/
|
|
585
907
|
async businessMapRole(slug) {
|
|
586
908
|
return (await this.request('GET', `/api/my-business/v2/map/roles/${encodeURIComponent(slug)}`)).role;
|
|
587
909
|
}
|
|
588
910
|
/**
|
|
589
911
|
* The business map in its first shape, where a department is a `division`, a sub-department a
|
|
590
|
-
* `department` and a role a `job`.
|
|
912
|
+
* `department` and a role a `job`. hosting:read, the business map on the account.
|
|
591
913
|
* @deprecated Use businessMap(), which uses the words the owner sees. v1 is removed once it has
|
|
592
914
|
* gone 30 days unused, and not before 2026-11-06.
|
|
593
915
|
*/
|
|
@@ -605,7 +927,8 @@ export class AwesomateClient {
|
|
|
605
927
|
/**
|
|
606
928
|
* One of the business's documents with its text (the current version), or null when the
|
|
607
929
|
* business has none of that kind yet. The text is the owner's own: use it for their business
|
|
608
|
-
* (agent instructions, site copy in their voice), never show it to anyone else.
|
|
930
|
+
* (agent instructions, site copy in their voice), never show it to anyone else. The account's
|
|
931
|
+
* token, hosting:read, every plan.
|
|
609
932
|
*/
|
|
610
933
|
async businessDocument(kind) {
|
|
611
934
|
try {
|
|
@@ -617,14 +940,80 @@ export class AwesomateClient {
|
|
|
617
940
|
throw err;
|
|
618
941
|
}
|
|
619
942
|
}
|
|
620
|
-
/** Details waiting for the owner's yes on Your business: from the website, the account, a team member or a file. */
|
|
943
|
+
/** Details waiting for the owner's yes on Your business: from the website, the account, a team member or a file. The account's token, hosting:read, every plan. */
|
|
621
944
|
async businessSuggestions() {
|
|
622
945
|
return (await this.request('GET', '/api/my-business/v1/proposals')).proposals;
|
|
623
946
|
}
|
|
624
|
-
/**
|
|
947
|
+
/**
|
|
948
|
+
* Every detail a business can have (key, label, group, type and longest value), set or not, for
|
|
949
|
+
* a page that offers the missing ones. No values: business() has those. The account's token,
|
|
950
|
+
* hosting:read, every plan.
|
|
951
|
+
*/
|
|
952
|
+
businessCatalogue() {
|
|
953
|
+
return this.request('GET', '/api/my-business/v1/catalogue');
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Grouped numbers (counts, sums) over the contact list, in the Business Data API's shape: the
|
|
957
|
+
* measures and dimensions datasets() lists. Counts and sums only, never a person. The account's
|
|
958
|
+
* token, crm:read, every plan; never an app key.
|
|
959
|
+
*/
|
|
625
960
|
aggregate(request) {
|
|
626
961
|
return this.request('POST', '/api/my-crm/v1/data/query', request);
|
|
627
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* What the contact list can be measured by: `people` (a count) and every number field the owner
|
|
965
|
+
* has named, each with its unit and other words for it. The catalogue for aggregate() and
|
|
966
|
+
* series(). The account's token, crm:read, every plan.
|
|
967
|
+
*/
|
|
968
|
+
async metrics() {
|
|
969
|
+
return (await this.request('GET', '/api/my-crm/v1/data/metrics')).metrics;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* One metric over time, every bucket filled (a bucket nobody landed in is 0 for counts and
|
|
973
|
+
* sums): `range` is a preset (7d, 30d, 90d, 12m, mtd, qtd, ytd, fytd, this_month, last_month,
|
|
974
|
+
* this_fy, last_fy) or give `from` and `to` (YYYY-MM-DD); with neither, the last 12 months.
|
|
975
|
+
* `compare: 'previous'` adds the window before. At most 800 points. The account's token,
|
|
976
|
+
* crm:read, every plan.
|
|
977
|
+
*
|
|
978
|
+
* @example
|
|
979
|
+
* const s = await db.series('people', { range: '90d', grain: 'week', compare: 'previous' });
|
|
980
|
+
* console.log(s.total, s.compare?.delta_pct);
|
|
981
|
+
*/
|
|
982
|
+
series(metric, options = {}) {
|
|
983
|
+
const q = new URLSearchParams({ metric });
|
|
984
|
+
for (const k of ['range', 'from', 'to', 'grain', 'agg', 'compare', 'anchor', 'tz']) {
|
|
985
|
+
const v = options[k];
|
|
986
|
+
if (v)
|
|
987
|
+
q.set(k, v);
|
|
988
|
+
}
|
|
989
|
+
return this.request('GET', `/api/my-crm/v1/data/series?${q}`);
|
|
990
|
+
}
|
|
991
|
+
/** The contact list as a dataset: its columns by kind (measures, dimensions to group by, and the dates people can be counted by). The account's token, crm:read. */
|
|
992
|
+
async datasets() {
|
|
993
|
+
return (await this.request('GET', '/api/my-crm/v1/data/datasets')).datasets;
|
|
994
|
+
}
|
|
995
|
+
/** One dataset by its id (`contact`), or null when there is none by that id. The account's token, crm:read. */
|
|
996
|
+
async dataset(id) {
|
|
997
|
+
try {
|
|
998
|
+
const r = await this.request('GET', `/api/my-crm/v1/data/datasets/${encodeURIComponent(id)}`);
|
|
999
|
+
const { as_at: _asAt, ...dataset } = r;
|
|
1000
|
+
return dataset;
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
if (err instanceof AwesomateError && err.code === 'not_found')
|
|
1004
|
+
return null;
|
|
1005
|
+
throw err;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* One app's AI assistant this month (UTC): how many conversations it looked at, replied to or
|
|
1010
|
+
* drafted for, how many replies asked Knowledge (those count towards Knowledge answers), and the
|
|
1011
|
+
* AI tokens, with how many were on the account's own AI key. The account's token, crm:read.
|
|
1012
|
+
*/
|
|
1013
|
+
async assistantUsage(appId) {
|
|
1014
|
+
const r = await this.request('GET', `/api/my-crm/v1/apps/${encodeURIComponent(appId)}/assistant/usage`);
|
|
1015
|
+
return { ...r.usage, own_key: r.own_key };
|
|
1016
|
+
}
|
|
628
1017
|
}
|
|
629
1018
|
function memoryStorage() {
|
|
630
1019
|
const m = new Map();
|
|
@@ -732,8 +1121,14 @@ export class AwesomateAppClient {
|
|
|
732
1121
|
this.link = entry;
|
|
733
1122
|
return entry.promise;
|
|
734
1123
|
},
|
|
735
|
-
/** The signed-in user, or null. */
|
|
1124
|
+
/** The signed-in user as this client last saw them (no call to the hub), or null. */
|
|
736
1125
|
user: async () => (await this.load())?.user ?? null,
|
|
1126
|
+
/**
|
|
1127
|
+
* The signed-in person read fresh from the hub (their current role and linked contact), with
|
|
1128
|
+
* the app's own settings: its name, its voice agent and whether it takes uploads. A person
|
|
1129
|
+
* the account has disabled is signed out, and this rejects with code `unauthenticated`.
|
|
1130
|
+
*/
|
|
1131
|
+
me: () => this.data('GET', '/me'),
|
|
737
1132
|
/**
|
|
738
1133
|
* Called with the user when someone signs in (or their details change: a new role, a linked
|
|
739
1134
|
* contact) and with null on sign-out, including a refresh that was refused. A routine token
|
|
@@ -778,9 +1173,7 @@ export class AwesomateAppClient {
|
|
|
778
1173
|
catch { /* a proxy's error page */ }
|
|
779
1174
|
if (res.ok)
|
|
780
1175
|
return json;
|
|
781
|
-
|
|
782
|
-
const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
|
|
783
|
-
throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof json.field === 'string' ? json.field : undefined, server, typeof json.message === 'string' ? json.message : undefined);
|
|
1176
|
+
throw hubError(res.status, json, typeof json.message === 'string' ? json.message : undefined);
|
|
784
1177
|
}
|
|
785
1178
|
async read() {
|
|
786
1179
|
try {
|
|
@@ -889,6 +1282,7 @@ export class AwesomateAppClient {
|
|
|
889
1282
|
/**
|
|
890
1283
|
* Rows of one kind that this person may read, a page at a time. The database applies the
|
|
891
1284
|
* kind's read rule for their role: a customer gets their own jobs, staff whatever theirs allows.
|
|
1285
|
+
* A signed-in person, on an account on Pro and above.
|
|
892
1286
|
*
|
|
893
1287
|
* @example
|
|
894
1288
|
* const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'], limit: 20 });
|
|
@@ -897,7 +1291,7 @@ export class AwesomateAppClient {
|
|
|
897
1291
|
const r = await this.data('POST', '/rows/query', { kind, ...options });
|
|
898
1292
|
return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, asAt: r.as_at };
|
|
899
1293
|
}
|
|
900
|
-
/** Every matching row this person may read, a page at a time. maxRows (default 10,000) guards against reading a whole list by accident. */
|
|
1294
|
+
/** Every matching row this person may read, a page at a time. maxRows (default 10,000) guards against reading a whole list by accident. Pro and above. */
|
|
901
1295
|
async *queryAll(kind, options = {}) {
|
|
902
1296
|
const { maxRows = 10_000, ...rest } = options;
|
|
903
1297
|
let after;
|
|
@@ -914,7 +1308,7 @@ export class AwesomateAppClient {
|
|
|
914
1308
|
}
|
|
915
1309
|
/**
|
|
916
1310
|
* One record by id, or null when there is none this user may read. Ids are UUIDs: anything
|
|
917
|
-
* else names no record, so it answers null without asking the hub.
|
|
1311
|
+
* else names no record, so it answers null without asking the hub. Pro and above.
|
|
918
1312
|
*/
|
|
919
1313
|
async get(kind, id) {
|
|
920
1314
|
if (!RECORD_ID.test(String(id)))
|
|
@@ -928,14 +1322,14 @@ export class AwesomateAppClient {
|
|
|
928
1322
|
throw err;
|
|
929
1323
|
}
|
|
930
1324
|
}
|
|
931
|
-
/** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
|
|
1325
|
+
/** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. Pro and above. */
|
|
932
1326
|
async write(kind, data, options = {}) {
|
|
933
1327
|
const r = await this.data('POST', '/call', {
|
|
934
1328
|
fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
|
|
935
1329
|
});
|
|
936
1330
|
return r.result.id;
|
|
937
1331
|
}
|
|
938
|
-
/** Archive a record, inside the kind's write rule for this person. */
|
|
1332
|
+
/** Archive a record, inside the kind's write rule for this person. Pro and above. */
|
|
939
1333
|
async archive(kind, id) {
|
|
940
1334
|
await this.data('POST', '/call', { fn: 'archive_record', args: { kind, id } });
|
|
941
1335
|
}
|
|
@@ -943,7 +1337,7 @@ export class AwesomateAppClient {
|
|
|
943
1337
|
* Run a recipe the account opened to this person's role: "accept this quote", something their
|
|
944
1338
|
* write rule would not let them do by hand. Every step commits or none does; a recipe they may
|
|
945
1339
|
* not run reads as not found. Never retried once sent: a write that may have landed is not safe
|
|
946
|
-
* to send twice (a refused sign-in is, and is retried after a refresh like every data call).
|
|
1340
|
+
* to send twice (a refused sign-in is, and is retried after a refresh like every data call). Pro and above.
|
|
947
1341
|
*/
|
|
948
1342
|
async call(recipe, args = {}) {
|
|
949
1343
|
return (await this.data('POST', '/call', { fn: recipe, args })).result;
|
|
@@ -951,7 +1345,7 @@ export class AwesomateAppClient {
|
|
|
951
1345
|
/**
|
|
952
1346
|
* Customers by name or email (two characters or more), or by id, so staff can find one and put
|
|
953
1347
|
* a record under them. Only for a role the account lets look customers up in this app; anyone
|
|
954
|
-
* else gets a forbidden error. Name, email and phone only, at most 50.
|
|
1348
|
+
* else gets a forbidden error. Name, email and phone only, at most 50. Pro and above.
|
|
955
1349
|
*/
|
|
956
1350
|
async lookupCustomers(options) {
|
|
957
1351
|
return (await this.data('POST', '/customers/lookup', options)).customers;
|
|
@@ -991,9 +1385,8 @@ export class AwesomateAppClient {
|
|
|
991
1385
|
json = text ? JSON.parse(text) : {};
|
|
992
1386
|
}
|
|
993
1387
|
catch { /* a proxy's error page */ }
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server, message);
|
|
1388
|
+
const err = hubError(res.status, json);
|
|
1389
|
+
throw new AwesomateError(err.code, err.message, err.status, undefined, err.serverCode, err.message, err);
|
|
997
1390
|
}
|
|
998
1391
|
/**
|
|
999
1392
|
* Files people attach in this app, such as a photo of a job or a signed form, kept in the
|
|
@@ -1058,7 +1451,7 @@ export class AwesomateAppClient {
|
|
|
1058
1451
|
* A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
|
|
1059
1452
|
* and again whenever a row in it is added, changed, removed, or stops being one this person may
|
|
1060
1453
|
* read. One connection serves every live list on this client; it reconnects by itself and takes
|
|
1061
|
-
* a fresh snapshot when it does. Returns stop().
|
|
1454
|
+
* a fresh snapshot when it does. Returns stop(). Pro and above.
|
|
1062
1455
|
*/
|
|
1063
1456
|
live(kind, options, handlers) {
|
|
1064
1457
|
const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
|
|
@@ -1079,7 +1472,7 @@ export class AwesomateAppClient {
|
|
|
1079
1472
|
* assistant appears while it writes a reply. The hub checks this person may read the record, and
|
|
1080
1473
|
* keeps checking; onError hears a refusal. Shares the one live connection. Two parts of a page (a
|
|
1081
1474
|
* thread and a sidebar) can open the same record: each hears onPeople, and it stays open until both
|
|
1082
|
-
* have left. Returns typing() and leave().
|
|
1475
|
+
* have left. Returns typing() and leave(). Pro and above.
|
|
1083
1476
|
*/
|
|
1084
1477
|
here(recordId, onPeople, onError) {
|
|
1085
1478
|
return this.liveSocket().join(recordId, onPeople, onError);
|
|
@@ -1429,6 +1822,115 @@ export function createAppClient(options) {
|
|
|
1429
1822
|
export function createClient(options) {
|
|
1430
1823
|
return new AwesomateClient(options);
|
|
1431
1824
|
}
|
|
1825
|
+
const JWKS_TTL_MS = 5 * 60_000;
|
|
1826
|
+
const jwksCache = new Map();
|
|
1827
|
+
function b64urlBytes(s) {
|
|
1828
|
+
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4);
|
|
1829
|
+
const bin = atob(b64);
|
|
1830
|
+
const out = new Uint8Array(bin.length);
|
|
1831
|
+
for (let i = 0; i < bin.length; i++)
|
|
1832
|
+
out[i] = bin.charCodeAt(i);
|
|
1833
|
+
return out;
|
|
1834
|
+
}
|
|
1835
|
+
async function subtleCrypto() {
|
|
1836
|
+
const subtle = globalThis.crypto?.subtle;
|
|
1837
|
+
if (subtle)
|
|
1838
|
+
return subtle;
|
|
1839
|
+
// Node 18 has Web Crypto without the global; a bundler never sees this specifier.
|
|
1840
|
+
const spec = 'node:crypto';
|
|
1841
|
+
return (await import(spec)).webcrypto.subtle;
|
|
1842
|
+
}
|
|
1843
|
+
/** A token naming a key we lack refetches at most this often, so made-up key ids cannot make every request fetch. */
|
|
1844
|
+
const JWKS_REFETCH_MS = 30_000;
|
|
1845
|
+
async function hubKeys(url, doFetch, fresh) {
|
|
1846
|
+
const hit = jwksCache.get(url);
|
|
1847
|
+
const age = hit ? Date.now() - hit.at : Infinity;
|
|
1848
|
+
if (hit && (fresh ? age < JWKS_REFETCH_MS : age < JWKS_TTL_MS))
|
|
1849
|
+
return hit.keys;
|
|
1850
|
+
let res;
|
|
1851
|
+
try {
|
|
1852
|
+
res = await doFetch(url, { headers: { accept: 'application/json' } });
|
|
1853
|
+
}
|
|
1854
|
+
catch (err) {
|
|
1855
|
+
throw new AwesomateError('unavailable', `The hub's keys could not be read: ${err.message}`, 0);
|
|
1856
|
+
}
|
|
1857
|
+
if (!res.ok)
|
|
1858
|
+
throw new AwesomateError('unavailable', `The hub's keys could not be read (${res.status}).`, res.status);
|
|
1859
|
+
let keys;
|
|
1860
|
+
try {
|
|
1861
|
+
keys = (await res.json()).keys ?? [];
|
|
1862
|
+
}
|
|
1863
|
+
catch {
|
|
1864
|
+
throw new AwesomateError('unavailable', 'The hub\'s keys could not be read (not JSON).', res.status);
|
|
1865
|
+
}
|
|
1866
|
+
jwksCache.set(url, { at: Date.now(), keys });
|
|
1867
|
+
return keys;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Verify an access token one of the account's apps sent your own server (from
|
|
1871
|
+
* app.auth.accessToken()), against the hub's public keys: ES256, signed by the hub, for this app,
|
|
1872
|
+
* not expired. Answers its claims; anything else rejects with code `unauthenticated`. The keys
|
|
1873
|
+
* are fetched from `<baseUrl>/api/sdk/v1/jwks.json` and kept for five minutes. Treat `role` as a
|
|
1874
|
+
* hint: the hub reads the person's current role on every call it answers.
|
|
1875
|
+
*
|
|
1876
|
+
* @example
|
|
1877
|
+
* import { verifyAppToken } from '@awesomate/sdk';
|
|
1878
|
+
*
|
|
1879
|
+
* const accessToken = 'the token your page sent, from app.auth.accessToken()';
|
|
1880
|
+
* const claims = await verifyAppToken(accessToken, { appId: 'app_your_app_id' });
|
|
1881
|
+
* console.log(claims.sub, claims.role);
|
|
1882
|
+
*/
|
|
1883
|
+
export async function verifyAppToken(token, options) {
|
|
1884
|
+
const bad = (why) => { throw new AwesomateError('unauthenticated', why, 401); };
|
|
1885
|
+
if (!options?.appId)
|
|
1886
|
+
throw new Error('verifyAppToken needs the app\'s id (app_...): a token is only good for the app it was made for.');
|
|
1887
|
+
const parts = typeof token === 'string' ? token.split('.') : [];
|
|
1888
|
+
if (parts.length !== 3 || token.length > 4096)
|
|
1889
|
+
bad('That is not a sign-in token.');
|
|
1890
|
+
let header;
|
|
1891
|
+
let claims;
|
|
1892
|
+
try {
|
|
1893
|
+
const text = (s) => new TextDecoder().decode(b64urlBytes(s));
|
|
1894
|
+
header = JSON.parse(text(parts[0]));
|
|
1895
|
+
claims = JSON.parse(text(parts[1]));
|
|
1896
|
+
}
|
|
1897
|
+
catch {
|
|
1898
|
+
return bad('That is not a sign-in token.');
|
|
1899
|
+
}
|
|
1900
|
+
if (header.alg !== 'ES256' || !header.kid)
|
|
1901
|
+
bad('That sign-in token was not signed by the hub.');
|
|
1902
|
+
const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
1903
|
+
if (!doFetch)
|
|
1904
|
+
throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
|
|
1905
|
+
const url = `${(options.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '')}/api/sdk/v1/jwks.json`;
|
|
1906
|
+
let jwk = (await hubKeys(url, doFetch, false)).find((k) => k.kid === header.kid);
|
|
1907
|
+
// A key the hub has added since the keys were last read.
|
|
1908
|
+
if (!jwk)
|
|
1909
|
+
jwk = (await hubKeys(url, doFetch, true)).find((k) => k.kid === header.kid);
|
|
1910
|
+
if (!jwk)
|
|
1911
|
+
return bad('That sign-in was not issued by this hub, or its key has been retired.');
|
|
1912
|
+
const subtle = await subtleCrypto();
|
|
1913
|
+
const key = await subtle.importKey('jwk', { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
1914
|
+
const ok = await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, b64urlBytes(parts[2]), new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
|
|
1915
|
+
if (!ok)
|
|
1916
|
+
bad('That sign-in token is not valid.');
|
|
1917
|
+
if (claims.iss !== (options.issuer ?? 'https://hub.awesomate.ai'))
|
|
1918
|
+
bad('That sign-in token was not issued by this hub.');
|
|
1919
|
+
if (claims.aud !== `awesomate-app:${options.appId}` || claims.app !== options.appId)
|
|
1920
|
+
bad('That sign-in token is for a different app.');
|
|
1921
|
+
if (typeof claims.exp !== 'number' || claims.exp * 1000 <= Date.now())
|
|
1922
|
+
bad('That sign-in has expired. Refresh it or sign in again.');
|
|
1923
|
+
if (typeof claims.sub !== 'string' || !claims.sub)
|
|
1924
|
+
bad('That sign-in token is not valid.');
|
|
1925
|
+
return {
|
|
1926
|
+
sub: claims.sub,
|
|
1927
|
+
app: claims.app,
|
|
1928
|
+
role: typeof claims.role === 'string' ? claims.role : 'member',
|
|
1929
|
+
contact: typeof claims.contact === 'string' ? claims.contact : null,
|
|
1930
|
+
exp: claims.exp,
|
|
1931
|
+
iat: typeof claims.iat === 'number' ? claims.iat : null,
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1432
1934
|
function staffBooking(b) {
|
|
1433
1935
|
return {
|
|
1434
1936
|
bookingId: b.booking_id, status: b.status, service: b.service,
|
|
@@ -1482,9 +1984,8 @@ export class AwesomateBookingsClient {
|
|
|
1482
1984
|
}
|
|
1483
1985
|
const data = (await res.json().catch(() => ({})));
|
|
1484
1986
|
if (!res.ok) {
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof data.field === 'string' ? data.field : undefined, server, message);
|
|
1987
|
+
const err = hubError(res.status, data);
|
|
1988
|
+
throw new AwesomateError(err.code, err.message, err.status, err.field, err.serverCode, err.message, err);
|
|
1488
1989
|
}
|
|
1489
1990
|
return data;
|
|
1490
1991
|
}
|