@awesomate/sdk 0.18.0 → 0.19.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 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.18.0";
27
+ export declare const VERSION = "0.19.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,87 @@ export interface Page<R> {
157
157
  /** When the hub read the rows. */
158
158
  asAt: string;
159
159
  }
160
+ /** Someone on the account, as Tasks names them. */
161
+ export interface TaskPerson {
162
+ email: string;
163
+ name: string | null;
164
+ }
165
+ /**
166
+ * One card on the account's Tasks board: something waiting on a person (a quote to accept, an
167
+ * email to approve) or a task someone gave. The card's main action happens on its own page
168
+ * (`openPath` in the hub); Tasks only moves the card (not now, pass on) and finishes given tasks.
169
+ */
170
+ export interface TaskCard {
171
+ /** Stable id of the card; pass it to notNow() and passOn(). */
172
+ key: string;
173
+ kind: string;
174
+ /** Where it came from, in the owner's words ("Build requests", "Contacts"). */
175
+ from: string;
176
+ title: string;
177
+ /** What is being asked, as a question. */
178
+ question: string;
179
+ context: string | null;
180
+ /** 1 routine to 5 urgent. */
181
+ priority: number;
182
+ createdAt: string;
183
+ dueAt: string | null;
184
+ /** The hub page that shows it in full. */
185
+ openPath: string;
186
+ /** Who has it, when it was passed on; null means anyone on the account. */
187
+ holder: TaskPerson | null;
188
+ /** When it comes back, if put off with Not now. */
189
+ snoozedUntil: string | null;
190
+ canAct: boolean;
191
+ /** Who it can be passed to. */
192
+ passTo: TaskPerson[];
193
+ /** Set for a task someone gave (give()); null for everything else. */
194
+ task: {
195
+ /** Pass it to update(), done() and drop(). */
196
+ id: number;
197
+ detail: string | null;
198
+ linkPath: string | null;
199
+ author: TaskPerson;
200
+ canFinish: boolean;
201
+ canDrop: boolean;
202
+ canEdit: boolean;
203
+ } | null;
204
+ }
205
+ /** The Tasks board, as the account's token sees it (the owner, so `scope: 'all'` works). */
206
+ export interface TaskBoard {
207
+ scope: 'mine' | 'all';
208
+ me: TaskPerson & {
209
+ role: string;
210
+ };
211
+ /** Who a task can be given to. */
212
+ people: Array<TaskPerson & {
213
+ me: boolean;
214
+ }>;
215
+ /** Waiting for a decision now. */
216
+ toDecide: TaskCard[];
217
+ /** Put off with Not now; back when the time comes. */
218
+ later: TaskCard[];
219
+ /** With someone else. */
220
+ waiting: TaskCard[];
221
+ /** Recently decided, for the record. */
222
+ decided: Array<{
223
+ key: string;
224
+ kind: string;
225
+ from: string;
226
+ text: string;
227
+ by: TaskPerson | null;
228
+ at: string;
229
+ }>;
230
+ }
231
+ /** A task to give: to someone on the account by email, or to the owner when `forEmail` is left out. */
232
+ export interface GiveTask {
233
+ title: string;
234
+ detail?: string;
235
+ forEmail?: string;
236
+ /** ISO time it is due. */
237
+ dueAt?: string;
238
+ /** A hub path the task points at, e.g. /contacts/people/... */
239
+ linkPath?: string;
240
+ }
160
241
  /** A file or folder in the account's Files (the folders on its automation account: public/, private/, temp/). */
161
242
  export interface FileEntry {
162
243
  /** Relative to the files folder, starting with its top folder: public/images/logo.png. */
@@ -297,6 +378,44 @@ export declare class AwesomateClient {
297
378
  trashPath: string;
298
379
  }>;
299
380
  };
381
+ private tasksCall;
382
+ /**
383
+ * The account's Tasks: what is waiting on its people, and tasks they give each other. An app's
384
+ * server or an n8n workflow can give someone a task ("call this customer back") and finish it.
385
+ * Needs the account's token, and Tasks on the account (a feature_unavailable refusal says
386
+ * when it is not).
387
+ *
388
+ * @example
389
+ * await db.tasks.give({ title: 'Call Sam back about the leak', forEmail: 'jo@brightwater.example', dueAt: '2026-10-06T09:00:00+11:00' });
390
+ */
391
+ readonly tasks: {
392
+ /** The board: waiting now, put off, with someone else, recently decided. `all` shows everyone's, not only yours. */
393
+ board: (options?: {
394
+ scope?: "mine" | "all";
395
+ }) => Promise<TaskBoard>;
396
+ /** Give a task. The person gets it on their board, and an email when they are not the one giving it. */
397
+ give: (task: GiveTask) => Promise<{
398
+ key: string;
399
+ id: number;
400
+ holder: TaskPerson;
401
+ emailed: boolean;
402
+ }>;
403
+ /** Change a given task: whoever wrote it, or the owner. */
404
+ update: (id: number | string, patch: Partial<Omit<GiveTask, "forEmail">>) => Promise<void>;
405
+ /** Mark a given task done: the person who has it, whoever wrote it, or the owner. */
406
+ done: (id: number | string) => Promise<void>;
407
+ /** Take a given task off the board without doing it: whoever wrote it, or the owner. */
408
+ drop: (id: number | string) => Promise<void>;
409
+ /** Put a card off until a time (at most 90 days), for this person only. */
410
+ notNow: (key: string, until: Date | string) => Promise<{
411
+ until: string;
412
+ }>;
413
+ /** Hand a card to someone on the account who can act on it. */
414
+ passOn: (key: string, toEmail: string, reason?: string) => Promise<{
415
+ holder: TaskPerson;
416
+ emailed: boolean;
417
+ }>;
418
+ };
300
419
  /** The kinds this token can read, their columns, operators and examples. */
301
420
  schema(): Promise<{
302
421
  kinds: Array<{
@@ -830,7 +949,7 @@ export interface HereHandle {
830
949
  * stopped (on send or blur). Without another call, typing ends on its own after a few seconds.
831
950
  */
832
951
  typing(on: boolean): void;
833
- /** Close the record: the others stop seeing this person on it. */
952
+ /** Close the record: the others stop seeing this person on it once every part of the page that opened it has left. */
834
953
  leave(): void;
835
954
  }
836
955
  /** What live() calls. */
@@ -1007,7 +1126,9 @@ export declare class AwesomateAppClient {
1007
1126
  * Open a record (a job's conversation) for who-is-here: onPeople gets everyone else who has it
1008
1127
  * open with their tab in front, and who is typing, at first and on every change; the app's
1009
1128
  * assistant appears while it writes a reply. The hub checks this person may read the record, and
1010
- * keeps checking; onError hears a refusal. Shares the one live connection. Returns typing() and leave().
1129
+ * keeps checking; onError hears a refusal. Shares the one live connection. Two parts of a page (a
1130
+ * thread and a sidebar) can open the same record: each hears onPeople, and it stays open until both
1131
+ * have left. Returns typing() and leave().
1011
1132
  */
1012
1133
  here(recordId: string, onPeople: (people: HerePerson[]) => void, onError?: (err: AwesomateError) => void): HereHandle;
1013
1134
  }
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.18.0';
27
+ export const VERSION = '0.19.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
  /**
@@ -230,6 +230,56 @@ export class AwesomateClient {
230
230
  /** Move to the trash. Not erased, and no longer counted toward the Files space. */
231
231
  remove: async (path) => this.filesCall('DELETE', `/api/files?path=${encodeURIComponent(path)}`),
232
232
  };
233
+ tasksCall(method, path, body) {
234
+ if (this.appKey) {
235
+ return Promise.reject(new AwesomateError('forbidden', 'Tasks needs the account\'s token (amt_pat_...), not an app key.', 0));
236
+ }
237
+ return this.request(method, path, body, false);
238
+ }
239
+ /**
240
+ * The account's Tasks: what is waiting on its people, and tasks they give each other. An app's
241
+ * server or an n8n workflow can give someone a task ("call this customer back") and finish it.
242
+ * Needs the account's token, and Tasks on the account (a feature_unavailable refusal says
243
+ * when it is not).
244
+ *
245
+ * @example
246
+ * await db.tasks.give({ title: 'Call Sam back about the leak', forEmail: 'jo@brightwater.example', dueAt: '2026-10-06T09:00:00+11:00' });
247
+ */
248
+ tasks = {
249
+ /** The board: waiting now, put off, with someone else, recently decided. `all` shows everyone's, not only yours. */
250
+ board: (options = {}) => this.tasksCall('GET', `/api/my-tasks${options.scope === 'all' ? '?scope=all' : ''}`),
251
+ /** Give a task. The person gets it on their board, and an email when they are not the one giving it. */
252
+ give: async (task) => {
253
+ const r = await this.tasksCall('POST', '/api/my-tasks/tasks', task);
254
+ return { key: r.key, id: r.id, holder: r.holder, emailed: r.emailed };
255
+ },
256
+ /** Change a given task: whoever wrote it, or the owner. */
257
+ update: async (id, patch) => {
258
+ await this.tasksCall('PATCH', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}`, patch);
259
+ },
260
+ /** Mark a given task done: the person who has it, whoever wrote it, or the owner. */
261
+ done: async (id) => {
262
+ await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/done`);
263
+ },
264
+ /** Take a given task off the board without doing it: whoever wrote it, or the owner. */
265
+ drop: async (id) => {
266
+ await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/drop`);
267
+ },
268
+ /** Put a card off until a time (at most 90 days), for this person only. */
269
+ notNow: async (key, until) => {
270
+ const r = await this.tasksCall('POST', `/api/my-tasks/items/${encodeURIComponent(key)}/not-now`, {
271
+ until: until instanceof Date ? until.toISOString() : until,
272
+ });
273
+ return { until: r.until };
274
+ },
275
+ /** Hand a card to someone on the account who can act on it. */
276
+ passOn: async (key, toEmail, reason) => {
277
+ const r = await this.tasksCall('POST', `/api/my-tasks/items/${encodeURIComponent(key)}/pass-on`, {
278
+ toEmail, ...(reason ? { reason } : {}),
279
+ });
280
+ return { holder: r.holder, emailed: r.emailed };
281
+ },
282
+ };
233
283
  /** The kinds this token can read, their columns, operators and examples. */
234
284
  schema() {
235
285
  return this.request('GET', '/api/my-crm/v1/rows/schema');
@@ -271,7 +321,7 @@ export class AwesomateClient {
271
321
  async get(kind, id, options = {}) {
272
322
  try {
273
323
  const qs = options.tz ? `?tz=${encodeURIComponent(options.tz)}` : '';
274
- const r = await this.request('GET', `/api/my-crm/v1/rows/${encodeURIComponent(kind)}/${encodeURIComponent(id)}${qs}`);
324
+ const r = await this.request('GET', `/api/my-crm/v1/rows/${encodeURIComponent(kind)}/${encodeURIComponent(String(id))}${qs}`);
275
325
  return r.row;
276
326
  }
277
327
  catch (err) {
@@ -658,7 +708,7 @@ export class AwesomateAppClient {
658
708
  /** One record by id, or null when there is none this user may read. */
659
709
  async get(kind, id) {
660
710
  try {
661
- return (await this.data('GET', `/rows/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`)).row;
711
+ return (await this.data('GET', `/rows/${encodeURIComponent(kind)}/${encodeURIComponent(String(id))}`)).row;
662
712
  }
663
713
  catch (err) {
664
714
  if (err instanceof AwesomateError && err.code === 'not_found')
@@ -799,7 +849,9 @@ export class AwesomateAppClient {
799
849
  * Open a record (a job's conversation) for who-is-here: onPeople gets everyone else who has it
800
850
  * open with their tab in front, and who is typing, at first and on every change; the app's
801
851
  * assistant appears while it writes a reply. The hub checks this person may read the record, and
802
- * keeps checking; onError hears a refusal. Shares the one live connection. Returns typing() and leave().
852
+ * keeps checking; onError hears a refusal. Shares the one live connection. Two parts of a page (a
853
+ * thread and a sidebar) can open the same record: each hears onPeople, and it stays open until both
854
+ * have left. Returns typing() and leave().
803
855
  */
804
856
  here(recordId, onPeople, onError) {
805
857
  return this.liveSocket().join(recordId, onPeople, onError);
@@ -842,32 +894,57 @@ class LiveSocket {
842
894
  if (this.ready && typeof d?.visibilityState === 'string')
843
895
  this.send({ type: 'presence', visible: d.visibilityState === 'visible' });
844
896
  }
845
- /** Records open with here(): who else is on them goes to onPeople. */
897
+ /**
898
+ * Records open with here(), one per record id however many parts of the page opened it: the hub
899
+ * hears `here on` once and `here off` when the last of them leaves, and every one of them gets
900
+ * onPeople. Typing is the person's, so one resend clock per record, shared by them all.
901
+ */
846
902
  rooms = new Map();
847
903
  get wanted() {
848
904
  return this.entries.size > 0 || this.rooms.size > 0;
849
905
  }
850
906
  join(id, onPeople, onError) {
851
- const room = { onPeople, onError, typingSentAt: 0 };
852
- this.rooms.set(id, room);
853
- if (this.ready)
854
- this.send({ type: 'here', id, on: true });
855
- else if (!this.ws && !this.connecting)
856
- void this.connect();
907
+ const listener = { onPeople, onError };
908
+ let room = this.rooms.get(id);
909
+ if (room) {
910
+ room.listeners.add(listener);
911
+ // Already open: the hub sends nothing new for a second join, so hand over who it last said was there.
912
+ const people = room.people;
913
+ if (people)
914
+ queueMicrotask(() => { if (this.rooms.get(id)?.listeners.has(listener))
915
+ onPeople(people); });
916
+ }
917
+ else {
918
+ room = { listeners: new Set([listener]), typingSentAt: 0, people: null };
919
+ this.rooms.set(id, room);
920
+ if (this.ready)
921
+ this.send({ type: 'here', id, on: true });
922
+ else if (!this.ws && !this.connecting)
923
+ void this.connect();
924
+ }
925
+ const open = () => {
926
+ const r = this.rooms.get(id);
927
+ return r?.listeners.has(listener) ? r : undefined;
928
+ };
857
929
  return {
858
930
  typing: (on) => {
859
- if (this.rooms.get(id) !== room || !this.ready)
931
+ const r = open();
932
+ if (!r || !this.ready)
860
933
  return;
861
934
  const now = Date.now();
862
- if (on && now - room.typingSentAt < TYPING_RESEND_MS)
935
+ if (on && now - r.typingSentAt < TYPING_RESEND_MS)
863
936
  return;
864
- if (!on && !room.typingSentAt)
937
+ if (!on && !r.typingSentAt)
865
938
  return;
866
- room.typingSentAt = on ? now : 0;
939
+ r.typingSentAt = on ? now : 0;
867
940
  this.send({ type: 'typing', id, on });
868
941
  },
869
942
  leave: () => {
870
- if (this.rooms.get(id) !== room)
943
+ const r = open();
944
+ if (!r)
945
+ return;
946
+ r.listeners.delete(listener);
947
+ if (r.listeners.size)
871
948
  return;
872
949
  this.rooms.delete(id);
873
950
  if (this.ready)
@@ -948,7 +1025,13 @@ class LiveSocket {
948
1025
  return;
949
1026
  }
950
1027
  if (f.type === 'people' && typeof f.id === 'string') {
951
- this.rooms.get(f.id)?.onPeople(Array.isArray(f.people) ? f.people : []);
1028
+ const room = this.rooms.get(f.id);
1029
+ if (!room)
1030
+ return;
1031
+ const people = Array.isArray(f.people) ? f.people : [];
1032
+ room.people = people;
1033
+ for (const l of [...room.listeners])
1034
+ l.onPeople(people);
952
1035
  return;
953
1036
  }
954
1037
  if (f.type === 'error' && typeof f.id === 'string' && f.id.startsWith('here:')) {
@@ -958,7 +1041,9 @@ class LiveSocket {
958
1041
  return;
959
1042
  this.rooms.delete(id);
960
1043
  const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
961
- room.onError?.(new AwesomateError(code, String(f.message ?? 'Refused.'), 0, undefined, String(f.code)));
1044
+ const err = new AwesomateError(code, String(f.message ?? 'Refused.'), 0, undefined, String(f.code));
1045
+ for (const l of room.listeners)
1046
+ l.onError?.(err);
962
1047
  if (!this.wanted)
963
1048
  this.shut();
964
1049
  return;
@@ -1013,6 +1098,8 @@ class LiveSocket {
1013
1098
  if (this.retry)
1014
1099
  clearTimeout(this.retry);
1015
1100
  this.pinger = this.retry = null;
1101
+ for (const room of this.rooms.values())
1102
+ room.people = null;
1016
1103
  if (ws && ws.readyState <= 1) {
1017
1104
  try {
1018
1105
  ws.send(JSON.stringify({ type: 'bye' }));
@@ -1039,6 +1126,9 @@ class LiveSocket {
1039
1126
  if (this.pinger)
1040
1127
  clearInterval(this.pinger);
1041
1128
  this.pinger = null;
1129
+ // Who was there is the old connection's word: a part opening a record now waits for the new one.
1130
+ for (const room of this.rooms.values())
1131
+ room.people = null;
1042
1132
  if (this.stopped || !this.wanted)
1043
1133
  return;
1044
1134
  if (code === 4401) {
@@ -1060,7 +1150,8 @@ class LiveSocket {
1060
1150
  for (const e of all)
1061
1151
  e.handlers.onError?.(err);
1062
1152
  for (const r of rooms)
1063
- r.onError?.(err);
1153
+ for (const l of r.listeners)
1154
+ l.onError?.(err);
1064
1155
  this.shut();
1065
1156
  }
1066
1157
  shut() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",