@typeb-digital/nucleus-sdk 0.5.3 → 0.8.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/CHANGELOG.md +107 -0
- package/README.md +137 -0
- package/dist/cjs/index.cjs +136 -107
- package/dist/cjs/index.d.cts +218 -9
- package/dist/cjs/index.d.cts.map +1 -1
- package/dist/es/index.d.ts +218 -9
- package/dist/es/index.d.ts.map +1 -1
- package/dist/es/index.js +134 -108
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,113 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the
|
|
7
7
|
package is pre-`1.0.0`, **breaking changes may land in a minor version** (e.g. `0.0.x` → `0.1.0`).
|
|
8
8
|
|
|
9
|
+
## [0.8.0] - 2026-09-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Google Chat messaging — `chat.send`.** Nucleus can now message people on Google Chat
|
|
14
|
+
as the company's Chat app, giving apps a push channel that reaches a phone without
|
|
15
|
+
handling a Google identity or a bot token of their own. Address a message to
|
|
16
|
+
`{ employeeId }` and Nucleus opens the direct-message conversation with that person the
|
|
17
|
+
first time, caches it, and reuses it forever after; or address `{ space: 'spaces/…' }`
|
|
18
|
+
to post into a shared space the app belongs to. Send `text`, a small `card` (title,
|
|
19
|
+
body, one link button, expanded into Google's `cardsV2` for you), or raw `cardsV2`.
|
|
20
|
+
Needs the new `chat:send` scope, which is approval-gated.
|
|
21
|
+
- **`requestId` makes a send idempotent.** Google returns the message already created
|
|
22
|
+
under that id rather than posting a second one, so a retried send cannot double-notify.
|
|
23
|
+
Key it on the event (`"leave-42-approved"`), not the attempt. Keys are scoped to your
|
|
24
|
+
app, so two apps cannot suppress each other's messages.
|
|
25
|
+
- **`thread.key` groups related messages** into one Chat thread — every update about one
|
|
26
|
+
leave request in one place. Named spaces only: Google does not thread direct messages,
|
|
27
|
+
so it is ignored for an `employeeId` target rather than failing.
|
|
28
|
+
|
|
29
|
+
### Notes
|
|
30
|
+
|
|
31
|
+
- **This is a transport, not a notification system.** There are no templates, no audience
|
|
32
|
+
resolution, no retry policy and no per-person preferences. Your app decides who should
|
|
33
|
+
hear what; Nucleus puts one message in one place. A durable outbox on your side is the
|
|
34
|
+
intended pattern, which is what `requestId` exists for.
|
|
35
|
+
- **A Chat message is more interruptive than an in-app badge.** It reaches a phone.
|
|
36
|
+
Choose the handful of notification kinds that genuinely warrant that, and give people a
|
|
37
|
+
way to turn them off — Nucleus deliberately holds no preferences of its own.
|
|
38
|
+
- **Test tokens cannot reach a real person.** Under an `el_test_` token the message is
|
|
39
|
+
redirected to a configured sandbox space with a banner naming who it was meant for, and
|
|
40
|
+
no direct message with a real person is opened. `sandbox: true` and `redirectedFrom`
|
|
41
|
+
on the response tell you what happened. With no sandbox space configured the send is
|
|
42
|
+
refused (`SANDBOX_NOT_CONFIGURED`) rather than reaching someone real.
|
|
43
|
+
- **`CHAT_NOT_AVAILABLE_FOR_EMPLOYEE` (422) is not retryable.** It means the Chat app's
|
|
44
|
+
visibility list does not cover that person, which only a Workspace administrator can
|
|
45
|
+
change. Drop the message rather than retrying it.
|
|
46
|
+
|
|
47
|
+
## [0.7.0] - 2026-09-08
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **Google Calendar writes — `calendar.createEvent`, `calendar.updateEvent`,
|
|
52
|
+
`calendar.deleteEvent`.** The calendar capability was read-only; it can now put real
|
|
53
|
+
events on real calendars. Every write names the identity it acts as, so the organiser
|
|
54
|
+
is never ambiguous: `{ employeeId }` for an event that person organises on their own
|
|
55
|
+
calendar, or `{ organizer: 'company' }` for an org-wide event owned by the platform's
|
|
56
|
+
company mailbox. Attendees may be given as `{ employeeId }` or `{ email }` — employee
|
|
57
|
+
ids are resolved to Workspace emails server-side, so your app never needs a Google
|
|
58
|
+
identity. `createMeet: true` attaches a Google Meet conference and returns it as
|
|
59
|
+
`meetLink`. Needs the new `calendar:create` / `calendar:update` / `calendar:delete`
|
|
60
|
+
scopes, which are approval-gated: ask the platform team to grant them.
|
|
61
|
+
- **`clientReference` makes a create idempotent.** Nucleus derives a stable Google event
|
|
62
|
+
id from it, so replaying a create — a queue retry, a redeploy mid-request — returns the
|
|
63
|
+
original event with `created: false` instead of double-booking. Give it a value that
|
|
64
|
+
identifies the _thing_ being scheduled (`"milestone:cycle-42"`), not the attempt. A
|
|
65
|
+
delete is likewise safe to repeat: an event Google reports as already cancelled counts
|
|
66
|
+
as deleted.
|
|
67
|
+
- **`error.apiCode` on every failed result.** `error.code` stays the small union you
|
|
68
|
+
branch on; `apiCode` carries the server's specific reason —
|
|
69
|
+
`GOOGLE_DELEGATION_NOT_GRANTED`, `SANDBOX_NOT_CONFIGURED`, `ATTENDEE_NOT_RESOLVABLE`,
|
|
70
|
+
`EVENT_NOT_FOUND` — for logging and for telling two 4xx cases apart.
|
|
71
|
+
- **`calendar.listEvents` accepts `calendarId`**, to read a calendar other than the
|
|
72
|
+
employee's `primary`. The returned event now also carries `eventType`, `meetLink`,
|
|
73
|
+
`iCalUID`, `updated` and `recurrence`.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- **`INVALID_SCOPE` now actually reaches you.** The transport read the API's error
|
|
78
|
+
_message_ where it meant to read the error _code_, so a missing-scope rejection always
|
|
79
|
+
arrived as the generic `FORBIDDEN`. Code that branches on `FORBIDDEN` keeps working;
|
|
80
|
+
code that wanted `INVALID_SCOPE` starts working.
|
|
81
|
+
- **Failed results carry the API's message, not ofetch's.** `error.message` was
|
|
82
|
+
`"[POST] …: 400 Bad Request"`; it is now the reason the API gave, e.g.
|
|
83
|
+
`"start.timeZone: is required when dateTime has no UTC offset"`.
|
|
84
|
+
|
|
85
|
+
### Notes
|
|
86
|
+
|
|
87
|
+
- **`sendUpdates` defaults to `none`.** Creating or changing an event emails nobody
|
|
88
|
+
unless you pass `'all'` (or `'externalOnly'`). This is deliberate: an automation that
|
|
89
|
+
quietly mails a hundred people is worse than one that mails nobody. Pass it explicitly
|
|
90
|
+
for anything a human is expected to accept, such as a meeting invitation.
|
|
91
|
+
- **Test tokens cannot reach a real calendar.** Under an `el_test_` token every write is
|
|
92
|
+
redirected to a dedicated sandbox calendar as the company mailbox, the summary is
|
|
93
|
+
stamped `[SANDBOX]`, attendees are stripped and returned in `suppressedAttendees`,
|
|
94
|
+
invitations are forced off, and out-of-office is downgraded to a plain event. The
|
|
95
|
+
response's `sandbox: true` tells you it happened. If the deployment has no sandbox
|
|
96
|
+
calendar configured, the write is refused (`SANDBOX_NOT_CONFIGURED`) rather than
|
|
97
|
+
landing somewhere real.
|
|
98
|
+
- **`eventType` cannot be changed after creation** (Google's rule), so `updateEvent`
|
|
99
|
+
does not accept it. `updateEvent` is a patch — omitted fields are left alone — with one
|
|
100
|
+
exception: `attendees` replaces the whole list, and `[]` clears it.
|
|
101
|
+
- **This release does not write the Nucleus company calendar.** `calendarEvents` (the
|
|
102
|
+
`calendar-events` resource) is unchanged and remains the date-only company calendar;
|
|
103
|
+
`calendar` is an employee's real Google Calendar. They are different things.
|
|
104
|
+
|
|
105
|
+
## [0.6.0] - 2026-09-07
|
|
106
|
+
|
|
107
|
+
### Added
|
|
108
|
+
|
|
109
|
+
- **`employeeCode` on the `employees:identity` bucket.** Nucleus now mirrors the
|
|
110
|
+
Google Workspace "Employee ID" (e.g. `"TB008"`) from the Directory API, so an app
|
|
111
|
+
can correlate an employee with payroll and HR systems that key on that code rather
|
|
112
|
+
than on the Nucleus UUID. It arrives with the fields you already read — no scope
|
|
113
|
+
change is needed if your token has `employees:identity` — and is `null` for anyone
|
|
114
|
+
whose directory entry has no employee ID set.
|
|
115
|
+
|
|
9
116
|
## [0.5.3] - 2026-09-04
|
|
10
117
|
|
|
11
118
|
### Fixed
|
package/README.md
CHANGED
|
@@ -259,6 +259,143 @@ Same pattern — `list(params?)` and `getById(id, options?)` on every resource a
|
|
|
259
259
|
|
|
260
260
|
---
|
|
261
261
|
|
|
262
|
+
## Capabilities
|
|
263
|
+
|
|
264
|
+
Most scopes are CRUD over a resource. A few are **actions the platform performs on your
|
|
265
|
+
behalf** — sending mail, writing a calendar — and they work differently: they are granted
|
|
266
|
+
per app at registration rather than declared in the client's typed `scopes` config, and
|
|
267
|
+
the accessor is always present (the server rejects the call if the capability was not
|
|
268
|
+
granted). Nucleus holds the Google credentials, so your app never handles a Google
|
|
269
|
+
identity: you address people by Nucleus `employeeId`.
|
|
270
|
+
|
|
271
|
+
### Email — needs `email:send`
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
await nucleus.email.send({ to: 'ada@example.com', subject: 'Payslip ready', html });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The From address is platform-configured per app and cannot be set from the request body.
|
|
278
|
+
|
|
279
|
+
### Google Calendar — needs `calendar:read`, and `calendar:create|update|delete` to write
|
|
280
|
+
|
|
281
|
+
Reading an employee's calendar:
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
const { data } = await nucleus.calendar.listEvents({
|
|
285
|
+
employeeId,
|
|
286
|
+
timeMin: '2026-09-01T00:00:00Z',
|
|
287
|
+
timeMax: '2026-09-30T23:59:59Z',
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Every write names the identity it acts as, so the organiser is never ambiguous — either
|
|
292
|
+
the employee whose calendar it is, or the company mailbox for an org-wide event:
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
// A 1:1, organised by the manager, with a Meet link and a real invitation.
|
|
296
|
+
const { data } = await nucleus.calendar.createEvent(
|
|
297
|
+
{ employeeId: managerId },
|
|
298
|
+
{
|
|
299
|
+
summary: 'Quarterly check-in',
|
|
300
|
+
start: { dateTime: '2026-09-10T09:00:00+05:30' },
|
|
301
|
+
end: { dateTime: '2026-09-10T09:30:00+05:30' },
|
|
302
|
+
attendees: [{ employeeId: reportId }],
|
|
303
|
+
createMeet: true,
|
|
304
|
+
sendUpdates: 'all',
|
|
305
|
+
clientReference: `milestone:${cycleId}`,
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
data.event.meetLink; // https://meet.google.com/…
|
|
309
|
+
data.created; // false if this was a retry of a create that already succeeded
|
|
310
|
+
|
|
311
|
+
// An all-hands, owned by the company mailbox rather than a person.
|
|
312
|
+
await nucleus.calendar.createEvent(
|
|
313
|
+
{ organizer: 'company' },
|
|
314
|
+
{
|
|
315
|
+
summary: 'Town hall',
|
|
316
|
+
start: { dateTime: '2026-10-01T15:00:00+05:30' },
|
|
317
|
+
end: { dateTime: '2026-10-01T16:00:00+05:30' },
|
|
318
|
+
attendees: [{ email: 'everyone@typeb.digital' }],
|
|
319
|
+
sendUpdates: 'all',
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Leave, as an out-of-office block on that person's own calendar.
|
|
324
|
+
await nucleus.calendar.createEvent(
|
|
325
|
+
{ employeeId },
|
|
326
|
+
{
|
|
327
|
+
summary: 'Annual leave',
|
|
328
|
+
eventType: 'outOfOffice',
|
|
329
|
+
outOfOffice: { autoDeclineMode: 'declineAllConflictingInvitations' },
|
|
330
|
+
start: { dateTime: '2026-09-14T00:00:00+05:30' },
|
|
331
|
+
end: { dateTime: '2026-09-19T00:00:00+05:30' },
|
|
332
|
+
clientReference: `leave:${leaveRequestId}`,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
await nucleus.calendar.updateEvent({ employeeId: managerId }, eventId, { location: 'Room 2' });
|
|
337
|
+
await nucleus.calendar.deleteEvent({ employeeId: managerId }, eventId, { sendUpdates: 'all' });
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Four things worth knowing before you ship against this:
|
|
341
|
+
|
|
342
|
+
- **Nothing is emailed unless you ask.** `sendUpdates` defaults to `none`. Pass `'all'`
|
|
343
|
+
for anything a human is expected to accept.
|
|
344
|
+
- **Retries are safe if you pass `clientReference`.** Nucleus derives a stable Google
|
|
345
|
+
event id from it, so a replayed create returns the original event with
|
|
346
|
+
`created: false` instead of double-booking. Key it on the thing being scheduled, not on
|
|
347
|
+
the attempt. Deletes are idempotent already.
|
|
348
|
+
- **`updateEvent` is a patch**, except `attendees`, which replaces the whole list (`[]`
|
|
349
|
+
clears it). `eventType` cannot be changed after creation, so it is not accepted.
|
|
350
|
+
- **A test token cannot reach a real calendar.** Writes are redirected to a sandbox
|
|
351
|
+
calendar, stamped `[SANDBOX]`, with attendees stripped into `suppressedAttendees` and
|
|
352
|
+
invitations forced off. Check `sandbox` on the response.
|
|
353
|
+
|
|
354
|
+
Nucleus's own **company calendar** is a different thing: that is `nucleus.calendarEvents`
|
|
355
|
+
(the `calendar-events` CRUD resource, date-only), not `nucleus.calendar`.
|
|
356
|
+
|
|
357
|
+
### Google Chat — needs `chat:send`
|
|
358
|
+
|
|
359
|
+
Messages are posted as the company's Chat app. Address a person by `employeeId` and
|
|
360
|
+
Nucleus opens their direct message with the app the first time, then reuses it:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
await nucleus.chat.send({
|
|
364
|
+
to: { employeeId },
|
|
365
|
+
card: {
|
|
366
|
+
title: 'Leave approved',
|
|
367
|
+
subtitle: '14–18 September',
|
|
368
|
+
body: 'Your request was approved by Grace.',
|
|
369
|
+
link: { text: 'Open OS', url: 'https://os.typeb.digital/hr/my-leave' },
|
|
370
|
+
},
|
|
371
|
+
requestId: `leave-${leaveRequestId}-approved`,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Or into a shared space the app belongs to, threaded by a key you choose.
|
|
375
|
+
await nucleus.chat.send({
|
|
376
|
+
to: { space: 'spaces/AAAA' },
|
|
377
|
+
text: 'Two expense claims are waiting for approval.',
|
|
378
|
+
thread: { key: `expenses-${weekOf}` },
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Worth knowing:
|
|
383
|
+
|
|
384
|
+
- **This is a transport, not a notification system.** No templates, no audiences, no
|
|
385
|
+
preferences — your app decides who hears what. Drive it from a durable outbox and use
|
|
386
|
+
`requestId` so a retry cannot double-notify.
|
|
387
|
+
- **A Chat message reaches a phone.** Reserve it for the few kinds that warrant an
|
|
388
|
+
interruption, and give people a way to opt out; Nucleus holds no preferences.
|
|
389
|
+
- **Threads are for named spaces only.** Google does not thread direct messages, so
|
|
390
|
+
`thread` is ignored for an `employeeId` target.
|
|
391
|
+
- **A test token cannot reach a real person.** Messages are redirected to a sandbox space
|
|
392
|
+
with a banner naming the intended recipient. Check `sandbox` and `redirectedFrom`.
|
|
393
|
+
- **`CHAT_NOT_AVAILABLE_FOR_EMPLOYEE` (422) is final**, not transient: the Chat app's
|
|
394
|
+
visibility list does not cover that person. Read it from `error.apiCode` and drop the
|
|
395
|
+
message rather than retrying.
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
262
399
|
## Result type
|
|
263
400
|
|
|
264
401
|
Every method returns a result object — **never throws** by default:
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -10,14 +10,23 @@ function statusToCode(status, apiCode) {
|
|
|
10
10
|
if (status === 429) return 'RATE_LIMITED';
|
|
11
11
|
return 'FORBIDDEN';
|
|
12
12
|
}
|
|
13
|
-
function makeErrorResult(code, message) {
|
|
13
|
+
function makeErrorResult(code, message, apiCode) {
|
|
14
14
|
return {
|
|
15
15
|
error: {
|
|
16
16
|
code,
|
|
17
|
-
message
|
|
17
|
+
message,
|
|
18
|
+
...apiCode ? {
|
|
19
|
+
apiCode
|
|
20
|
+
} : {}
|
|
18
21
|
}
|
|
19
22
|
};
|
|
20
23
|
}
|
|
24
|
+
function stripUndefined(query) {
|
|
25
|
+
if (!query) return undefined;
|
|
26
|
+
return Object.fromEntries(Object.entries(query).filter((entry)=>{
|
|
27
|
+
return entry[1] !== undefined;
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
21
30
|
class NucleusTransport {
|
|
22
31
|
constructor(token, baseUrl){
|
|
23
32
|
this.bodylessHeaders = {
|
|
@@ -29,117 +38,78 @@ class NucleusTransport {
|
|
|
29
38
|
};
|
|
30
39
|
this.baseUrl = (baseUrl ?? DEFAULT_BASE_URL$1).replace(/\/$/, '');
|
|
31
40
|
}
|
|
32
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The one place a request is issued and a failure is interpreted. Every verb below
|
|
43
|
+
* funnels through here: when the error mapping lived in each verb, a fix to it had to
|
|
44
|
+
* be made five times, and one of them (reading the API's `code`) was wrong for years.
|
|
45
|
+
*/ async request(method, path, opts, onSuccess) {
|
|
33
46
|
try {
|
|
34
|
-
const params = query ? Object.fromEntries(Object.entries(query).filter(([, v])=>v !== undefined)) : undefined;
|
|
35
47
|
const envelope = await ofetch.$fetch(`${this.baseUrl}${path}`, {
|
|
36
|
-
method
|
|
37
|
-
headers: this.bodylessHeaders,
|
|
38
|
-
|
|
48
|
+
method,
|
|
49
|
+
headers: opts.hasBody ? this.headers : this.bodylessHeaders,
|
|
50
|
+
...opts.hasBody ? {
|
|
51
|
+
body: JSON.stringify(opts.body)
|
|
52
|
+
} : {},
|
|
53
|
+
params: stripUndefined(opts.query)
|
|
39
54
|
});
|
|
55
|
+
// A 2xx carrying `success: false` shouldn't happen, but the envelope allows it.
|
|
40
56
|
if (!envelope.success) {
|
|
41
|
-
return makeErrorResult(
|
|
57
|
+
return makeErrorResult(statusToCode(0, envelope.code), envelope.error, envelope.code);
|
|
42
58
|
}
|
|
43
|
-
return
|
|
44
|
-
data: envelope.data
|
|
45
|
-
};
|
|
59
|
+
return onSuccess(envelope);
|
|
46
60
|
} catch (err) {
|
|
47
61
|
if (err instanceof ofetch.FetchError) {
|
|
48
62
|
const body = err.data;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return makeErrorResult('NETWORK_ERROR', String(err));
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
async post(path, body) {
|
|
56
|
-
try {
|
|
57
|
-
const envelope = await ofetch.$fetch(`${this.baseUrl}${path}`, {
|
|
58
|
-
method: 'POST',
|
|
59
|
-
headers: this.headers,
|
|
60
|
-
body: JSON.stringify(body)
|
|
61
|
-
});
|
|
62
|
-
if (!envelope.success) return makeErrorResult('FORBIDDEN', envelope.error);
|
|
63
|
-
return {
|
|
64
|
-
data: envelope.data
|
|
65
|
-
};
|
|
66
|
-
} catch (err) {
|
|
67
|
-
if (err instanceof ofetch.FetchError) {
|
|
68
|
-
const errBody = err.data;
|
|
69
|
-
return makeErrorResult(statusToCode(err.status ?? 0, errBody?.error), err.message);
|
|
63
|
+
// Prefer the API's own message ("start.timeZone: is required") over ofetch's
|
|
64
|
+
// generic "[POST] …: 400 Bad Request", which says nothing actionable.
|
|
65
|
+
return makeErrorResult(statusToCode(err.status ?? 0, body?.code), body?.error ?? err.message, body?.code);
|
|
70
66
|
}
|
|
71
67
|
return makeErrorResult('NETWORK_ERROR', String(err));
|
|
72
68
|
}
|
|
73
69
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
headers: this.headers,
|
|
79
|
-
body: JSON.stringify(body)
|
|
80
|
-
});
|
|
81
|
-
if (!envelope.success) return makeErrorResult('FORBIDDEN', envelope.error);
|
|
82
|
-
return {
|
|
83
|
-
data: envelope.data
|
|
84
|
-
};
|
|
85
|
-
} catch (err) {
|
|
86
|
-
if (err instanceof ofetch.FetchError) {
|
|
87
|
-
const errBody = err.data;
|
|
88
|
-
return makeErrorResult(statusToCode(err.status ?? 0, errBody?.error), err.message);
|
|
89
|
-
}
|
|
90
|
-
return makeErrorResult('NETWORK_ERROR', String(err));
|
|
91
|
-
}
|
|
70
|
+
single(envelope) {
|
|
71
|
+
return {
|
|
72
|
+
data: envelope.data
|
|
73
|
+
};
|
|
92
74
|
}
|
|
93
|
-
async
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
75
|
+
async get(path, query) {
|
|
76
|
+
return this.request('GET', path, {
|
|
77
|
+
query,
|
|
78
|
+
hasBody: false
|
|
79
|
+
}, (e)=>this.single(e));
|
|
80
|
+
}
|
|
81
|
+
async post(path, body, query) {
|
|
82
|
+
return this.request('POST', path, {
|
|
83
|
+
body,
|
|
84
|
+
query,
|
|
85
|
+
hasBody: true
|
|
86
|
+
}, (e)=>this.single(e));
|
|
87
|
+
}
|
|
88
|
+
async put(path, body, query) {
|
|
89
|
+
return this.request('PUT', path, {
|
|
90
|
+
body,
|
|
91
|
+
query,
|
|
92
|
+
hasBody: true
|
|
93
|
+
}, (e)=>this.single(e));
|
|
94
|
+
}
|
|
95
|
+
async patch(path, body, query) {
|
|
96
|
+
return this.request('PATCH', path, {
|
|
97
|
+
body,
|
|
98
|
+
query,
|
|
99
|
+
hasBody: true
|
|
100
|
+
}, (e)=>this.single(e));
|
|
111
101
|
}
|
|
112
102
|
async del(path, query) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
headers: this.bodylessHeaders,
|
|
118
|
-
params
|
|
119
|
-
});
|
|
120
|
-
if (!envelope.success) return makeErrorResult('FORBIDDEN', envelope.error);
|
|
121
|
-
return {
|
|
122
|
-
data: envelope.data
|
|
123
|
-
};
|
|
124
|
-
} catch (err) {
|
|
125
|
-
if (err instanceof ofetch.FetchError) {
|
|
126
|
-
const errBody = err.data;
|
|
127
|
-
return makeErrorResult(statusToCode(err.status ?? 0, errBody?.error), err.message);
|
|
128
|
-
}
|
|
129
|
-
return makeErrorResult('NETWORK_ERROR', String(err));
|
|
130
|
-
}
|
|
103
|
+
return this.request('DELETE', path, {
|
|
104
|
+
query,
|
|
105
|
+
hasBody: false
|
|
106
|
+
}, (e)=>this.single(e));
|
|
131
107
|
}
|
|
132
108
|
async getList(path, query) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
headers: this.bodylessHeaders,
|
|
138
|
-
params
|
|
139
|
-
});
|
|
140
|
-
if (!envelope.success) {
|
|
141
|
-
return makeErrorResult('FORBIDDEN', envelope.error);
|
|
142
|
-
}
|
|
109
|
+
return this.request('GET', path, {
|
|
110
|
+
query,
|
|
111
|
+
hasBody: false
|
|
112
|
+
}, (envelope)=>{
|
|
143
113
|
const { data, meta } = envelope;
|
|
144
114
|
const listMeta = {
|
|
145
115
|
total: meta.total,
|
|
@@ -151,14 +121,7 @@ class NucleusTransport {
|
|
|
151
121
|
data,
|
|
152
122
|
meta: listMeta
|
|
153
123
|
};
|
|
154
|
-
}
|
|
155
|
-
if (err instanceof ofetch.FetchError) {
|
|
156
|
-
const body = err.data;
|
|
157
|
-
const code = statusToCode(err.status ?? 0, body?.error);
|
|
158
|
-
return makeErrorResult(code, err.message);
|
|
159
|
-
}
|
|
160
|
-
return makeErrorResult('NETWORK_ERROR', String(err));
|
|
161
|
-
}
|
|
124
|
+
});
|
|
162
125
|
}
|
|
163
126
|
}
|
|
164
127
|
|
|
@@ -247,6 +210,7 @@ function transformEmployee(raw) {
|
|
|
247
210
|
if ('job_title' in r) out['jobTitle'] = str(r['job_title']);
|
|
248
211
|
if ('department' in r) out['department'] = str(r['department']);
|
|
249
212
|
if ('employment_status' in r) out['employmentStatus'] = str(r['employment_status']);
|
|
213
|
+
if ('employee_code' in r) out['employeeCode'] = str(r['employee_code']);
|
|
250
214
|
if ('employment_type' in r) out['employmentType'] = str(r['employment_type']);
|
|
251
215
|
if ('is_external' in r) out['isExternal'] = bool(r['is_external']);
|
|
252
216
|
// employment
|
|
@@ -1644,9 +1608,22 @@ class TimesheetsAccessor {
|
|
|
1644
1608
|
}
|
|
1645
1609
|
}
|
|
1646
1610
|
|
|
1611
|
+
function actorQuery(actor) {
|
|
1612
|
+
return {
|
|
1613
|
+
employeeId: actor.employeeId,
|
|
1614
|
+
organizer: actor.organizer,
|
|
1615
|
+
calendarId: actor.calendarId
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1647
1618
|
/**
|
|
1648
|
-
* CalendarAccessor — read workspace users' Google Calendar events (WS3).
|
|
1649
|
-
* Requires the app's `calendar:read` scope
|
|
1619
|
+
* CalendarAccessor — read and write workspace users' Google Calendar events (WS3).
|
|
1620
|
+
* Requires the app's `calendar:read` scope to list, and `calendar:create` /
|
|
1621
|
+
* `calendar:update` / `calendar:delete` to write. Not part of the bucket/CRUD scope
|
|
1622
|
+
* system, so these are not declared in the client's typed `scopes` config.
|
|
1623
|
+
*
|
|
1624
|
+
* Writes are mode-aware: under a test token every event is redirected to a sandbox
|
|
1625
|
+
* calendar with attendees stripped (reported in `suppressedAttendees`) and invitations
|
|
1626
|
+
* forced off, so a sandbox caller can never touch a real calendar or email a real guest.
|
|
1650
1627
|
*/ class CalendarAccessor {
|
|
1651
1628
|
constructor(transport){
|
|
1652
1629
|
this.transport = transport;
|
|
@@ -1656,7 +1633,32 @@ class TimesheetsAccessor {
|
|
|
1656
1633
|
employeeId: params.employeeId,
|
|
1657
1634
|
timeMin: params.timeMin,
|
|
1658
1635
|
timeMax: params.timeMax,
|
|
1659
|
-
q: params.q
|
|
1636
|
+
q: params.q,
|
|
1637
|
+
calendarId: params.calendarId
|
|
1638
|
+
});
|
|
1639
|
+
if ('error' in result) return result;
|
|
1640
|
+
return {
|
|
1641
|
+
data: result.data
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
async createEvent(actor, input) {
|
|
1645
|
+
const result = await this.transport.post('/api/v1/data/calendar/events', input, actorQuery(actor));
|
|
1646
|
+
if ('error' in result) return result;
|
|
1647
|
+
return {
|
|
1648
|
+
data: result.data
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
async updateEvent(actor, eventId, input) {
|
|
1652
|
+
const result = await this.transport.patch(`/api/v1/data/calendar/events/${encodeURIComponent(eventId)}`, input, actorQuery(actor));
|
|
1653
|
+
if ('error' in result) return result;
|
|
1654
|
+
return {
|
|
1655
|
+
data: result.data
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
async deleteEvent(actor, eventId, opts) {
|
|
1659
|
+
const result = await this.transport.del(`/api/v1/data/calendar/events/${encodeURIComponent(eventId)}`, {
|
|
1660
|
+
...actorQuery(actor),
|
|
1661
|
+
sendUpdates: opts?.sendUpdates
|
|
1660
1662
|
});
|
|
1661
1663
|
if ('error' in result) return result;
|
|
1662
1664
|
return {
|
|
@@ -1665,6 +1667,29 @@ class TimesheetsAccessor {
|
|
|
1665
1667
|
}
|
|
1666
1668
|
}
|
|
1667
1669
|
|
|
1670
|
+
/**
|
|
1671
|
+
* ChatAccessor — send Google Chat messages as the company's Chat app. Requires the
|
|
1672
|
+
* app's `chat:send` scope. Not part of the bucket/CRUD scope system.
|
|
1673
|
+
*
|
|
1674
|
+
* This is a transport, not a notification system: no templates, no audience resolution,
|
|
1675
|
+
* no per-person preferences. Your app decides who should hear what.
|
|
1676
|
+
*
|
|
1677
|
+
* Mode-aware: under a test token the message is redirected to a sandbox space with a
|
|
1678
|
+
* banner naming the intended recipient, and no direct message with a real person is ever
|
|
1679
|
+
* opened.
|
|
1680
|
+
*/ class ChatAccessor {
|
|
1681
|
+
constructor(transport){
|
|
1682
|
+
this.transport = transport;
|
|
1683
|
+
}
|
|
1684
|
+
async send(input) {
|
|
1685
|
+
const result = await this.transport.post('/api/v1/data/chat/messages', input);
|
|
1686
|
+
if ('error' in result) return result;
|
|
1687
|
+
return {
|
|
1688
|
+
data: result.data
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1668
1693
|
/**
|
|
1669
1694
|
* EmailAccessor — send Nucleus-mediated email (WS5). Requires the app's `email:send` scope.
|
|
1670
1695
|
* In test mode the message is marked `[SANDBOX]` and external recipients are suppressed.
|
|
@@ -2033,6 +2058,7 @@ class AuthAccessor {
|
|
|
2033
2058
|
this.timesheets = new TimesheetsAccessor(transport);
|
|
2034
2059
|
this.calendar = new CalendarAccessor(transport);
|
|
2035
2060
|
this.email = new EmailAccessor(transport);
|
|
2061
|
+
this.chat = new ChatAccessor(transport);
|
|
2036
2062
|
this.employeeCompensation = new EmployeeCompensationAccessor(transport);
|
|
2037
2063
|
this.employeeProfiles = new EmployeeProfilesAccessor(transport);
|
|
2038
2064
|
this.employeeReviews = new EmployeeReviewsAccessor(transport);
|
|
@@ -2054,6 +2080,9 @@ function isError(result) {
|
|
|
2054
2080
|
return 'error' in result;
|
|
2055
2081
|
}
|
|
2056
2082
|
|
|
2083
|
+
exports.CalendarAccessor = CalendarAccessor;
|
|
2084
|
+
exports.ChatAccessor = ChatAccessor;
|
|
2085
|
+
exports.EmailAccessor = EmailAccessor;
|
|
2057
2086
|
exports.FilesAccessor = FilesAccessor;
|
|
2058
2087
|
exports.NucleusClient = NucleusClient;
|
|
2059
2088
|
exports.isError = isError;
|