Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM="

# Google sign-in, and connecting a Google account. Register http://localhost:5173/auth/google/callback
# and http://localhost:5173/api/connections/google/callback as redirect URIs, and enable the Gmail,
# Calendar, Drive, Docs, Sheets, Slides and People APIs.
# Calendar, Drive, Sheets, People and Tasks APIs.
# GOOGLE_CLIENT_ID=""
# GOOGLE_CLIENT_SECRET=""

Expand Down
27 changes: 24 additions & 3 deletions packages/api/src/connections/google/calendar-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import type {ApiError} from '@proxy/utils';
import {googleRequest, googleWrite} from './google-client';

/**
* The Calendar v3 calls the `events` collection makes, and the reading of their answers: the
* calendars the account has in its list, and their events, each time a repeating event comes round
* its own, as Google Calendar shows them.
* The Calendar v3 calls the `events` and `availability` collections make, and the reading of their
* answers: the calendars the account has in its list, and their events, each time a repeating event
* comes round its own, as Google Calendar shows them; and when calendars are busy.
*/

const CALENDAR = 'https://www.googleapis.com/calendar/v3';
Expand Down Expand Up @@ -151,3 +151,24 @@ export function insertEvent(accessToken: string, calendarId: string, event: Even
export function patchEvent(accessToken: string, calendarId: string, eventId: string, event: EventWrite, sendUpdates: 'all' | 'none'): Promise<Result<GoogleEvent, ApiError>> {
return googleWrite(accessToken, 'PATCH', eventUrl(calendarId, eventId, sendUpdates), event, eventSchema);
}

const freeBusySchema = z.object({
calendars: z
.record(
z.string(),
z.object({
busy: z.array(z.object({start: z.string(), end: z.string()})).optional(),
// Why a calendar wasn't answered for, such as `notFound` for one the account can't see.
errors: z.array(z.object({reason: z.string().optional()})).optional(),
}),
)
.optional(),
});

export type FreeBusy = z.infer<typeof freeBusySchema>;

/** When each calendar given is busy between `from` and `to`, instants with their offsets, by the calendar's id as given. */
export function queryFreeBusy(accessToken: string, args: {from: string; to: string; calendars: string[]}): Promise<Result<FreeBusy, ApiError>> {
const body = {timeMin: args.from, timeMax: args.to, items: args.calendars.map((id) => ({id}))};
return googleWrite(accessToken, 'POST', new URL(`${CALENDAR}/freeBusy`), body, freeBusySchema);
}
2 changes: 1 addition & 1 deletion packages/api/src/connections/google/google-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const TIMEOUT_MS = 15_000;
// Refreshed a minute early, so a token doesn't run out between the check and the call.
const EXPIRY_MARGIN_MS = 60_000;

/** Every scope a sign-in asks for: the account's address, then Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts. */
/** Every scope a sign-in asks for: the account's address, then Gmail, Calendar, Drive, Contacts and Tasks. */
export const GOOGLE_SCOPES = ['openid', 'https://www.googleapis.com/auth/userinfo.email', ...PRODUCT_SCOPES];

/** What a Google connection stores, encrypted: its tokens, the scopes Google granted, and the account. */
Expand Down
50 changes: 47 additions & 3 deletions packages/api/src/connections/google/people-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import {type ApiError, Do} from '@proxy/utils';
import {googleRequest, googleWrite} from './google-client';

/**
* The People v1 calls the `contacts` collection makes, and the reading of their answers: the
* account's contacts in Google Contacts, newest changed first, Google's search of them, and making
* and changing one, a field at a time.
* The People v1 calls the `contacts` and `otherContacts` collections make, and the reading of their
* answers: the account's contacts in Google Contacts, newest changed first, Google's search of them,
* and making and changing one, a field at a time; and the people Gmail keeps under Other contacts,
* listed, searched and copied into the contacts.
*/

const PEOPLE = 'https://people.googleapis.com/v1';
Expand Down Expand Up @@ -56,6 +57,12 @@ export type PersonWrite = {
biographies?: Array<{value: string; contentType: 'TEXT_PLAIN'}>;
};

/** The fields an other contact is read with: all Google keeps of one. */
const OTHER_FIELDS = 'names,emailAddresses,phoneNumbers,metadata';

/** The most other contacts a page of Google's list holds. */
export const OTHER_CONTACTS_MAX_PAGE = 1000;

const connectionsSchema = z.object({connections: z.array(personSchema).optional(), nextPageToken: z.string().optional()});

const searchSchema = z.object({results: z.array(z.object({person: personSchema})).optional()});
Expand Down Expand Up @@ -120,3 +127,40 @@ export function updateContact(accessToken: string, before: Person, person: Perso
const sources = (before.metadata?.sources ?? []).filter((source) => source.type === 'CONTACT');
return googleWrite(accessToken, 'PATCH', url, {...person, etag: before.etag, metadata: {sources}}, personSchema);
}

const otherContactsSchema = z.object({otherContacts: z.array(personSchema).optional(), nextPageToken: z.string().optional()});

/** A page of the account's other contacts, in Google's own order. */
export async function listOtherContacts(accessToken: string, page: string | null, pageSize = CONTACTS_PAGE_SIZE): Promise<Result<{people: Person[]; nextPage: string | null}, ApiError>> {
const url = new URL(`${PEOPLE}/otherContacts`);
url.searchParams.set('readMask', OTHER_FIELDS);
url.searchParams.set('pageSize', String(pageSize));
if (page) {
url.searchParams.set('pageToken', page);
}
const answer = await googleRequest(accessToken, url, otherContactsSchema);
return answer.map(({otherContacts = [], nextPageToken}) => ({people: otherContacts, nextPage: nextPageToken ?? null}));
}

function otherSearchUrl(query: string): URL {
const url = new URL(`${PEOPLE}/otherContacts:search`);
url.searchParams.set('query', query);
url.searchParams.set('readMask', OTHER_FIELDS);
url.searchParams.set('pageSize', String(SEARCH_SIZE));
return url;
}

/** The other contacts whose names, addresses or numbers begin with the words searched for, after the empty search Google asks for first. */
export function searchOtherContacts(accessToken: string, query: string): Promise<Result<Person[], ApiError>> {
return Do(async ($) => {
$(await googleRequest(accessToken, otherSearchUrl(''), searchSchema));
const answer = $(await googleRequest(accessToken, otherSearchUrl(query), searchSchema));
return (answer.results ?? []).map(({person}) => person);
});
}

/** Copies an other contact's names, addresses and numbers into a new contact in Google Contacts. */
export function copyOtherContact(accessToken: string, resourceName: string): Promise<Result<Person, ApiError>> {
const body = {copyMask: 'names,emailAddresses,phoneNumbers', readMask: PERSON_FIELDS};
return googleWrite(accessToken, 'POST', new URL(`${PEOPLE}/${resourceName}:copyOtherContactToMyContactsGroup`), body, personSchema);
}
10 changes: 5 additions & 5 deletions packages/api/src/connections/google/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ const SCOPES = [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
'https://www.googleapis.com/auth/calendar.events.freebusy',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/presentations',
'https://www.googleapis.com/auth/contacts',
'https://www.googleapis.com/auth/contacts.other.readonly',
'https://www.googleapis.com/auth/tasks',
];

function base64url(value: object): string {
Expand All @@ -80,7 +80,7 @@ spyOn(globalThis, 'fetch').mockImplementation((async (input: string | URL | Requ
refresh_token: 'refresh-1',
expires_in: 3599,
token_type: 'Bearer',
scope: 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/documents',
scope: 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/tasks',
id_token: google.idToken,
});
}) as typeof fetch);
Expand Down Expand Up @@ -196,7 +196,7 @@ describe('handleGoogleCallbackRoute', () => {
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresAt: expect.any(String),
scopes: ['openid', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/documents'],
scopes: ['openid', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/tasks'],
email: 'alex@example.com',
});
expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/org-1/connections/conn-1?added');
Expand Down
21 changes: 17 additions & 4 deletions packages/api/src/connections/google/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,32 @@ const GMAIL = [[scope('gmail.modify')]];
// read for their names and time zones, `calendar.calendarlist.readonly`.
const CALENDAR = [[scope('calendar.events'), scope('calendar.calendarlist.readonly')]];

// Who is free takes `calendar.events.freebusy`, and the primary calendar's time zone, which times
// without an offset are on, `calendar.calendarlist.readonly`.
const AVAILABILITY = [[scope('calendar.events.freebusy'), scope('calendar.calendarlist.readonly')]];

// Drive's export and uploads and the Sheets API take `drive` for reading and writing alike, so it
// alone covers every file action.
// alone covers every file action; the Docs, Sheets and Slides scopes would add nothing to it.
const DRIVE = [[scope('drive')]];

const CONTACTS = [[scope('contacts')]];

// The people Gmail keeps under Other contacts are read with a scope of their own, only ever read.
const OTHER_CONTACTS = [[scope('contacts.other.readonly')]];

const TASKS = [[scope('tasks')]];

/** Every scope a sign-in asks for beyond the account's address, in the order Google shows them. */
export const PRODUCT_SCOPES = [
scope('gmail.modify'),
scope('gmail.send'),
scope('calendar.events'),
scope('calendar.calendarlist.readonly'),
scope('calendar.events.freebusy'),
scope('drive'),
scope('documents'),
scope('spreadsheets'),
scope('presentations'),
scope('contacts'),
scope('contacts.other.readonly'),
scope('tasks'),
];

const ACTION_SCOPES: Partial<Record<string, string[][]>> = {
Expand All @@ -48,6 +57,7 @@ const ACTION_SCOPES: Partial<Record<string, string[][]>> = {
trashEmails: GMAIL,
sendEmails: [[scope('gmail.send')], [scope('gmail.modify')]],
readEvents: CALENDAR,
readAvailability: AVAILABILITY,
writeEvents: CALENDAR,
respondEvents: CALENDAR,
inviteGuests: CALENDAR,
Expand All @@ -57,6 +67,9 @@ const ACTION_SCOPES: Partial<Record<string, string[][]>> = {
trashFiles: DRIVE,
readContacts: CONTACTS,
writeContacts: CONTACTS,
readOtherContacts: OTHER_CONTACTS,
readTasks: TASKS,
writeTasks: TASKS,
};

/** The actions a Google sign-in didn't cover, in the catalog's order; one the map leaves out is never covered. */
Expand Down
123 changes: 123 additions & 0 deletions packages/api/src/connections/google/tasks-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type {Result} from 'ts-results-es';
import {z} from 'zod';

import type {ApiError} from '@proxy/utils';

import {googleRequest, googleWrite} from './google-client';

/**
* The Tasks v1 calls the `tasks` collection makes, and the reading of their answers: the account's
* task lists, and the tasks in each, subtasks and those assigned from Docs and Chat among them, in
* Google's own order.
*/

const TASKS = 'https://tasks.googleapis.com/tasks/v1';

/** The tasks a page of a list holds. */
export const TASKS_PAGE_SIZE = 50;

// Google's most, far more lists than anyone keeps.
const LISTS_SIZE = 100;

const taskListSchema = z.object({id: z.string(), title: z.string().optional(), updated: z.string().optional()});

export type TaskList = z.infer<typeof taskListSchema>;

const taskListsSchema = z.object({items: z.array(taskListSchema).optional()});

const taskSchema = z.object({
id: z.string(),
title: z.string().optional(),
notes: z.string().optional(),
// `needsAction` or `completed`.
status: z.string().optional(),
// A day at midnight UTC: Google Tasks keeps no time of day.
due: z.string().optional(),
completed: z.string().optional(),
// The task a subtask is under.
parent: z.string().optional(),
updated: z.string().optional(),
webViewLink: z.string().optional(),
// What a task was made from, such as an email in Gmail.
links: z.array(z.object({type: z.string().optional(), description: z.string().optional(), link: z.string().optional()})).optional(),
deleted: z.boolean().optional(),
});

export type Task = z.infer<typeof taskSchema>;

const tasksSchema = z.object({items: z.array(taskSchema).optional(), nextPageToken: z.string().optional()});

/** What a task is written with: only what changes, on an edit; a due date of null takes it off. */
export type TaskWrite = {
title?: string;
notes?: string;
due?: string | null;
status?: 'needsAction' | 'completed';
completed?: null;
};

/** Which of a list's tasks to list: all of them, or only those still to do or done. */
export type TaskStatus = 'open' | 'done' | null;

function listUrl(listId: string, rest = ''): URL {
return new URL(`${TASKS}/lists/${encodeURIComponent(listId)}/tasks${rest}`);
}

function taskUrl(listId: string, taskId: string, rest = ''): URL {
return listUrl(listId, `/${encodeURIComponent(taskId)}${rest}`);
}

/** The account's task lists, as Google Tasks shows them. */
export async function listTaskLists(accessToken: string): Promise<Result<TaskList[], ApiError>> {
const url = new URL(`${TASKS}/users/@me/lists`);
url.searchParams.set('maxResults', String(LISTS_SIZE));
const answer = await googleRequest(accessToken, url, taskListsSchema);
return answer.map(({items = []}) => items);
}

export function getTaskList(accessToken: string, listId: string): Promise<Result<TaskList, ApiError>> {
return googleRequest(accessToken, new URL(`${TASKS}/users/@me/lists/${encodeURIComponent(listId)}`), taskListSchema);
}

/**
* A page of a list's tasks, in Google's order, those assigned from Docs and Chat among them, which
* Google leaves out unless asked. Tasks completed in Google Tasks are hidden
* there, so done ones are asked for with the hidden ones; a lower bound on when a task was
* completed leaves out the ones still to do.
*/
export async function listTasks(accessToken: string, listId: string, args: {status: TaskStatus; page: string | null}): Promise<Result<{tasks: Task[]; nextPage: string | null}, ApiError>> {
const url = listUrl(listId);
url.searchParams.set('maxResults', String(TASKS_PAGE_SIZE));
url.searchParams.set('showCompleted', String(args.status !== 'open'));
url.searchParams.set('showHidden', String(args.status !== 'open'));
url.searchParams.set('showAssigned', 'true');
if (args.status === 'done') {
url.searchParams.set('completedMin', '1970-01-01T00:00:00.000Z');
}
if (args.page) {
url.searchParams.set('pageToken', args.page);
}
const answer = await googleRequest(accessToken, url, tasksSchema);
return answer.map(({items = [], nextPageToken}) => ({tasks: items, nextPage: nextPageToken ?? null}));
}

export function getTask(accessToken: string, listId: string, taskId: string): Promise<Result<Task, ApiError>> {
return googleRequest(accessToken, taskUrl(listId, taskId), taskSchema);
}

/** Makes a task at the top of a list. */
export function insertTask(accessToken: string, listId: string, task: TaskWrite): Promise<Result<Task, ApiError>> {
return googleWrite(accessToken, 'POST', listUrl(listId), task, taskSchema);
}

/** Changes what is given of a task, the rest as it is. */
export function patchTask(accessToken: string, listId: string, taskId: string, task: TaskWrite): Promise<Result<Task, ApiError>> {
return googleWrite(accessToken, 'PATCH', taskUrl(listId, taskId), task, taskSchema);
}

/** Moves a task to the top of another list; Google turns a repeating one down. */
export function moveTask(accessToken: string, listId: string, taskId: string, destinationListId: string): Promise<Result<Task, ApiError>> {
const url = taskUrl(listId, taskId, '/move');
url.searchParams.set('destinationTasklist', destinationListId);
return googleWrite(accessToken, 'POST', url, {}, taskSchema);
}
3 changes: 3 additions & 0 deletions packages/api/src/connections/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ const withoutDrive = googleRow([
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
'https://www.googleapis.com/auth/calendar.events.freebusy',
'https://www.googleapis.com/auth/contacts',
'https://www.googleapis.com/auth/contacts.other.readonly',
'https://www.googleapis.com/auth/tasks',
]);

function makeRequest(params: Record<string, string> = {}, body: unknown = undefined) {
Expand Down
Loading
Loading