diff --git a/packages/api/src/routers/calendars.ts b/packages/api/src/routers/calendars.ts index 0decb901..c6a357a6 100644 --- a/packages/api/src/routers/calendars.ts +++ b/packages/api/src/routers/calendars.ts @@ -26,17 +26,19 @@ export const calendarsRouter = createTRPCRouter({ }); } - const calendar = await provider.client.createCalendar({ - name: input.name, - description: input.description, - timeZone: input.timeZone, + const calendar = await provider.client.calendars.create({ + calendar: { + name: input.name, + description: input.description, + timeZone: input.timeZone, + }, }); return { calendar }; }), sync: calendarProcedure.query(async ({ ctx }) => { const promises = ctx.providers.map(async ({ client }) => { - return client.calendars(); + return client.calendars.list(); }); const results = await Promise.all(promises); @@ -45,7 +47,7 @@ export const calendarsRouter = createTRPCRouter({ }), list: calendarProcedure.query(async ({ ctx }) => { const promises = ctx.providers.map(async ({ client, account }) => { - const calendars = await client.calendars(); + const calendars = await client.calendars.list(); return { id: account.id, @@ -83,8 +85,12 @@ export const calendarsRouter = createTRPCRouter({ calendars: account.calendars .sort((a, b) => { // Primary calendars come first - if (a.primary && !b.primary) return -1; - if (!a.primary && b.primary) return 1; + if (a.primary && !b.primary) { + return -1; + } + if (!a.primary && b.primary) { + return 1; + } // Otherwise maintain alphabetical order by name return a.name.localeCompare(b.name); @@ -130,7 +136,9 @@ export const calendarsRouter = createTRPCRouter({ }); } - const calendar = await provider.client.calendar(input.calendarId); + const calendar = await provider.client.calendars.get({ + calendarId: input.calendarId, + }); return { calendar }; }), @@ -158,7 +166,7 @@ export const calendarsRouter = createTRPCRouter({ }); } - const calendars = await provider.client.calendars(); + const calendars = await provider.client.calendars.list(); const calendar = calendars.find((c) => c.id === input.id); if (!calendar) { @@ -175,9 +183,12 @@ export const calendarsRouter = createTRPCRouter({ }); } - const updated = await provider.client.updateCalendar(input.id, { - name: input.name, - timeZone: input.timeZone, + const updated = await provider.client.calendars.update({ + calendarId: input.id, + calendar: { + name: input.name, + timeZone: input.timeZone, + }, }); return { calendar: updated }; @@ -204,7 +215,7 @@ export const calendarsRouter = createTRPCRouter({ }); } - const calendars = await provider.client.calendars(); + const calendars = await provider.client.calendars.list(); const calendar = calendars.find((c) => c.id === input.calendarId); if (!calendar) { @@ -221,7 +232,9 @@ export const calendarsRouter = createTRPCRouter({ }); } - await provider.client.deleteCalendar(input.calendarId); + await provider.client.calendars.delete({ + calendarId: input.calendarId, + }); return { success: true }; }), @@ -249,7 +262,7 @@ export const calendarsRouter = createTRPCRouter({ }); } - const calendars = await account.client.calendars(); + const calendars = await account.client.calendars.list(); const calendar = calendars.find( (calendar) => calendar.id === input.calendarId, ); diff --git a/packages/api/src/routers/conferencing.ts b/packages/api/src/routers/conferencing.ts index 1983c535..219cf741 100644 --- a/packages/api/src/routers/conferencing.ts +++ b/packages/api/src/routers/conferencing.ts @@ -36,7 +36,7 @@ export const conferencingRouter = createTRPCRouter({ const { client } = provider; - const calendar = (await client.calendars()).find( + const calendar = (await client.calendars.list()).find( (c) => c.id === input.calendarId, ); @@ -67,14 +67,14 @@ export const conferencingRouter = createTRPCRouter({ input.providerId as "google" | "zoom", ); - conference = await conferencingProvider.createConference( - input.agenda, - input.startTime, - input.endTime, - input.timeZone, - input.calendarId, - input.eventId, - ); + conference = await conferencingProvider.createConference({ + agenda: input.agenda, + startTime: input.startTime, + endTime: input.endTime, + timeZone: input.timeZone, + calendarId: input.calendarId, + eventId: input.eventId, + }); } catch (error) { throw new TRPCError({ code: "PRECONDITION_FAILED", @@ -90,17 +90,21 @@ export const conferencingRouter = createTRPCRouter({ const start = startInstant.toZonedDateTimeISO(tz); const end = endInstant.toZonedDateTimeISO(tz); - const event = await client.updateEvent(calendar, input.eventId, { - id: input.eventId, - title: input.agenda, - calendar: { - id: calendar.id, - provider: calendar.provider, + const event = await client.events.update({ + calendar, + eventId: input.eventId, + event: { + id: input.eventId, + title: input.agenda, + calendar: { + id: calendar.id, + provider: calendar.provider, + }, + readOnly: calendar.readOnly, + start, + end, + conference, }, - readOnly: calendar.readOnly, - start, - end, - conference, }); return { conference: event.conference }; diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts index 122e61da..10a7f39b 100644 --- a/packages/api/src/routers/events.ts +++ b/packages/api/src/routers/events.ts @@ -24,7 +24,7 @@ export const eventsRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const results = await Promise.all( ctx.providers.map(async ({ client }) => { - const calendars = await client.calendars(); + const calendars = await client.calendars.list(); const requestedCalendars = input.calendarIds.length === 0 @@ -33,12 +33,13 @@ export const eventsRouter = createTRPCRouter({ const providerEvents = await Promise.all( requestedCalendars.map(async (calendar) => { - const { events, recurringMasterEvents } = await client.events( - calendar, - input.timeMin, - input.timeMax, - input.defaultTimeZone, - ); + const { events, recurringMasterEvents } = + await client.events.list({ + calendar, + timeMin: input.timeMin, + timeMax: input.timeMax, + timeZone: input.defaultTimeZone, + }); return { events, @@ -98,7 +99,7 @@ export const eventsRouter = createTRPCRouter({ }); } - const calendars = await provider.client.calendars(); + const calendars = await provider.client.calendars.list(); const calendar = calendars.find((c) => c.id === input.calendar.id); @@ -109,7 +110,7 @@ export const eventsRouter = createTRPCRouter({ }); } - const { changes, syncToken, status } = await provider.client.sync({ + const { changes, syncToken, status } = await provider.client.events.sync({ calendar, initialSyncToken: input.calendar.syncToken, timeMin: input.timeMin, @@ -150,7 +151,7 @@ export const eventsRouter = createTRPCRouter({ }); } - const calendars = await provider.client.calendars(); + const calendars = await provider.client.calendars.list(); const calendar = calendars.find((c) => c.id === input.calendar.id); @@ -161,11 +162,11 @@ export const eventsRouter = createTRPCRouter({ }); } - const event = await provider.client.event( + const event = await provider.client.events.get({ calendar, - input.eventId, - input.timeZone, - ); + eventId: input.eventId, + timeZone: input.timeZone, + }); return { event }; }), @@ -184,7 +185,7 @@ export const eventsRouter = createTRPCRouter({ }); } - const calendars = await provider.client.calendars(); + const calendars = await provider.client.calendars.list(); const calendar = calendars.find((c) => c.id === input.calendar.id); @@ -195,7 +196,10 @@ export const eventsRouter = createTRPCRouter({ }); } - const event = await provider.client.createEvent(calendar, input); + const event = await provider.client.events.create({ + calendar, + event: input, + }); return { event }; }), @@ -240,7 +244,7 @@ export const eventsRouter = createTRPCRouter({ }); } - const calendars = await provider.client.calendars(); + const calendars = await provider.client.calendars.list(); const calendar = calendars.find((c) => c.id === data.calendar.id); if (!calendar) { @@ -250,11 +254,11 @@ export const eventsRouter = createTRPCRouter({ }); } - const event = await provider.client.updateEvent( + const event = await provider.client.events.update({ calendar, - data.id, - data, - ); + eventId: data.id, + event: data, + }); return { event }; } @@ -271,8 +275,8 @@ export const eventsRouter = createTRPCRouter({ ); const [sourceCalendars, destinationCalendars] = await Promise.all([ - sourceProvider.client.calendars(), - destinationProvider.client.calendars(), + sourceProvider.client.calendars.list(), + destinationProvider.client.calendars.list(), ]); const sourceCalendar = findCalendarOrThrow( @@ -290,11 +294,11 @@ export const eventsRouter = createTRPCRouter({ move.destination.provider.accountId && move.source.id === move.destination.id ) { - const event = await sourceProvider.client.updateEvent( - sourceCalendar, - data.id, - data, - ); + const event = await sourceProvider.client.events.update({ + calendar: sourceCalendar, + eventId: data.id, + event: data, + }); return { event }; } @@ -313,30 +317,30 @@ export const eventsRouter = createTRPCRouter({ if ( move.source.provider.accountId === move.destination.provider.accountId ) { - const moved = await sourceProvider.client.moveEvent( + const moved = await sourceProvider.client.events.move({ sourceCalendar, destinationCalendar, - data.id, - data.response?.sendUpdate ?? true, - ); + eventId: data.id, + sendUpdate: data.response?.sendUpdate ?? true, + }); - const updated = await destinationProvider.client.updateEvent( - destinationCalendar, - moved.id, - { + const updated = await destinationProvider.client.events.update({ + calendar: destinationCalendar, + eventId: moved.id, + event: { ...data, id: moved.id, calendar: move.destination, }, - ); + }); return { event: updated }; } // Different Google accounts → clone then delete - const created = await destinationProvider.client.createEvent( - destinationCalendar, - { + const created = await destinationProvider.client.events.create({ + calendar: destinationCalendar, + event: { ...data, id: crypto.randomUUID(), calendar: { @@ -344,13 +348,13 @@ export const eventsRouter = createTRPCRouter({ provider: move.destination.provider, }, }, - ); + }); - await sourceProvider.client.deleteEvent( - sourceCalendar.id, - data.id, - data.response?.sendUpdate ?? true, - ); + await sourceProvider.client.events.delete({ + calendarId: sourceCalendar.id, + eventId: data.id, + sendUpdate: data.response?.sendUpdate ?? true, + }); return { event: created }; }), @@ -381,11 +385,11 @@ export const eventsRouter = createTRPCRouter({ }); } - await provider.client.deleteEvent( - input.calendar.id, - input.eventId, - input.sendUpdate, - ); + await provider.client.events.delete({ + calendarId: input.calendar.id, + eventId: input.eventId, + sendUpdate: input.sendUpdate, + }); return { success: true }; }), @@ -421,8 +425,8 @@ export const eventsRouter = createTRPCRouter({ ); const [sourceCalendars, destinationCalendars] = await Promise.all([ - sourceProvider.client.calendars(), - destinationProvider.client.calendars(), + sourceProvider.client.calendars.list(), + destinationProvider.client.calendars.list(), ]); const sourceCalendar = findCalendarOrThrow( @@ -439,37 +443,37 @@ export const eventsRouter = createTRPCRouter({ if ( input.source.provider.accountId === input.destination.provider.accountId ) { - const event = await sourceProvider.client.moveEvent( + const event = await sourceProvider.client.events.move({ sourceCalendar, destinationCalendar, - input.eventId, - input.sendUpdate, - ); + eventId: input.eventId, + sendUpdate: input.sendUpdate, + }); return { event }; } // Different Google accounts → clone then delete - const sourceEvent = await sourceProvider.client.event( - sourceCalendar, - input.eventId, - ); + const sourceEvent = await sourceProvider.client.events.get({ + calendar: sourceCalendar, + eventId: input.eventId, + }); // TODO: what happens to attendees? - const created = await destinationProvider.client.createEvent( - destinationCalendar, - { + const created = await destinationProvider.client.events.create({ + calendar: destinationCalendar, + event: { ...sourceEvent, id: crypto.randomUUID(), calendar: input.destination, }, - ); + }); - await sourceProvider.client.deleteEvent( - sourceCalendar.id, - input.eventId, - input.sendUpdate, - ); + await sourceProvider.client.events.delete({ + calendarId: sourceCalendar.id, + eventId: input.eventId, + sendUpdate: input.sendUpdate, + }); return { event: created }; }), @@ -504,10 +508,14 @@ export const eventsRouter = createTRPCRouter({ }); } - await provider.client.responseToEvent(input.calendar.id, input.eventId, { - status: input.response.status, - comment: input.response.comment, - sendUpdate: input.response.sendUpdate, + await provider.client.events.respond({ + calendarId: input.calendar.id, + eventId: input.eventId, + response: { + status: input.response.status, + comment: input.response.comment, + sendUpdate: input.response.sendUpdate, + }, }); return { success: true }; diff --git a/packages/api/src/routers/free-busy.ts b/packages/api/src/routers/free-busy.ts index eb6916d8..bff2bad9 100644 --- a/packages/api/src/routers/free-busy.ts +++ b/packages/api/src/routers/free-busy.ts @@ -18,7 +18,11 @@ export const freeBusyRouter = createTRPCRouter({ return []; } - return client.freeBusy(input.schedules, input.timeMin, input.timeMax); + return client.freeBusy.query({ + schedules: input.schedules, + timeMin: input.timeMin, + timeMax: input.timeMax, + }); }); const freeBusy = await Promise.all(promises); diff --git a/packages/api/src/routers/tasks.ts b/packages/api/src/routers/tasks.ts index 0728484e..9b03181d 100644 --- a/packages/api/src/routers/tasks.ts +++ b/packages/api/src/routers/tasks.ts @@ -19,13 +19,13 @@ export const tasksRouter = createTRPCRouter({ }); } - const task = await provider.client.createTask(input); + const task = await provider.client.tasks.create({ task: input }); return { task }; }), list: taskProcedure.query(async ({ ctx }) => { const promises = ctx.providers.map(async ({ client, account }) => { - const tasks = await client.tasks(); + const tasks = await client.tasks.list(); return { id: account.id, diff --git a/packages/auth/src/utils/account-linking.ts b/packages/auth/src/utils/account-linking.ts index f82155cd..9ac9562a 100644 --- a/packages/auth/src/utils/account-linking.ts +++ b/packages/auth/src/utils/account-linking.ts @@ -87,7 +87,7 @@ export const handleUnlinkAccount = createAuthMiddleware(async (ctx) => { } const calendarProvider = getCalendarProvider(newDefaultAccount); - const calendars = await calendarProvider.calendars(); + const calendars = await calendarProvider.calendars.list(); const primaryCalendar = calendars.find((calendar) => calendar.primary); await ctx.context.internalAdapter.updateUser(user.id, { diff --git a/packages/providers/src/calendars/google-calendar.ts b/packages/providers/src/calendars/google-calendar.ts index f633965f..ab609536 100644 --- a/packages/providers/src/calendars/google-calendar.ts +++ b/packages/providers/src/calendars/google-calendar.ts @@ -1,34 +1,12 @@ -import { Temporal } from "temporal-polyfill"; +import { GoogleCalendar } from "@repo/google-calendar"; -import { APIError, ConflictError, GoogleCalendar } from "@repo/google-calendar"; -import type { - CreateCalendarInput, - CreateEventInput, - UpdateEventInput, -} from "@repo/schemas"; +import type { CalendarProvider } from "../interfaces/providers"; +import { GoogleCalendarCalendars } from "./google-calendar/calendars"; +import { GoogleCalendarEvents } from "./google-calendar/events"; +import { GoogleCalendarFreeBusy } from "./google-calendar/freebusy"; +import { GoogleCalendarNotifications } from "./google-calendar/notifications"; -import type { - Calendar, - CalendarEvent, - CalendarEventSyncItem, - CalendarFreeBusy, -} from "../interfaces"; -import type { - CalendarProvider, - CalendarProviderSyncOptions, - ResponseToEventInput, -} from "../interfaces/providers"; -import { ProviderError } from "../lib/provider-error"; -import { parseGoogleCalendarCalendarListEntry } from "./google-calendar/calendars"; -import { - createEventParams, - parseGoogleCalendarEvent, - toGoogleCalendarAttendeeResponseStatus, - updateEventParams, -} from "./google-calendar/events"; -import { parseGoogleCalendarFreeBusy } from "./google-calendar/freebusy"; - -const MAX_EVENTS_PER_CALENDAR = 250; +export type { GoogleCalendarEvent } from "./google-calendar/interfaces"; interface GoogleCalendarProviderOptions { accessToken: string; @@ -39,6 +17,10 @@ export class GoogleCalendarProvider implements CalendarProvider { public readonly providerId = "google" as const; public readonly providerAccountId: string; private client: GoogleCalendar; + public readonly calendars: GoogleCalendarCalendars; + public readonly events: GoogleCalendarEvents; + public readonly freeBusy: GoogleCalendarFreeBusy; + public readonly notifications: GoogleCalendarNotifications; constructor({ accessToken, @@ -48,496 +30,12 @@ export class GoogleCalendarProvider implements CalendarProvider { this.client = new GoogleCalendar({ accessToken, }); - } - - async calendars(): Promise { - return this.withErrorHandler("calendars", async () => { - const { items } = await this.client.users.me.calendarList.list(); - - if (!items) return []; - - return items.map((calendar) => { - const parsedCalendar = parseGoogleCalendarCalendarListEntry({ - providerAccountId: this.providerAccountId, - entry: calendar, - }); - - return parsedCalendar; - }); - }); - } - - async calendar(calendarId: string): Promise { - return this.withErrorHandler("calendar", async () => { - const calendar = - await this.client.users.me.calendarList.retrieve(calendarId); - - return parseGoogleCalendarCalendarListEntry({ - providerAccountId: this.providerAccountId, - entry: calendar, - }); - }); - } - - async createCalendar(calendar: CreateCalendarInput): Promise { - return this.withErrorHandler("createCalendar", async () => { - const createdCalendar = await this.client.calendars.create({ - summary: calendar.name, - description: calendar.description, - timeZone: calendar.timeZone, - }); - - return parseGoogleCalendarCalendarListEntry({ - providerAccountId: this.providerAccountId, - entry: createdCalendar, - }); - }); - } - - async updateCalendar( - calendarId: string, - calendar: Partial, - ): Promise { - return this.withErrorHandler("updateCalendar", async () => { - const updatedCalendar = await this.client.calendars.update(calendarId, { - summary: calendar.name, - }); - - return parseGoogleCalendarCalendarListEntry({ - providerAccountId: this.providerAccountId, - entry: updatedCalendar, - }); - }); - } - - async deleteCalendar(calendarId: string): Promise { - return this.withErrorHandler("deleteCalendar", async () => { - await this.client.calendars.delete(calendarId); - }); - } - - async events( - calendar: Calendar, - timeMin: Temporal.ZonedDateTime, - timeMax: Temporal.ZonedDateTime, - timeZone?: string, - ): Promise<{ - events: CalendarEvent[]; - recurringMasterEvents: CalendarEvent[]; - }> { - return this.withErrorHandler("events", async () => { - const { items } = await this.client.calendars.events.list(calendar.id, { - timeMin: timeMin.withTimeZone("UTC").toInstant().toString(), - timeMax: timeMax.withTimeZone("UTC").toInstant().toString(), - singleEvents: true, - orderBy: "startTime", - maxResults: MAX_EVENTS_PER_CALENDAR, - }); - - const events: CalendarEvent[] = - items?.map((event) => - parseGoogleCalendarEvent({ - calendar, - event, - defaultTimeZone: timeZone ?? "UTC", - }), - ) ?? []; - - const instances = events.filter((e) => e.recurringEventId); - const masters = new Set([]); - - for (const instance of instances) { - masters.add(instance.recurringEventId!); - } - - if (masters.size === 0) { - return { events, recurringMasterEvents: [] }; - } - - const recurringMasterEvents = await Promise.all( - Array.from(masters).map((id) => this.event(calendar, id, timeZone)), - ); - - return { events, recurringMasterEvents }; - }); - } - - async recurringEvents( - calendar: Calendar, - recurringEventIds: string[], - timeZone?: string, - ): Promise { - return this.withErrorHandler("recurringEvents", async () => { - const map = new Set(recurringEventIds); - - if (map.size === 0) { - return []; - } - - return Promise.all( - Array.from(map).map((id) => this.event(calendar, id, timeZone)), - ); - }); - } - - async sync({ - calendar, - initialSyncToken, - timeZone, - }: CalendarProviderSyncOptions): Promise<{ - changes: CalendarEventSyncItem[]; - syncToken: string | undefined; - status: "incremental" | "full"; - }> { - const runSync = async (token: string | undefined) => { - let currentSyncToken = token; - let pageToken: string | undefined; - - const changes: CalendarEventSyncItem[] = []; - - do { - const { items, nextSyncToken, nextPageToken } = - await this.client.calendars.events.list(calendar.id, { - singleEvents: true, - showDeleted: true, - maxResults: MAX_EVENTS_PER_CALENDAR, - pageToken, - syncToken: currentSyncToken, - }); - - if (nextSyncToken) { - currentSyncToken = nextSyncToken; - } - - pageToken = nextPageToken; - - if (!items) { - continue; - } - - for (const event of items) { - if (event.status === "cancelled") { - changes.push({ - status: "deleted", - event: { - id: event.id!, - calendar: { - id: calendar.id, - provider: calendar.provider, - }, - }, - }); - continue; - } - - const parsedEvent = parseGoogleCalendarEvent({ - calendar, - event, - defaultTimeZone: timeZone, - }); - - changes.push({ - status: "updated", - event: parsedEvent, - }); - } - } while (pageToken); - - const instances = changes - .filter((e) => e.status !== "deleted" && e.event.recurringEventId) - .map(({ event }) => (event as CalendarEvent).recurringEventId!); - - const recurringEvents = await this.recurringEvents( - calendar, - instances, - timeZone, - ); - - changes.push( - ...recurringEvents.map((event) => ({ - status: "updated" as const, - event, - })), - ); - - return { - changes, - syncToken: currentSyncToken, - }; - }; - - return this.withErrorHandler("sync", async () => { - try { - const result = await runSync(initialSyncToken); - - return { ...result, status: "incremental" }; - } catch (error) { - if (!this.isFullSyncRequiredError(error)) { - throw error; - } - - const result = await runSync(undefined); - - // KNOWN ISSUE: holiday calendars always return a 410 error, https://issuetracker.google.com/issues/372283558 - // Assume if the new sync token is equal to the initial sync token, content hasn't changed - if (initialSyncToken === result.syncToken) { - return { - changes: [], - syncToken: initialSyncToken, - status: "incremental", - }; - } - - return { ...result, status: "full" }; - } - }); - } - - async event( - calendar: Calendar, - eventId: string, - timeZone?: string, - ): Promise { - return this.withErrorHandler("event", async () => { - const event = await this.client.calendars.events.retrieve(eventId, { - calendarId: calendar.id, - }); - - return parseGoogleCalendarEvent({ - calendar, - event, - defaultTimeZone: timeZone ?? "UTC", - }); - }); - } - - async createEvent( - calendar: Calendar, - event: CreateEventInput, - ): Promise { - return this.withErrorHandler("createEvent", async () => { - try { - const createdEvent = await this.client.calendars.events.create( - calendar.id, - createEventParams(event), - ); - - return parseGoogleCalendarEvent({ - calendar, - event: createdEvent, - }); - } catch (error) { - // If the event already exists, update it instead of throwing an error - if (error instanceof ConflictError) { - return await this.updateEvent(calendar, event.id, event); - } - - throw error; - } - }); - } - - async updateEvent( - calendar: Calendar, - eventId: string, - event: UpdateEventInput, - ): Promise { - return this.withErrorHandler("updateEvent", async () => { - const existingEvent = await this.client.calendars.events.retrieve( - eventId, - { - calendarId: calendar.id, - }, - ); - - let eventToUpdate = { - ...existingEvent, - ...updateEventParams(event), - }; - - // Handle response status update within the same call for Google Calendar - if (event.response && event.response.status !== "unknown") { - if (!existingEvent.attendees) { - throw new Error("Event has no attendees"); - } - - const selfIndex = existingEvent.attendees.findIndex( - (attendee) => attendee.self, - ); - - if (selfIndex === -1) { - throw new Error("User is not an attendee"); - } - - const updatedAttendees = [...existingEvent.attendees]; - updatedAttendees[selfIndex] = { - ...updatedAttendees[selfIndex], - responseStatus: toGoogleCalendarAttendeeResponseStatus( - event.response.status, - ), - }; - - eventToUpdate = { - ...eventToUpdate, - attendees: updatedAttendees, - sendUpdates: event.response.sendUpdate ? "all" : "none", - }; - } - - const updatedEvent = await this.client.calendars.events.update( - eventId, - eventToUpdate, - // TODO: Handle conflicts gracefully - // event.etag ? { headers: { "If-Match": event.etag } } : undefined, - ); - - return parseGoogleCalendarEvent({ - calendar, - event: updatedEvent, - }); - }); - } - - async deleteEvent( - calendarId: string, - eventId: string, - sendUpdate: boolean, - ): Promise { - return this.withErrorHandler("deleteEvent", async () => { - await this.client.calendars.events.delete(eventId, { - calendarId, - sendUpdates: sendUpdate ? "all" : "none", - }); - }); - } - - async moveEvent( - sourceCalendar: Calendar, - destinationCalendar: Calendar, - eventId: string, - sendUpdate: boolean = true, - ): Promise { - return this.withErrorHandler("moveEvent", async () => { - const moved = await this.client.calendars.events.move(eventId, { - calendarId: sourceCalendar.id, - destination: destinationCalendar.id, - sendUpdates: sendUpdate ? "all" : "none", - }); - - return parseGoogleCalendarEvent({ - calendar: destinationCalendar, - event: moved, - }); - }); - } - - async acceptEvent(calendarId: string, eventId: string): Promise { - return this.withErrorHandler("acceptEvent", async () => { - const event = await this.client.calendars.events.retrieve(eventId, { - calendarId, - }); - - const attendees = event.attendees ?? []; - const selfIndex = attendees.findIndex((a) => a.self); - - if (selfIndex >= 0) { - attendees[selfIndex] = { - ...attendees[selfIndex], - responseStatus: "accepted", - }; - } else { - attendees.push({ self: true, responseStatus: "accepted" }); - } - - await this.client.calendars.events.update(eventId, { - ...event, - calendarId, - attendees, - sendUpdates: "all", - }); - }); - } - - async responseToEvent( - calendarId: string, - eventId: string, - response: ResponseToEventInput, - ): Promise { - return this.withErrorHandler("responseToEvent", async () => { - if (response.status === "unknown") { - return; - } - - const event = await this.client.calendars.events.retrieve(eventId, { - calendarId, - }); - - if (!event.attendees) { - throw new Error("Event has no attendees"); - } - - const selfIndex = event.attendees.findIndex((attendee) => attendee.self); - - if (selfIndex === -1) { - throw new Error("User is not an attendee"); - } - - event.attendees[selfIndex] = { - ...event.attendees[selfIndex], - responseStatus: toGoogleCalendarAttendeeResponseStatus(response.status), - }; - - await this.client.calendars.events.update(eventId, { - ...event, - calendarId, - sendUpdates: response.sendUpdate ? "all" : "none", - }); - }); - } - - async freeBusy( - schedules: string[], - timeMin: Temporal.ZonedDateTime, - timeMax: Temporal.ZonedDateTime, - ): Promise { - return this.withErrorHandler("freeBusy", async () => { - const response = await this.client.checkFreeBusy.checkFreeBusy({ - timeMin: timeMin.withTimeZone("UTC").toInstant().toString(), - timeMax: timeMax.withTimeZone("UTC").toInstant().toString(), - timeZone: "UTC", - items: schedules.map((id) => ({ id })), - }); - - return parseGoogleCalendarFreeBusy(response); - }); - } - - private async withErrorHandler( - operation: string, - fn: () => Promise | T, - context?: Record, - ): Promise { - try { - return await Promise.resolve(fn()); - } catch (error: unknown) { - console.error(`Failed to ${operation}:`, error); - - throw new ProviderError(error as Error, operation, context); - } - } - - private isFullSyncRequiredError(error: unknown): boolean { - if (!(error instanceof APIError)) { - return false; - } - - if (error.status === 410) { - return true; - } - - const details = - (error.error as { errors?: Array<{ reason?: string }> })?.errors ?? []; - - return details.some(({ reason }) => reason === "fullSyncRequired"); + this.calendars = new GoogleCalendarCalendars( + this.client, + providerAccountId, + ); + this.events = new GoogleCalendarEvents(this.client); + this.freeBusy = new GoogleCalendarFreeBusy(this.client); + this.notifications = new GoogleCalendarNotifications(this.client); } } diff --git a/packages/providers/src/calendars/google-calendar/calendars/index.ts b/packages/providers/src/calendars/google-calendar/calendars/index.ts new file mode 100644 index 00000000..606fe17b --- /dev/null +++ b/packages/providers/src/calendars/google-calendar/calendars/index.ts @@ -0,0 +1,104 @@ +import type { GoogleCalendar } from "@repo/google-calendar"; + +import type { Calendar } from "../../../interfaces"; +import type { + CalendarProviderCalendarsCreateOptions, + CalendarProviderCalendarsDeleteOptions, + CalendarProviderCalendarsGetOptions, + CalendarProviderCalendarsUpdateOptions, +} from "../../../interfaces/providers"; +import { ProviderError } from "../../../lib/provider-error"; +import { parseGoogleCalendarCalendarListEntry } from "./utils"; + +export class GoogleCalendarCalendars { + constructor( + private readonly client: GoogleCalendar, + private readonly providerAccountId: string, + ) {} + + async list(): Promise { + return this.withErrorHandler("calendars.list", async () => { + const { items } = await this.client.users.me.calendarList.list(); + + if (!items) { + return []; + } + + return items.map((calendar) => + parseGoogleCalendarCalendarListEntry({ + providerAccountId: this.providerAccountId, + entry: calendar, + }), + ); + }); + } + + async get({ + calendarId, + }: CalendarProviderCalendarsGetOptions): Promise { + return this.withErrorHandler("calendars.get", async () => { + const calendar = + await this.client.users.me.calendarList.retrieve(calendarId); + + return parseGoogleCalendarCalendarListEntry({ + providerAccountId: this.providerAccountId, + entry: calendar, + }); + }); + } + + async create({ + calendar, + }: CalendarProviderCalendarsCreateOptions): Promise { + return this.withErrorHandler("calendars.create", async () => { + const createdCalendar = await this.client.calendars.create({ + summary: calendar.name, + description: calendar.description, + timeZone: calendar.timeZone, + }); + + return parseGoogleCalendarCalendarListEntry({ + providerAccountId: this.providerAccountId, + entry: createdCalendar, + }); + }); + } + + async update({ + calendarId, + calendar, + }: CalendarProviderCalendarsUpdateOptions): Promise { + return this.withErrorHandler("calendars.update", async () => { + const updatedCalendar = await this.client.calendars.update(calendarId, { + summary: calendar.name, + }); + + return parseGoogleCalendarCalendarListEntry({ + providerAccountId: this.providerAccountId, + entry: updatedCalendar, + }); + }); + } + + async delete({ + calendarId, + }: CalendarProviderCalendarsDeleteOptions): Promise { + return this.withErrorHandler("calendars.delete", async () => { + await this.client.calendars.delete(calendarId); + }); + } + + private async withErrorHandler( + operation: string, + fn: () => Promise | T, + context?: Record, + ): Promise { + try { + return await Promise.resolve(fn()); + } catch (error: unknown) { + console.error(`Failed to ${operation}:`, error); + + throw new ProviderError(error as Error, operation, context); + } + } +} diff --git a/packages/providers/src/calendars/google-calendar/calendars.ts b/packages/providers/src/calendars/google-calendar/calendars/utils.ts similarity index 85% rename from packages/providers/src/calendars/google-calendar/calendars.ts rename to packages/providers/src/calendars/google-calendar/calendars/utils.ts index 985bfc79..bc4b76b2 100644 --- a/packages/providers/src/calendars/google-calendar/calendars.ts +++ b/packages/providers/src/calendars/google-calendar/calendars/utils.ts @@ -1,5 +1,5 @@ -import type { Calendar } from "../../interfaces"; -import type { GoogleCalendarCalendarListEntry } from "./interfaces"; +import type { Calendar } from "../../../interfaces"; +import type { GoogleCalendarCalendarListEntry } from "../interfaces"; interface ParsedGoogleCalendarCalendarListEntryOptions { providerAccountId: string; diff --git a/packages/providers/src/calendars/google-calendar/conferences.ts b/packages/providers/src/calendars/google-calendar/events/conferences/utils.ts similarity index 96% rename from packages/providers/src/calendars/google-calendar/conferences.ts rename to packages/providers/src/calendars/google-calendar/events/conferences/utils.ts index 5294f8b8..ba841500 100644 --- a/packages/providers/src/calendars/google-calendar/conferences.ts +++ b/packages/providers/src/calendars/google-calendar/events/conferences/utils.ts @@ -1,15 +1,15 @@ import { detectMeetingLink } from "@analog/meeting-links"; -import type { Conference } from "../../interfaces"; +import type { Conference } from "../../../../interfaces"; import type { GoogleCalendarEvent, GoogleCalendarEventConferenceData, -} from "./interfaces"; +} from "../../interfaces"; -function extractUrls(text: string): string[] { +function extractUrls(text: string) { const urlRegex = /https?:\/\/[^\s<>"'{}|\\^`[\]]+/gi; - return text.match(urlRegex) || []; + return text.match(urlRegex) ?? []; } function parseMeetingLink(url: string): Conference | undefined { diff --git a/packages/providers/src/calendars/google-calendar/events/index.ts b/packages/providers/src/calendars/google-calendar/events/index.ts new file mode 100644 index 00000000..1eed0b5a --- /dev/null +++ b/packages/providers/src/calendars/google-calendar/events/index.ts @@ -0,0 +1,446 @@ +import { APIError, ConflictError, GoogleCalendar } from "@repo/google-calendar"; + +import type { + Calendar, + CalendarEvent, + CalendarEventSyncItem, +} from "../../../interfaces"; +import type { + CalendarProviderEvents, + CalendarProviderEventsAcceptOptions, + CalendarProviderEventsCreateOptions, + CalendarProviderEventsDeleteOptions, + CalendarProviderEventsGetOptions, + CalendarProviderEventsListOptions, + CalendarProviderEventsMoveOptions, + CalendarProviderEventsRespondOptions, + CalendarProviderEventsUpdateOptions, + CalendarProviderSyncOptions, + CalendarProviderSyncResult, +} from "../../../interfaces/providers"; +import { ProviderError } from "../../../lib/provider-error"; +import { + createEventParams, + parseGoogleCalendarEvent, + toGoogleCalendarAttendeeResponseStatus, + updateEventParams, +} from "./utils"; + +const MAX_EVENTS_PER_CALENDAR = 250; + +export class GoogleCalendarEvents implements CalendarProviderEvents { + constructor(private readonly client: GoogleCalendar) {} + + async list({ + calendar, + timeMin, + timeMax, + timeZone, + }: CalendarProviderEventsListOptions) { + return this.withErrorHandler("events.list", async () => { + const { items } = await this.client.calendars.events.list(calendar.id, { + timeMin: timeMin.withTimeZone("UTC").toInstant().toString(), + timeMax: timeMax.withTimeZone("UTC").toInstant().toString(), + singleEvents: true, + orderBy: "startTime", + maxResults: MAX_EVENTS_PER_CALENDAR, + }); + + const events: CalendarEvent[] = + items?.map((event) => + parseGoogleCalendarEvent({ + calendar, + event, + defaultTimeZone: timeZone ?? "UTC", + }), + ) ?? []; + + const instances = events.filter((e) => e.recurringEventId); + const masters = new Set([]); + + for (const instance of instances) { + masters.add(instance.recurringEventId!); + } + + if (masters.size === 0) { + return { events, recurringMasterEvents: [] }; + } + + const recurringMasterEvents = await Promise.all( + Array.from(masters).map((eventId) => + this.get({ calendar, eventId, timeZone }), + ), + ); + + return { events, recurringMasterEvents }; + }); + } + + async sync({ + calendar, + initialSyncToken, + timeZone, + }: CalendarProviderSyncOptions): Promise { + const runSync = async (token: string | undefined) => { + let currentSyncToken = token; + let pageToken: string | undefined; + + const changes: CalendarEventSyncItem[] = []; + + do { + const { items, nextSyncToken, nextPageToken } = + await this.client.calendars.events.list(calendar.id, { + singleEvents: true, + showDeleted: true, + maxResults: MAX_EVENTS_PER_CALENDAR, + pageToken, + syncToken: currentSyncToken, + }); + + if (nextSyncToken) { + currentSyncToken = nextSyncToken; + } + + pageToken = nextPageToken; + + if (!items) { + continue; + } + + for (const event of items) { + if (event.status === "cancelled") { + changes.push({ + status: "deleted", + event: { + id: event.id!, + calendar: { + id: calendar.id, + provider: calendar.provider, + }, + }, + }); + continue; + } + + changes.push({ + status: "updated", + event: parseGoogleCalendarEvent({ + calendar, + event, + defaultTimeZone: timeZone, + }), + }); + } + } while (pageToken); + + const recurringEventIds = changes.flatMap((change) => { + if (change.status === "deleted") { + return []; + } + + if (!change.event.recurringEventId) { + return []; + } + + return [change.event.recurringEventId]; + }); + + const recurringEvents = await this.recurringEvents( + calendar, + recurringEventIds, + timeZone, + ); + + const recurringChanges: CalendarEventSyncItem[] = recurringEvents.map( + (event) => ({ + status: "updated", + event, + }), + ); + + changes.push(...recurringChanges); + + return { + changes, + syncToken: currentSyncToken, + }; + }; + + return this.withErrorHandler("events.sync", async () => { + try { + const result = await runSync(initialSyncToken); + + return { ...result, status: "incremental" }; + } catch (error) { + if (!this.isFullSyncRequiredError(error)) { + throw error; + } + + const result = await runSync(undefined); + + // KNOWN ISSUE: holiday calendars always return a 410 error, https://issuetracker.google.com/issues/372283558 + // Assume if the new sync token is equal to the initial sync token, content hasn't changed + if (initialSyncToken === result.syncToken) { + return { + changes: [], + syncToken: initialSyncToken, + status: "incremental", + }; + } + + return { ...result, status: "full" }; + } + }); + } + + async get({ calendar, eventId, timeZone }: CalendarProviderEventsGetOptions) { + return this.withErrorHandler("events.get", async () => { + const event = await this.client.calendars.events.retrieve(eventId, { + calendarId: calendar.id, + }); + + return parseGoogleCalendarEvent({ + calendar, + event, + defaultTimeZone: timeZone ?? "UTC", + }); + }); + } + + async create({ calendar, event }: CalendarProviderEventsCreateOptions) { + return this.withErrorHandler("events.create", async () => { + try { + const createdEvent = await this.client.calendars.events.create( + calendar.id, + createEventParams(event), + ); + + return parseGoogleCalendarEvent({ + calendar, + event: createdEvent, + }); + } catch (error) { + // If the event already exists, update it instead of throwing an error + if (error instanceof ConflictError) { + return await this.update({ + calendar, + eventId: event.id, + event, + }); + } + + throw error; + } + }); + } + + async update({ + calendar, + eventId, + event, + }: CalendarProviderEventsUpdateOptions) { + return this.withErrorHandler("events.update", async () => { + const existingEvent = await this.client.calendars.events.retrieve( + eventId, + { + calendarId: calendar.id, + }, + ); + + let eventToUpdate = { + ...existingEvent, + ...updateEventParams(event), + }; + + // Handle response status update within the same call for Google Calendar + if (event.response && event.response.status !== "unknown") { + if (!existingEvent.attendees) { + throw new Error("Event has no attendees"); + } + + const selfIndex = existingEvent.attendees.findIndex( + (attendee) => attendee.self, + ); + + if (selfIndex === -1) { + throw new Error("User is not an attendee"); + } + + const updatedAttendees = [...existingEvent.attendees]; + updatedAttendees[selfIndex] = { + ...updatedAttendees[selfIndex], + responseStatus: toGoogleCalendarAttendeeResponseStatus( + event.response.status, + ), + }; + + eventToUpdate = { + ...eventToUpdate, + attendees: updatedAttendees, + sendUpdates: event.response.sendUpdate ? "all" : "none", + }; + } + + const updatedEvent = await this.client.calendars.events.update( + eventId, + eventToUpdate, + // TODO: Handle conflicts gracefully + // event.etag ? { headers: { "If-Match": event.etag } } : undefined, + ); + + return parseGoogleCalendarEvent({ + calendar, + event: updatedEvent, + }); + }); + } + + async delete({ + calendarId, + eventId, + sendUpdate, + }: CalendarProviderEventsDeleteOptions) { + return this.withErrorHandler("events.delete", async () => { + await this.client.calendars.events.delete(eventId, { + calendarId, + sendUpdates: sendUpdate ? "all" : "none", + }); + }); + } + + async acceptEvent({ + calendarId, + eventId, + }: CalendarProviderEventsAcceptOptions) { + return this.withErrorHandler("acceptEvent", async () => { + const event = await this.client.calendars.events.retrieve(eventId, { + calendarId, + }); + + const attendees = event.attendees ?? []; + const selfIndex = attendees.findIndex((a) => a.self); + + if (selfIndex >= 0) { + attendees[selfIndex] = { + ...attendees[selfIndex], + responseStatus: "accepted", + }; + } else { + attendees.push({ self: true, responseStatus: "accepted" }); + } + + await this.client.calendars.events.update(eventId, { + ...event, + calendarId, + attendees, + sendUpdates: "all", + }); + }); + } + + async move({ + sourceCalendar, + destinationCalendar, + eventId, + sendUpdate = true, + }: CalendarProviderEventsMoveOptions) { + return this.withErrorHandler("events.move", async () => { + const moved = await this.client.calendars.events.move(eventId, { + calendarId: sourceCalendar.id, + destination: destinationCalendar.id, + sendUpdates: sendUpdate ? "all" : "none", + }); + + return parseGoogleCalendarEvent({ + calendar: destinationCalendar, + event: moved, + }); + }); + } + + async respond({ + calendarId, + eventId, + response, + }: CalendarProviderEventsRespondOptions) { + return this.withErrorHandler("events.respond", async () => { + if (response.status === "unknown") { + return; + } + + const event = await this.client.calendars.events.retrieve(eventId, { + calendarId, + }); + + if (!event.attendees) { + throw new Error("Event has no attendees"); + } + + const selfIndex = event.attendees.findIndex((attendee) => attendee.self); + + if (selfIndex === -1) { + throw new Error("User is not an attendee"); + } + + event.attendees[selfIndex] = { + ...event.attendees[selfIndex], + responseStatus: toGoogleCalendarAttendeeResponseStatus(response.status), + }; + + await this.client.calendars.events.update(eventId, { + ...event, + calendarId, + sendUpdates: response.sendUpdate ? "all" : "none", + }); + }); + } + + private async recurringEvents( + calendar: Calendar, + recurringEventIds: string[], + timeZone?: string, + ) { + return this.withErrorHandler("events.recurring", async () => { + const eventIds = new Set(recurringEventIds); + + if (eventIds.size === 0) { + return []; + } + + return Promise.all( + Array.from(eventIds).map((eventId) => + this.get({ calendar, eventId, timeZone }), + ), + ); + }); + } + + private async withErrorHandler( + operation: string, + fn: () => Promise | T, + context?: Record, + ): Promise { + try { + return await Promise.resolve(fn()); + } catch (error: unknown) { + console.error(`Failed to ${operation}:`, error); + + throw new ProviderError(error as Error, operation, context); + } + } + + private isFullSyncRequiredError(error: unknown): boolean { + if (!(error instanceof APIError)) { + return false; + } + + if (error.status === 410) { + return true; + } + + const details = + (error.error as { errors?: Array<{ reason?: string }> })?.errors ?? []; + + return details.some(({ reason }) => reason === "fullSyncRequired"); + } +} diff --git a/packages/providers/src/calendars/google-calendar/events.ts b/packages/providers/src/calendars/google-calendar/events/utils.ts similarity index 97% rename from packages/providers/src/calendars/google-calendar/events.ts rename to packages/providers/src/calendars/google-calendar/events/utils.ts index bb670f44..aa64abc4 100644 --- a/packages/providers/src/calendars/google-calendar/events.ts +++ b/packages/providers/src/calendars/google-calendar/events/utils.ts @@ -8,10 +8,9 @@ import type { Calendar, CalendarEvent, Recurrence, -} from "../../interfaces"; -import { toRecurrenceProperties } from "../../lib/recurrences/export"; -import { parseTextRecurrence } from "../../lib/recurrences/parse"; -import { parseConferenceData, toConferenceData } from "./conferences"; +} from "../../../interfaces"; +import { toRecurrenceProperties } from "../../../lib/recurrences/export"; +import { parseTextRecurrence } from "../../../lib/recurrences/parse"; import type { GoogleCalendarDate, GoogleCalendarDateTime, @@ -20,7 +19,8 @@ import type { GoogleCalendarEventAttendeeResponseStatus, GoogleCalendarEventCreateParams, GoogleCalendarEventUpdateParams, -} from "./interfaces"; +} from "../interfaces"; +import { parseConferenceData, toConferenceData } from "./conferences/utils"; export function toGoogleCalendarDate( value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, diff --git a/packages/providers/src/calendars/google-calendar/freebusy/index.ts b/packages/providers/src/calendars/google-calendar/freebusy/index.ts new file mode 100644 index 00000000..559351c7 --- /dev/null +++ b/packages/providers/src/calendars/google-calendar/freebusy/index.ts @@ -0,0 +1,43 @@ +import type { GoogleCalendar } from "@repo/google-calendar"; + +import type { + CalendarProviderFreeBusy, + CalendarProviderFreeBusyQueryOptions, +} from "../../../interfaces/providers"; +import { ProviderError } from "../../../lib/provider-error"; +import { parseGoogleCalendarFreeBusy } from "./utils"; + +export class GoogleCalendarFreeBusy implements CalendarProviderFreeBusy { + constructor(private readonly client: GoogleCalendar) {} + + async query({ + schedules, + timeMin, + timeMax, + }: CalendarProviderFreeBusyQueryOptions) { + return this.withErrorHandler("freeBusy.query", async () => { + const response = await this.client.checkFreeBusy.checkFreeBusy({ + timeMin: timeMin.withTimeZone("UTC").toInstant().toString(), + timeMax: timeMax.withTimeZone("UTC").toInstant().toString(), + timeZone: "UTC", + items: schedules.map((id) => ({ id })), + }); + + return parseGoogleCalendarFreeBusy(response); + }); + } + + private async withErrorHandler( + operation: string, + fn: () => Promise | T, + context?: Record, + ): Promise { + try { + return await Promise.resolve(fn()); + } catch (error: unknown) { + console.error(`Failed to ${operation}:`, error); + + throw new ProviderError(error as Error, operation, context); + } + } +} diff --git a/packages/providers/src/calendars/google-calendar/freebusy.ts b/packages/providers/src/calendars/google-calendar/freebusy/utils.ts similarity index 92% rename from packages/providers/src/calendars/google-calendar/freebusy.ts rename to packages/providers/src/calendars/google-calendar/freebusy/utils.ts index 0df56146..0ee6e941 100644 --- a/packages/providers/src/calendars/google-calendar/freebusy.ts +++ b/packages/providers/src/calendars/google-calendar/freebusy/utils.ts @@ -1,10 +1,10 @@ import { Temporal } from "temporal-polyfill"; -import type { CalendarFreeBusy, FreeBusySlot } from "../../interfaces"; +import type { CalendarFreeBusy, FreeBusySlot } from "../../../interfaces"; import type { GoogleCalendarFreeBusyResponse, GoogleCalendarFreeBusyResponseCalendars, -} from "./interfaces"; +} from "../interfaces"; function parseGoogleCalendarFreeBusySlot( calendar: GoogleCalendarFreeBusyResponseCalendars, diff --git a/packages/providers/src/calendars/google-calendar/notifications/index.ts b/packages/providers/src/calendars/google-calendar/notifications/index.ts new file mode 100644 index 00000000..5cda00e3 --- /dev/null +++ b/packages/providers/src/calendars/google-calendar/notifications/index.ts @@ -0,0 +1,57 @@ +import { Temporal } from "temporal-polyfill"; + +import type { GoogleCalendar } from "@repo/google-calendar"; + +import { ProviderError } from "../../../lib/provider-error"; + +const DEFAULT_EXPIRATION_HOURS = 30 * 24; + +export class GoogleCalendarNotifications { + constructor(private readonly client: GoogleCalendar) {} + + async subscribe(calendarId: string, webhookUrl: string) { + return this.withErrorHandler("notifications.subscribe", async () => { + const response = await this.client.calendars.events.watch(calendarId, { + id: crypto.randomUUID(), + type: "web_hook", + address: webhookUrl, + token: crypto.randomUUID(), + expiration: Temporal.Now.instant() + .add({ hours: DEFAULT_EXPIRATION_HOURS }) + .epochMilliseconds.toString(), + }); + + return { + id: response.id!, + resourceId: response.resourceId!, + token: response.token!, + expiresAt: Temporal.Instant.fromEpochMilliseconds( + Number(response.expiration!), + ), + }; + }); + } + + async unsubscribe(subscriptionId: string, resourceId?: string) { + return this.withErrorHandler("notifications.unsubscribe", async () => { + await this.client.stopWatching.stopWatching({ + id: subscriptionId, + resourceId, + }); + }); + } + + private async withErrorHandler( + operation: string, + fn: () => Promise | T, + context?: Record, + ): Promise { + try { + return await Promise.resolve(fn()); + } catch (error: unknown) { + console.error(`Failed to ${operation}:`, error); + + throw new ProviderError(error as Error, operation, context); + } + } +} diff --git a/packages/providers/src/calendars/microsoft-calendar.ts b/packages/providers/src/calendars/microsoft-calendar.ts index 082e9085..06e822f0 100644 --- a/packages/providers/src/calendars/microsoft-calendar.ts +++ b/packages/providers/src/calendars/microsoft-calendar.ts @@ -1,45 +1,11 @@ import "server-only"; import { Client } from "@microsoft/microsoft-graph-client"; -import type { - Calendar as MicrosoftCalendar, - ScheduleInformation, -} from "@microsoft/microsoft-graph-types"; -import { Temporal } from "temporal-polyfill"; -import type { - CreateCalendarInput, - CreateEventInput, - UpdateCalendarInput, - UpdateEventInput, -} from "@repo/schemas"; - -import type { - Calendar, - CalendarEvent, - CalendarEventSyncItem, - CalendarFreeBusy, - CalendarProviderSyncOptions, -} from "../interfaces"; -import type { - CalendarProvider, - ResponseToEventInput, -} from "../interfaces/providers"; -import { ProviderError } from "../lib/provider-error"; -import { - calendarPath, - parseMicrosoftCalendar, -} from "./microsoft-calendar/calendars"; -import { - eventResponseStatusPath, - parseMicrosoftEvent, - toMicrosoftDate, - toMicrosoftEvent, -} from "./microsoft-calendar/events"; -import { parseScheduleItem } from "./microsoft-calendar/freebusy"; -import type { MicrosoftEvent } from "./microsoft-calendar/interfaces"; - -const MAX_EVENTS_PER_CALENDAR = 250; +import type { CalendarProvider } from "../interfaces/providers"; +import { MicrosoftCalendarCalendars } from "./microsoft-calendar/calendars"; +import { MicrosoftCalendarEvents } from "./microsoft-calendar/events"; +import { MicrosoftCalendarFreeBusy } from "./microsoft-calendar/freebusy"; interface MicrosoftCalendarProviderOptions { accessToken: string; @@ -50,6 +16,9 @@ export class MicrosoftCalendarProvider implements CalendarProvider { public readonly providerId = "microsoft" as const; public readonly providerAccountId: string; private graphClient: Client; + public readonly calendars: MicrosoftCalendarCalendars; + public readonly events: MicrosoftCalendarEvents; + public readonly freeBusy: MicrosoftCalendarFreeBusy; constructor({ accessToken, @@ -61,365 +30,11 @@ export class MicrosoftCalendarProvider implements CalendarProvider { getAccessToken: async () => accessToken, }, }); - } - - async calendars(): Promise { - return this.withErrorHandler("calendars", async () => { - // Microsoft Graph API does not work without $select due to a bug - const response = await this.graphClient - .api( - "/me/calendars?$select=id,name,isDefaultCalendar,canEdit,hexColor,isRemovable,owner,calendarPermissions", - ) - .get(); - - return (response.value as MicrosoftCalendar[]).map((calendar) => ({ - ...parseMicrosoftCalendar({ - calendar, - providerAccountId: this.providerAccountId, - }), - })); - }); - } - - async calendar(calendarId: string): Promise { - return this.withErrorHandler("calendar", async () => { - const calendar = (await this.graphClient - .api(calendarPath(calendarId)) - .select( - "id,name,isDefaultCalendar,canEdit,hexColor,owner,calendarPermissions", - ) - .get()) as MicrosoftCalendar; - - return parseMicrosoftCalendar({ - calendar, - providerAccountId: this.providerAccountId, - }); - }); - } - - async createCalendar(calendar: CreateCalendarInput): Promise { - return this.withErrorHandler("createCalendar", async () => { - const createdCalendar: MicrosoftCalendar = await this.graphClient - .api("/me/calendars") - .post({ - name: calendar.name, - }); - - return parseMicrosoftCalendar({ - calendar: createdCalendar, - providerAccountId: this.providerAccountId, - }); - }); - } - - async updateCalendar( - calendarId: string, - calendar: UpdateCalendarInput, - ): Promise { - return this.withErrorHandler("updateCalendar", async () => { - const updatedCalendar: MicrosoftCalendar = await this.graphClient - .api(calendarPath(calendarId)) - .patch(calendar); - - return parseMicrosoftCalendar({ - calendar: updatedCalendar, - providerAccountId: this.providerAccountId, - }); - }); - } - - async deleteCalendar(calendarId: string): Promise { - return this.withErrorHandler("deleteCalendar", async () => { - await this.graphClient.api(calendarPath(calendarId)).delete(); - }); - } - - async events( - calendar: Calendar, - timeMin: Temporal.ZonedDateTime, - timeMax: Temporal.ZonedDateTime, - timeZone: string, - ): Promise<{ - events: CalendarEvent[]; - recurringMasterEvents: CalendarEvent[]; - }> { - return this.withErrorHandler("events", async () => { - const startTime = timeMin.withTimeZone("UTC").toInstant().toString(); - const endTime = timeMax.withTimeZone("UTC").toInstant().toString(); - - const response = await this.graphClient - .api(`${calendarPath(calendar.id)}/events`) - .header("Prefer", `outlook.timezone="${timeZone}"`) - .filter( - `start/dateTime ge '${startTime}' and end/dateTime le '${endTime}'`, - ) - .orderby("start/dateTime") - .top(MAX_EVENTS_PER_CALENDAR) - .get(); - - const events = (response.value as MicrosoftEvent[]).map( - (event: MicrosoftEvent) => parseMicrosoftEvent({ event, calendar }), - ); - - return { events, recurringMasterEvents: [] }; - }); - } - - async sync({ - calendar, - initialSyncToken, - timeMin, - timeMax, - timeZone, - }: CalendarProviderSyncOptions): Promise<{ - changes: CalendarEventSyncItem[]; - syncToken: string | undefined; - status: "incremental" | "full"; - }> { - return this.withErrorHandler("sync", async () => { - const startTime = timeMin?.withTimeZone("UTC").toInstant().toString(); - const endTime = timeMax?.withTimeZone("UTC").toInstant().toString(); - - let syncToken: string | undefined; - let pageToken: string | undefined = undefined; - - const baseUrl = new URL( - `${calendarPath(calendar.id)}/calendarView/delta`, - ); - - if (startTime) { - baseUrl.searchParams.set("startDateTime", startTime); - } - - if (endTime) { - baseUrl.searchParams.set("endDateTime", endTime); - } - - const changes: CalendarEventSyncItem[] = []; - - do { - const url: string = pageToken ?? initialSyncToken ?? baseUrl.toString(); - - const response = await this.graphClient - .api(url) - .header("Prefer", `outlook.timezone="${timeZone}"`) - .orderby("start/dateTime") - .top(MAX_EVENTS_PER_CALENDAR) - .get(); - - // if (!initialSyncToken && !pageToken && startTime && endTime) { - // request.filter( - // `start/dateTime ge '${startTime}' and end/dateTime le '${endTime}'`, - // ); - // } - - for (const item of response.value as MicrosoftEvent[]) { - if (!item?.id) { - continue; - } - - if (item["@removed"]) { - changes.push({ - status: "deleted", - event: { - id: item.id, - calendar: { - id: calendar.id, - provider: calendar.provider, - }, - }, - }); - - continue; - } - - changes.push({ - status: "updated", - event: parseMicrosoftEvent({ - event: item, - calendar, - }), - }); - } - - pageToken = response["@odata.nextLink"]; - syncToken = response["@odata.deltaLink"]; - } while (pageToken); - - return { - changes, - syncToken, - status: "incremental", - }; - }); - } - - async event( - calendar: Calendar, - eventId: string, - timeZone: string, - ): Promise { - return this.withErrorHandler("event", async () => { - const event: MicrosoftEvent = await this.graphClient - .api(`${calendarPath(calendar.id)}/events/${eventId}`) - .header("Prefer", `outlook.timezone="${timeZone}"`) - .get(); - - return parseMicrosoftEvent({ - event, - calendar, - }); - }); - } - - async createEvent( - calendar: Calendar, - event: CreateEventInput, - ): Promise { - return this.withErrorHandler("createEvent", async () => { - const createdEvent: MicrosoftEvent = await this.graphClient - .api(`${calendarPath(calendar.id)}/events`) - .post(toMicrosoftEvent(event)); - - return parseMicrosoftEvent({ - event: createdEvent, - calendar, - }); - }); - } - - async updateEvent( - calendar: Calendar, - eventId: string, - event: UpdateEventInput, - ): Promise { - return this.withErrorHandler("updateEvent", async () => { - // First, perform the regular event update - const updatedEvent: MicrosoftEvent = await this.graphClient - .api(`${calendarPath(calendar.id)}/events/${eventId}`) - // TODO: Handle conflicts gracefully - // .headers({ - // ...(event.etag ? { "If-Match": event.etag } : {}), - // }) - .patch(toMicrosoftEvent(event)); - - // Then, handle response status update if present (Microsoft-specific approach) - if (event.response && event.response.status !== "unknown") { - await this.graphClient - .api( - `/me/events/${eventId}/${eventResponseStatusPath(event.response.status)}`, - ) - .post({ - comment: event.response.comment, - sendResponse: event.response.sendUpdate, - }); - } - - return parseMicrosoftEvent({ - event: updatedEvent, - calendar, - }); - }); - } - - /** - * Deletes an event from the calendar - * - * @param calendarId - The calendar identifier - * @param eventId - The event identifier - */ - async deleteEvent( - calendarId: string, - eventId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - sendUpdate: boolean = true, - ): Promise { - await this.withErrorHandler("deleteEvent", async () => { - await this.graphClient - .api(`${calendarPath(calendarId)}/events/${eventId}`) - .delete(); - }); - } - - async moveEvent( - sourceCalendar: Calendar, - destinationCalendar: Calendar, - eventId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - sendUpdate: boolean = true, - ): Promise { - return this.withErrorHandler("moveEvent", async () => { - // Placeholder: Microsoft Graph does not have a direct move endpoint. - // This could be implemented by creating a new event in destination and deleting the original, - // preserving fields as needed. - const event = await this.event(sourceCalendar, eventId, "UTC"); - - return { - ...event, - calendar: { - id: destinationCalendar.id, - provider: destinationCalendar.provider, - }, - readOnly: event.readOnly, - }; - }); - } - - async responseToEvent( - calendarId: string, - eventId: string, - response: ResponseToEventInput, - ): Promise { - await this.withErrorHandler("responseToEvent", async () => { - if (response.status === "unknown") { - return; - } - - await this.graphClient - .api( - `/me/events/${eventId}/${eventResponseStatusPath(response.status)}`, - ) - .post({ comment: response.comment, sendResponse: response.sendUpdate }); - }); - } - - async freeBusy( - schedules: string[], - timeMin: Temporal.ZonedDateTime, - timeMax: Temporal.ZonedDateTime, - ): Promise { - return this.withErrorHandler("getSchedule", async () => { - const body = { - schedules, - startTime: toMicrosoftDate({ value: timeMin }), - endTime: toMicrosoftDate({ value: timeMax }), - }; - - const response = await this.graphClient - .api("/me/calendar/getSchedule") - .post(body); - - // TODO: Handle errors - const data = response.value as ScheduleInformation[]; - - return data.map((info) => ({ - scheduleId: info.scheduleId as string, - busy: info.scheduleItems?.map(parseScheduleItem) ?? [], - })); - }); - } - - private async withErrorHandler( - operation: string, - fn: () => Promise | T, - context?: Record, - ): Promise { - try { - return await Promise.resolve(fn()); - } catch (error: unknown) { - console.error(`Failed to ${operation}:`, error); - - throw new ProviderError(error as Error, operation, context); - } + this.calendars = new MicrosoftCalendarCalendars( + this.graphClient, + providerAccountId, + ); + this.events = new MicrosoftCalendarEvents(this.graphClient); + this.freeBusy = new MicrosoftCalendarFreeBusy(this.graphClient); } } diff --git a/packages/providers/src/calendars/microsoft-calendar/calendars/index.ts b/packages/providers/src/calendars/microsoft-calendar/calendars/index.ts new file mode 100644 index 00000000..ae198cca --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/calendars/index.ts @@ -0,0 +1,110 @@ +import { Client } from "@microsoft/microsoft-graph-client"; +import type { Calendar as MicrosoftCalendar } from "@microsoft/microsoft-graph-types"; + +import type { Calendar } from "../../../interfaces"; +import type { + CalendarProviderCalendarsCreateOptions, + CalendarProviderCalendarsDeleteOptions, + CalendarProviderCalendarsGetOptions, + CalendarProviderCalendarsUpdateOptions, +} from "../../../interfaces/providers"; +import { ProviderError } from "../../../lib/provider-error"; +import { calendarPath } from "../utils"; +import { parseMicrosoftCalendar } from "./utils"; + +export class MicrosoftCalendarCalendars { + constructor( + private readonly graphClient: Client, + private readonly providerAccountId: string, + ) {} + + async list(): Promise { + return this.withErrorHandler("calendars.list", async () => { + const response: { value: MicrosoftCalendar[] } = await this.graphClient + .api( + "/me/calendars?$select=id,name,isDefaultCalendar,canEdit,hexColor,isRemovable,owner,calendarPermissions", + ) + .get(); + + return response.value.map((calendar) => + parseMicrosoftCalendar({ + calendar, + providerAccountId: this.providerAccountId, + }), + ); + }); + } + + async get({ + calendarId, + }: CalendarProviderCalendarsGetOptions): Promise { + return this.withErrorHandler("calendars.get", async () => { + const calendar: MicrosoftCalendar = await this.graphClient + .api(calendarPath(calendarId)) + .select( + "id,name,isDefaultCalendar,canEdit,hexColor,owner,calendarPermissions", + ) + .get(); + + return parseMicrosoftCalendar({ + calendar, + providerAccountId: this.providerAccountId, + }); + }); + } + + async create({ + calendar, + }: CalendarProviderCalendarsCreateOptions): Promise { + return this.withErrorHandler("calendars.create", async () => { + const createdCalendar: MicrosoftCalendar = await this.graphClient + .api("/me/calendars") + .post({ + name: calendar.name, + }); + + return parseMicrosoftCalendar({ + calendar: createdCalendar, + providerAccountId: this.providerAccountId, + }); + }); + } + + async update({ + calendarId, + calendar, + }: CalendarProviderCalendarsUpdateOptions): Promise { + return this.withErrorHandler("calendars.update", async () => { + const updatedCalendar: MicrosoftCalendar = await this.graphClient + .api(calendarPath(calendarId)) + .patch(calendar); + + return parseMicrosoftCalendar({ + calendar: updatedCalendar, + providerAccountId: this.providerAccountId, + }); + }); + } + + async delete({ + calendarId, + }: CalendarProviderCalendarsDeleteOptions): Promise { + return this.withErrorHandler("calendars.delete", async () => { + await this.graphClient.api(calendarPath(calendarId)).delete(); + }); + } + + private async withErrorHandler( + operation: string, + fn: () => Promise | T, + context?: Record, + ): Promise { + try { + return await Promise.resolve(fn()); + } catch (error: unknown) { + console.error(`Failed to ${operation}:`, error); + + throw new ProviderError(error as Error, operation, context); + } + } +} diff --git a/packages/providers/src/calendars/microsoft-calendar/calendars.ts b/packages/providers/src/calendars/microsoft-calendar/calendars/utils.ts similarity index 75% rename from packages/providers/src/calendars/microsoft-calendar/calendars.ts rename to packages/providers/src/calendars/microsoft-calendar/calendars/utils.ts index d6610ef8..795193e9 100644 --- a/packages/providers/src/calendars/microsoft-calendar/calendars.ts +++ b/packages/providers/src/calendars/microsoft-calendar/calendars/utils.ts @@ -1,6 +1,6 @@ import type { Calendar as MicrosoftCalendar } from "@microsoft/microsoft-graph-types"; -import type { Calendar } from "../../interfaces"; +import type { Calendar } from "../../../interfaces"; interface ParseMicrosoftCalendarOptions { providerAccountId: string; @@ -24,9 +24,3 @@ export function parseMicrosoftCalendar({ syncToken: null, }; } - -export function calendarPath(calendarId: string) { - return calendarId === "primary" - ? "/me/calendar" - : `/me/calendars/${calendarId}`; -} diff --git a/packages/providers/src/calendars/microsoft-calendar/conferences.ts b/packages/providers/src/calendars/microsoft-calendar/events/conferences/utils.ts similarity index 97% rename from packages/providers/src/calendars/microsoft-calendar/conferences.ts rename to packages/providers/src/calendars/microsoft-calendar/events/conferences/utils.ts index f6b08c11..bc7613eb 100644 --- a/packages/providers/src/calendars/microsoft-calendar/conferences.ts +++ b/packages/providers/src/calendars/microsoft-calendar/events/conferences/utils.ts @@ -1,7 +1,7 @@ import { detectMeetingLink } from "@analog/meeting-links"; import type { Event as MicrosoftEvent } from "@microsoft/microsoft-graph-types"; -import type { Conference } from "../../interfaces"; +import type { Conference } from "../../../../interfaces"; export function toMicrosoftConferenceData(conference: Conference) { if (conference.type !== "create") { diff --git a/packages/providers/src/calendars/microsoft-calendar/events/index.ts b/packages/providers/src/calendars/microsoft-calendar/events/index.ts new file mode 100644 index 00000000..dfd76ffd --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/events/index.ts @@ -0,0 +1,273 @@ +import { Client } from "@microsoft/microsoft-graph-client"; + +import type { CalendarEventSyncItem } from "../../../interfaces"; +import type { + CalendarProviderEvents, + CalendarProviderEventsAcceptOptions, + CalendarProviderEventsCreateOptions, + CalendarProviderEventsDeleteOptions, + CalendarProviderEventsGetOptions, + CalendarProviderEventsListOptions, + CalendarProviderEventsMoveOptions, + CalendarProviderEventsRespondOptions, + CalendarProviderEventsUpdateOptions, + CalendarProviderSyncOptions, + CalendarProviderSyncResult, +} from "../../../interfaces/providers"; +import { ProviderError } from "../../../lib/provider-error"; +import type { MicrosoftEvent } from "../interfaces"; +import { calendarPath } from "../utils"; +import { + eventResponseStatusPath, + parseMicrosoftEvent, + toMicrosoftEvent, +} from "./utils"; + +const MAX_EVENTS_PER_CALENDAR = 250; + +interface MicrosoftEventsResponse { + value: MicrosoftEvent[]; + "@odata.nextLink"?: string | undefined; + "@odata.deltaLink"?: string | undefined; +} + +export class MicrosoftCalendarEvents implements CalendarProviderEvents { + constructor(private readonly graphClient: Client) {} + + async list({ + calendar, + timeMin, + timeMax, + timeZone, + }: CalendarProviderEventsListOptions) { + return this.withErrorHandler("events.list", async () => { + const startTime = timeMin.withTimeZone("UTC").toInstant().toString(); + const endTime = timeMax.withTimeZone("UTC").toInstant().toString(); + + const response: MicrosoftEventsResponse = await this.graphClient + .api(`${calendarPath(calendar.id)}/events`) + .header("Prefer", `outlook.timezone="${timeZone ?? "UTC"}"`) + .filter( + `start/dateTime ge '${startTime}' and end/dateTime le '${endTime}'`, + ) + .orderby("start/dateTime") + .top(MAX_EVENTS_PER_CALENDAR) + .get(); + + const events = response.value.map((event) => + parseMicrosoftEvent({ event, calendar }), + ); + + return { events, recurringMasterEvents: [] }; + }); + } + + async sync({ + calendar, + initialSyncToken, + timeMin, + timeMax, + timeZone, + }: CalendarProviderSyncOptions): Promise { + return this.withErrorHandler("events.sync", async () => { + const startTime = timeMin?.withTimeZone("UTC").toInstant().toString(); + const endTime = timeMax?.withTimeZone("UTC").toInstant().toString(); + + let syncToken: string | undefined; + let pageToken: string | undefined; + + const baseUrl = new URL( + `${calendarPath(calendar.id)}/calendarView/delta`, + ); + + if (startTime) { + baseUrl.searchParams.set("startDateTime", startTime); + } + + if (endTime) { + baseUrl.searchParams.set("endDateTime", endTime); + } + + const changes: CalendarEventSyncItem[] = []; + + do { + const url = pageToken ?? initialSyncToken ?? baseUrl.toString(); + const response: MicrosoftEventsResponse = await this.graphClient + .api(url) + .header("Prefer", `outlook.timezone="${timeZone}"`) + .orderby("start/dateTime") + .top(MAX_EVENTS_PER_CALENDAR) + .get(); + + for (const item of response.value) { + if (!item?.id) { + continue; + } + + if (item["@removed"]) { + changes.push({ + status: "deleted", + event: { + id: item.id, + calendar: { + id: calendar.id, + provider: calendar.provider, + }, + }, + }); + + continue; + } + + changes.push({ + status: "updated", + event: parseMicrosoftEvent({ + event: item, + calendar, + }), + }); + } + + pageToken = response["@odata.nextLink"]; + syncToken = response["@odata.deltaLink"]; + } while (pageToken); + + return { + changes, + syncToken, + status: "incremental", + }; + }); + } + + async get({ calendar, eventId, timeZone }: CalendarProviderEventsGetOptions) { + return this.withErrorHandler("events.get", async () => { + const event: MicrosoftEvent = await this.graphClient + .api(`${calendarPath(calendar.id)}/events/${eventId}`) + .header("Prefer", `outlook.timezone="${timeZone ?? "UTC"}"`) + .get(); + + return parseMicrosoftEvent({ + event, + calendar, + }); + }); + } + + async create({ calendar, event }: CalendarProviderEventsCreateOptions) { + return this.withErrorHandler("events.create", async () => { + const createdEvent: MicrosoftEvent = await this.graphClient + .api(`${calendarPath(calendar.id)}/events`) + .post(toMicrosoftEvent(event)); + + return parseMicrosoftEvent({ + event: createdEvent, + calendar, + }); + }); + } + + async update({ + calendar, + eventId, + event, + }: CalendarProviderEventsUpdateOptions) { + return this.withErrorHandler("events.update", async () => { + // First, perform the regular event update + const updatedEvent: MicrosoftEvent = await this.graphClient + .api(`${calendarPath(calendar.id)}/events/${eventId}`) + // TODO: Handle conflicts gracefully + // .headers({ + // ...(event.etag ? { "If-Match": event.etag } : {}), + // }) + .patch(toMicrosoftEvent(event)); + + // Then, handle response status update if present (Microsoft-specific approach) + if (event.response && event.response.status !== "unknown") { + await this.graphClient + .api( + `/me/events/${eventId}/${eventResponseStatusPath(event.response.status)}`, + ) + .post({ + comment: event.response.comment, + sendResponse: event.response.sendUpdate, + }); + } + + return parseMicrosoftEvent({ + event: updatedEvent, + calendar, + }); + }); + } + + async delete({ calendarId, eventId }: CalendarProviderEventsDeleteOptions) { + await this.withErrorHandler("events.delete", async () => { + await this.graphClient + .api(`${calendarPath(calendarId)}/events/${eventId}`) + .delete(); + }); + } + + async acceptEvent({ eventId }: CalendarProviderEventsAcceptOptions) { + return this.withErrorHandler("acceptEvent", async () => { + await this.graphClient + .api(`/me/events/${eventId}/accept`) + .post({ sendResponse: true }); + }); + } + + async move({ + sourceCalendar, + destinationCalendar, + eventId, + }: CalendarProviderEventsMoveOptions) { + return this.withErrorHandler("events.move", async () => { + // Placeholder: Microsoft Graph does not have a direct move endpoint. + // This could be implemented by creating a new event in destination and deleting the original, + // preserving fields as needed. + const event = await this.get({ + calendar: sourceCalendar, + eventId, + timeZone: "UTC", + }); + + return { + ...event, + calendar: { + id: destinationCalendar.id, + provider: destinationCalendar.provider, + }, + readOnly: event.readOnly, + }; + }); + } + + async respond({ eventId, response }: CalendarProviderEventsRespondOptions) { + await this.withErrorHandler("events.respond", async () => { + if (response.status === "unknown") { + return; + } + + await this.graphClient + .api( + `/me/events/${eventId}/${eventResponseStatusPath(response.status)}`, + ) + .post({ comment: response.comment, sendResponse: response.sendUpdate }); + }); + } + + private async withErrorHandler( + operation: string, + fn: () => Promise | T, + context?: Record, + ): Promise { + try { + return await Promise.resolve(fn()); + } catch (error: unknown) { + console.error(`Failed to ${operation}:`, error); + + throw new ProviderError(error as Error, operation, context); + } + } +} diff --git a/packages/providers/src/calendars/microsoft-calendar/events.ts b/packages/providers/src/calendars/microsoft-calendar/events/utils.ts similarity index 84% rename from packages/providers/src/calendars/microsoft-calendar/events.ts rename to packages/providers/src/calendars/microsoft-calendar/events/utils.ts index 0d007eb3..96880f2b 100644 --- a/packages/providers/src/calendars/microsoft-calendar/events.ts +++ b/packages/providers/src/calendars/microsoft-calendar/events/utils.ts @@ -16,53 +16,12 @@ import type { AttendeeStatus, Calendar, CalendarEvent, -} from "../../interfaces"; +} from "../../../interfaces"; +import { parseDateTime, parseTimeZone, toMicrosoftDate } from "../utils"; import { parseMicrosoftConference, toMicrosoftConferenceData, -} from "./conferences"; -import { parseDateTime, parseTimeZone } from "./utils"; - -interface ToMicrosoftDateOptions { - value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime; - originalTimeZone?: { - raw: string; - parsed?: string; - }; -} - -export function toMicrosoftDate({ - value, - originalTimeZone, -}: ToMicrosoftDateOptions) { - if (value instanceof Temporal.PlainDate) { - return { - dateTime: value.toString(), - timeZone: originalTimeZone?.raw ?? "UTC", - }; - } - - // These events were created using another provider. - if (value instanceof Temporal.Instant) { - const dateTime = value - .toZonedDateTimeISO("UTC") - .toPlainDateTime() - .toString(); - - return { - dateTime, - timeZone: "UTC", - }; - } - - return { - dateTime: value.toInstant().toString(), - timeZone: - originalTimeZone?.parsed === value.timeZoneId - ? originalTimeZone?.raw - : value.timeZoneId, - }; -} +} from "./conferences/utils"; function parseDate(date: string) { return Temporal.PlainDate.from(date); diff --git a/packages/providers/src/calendars/microsoft-calendar/freebusy/index.ts b/packages/providers/src/calendars/microsoft-calendar/freebusy/index.ts new file mode 100644 index 00000000..dcc07b45 --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/freebusy/index.ts @@ -0,0 +1,58 @@ +import { Client } from "@microsoft/microsoft-graph-client"; +import type { ScheduleInformation } from "@microsoft/microsoft-graph-types"; + +import type { + CalendarProviderFreeBusy, + CalendarProviderFreeBusyQueryOptions, +} from "../../../interfaces/providers"; +import { ProviderError } from "../../../lib/provider-error"; +import { toMicrosoftDate } from "../utils"; +import { parseScheduleItem } from "./utils"; + +interface MicrosoftScheduleResponse { + value: ScheduleInformation[]; +} + +export class MicrosoftCalendarFreeBusy implements CalendarProviderFreeBusy { + constructor(private readonly graphClient: Client) {} + + async query({ + schedules, + timeMin, + timeMax, + }: CalendarProviderFreeBusyQueryOptions) { + return this.withErrorHandler("freeBusy.query", async () => { + const body = { + schedules, + startTime: toMicrosoftDate({ value: timeMin }), + endTime: toMicrosoftDate({ value: timeMax }), + }; + + const response: MicrosoftScheduleResponse = await this.graphClient + .api("/me/calendar/getSchedule") + .post(body); + + // TODO: Handle errors + const data = response.value; + + return data.map((info) => ({ + scheduleId: info.scheduleId!, + busy: info.scheduleItems?.map(parseScheduleItem) ?? [], + })); + }); + } + + private async withErrorHandler( + operation: string, + fn: () => Promise | T, + context?: Record, + ): Promise { + try { + return await Promise.resolve(fn()); + } catch (error: unknown) { + console.error(`Failed to ${operation}:`, error); + + throw new ProviderError(error as Error, operation, context); + } + } +} diff --git a/packages/providers/src/calendars/microsoft-calendar/freebusy.ts b/packages/providers/src/calendars/microsoft-calendar/freebusy/utils.ts similarity index 93% rename from packages/providers/src/calendars/microsoft-calendar/freebusy.ts rename to packages/providers/src/calendars/microsoft-calendar/freebusy/utils.ts index 1998ab40..fce7f06a 100644 --- a/packages/providers/src/calendars/microsoft-calendar/freebusy.ts +++ b/packages/providers/src/calendars/microsoft-calendar/freebusy/utils.ts @@ -1,6 +1,6 @@ import type { ScheduleItem } from "@microsoft/microsoft-graph-types"; -import { parseDateTime } from "./utils"; +import { parseDateTime } from "../utils"; export function parseScheduleItemStatus(status: ScheduleItem["status"]) { // TODO: Handle additional statuses diff --git a/packages/providers/src/calendars/microsoft-calendar/utils.ts b/packages/providers/src/calendars/microsoft-calendar/utils.ts index ab89eba1..6da687a3 100644 --- a/packages/providers/src/calendars/microsoft-calendar/utils.ts +++ b/packages/providers/src/calendars/microsoft-calendar/utils.ts @@ -28,3 +28,50 @@ export function parseDateTime(dateTime: string, timeZone: string) { parseTimeZone(timeZone) ?? "UTC", ); } + +export function calendarPath(calendarId: string) { + return calendarId === "primary" + ? "/me/calendar" + : `/me/calendars/${calendarId}`; +} + +interface ToMicrosoftDateOptions { + value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime; + originalTimeZone?: { + raw: string; + parsed?: string; + }; +} + +export function toMicrosoftDate({ + value, + originalTimeZone, +}: ToMicrosoftDateOptions) { + if (value instanceof Temporal.PlainDate) { + return { + dateTime: value.toString(), + timeZone: originalTimeZone?.raw ?? "UTC", + }; + } + + // These events were created using another provider. + if (value instanceof Temporal.Instant) { + const dateTime = value + .toZonedDateTimeISO("UTC") + .toPlainDateTime() + .toString(); + + return { + dateTime, + timeZone: "UTC", + }; + } + + return { + dateTime: value.toInstant().toString(), + timeZone: + originalTimeZone?.parsed === value.timeZoneId + ? originalTimeZone?.raw + : value.timeZoneId, + }; +} diff --git a/packages/providers/src/conferencing/google-meet.ts b/packages/providers/src/conferencing/google-meet.ts index 7a5de7ca..1ba4238b 100644 --- a/packages/providers/src/conferencing/google-meet.ts +++ b/packages/providers/src/conferencing/google-meet.ts @@ -1,7 +1,8 @@ -import GoogleCalendar from "@repo/google-calendar"; +import { GoogleCalendar } from "@repo/google-calendar"; -import { parseConferenceData } from "../calendars/google-calendar/conferences"; +import { parseConferenceData } from "../calendars/google-calendar/events/conferences/utils"; import type { Conference, ConferencingProvider } from "../interfaces"; +import type { ConferencingProviderCreateConferenceOptions } from "../interfaces/providers"; import { ProviderError } from "../lib/provider-error"; interface GoogleMeetProviderOptions { @@ -21,14 +22,10 @@ export class GoogleMeetProvider implements ConferencingProvider { }); } - async createConference( - agenda: string, - startTime: string, - endTime: string, - timeZone?: string, - calendarId?: string, - eventId?: string, - ): Promise { + async createConference({ + calendarId, + eventId, + }: ConferencingProviderCreateConferenceOptions): Promise { return this.withErrorHandler("createConferencing", async () => { if (!eventId || !calendarId) { throw new Error("Google Meet requires a calendarId and eventId"); diff --git a/packages/providers/src/conferencing/zoom.ts b/packages/providers/src/conferencing/zoom.ts index 27b4eded..0518da50 100644 --- a/packages/providers/src/conferencing/zoom.ts +++ b/packages/providers/src/conferencing/zoom.ts @@ -1,6 +1,7 @@ import { Temporal } from "temporal-polyfill"; import type { Conference, ConferencingProvider } from "../interfaces"; +import type { ConferencingProviderCreateConferenceOptions } from "../interfaces/providers"; import { ProviderError } from "../lib/provider-error"; interface ZoomProviderOptions { @@ -19,17 +20,15 @@ export class ZoomProvider implements ConferencingProvider { /** * Create a Zoom meeting and return the details in the generic `Conference` format. * - * The `calendarId` and `eventId` parameters are accepted to satisfy the - * `ConferencingProvider` interface, however Zoom does not need them so they - * are ignored. They are forwarded inside the `context` object that is passed - * to the error-handler for easier debugging. + * `calendarId` and `eventId` on the options object are accepted to satisfy the + * `ConferencingProvider` interface; Zoom does not use them. */ - async createConference( - agenda: string, - startTime: string, - endTime: string, + async createConference({ + agenda, + startTime, + endTime, timeZone = "UTC", - ): Promise { + }: ConferencingProviderCreateConferenceOptions): Promise { return this.withErrorHandler("createConferencing", async () => { // Default 60-minute duration let duration = 60; diff --git a/packages/providers/src/interfaces/providers/calendar.ts b/packages/providers/src/interfaces/providers/calendar.ts index 10d19f2b..04127e67 100644 --- a/packages/providers/src/interfaces/providers/calendar.ts +++ b/packages/providers/src/interfaces/providers/calendar.ts @@ -33,61 +33,111 @@ export interface CalendarProviderSyncResult { status: "incremental" | "full"; } -export interface CalendarProvider { - providerId: "google" | "microsoft"; - calendars(): Promise; - calendar(calendarId: string): Promise; - createCalendar(calendar: CreateCalendarInput): Promise; - updateCalendar( - calendarId: string, - calendar: Partial, - ): Promise; - deleteCalendar(calendarId: string): Promise; - events( - calendar: Calendar, - timeMin: Temporal.ZonedDateTime, - timeMax: Temporal.ZonedDateTime, - timeZone?: string, - ): Promise<{ +export interface CalendarProviderCalendarsGetOptions { + calendarId: string; +} + +export interface CalendarProviderCalendarsCreateOptions { + calendar: CreateCalendarInput; +} + +export interface CalendarProviderCalendarsUpdateOptions { + calendarId: string; + calendar: Partial; +} + +export interface CalendarProviderCalendarsDeleteOptions { + calendarId: string; +} + +export interface CalendarProviderCalendars { + list(): Promise; + get(options: CalendarProviderCalendarsGetOptions): Promise; + create(options: CalendarProviderCalendarsCreateOptions): Promise; + update(options: CalendarProviderCalendarsUpdateOptions): Promise; + delete(options: CalendarProviderCalendarsDeleteOptions): Promise; +} + +export interface CalendarProviderEventsListOptions { + calendar: Calendar; + timeMin: Temporal.ZonedDateTime; + timeMax: Temporal.ZonedDateTime; + timeZone?: string; +} + +export interface CalendarProviderEventsGetOptions { + calendar: Calendar; + eventId: string; + timeZone?: string; +} + +export interface CalendarProviderEventsCreateOptions { + calendar: Calendar; + event: CreateEventInput; +} + +export interface CalendarProviderEventsUpdateOptions { + calendar: Calendar; + eventId: string; + event: UpdateEventInput; +} + +export interface CalendarProviderEventsDeleteOptions { + calendarId: string; + eventId: string; + sendUpdate: boolean; +} + +export interface CalendarProviderEventsAcceptOptions { + calendarId: string; + eventId: string; +} + +export interface CalendarProviderEventsRespondOptions { + calendarId: string; + eventId: string; + response: ResponseToEventInput; +} + +export interface CalendarProviderEventsMoveOptions { + sourceCalendar: Calendar; + destinationCalendar: Calendar; + eventId: string; + sendUpdate?: boolean; +} + +export interface CalendarProviderEvents { + list(options: CalendarProviderEventsListOptions): Promise<{ events: CalendarEvent[]; recurringMasterEvents: CalendarEvent[]; }>; sync( options: CalendarProviderSyncOptions, ): Promise; - event( - calendar: Calendar, - eventId: string, - timeZone?: string, - ): Promise; - createEvent( - calendar: Calendar, - event: CreateEventInput, - ): Promise; - updateEvent( - calendar: Calendar, - eventId: string, - event: UpdateEventInput, - ): Promise; - deleteEvent( - calendarId: string, - eventId: string, - sendUpdate?: boolean, - ): Promise; - responseToEvent( - calendarId: string, - eventId: string, - response: ResponseToEventInput, - ): Promise; - moveEvent( - sourceCalendar: Calendar, - destinationCalendar: Calendar, - eventId: string, - sendUpdate?: boolean, - ): Promise; - freeBusy( - schedules: string[], - timeMin: Temporal.ZonedDateTime, - timeMax: Temporal.ZonedDateTime, + get(options: CalendarProviderEventsGetOptions): Promise; + create(options: CalendarProviderEventsCreateOptions): Promise; + update(options: CalendarProviderEventsUpdateOptions): Promise; + delete(options: CalendarProviderEventsDeleteOptions): Promise; + acceptEvent(options: CalendarProviderEventsAcceptOptions): Promise; + respond(options: CalendarProviderEventsRespondOptions): Promise; + move(options: CalendarProviderEventsMoveOptions): Promise; +} + +export interface CalendarProviderFreeBusyQueryOptions { + schedules: string[]; + timeMin: Temporal.ZonedDateTime; + timeMax: Temporal.ZonedDateTime; +} + +export interface CalendarProviderFreeBusy { + query( + options: CalendarProviderFreeBusyQueryOptions, ): Promise; } + +export interface CalendarProvider { + providerId: "google" | "microsoft"; + calendars: CalendarProviderCalendars; + events: CalendarProviderEvents; + freeBusy: CalendarProviderFreeBusy; +} diff --git a/packages/providers/src/interfaces/providers/conference.ts b/packages/providers/src/interfaces/providers/conference.ts index 584be8b8..53ad22f3 100644 --- a/packages/providers/src/interfaces/providers/conference.ts +++ b/packages/providers/src/interfaces/providers/conference.ts @@ -1,13 +1,17 @@ import type { Conference } from "../../interfaces"; +export interface ConferencingProviderCreateConferenceOptions { + agenda: string; + startTime: string; + endTime: string; + timeZone?: string; + calendarId?: string; + eventId?: string; +} + export interface ConferencingProvider { providerId: "zoom" | "google"; createConference( - agenda: string, - startTime: string, - endTime: string, - timeZone?: string, - calendarId?: string, - eventId?: string, + options: ConferencingProviderCreateConferenceOptions, ): Promise; } diff --git a/packages/providers/src/interfaces/providers/task.ts b/packages/providers/src/interfaces/providers/task.ts index 1ba758c5..c0c156c6 100644 --- a/packages/providers/src/interfaces/providers/task.ts +++ b/packages/providers/src/interfaces/providers/task.ts @@ -2,12 +2,37 @@ import type { CreateTaskInput, UpdateTaskInput } from "@repo/schemas"; import type { Task, TaskCollection, TaskCollectionWithTasks } from "../tasks"; +export interface TaskProviderTasksForTaskCollectionOptions { + taskCollectionId: string; +} + +export interface TaskProviderCollections { + list(): Promise; + tasks(options: TaskProviderTasksForTaskCollectionOptions): Promise; +} + +export interface TaskProviderCreateTaskOptions { + task: CreateTaskInput; +} + +export interface TaskProviderUpdateTaskOptions { + task: UpdateTaskInput; +} + +export interface TaskProviderDeleteTaskOptions { + taskCollectionId: string; + taskId: string; +} + +export interface TaskProviderTasks { + list(): Promise; + create(options: TaskProviderCreateTaskOptions): Promise; + update(options: TaskProviderUpdateTaskOptions): Promise; + delete(options: TaskProviderDeleteTaskOptions): Promise; +} + export interface TaskProvider { providerId: "google"; - tasks(): Promise; - taskCollections(): Promise; - tasksForTaskCollection(taskCollectionId: string): Promise; - createTask(task: CreateTaskInput): Promise; - updateTask(task: UpdateTaskInput): Promise; - deleteTask(taskCollectionId: string, taskId: string): Promise; + collections: TaskProviderCollections; + tasks: TaskProviderTasks; } diff --git a/packages/providers/src/tasks/google-tasks.ts b/packages/providers/src/tasks/google-tasks.ts index 482dd8a4..74e0204d 100644 --- a/packages/providers/src/tasks/google-tasks.ts +++ b/packages/providers/src/tasks/google-tasks.ts @@ -1,12 +1,14 @@ import { GoogleTasks } from "@repo/google-tasks"; -import type { CreateTaskInput, UpdateTaskInput } from "@repo/schemas"; import type { - Task, - TaskCollection, - TaskCollectionWithTasks, -} from "../interfaces"; -import type { TaskProvider } from "../interfaces/providers"; + TaskProvider, + TaskProviderCollections, + TaskProviderCreateTaskOptions, + TaskProviderDeleteTaskOptions, + TaskProviderTasks, + TaskProviderTasksForTaskCollectionOptions, + TaskProviderUpdateTaskOptions, +} from "../interfaces/providers"; import { ProviderError } from "../lib/provider-error"; import { parseGoogleTask, toGoogleTask } from "./google-tasks/utils"; @@ -15,19 +17,13 @@ interface GoogleTasksProviderOptions { providerAccountId: string; } -export class GoogleTasksProvider implements TaskProvider { - public readonly providerId = "google" as const; - public readonly providerAccountId: string; - private client: GoogleTasks; - - constructor({ accessToken, providerAccountId }: GoogleTasksProviderOptions) { - this.providerAccountId = providerAccountId; - this.client = new GoogleTasks({ - accessToken, - }); - } +class GoogleTasksCollections implements TaskProviderCollections { + constructor( + private client: GoogleTasks, + private providerAccountId: string, + ) {} - async taskCollections(): Promise { + async list() { return this.withErrorHandler("taskCollections", async () => { const { items: taskCollections } = await this.client.tasks.v1.users.me.lists.list(); @@ -44,7 +40,45 @@ export class GoogleTasksProvider implements TaskProvider { })); }); } - async tasks(): Promise { + + async tasks({ taskCollectionId }: TaskProviderTasksForTaskCollectionOptions) { + return this.withErrorHandler("tasksForTaskCollection", async () => { + const { items: tasks } = + await this.client.tasks.v1.lists.tasks.list(taskCollectionId); + return ( + tasks?.map((task) => + parseGoogleTask({ + task, + collectionId: taskCollectionId, + providerAccountId: this.providerAccountId, + }), + ) ?? [] + ); + }); + } + + private async withErrorHandler( + operation: string, + fn: () => Promise | T, + context?: Record, + ): Promise { + try { + return await Promise.resolve(fn()); + } catch (error: unknown) { + console.error(`Failed to ${operation}:`, error); + + throw new ProviderError(error as Error, operation, context); + } + } +} + +class GoogleTasksTasks implements TaskProviderTasks { + constructor( + private client: GoogleTasks, + private providerAccountId: string, + ) {} + + async list() { return this.withErrorHandler("tasks", async () => { const { items: taskCollections } = await this.client.tasks.v1.users.me.lists.list(); @@ -74,7 +108,7 @@ export class GoogleTasksProvider implements TaskProvider { }); } - createTask(task: CreateTaskInput): Promise { + create({ task }: TaskProviderCreateTaskOptions) { return this.withErrorHandler("createTask", async () => { const createdTask = await this.client.tasks.v1.lists.tasks.create( task.taskCollectionId, @@ -88,23 +122,7 @@ export class GoogleTasksProvider implements TaskProvider { }); } - tasksForTaskCollection(taskCollectionId: string): Promise { - return this.withErrorHandler("tasksForTaskCollection", async () => { - const { items: tasks } = - await this.client.tasks.v1.lists.tasks.list(taskCollectionId); - return ( - tasks?.map((task) => - parseGoogleTask({ - task, - collectionId: taskCollectionId, - providerAccountId: this.providerAccountId, - }), - ) ?? [] - ); - }); - } - - updateTask(task: UpdateTaskInput): Promise { + update({ task }: TaskProviderUpdateTaskOptions) { return this.withErrorHandler("updateTask", async () => { const updatedTask = await this.client.tasks.v1.lists.tasks.update( task.id, @@ -118,7 +136,7 @@ export class GoogleTasksProvider implements TaskProvider { }); } - deleteTask(taskCollectionId: string, taskId: string): Promise { + delete({ taskCollectionId, taskId }: TaskProviderDeleteTaskOptions) { return this.withErrorHandler("deleteTask", async () => { await this.client.tasks.v1.lists.tasks.delete(taskId, { tasklist: taskCollectionId, @@ -140,3 +158,23 @@ export class GoogleTasksProvider implements TaskProvider { } } } + +export class GoogleTasksProvider implements TaskProvider { + public readonly providerId = "google" as const; + public readonly providerAccountId: string; + private client: GoogleTasks; + public readonly collections: GoogleTasksCollections; + public readonly tasks: GoogleTasksTasks; + + constructor({ accessToken, providerAccountId }: GoogleTasksProviderOptions) { + this.providerAccountId = providerAccountId; + this.client = new GoogleTasks({ + accessToken, + }); + this.collections = new GoogleTasksCollections( + this.client, + providerAccountId, + ); + this.tasks = new GoogleTasksTasks(this.client, providerAccountId); + } +}