diff --git a/packages/providers/src/calendars/microsoft-calendar.ts b/packages/providers/src/calendars/microsoft-calendar.ts index 082e9085..c136533b 100644 --- a/packages/providers/src/calendars/microsoft-calendar.ts +++ b/packages/providers/src/calendars/microsoft-calendar.ts @@ -26,18 +26,15 @@ import type { ResponseToEventInput, } from "../interfaces/providers"; import { ProviderError } from "../lib/provider-error"; +import { parseCalendar } from "./microsoft-calendar/calendars"; +import { formatDate, formatEvent } from "./microsoft-calendar/events/format"; +import { parseEvent } from "./microsoft-calendar/events/parse"; +import { parseScheduleItem } from "./microsoft-calendar/freebusy"; +import type { MicrosoftEvent } from "./microsoft-calendar/interfaces"; 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"; +} from "./microsoft-calendar/utils"; const MAX_EVENTS_PER_CALENDAR = 250; @@ -73,7 +70,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { .get(); return (response.value as MicrosoftCalendar[]).map((calendar) => ({ - ...parseMicrosoftCalendar({ + ...parseCalendar({ calendar, providerAccountId: this.providerAccountId, }), @@ -90,7 +87,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { ) .get()) as MicrosoftCalendar; - return parseMicrosoftCalendar({ + return parseCalendar({ calendar, providerAccountId: this.providerAccountId, }); @@ -105,7 +102,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { name: calendar.name, }); - return parseMicrosoftCalendar({ + return parseCalendar({ calendar: createdCalendar, providerAccountId: this.providerAccountId, }); @@ -121,7 +118,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { .api(calendarPath(calendarId)) .patch(calendar); - return parseMicrosoftCalendar({ + return parseCalendar({ calendar: updatedCalendar, providerAccountId: this.providerAccountId, }); @@ -158,10 +155,45 @@ export class MicrosoftCalendarProvider implements CalendarProvider { .get(); const events = (response.value as MicrosoftEvent[]).map( - (event: MicrosoftEvent) => parseMicrosoftEvent({ event, calendar }), + (event: MicrosoftEvent) => parseEvent({ event, calendar }), ); - return { events, recurringMasterEvents: [] }; + 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 this.recurringEvents( + calendar, + Array.from(masters), + timeZone, + ); + + return { events, recurringMasterEvents }; + }); + } + + async recurringEvents( + calendar: Calendar, + recurringEventIds: string[], + timeZone: string, + ): Promise { + return this.withErrorHandler("recurringEvents", async () => { + const uniqueIds = new Set(recurringEventIds); + + if (uniqueIds.size === 0) { + return []; + } + + return Promise.all( + Array.from(uniqueIds).map((id) => this.event(calendar, id, timeZone)), + ); }); } @@ -176,7 +208,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { syncToken: string | undefined; status: "incremental" | "full"; }> { - return this.withErrorHandler("sync", async () => { + const runSync = async (token: string | undefined) => { const startTime = timeMin?.withTimeZone("UTC").toInstant().toString(); const endTime = timeMax?.withTimeZone("UTC").toInstant().toString(); @@ -198,7 +230,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { const changes: CalendarEventSyncItem[] = []; do { - const url: string = pageToken ?? initialSyncToken ?? baseUrl.toString(); + const url: string = pageToken ?? token ?? baseUrl.toString(); const response = await this.graphClient .api(url) @@ -207,12 +239,6 @@ export class MicrosoftCalendarProvider implements CalendarProvider { .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; @@ -235,7 +261,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { changes.push({ status: "updated", - event: parseMicrosoftEvent({ + event: parseEvent({ event: item, calendar, }), @@ -246,14 +272,72 @@ export class MicrosoftCalendarProvider implements CalendarProvider { syncToken = response["@odata.deltaLink"]; } 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, - status: "incremental", }; + }; + + 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); + + if (initialSyncToken === result.syncToken) { + return { + changes: [], + syncToken: initialSyncToken, + status: "incremental", + }; + } + + return { ...result, status: "full" }; + } }); } + private isFullSyncRequiredError(error: unknown): boolean { + if (typeof error !== "object" || error === null) { + return false; + } + + const err = error as { statusCode?: number; code?: string }; + + if (err.statusCode === 410) { + return true; + } + + if (err.code === "syncStateNotFound" || err.code === "resyncRequired") { + return true; + } + + return false; + } + async event( calendar: Calendar, eventId: string, @@ -265,7 +349,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { .header("Prefer", `outlook.timezone="${timeZone}"`) .get(); - return parseMicrosoftEvent({ + return parseEvent({ event, calendar, }); @@ -279,9 +363,9 @@ export class MicrosoftCalendarProvider implements CalendarProvider { return this.withErrorHandler("createEvent", async () => { const createdEvent: MicrosoftEvent = await this.graphClient .api(`${calendarPath(calendar.id)}/events`) - .post(toMicrosoftEvent(event)); + .post(formatEvent(event)); - return parseMicrosoftEvent({ + return parseEvent({ event: createdEvent, calendar, }); @@ -301,7 +385,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { // .headers({ // ...(event.etag ? { "If-Match": event.etag } : {}), // }) - .patch(toMicrosoftEvent(event)); + .patch(formatEvent(event)); // Then, handle response status update if present (Microsoft-specific approach) if (event.response && event.response.status !== "unknown") { @@ -315,7 +399,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { }); } - return parseMicrosoftEvent({ + return parseEvent({ event: updatedEvent, calendar, }); @@ -327,17 +411,29 @@ export class MicrosoftCalendarProvider implements CalendarProvider { * * @param calendarId - The calendar identifier * @param eventId - The event identifier + * @param sendUpdate - Whether to notify attendees (cancels the event if true) */ 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(); + if (sendUpdate) { + try { + await this.graphClient + .api(`${calendarPath(calendarId)}/events/${eventId}/cancel`) + .post({}); + } catch { + await this.graphClient + .api(`${calendarPath(calendarId)}/events/${eventId}`) + .delete(); + } + } else { + await this.graphClient + .api(`${calendarPath(calendarId)}/events/${eventId}`) + .delete(); + } }); } @@ -345,23 +441,27 @@ export class MicrosoftCalendarProvider implements CalendarProvider { 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"); + const sourceEvent: MicrosoftEvent = await this.graphClient + .api(`${calendarPath(sourceCalendar.id)}/events/${eventId}`) + .header("Prefer", `outlook.timezone="UTC"`) + .get(); - return { - ...event, - calendar: { - id: destinationCalendar.id, - provider: destinationCalendar.provider, - }, - readOnly: event.readOnly, - }; + const createdEvent: MicrosoftEvent = await this.graphClient + .api(`${calendarPath(destinationCalendar.id)}/events`) + .post(sourceEvent); + + await this.graphClient + .api(`${calendarPath(sourceCalendar.id)}/events/${eventId}`) + .header("Prefer", sendUpdate ? "" : "return=minimal") + .delete(); + + return parseEvent({ + event: createdEvent, + calendar: destinationCalendar, + }); }); } @@ -389,15 +489,13 @@ export class MicrosoftCalendarProvider implements CalendarProvider { 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); + .post({ + schedules, + startTime: formatDate({ value: timeMin }), + endTime: formatDate({ value: timeMax }), + }); // TODO: Handle errors const data = response.value as ScheduleInformation[]; diff --git a/packages/providers/src/calendars/microsoft-calendar/calendars.ts b/packages/providers/src/calendars/microsoft-calendar/calendars.ts index d6610ef8..028208dc 100644 --- a/packages/providers/src/calendars/microsoft-calendar/calendars.ts +++ b/packages/providers/src/calendars/microsoft-calendar/calendars.ts @@ -2,31 +2,25 @@ import type { Calendar as MicrosoftCalendar } from "@microsoft/microsoft-graph-t import type { Calendar } from "../../interfaces"; -interface ParseMicrosoftCalendarOptions { +interface ParseCalendarOptions { providerAccountId: string; calendar: MicrosoftCalendar; } -export function parseMicrosoftCalendar({ +export function parseCalendar({ providerAccountId, calendar, -}: ParseMicrosoftCalendarOptions): Calendar { +}: ParseCalendarOptions): Calendar { return { id: calendar.id!, name: calendar.name!, - primary: calendar.isDefaultCalendar!, + primary: Boolean(calendar.isDefaultCalendar), provider: { id: "microsoft", accountId: providerAccountId, }, color: calendar.hexColor!, - readOnly: !calendar.canEdit, + readOnly: calendar.canEdit === false, 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/conferences.ts index f6b08c11..fdc48967 100644 --- a/packages/providers/src/calendars/microsoft-calendar/conferences.ts +++ b/packages/providers/src/calendars/microsoft-calendar/conferences.ts @@ -1,17 +1,18 @@ import { detectMeetingLink } from "@analog/meeting-links"; import type { Event as MicrosoftEvent } from "@microsoft/microsoft-graph-types"; +import { CreateEventInput, UpdateEventInput } from "@repo/schemas"; + import type { Conference } from "../../interfaces"; -export function toMicrosoftConferenceData(conference: Conference) { - if (conference.type !== "create") { +export function formatOnlineMeetingProvider( + event: CreateEventInput | UpdateEventInput, +) { + if (event.conference?.type !== "create") { return undefined; } - return { - isOnlineMeeting: true, - onlineMeetingProvider: "teamsForBusiness" as const, - }; + return "teamsForBusiness" as const; } function parseConferenceFallback( @@ -62,9 +63,7 @@ function parseConferenceFallback( }; } -export function parseMicrosoftConference( - event: MicrosoftEvent, -): Conference | undefined { +export function parseConference(event: MicrosoftEvent): Conference | undefined { const joinUrl = event.onlineMeeting?.joinUrl ?? event.onlineMeetingUrl; if (!joinUrl) { @@ -96,12 +95,18 @@ export function parseMicrosoftConference( phone: phoneNumbers.map((number) => ({ joinUrl: { label: number, - value: number.startsWith("tel:") - ? number - : `tel:${number.replace(/[- ]/g, "")}`, + value: parsePhoneNumber(number), }, })), } : {}), }; } + +function parsePhoneNumber(value: string) { + if (value.startsWith("tel:")) { + return value; + } + + return `tel:${value.replace(/[- ]/g, "")}`; +} diff --git a/packages/providers/src/calendars/microsoft-calendar/events.ts b/packages/providers/src/calendars/microsoft-calendar/events.ts deleted file mode 100644 index 45795979..00000000 --- a/packages/providers/src/calendars/microsoft-calendar/events.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { - Event as MicrosoftEvent, - Attendee as MicrosoftEventAttendee, - ResponseStatus as MicrosoftEventAttendeeResponseStatus, -} from "@microsoft/microsoft-graph-types"; -import { Temporal } from "temporal-polyfill"; - -import type { - CreateEventInput, - MicrosoftEventMetadata, - UpdateEventInput, -} from "@repo/schemas"; - -import type { - Attendee, - AttendeeStatus, - Calendar, - CalendarEvent, -} from "../../interfaces"; -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, - }; -} - -function parseDate(date: string) { - return Temporal.PlainDate.from(date); -} - -interface ParseMicrosoftEventOptions { - calendar: Calendar; - event: MicrosoftEvent; -} - -function parseResponseStatus( - event: MicrosoftEvent, -): AttendeeStatus | undefined { - const organizerIsAttendee = - event.attendees?.some( - (attendee) => attendee.status?.response === "organizer", - ) ?? false; - - if ( - !event.attendees || - !organizerIsAttendee || - event.attendees.length === 0 - ) { - return undefined; - } - - const hasOtherAttendees = organizerIsAttendee && event.attendees.length > 1; - - if (!hasOtherAttendees) { - return undefined; - } - - return event.responseStatus?.response - ? parseMicrosoftAttendeeStatus(event.responseStatus.response) - : undefined; -} - -export function parseMicrosoftEvent({ - calendar, - event, -}: ParseMicrosoftEventOptions): CalendarEvent { - const { start, end, isAllDay } = event; - - if (!start || !end) { - throw new Error("Event start or end is missing"); - } - - const responseStatus = parseResponseStatus(event); - - return { - id: event.id!, - title: event.subject!, - description: event.bodyPreview ?? undefined, - start: isAllDay - ? parseDate(start.dateTime!) - : parseDateTime(start.dateTime!, start.timeZone!), - end: isAllDay - ? parseDate(end.dateTime!) - : parseDateTime(end.dateTime!, end.timeZone!), - allDay: isAllDay ?? false, - location: event.location?.displayName ?? undefined, - availability: event.showAs === "free" ? "free" : "busy", - attendees: event.attendees?.map(parseMicrosoftAttendee) ?? [], - url: event.webLink ?? undefined, - // @ts-expect-error -- type from Graph API package is incorrect - etag: event["@odata.etag"], - calendar: { - id: calendar.id, - provider: calendar.provider, - }, - readOnly: calendar.readOnly, - conference: parseMicrosoftConference(event), - ...(responseStatus ? { response: { status: responseStatus } } : {}), - ...(event.createdDateTime - ? { createdAt: Temporal.Instant.from(event.createdDateTime) } - : {}), - ...(event.lastModifiedDateTime - ? { updatedAt: Temporal.Instant.from(event.lastModifiedDateTime) } - : {}), - metadata: { - ...(event.originalStartTimeZone - ? { - originalStartTimeZone: { - raw: event.originalStartTimeZone, - parsed: event.originalStartTimeZone - ? parseTimeZone(event.originalStartTimeZone) - : undefined, - }, - } - : {}), - ...(event.originalEndTimeZone - ? { - originalEndTimeZone: { - raw: event.originalEndTimeZone, - parsed: event.originalEndTimeZone - ? parseTimeZone(event.originalEndTimeZone) - : undefined, - }, - } - : {}), - onlineMeeting: event.onlineMeeting, - }, - }; -} - -export function toMicrosoftEvent( - event: CreateEventInput | UpdateEventInput, -): MicrosoftEvent { - const metadata = event.metadata as MicrosoftEventMetadata | undefined | null; - - return { - subject: event.title, - ...(event.description - ? { - body: { contentType: "text", content: event.description }, - } - : {}), - start: toMicrosoftDate({ - value: event.start, - originalTimeZone: metadata?.originalStartTimeZone, - }), - end: toMicrosoftDate({ - value: event.end, - originalTimeZone: metadata?.originalEndTimeZone, - }), - isAllDay: event.allDay ?? false, - ...(event.location ? { location: { displayName: event.location } } : {}), - ...(event.conference ? toMicrosoftConferenceData(event.conference) : {}), - showAs: event.availability, - }; -} - -export function eventResponseStatusPath( - status: "accepted" | "tentative" | "declined", -): "accept" | "tentativelyAccept" | "decline" { - if (status === "accepted") { - return `accept`; - } - - if (status === "tentative") { - return `tentativelyAccept`; - } - - if (status === "declined") { - return `decline`; - } - - throw new Error("Invalid status"); -} - -function parseMicrosoftAttendeeStatus( - status: MicrosoftEventAttendeeResponseStatus["response"], -): AttendeeStatus { - if (status === "notResponded" || status === "none") { - return "unknown"; - } - - if (status === "accepted" || status === "organizer") { - return "accepted"; - } - - if (status === "tentativelyAccepted") { - return "tentative"; - } - - if (status === "declined") { - return "declined"; - } - - return "unknown"; -} - -export function parseMicrosoftAttendee( - attendee: MicrosoftEventAttendee, -): Attendee { - return { - email: attendee.emailAddress!.address!, - name: attendee.emailAddress?.name ?? undefined, - status: parseMicrosoftAttendeeStatus(attendee.status?.response), - type: attendee.type!, - }; -} diff --git a/packages/providers/src/calendars/microsoft-calendar/events/format.ts b/packages/providers/src/calendars/microsoft-calendar/events/format.ts new file mode 100644 index 00000000..028d0e4e --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/events/format.ts @@ -0,0 +1,168 @@ +import type { + Event as MicrosoftEvent, + Attendee as MicrosoftEventAttendee, +} from "@microsoft/microsoft-graph-types"; +import { Temporal } from "temporal-polyfill"; + +import type { + CreateEventInput, + MicrosoftEventMetadata, + UpdateEventInput, +} from "@repo/schemas"; + +import type { Attendee, AttendeeStatus } from "../../../interfaces"; +import { formatOnlineMeetingProvider } from "../conferences"; +import { formatRecurrence } from "../recurrence/format"; + +interface FormatDateOptions { + value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime; + originalTimeZone?: { + raw: string; + parsed?: string; + }; +} + +export function formatDate({ value, originalTimeZone }: FormatDateOptions) { + 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, + }; +} + +function formatBody(event: CreateEventInput | UpdateEventInput) { + if (!event.description) { + return undefined; + } + + return { contentType: "text" as const, content: event.description }; +} + +function formatStart(event: CreateEventInput | UpdateEventInput) { + const metadata = event.metadata as MicrosoftEventMetadata | undefined | null; + + return formatDate({ + value: event.start, + originalTimeZone: metadata?.originalStartTimeZone, + }); +} + +function formatEnd(event: CreateEventInput | UpdateEventInput) { + const metadata = event.metadata as MicrosoftEventMetadata | undefined | null; + + return formatDate({ + value: event.end, + originalTimeZone: metadata?.originalEndTimeZone, + }); +} + +function formatLocation(event: CreateEventInput | UpdateEventInput) { + if (!event.location) { + return undefined; + } + + return { displayName: event.location }; +} + +function formatAttendees(event: CreateEventInput | UpdateEventInput) { + if (!event.attendees) { + return undefined; + } + + return event.attendees.map(formatAttendee); +} + +// map: visibility -> sensitivity +function formatSensitivity(event: CreateEventInput | UpdateEventInput) { + if (!event.visibility) { + return undefined; + } + + switch (event.visibility) { + case "default": + return "normal"; + case "public": + return "normal"; + case "private": + return "private"; + case "confidential": + return "confidential"; + default: + return "normal"; + } +} + +function formatAttendeeResponseStatus(status: AttendeeStatus) { + switch (status) { + case "unknown": + return "none"; + case "accepted": + return "accepted"; + case "tentative": + return "tentativelyAccepted"; + case "declined": + return "declined"; + default: + return "none"; + } +} + +function formatAttendee(attendee: Attendee): MicrosoftEventAttendee { + return { + emailAddress: { + address: attendee.email, + name: attendee.name, + }, + type: attendee.type, + status: { + response: formatAttendeeResponseStatus(attendee.status), + }, + }; +} + +export function formatIsOnlineMeeting( + event: CreateEventInput | UpdateEventInput, +) { + return event.conference?.type === "create"; +} + +export function formatEvent( + event: CreateEventInput | UpdateEventInput, +): MicrosoftEvent { + return { + subject: event.title, + body: formatBody(event), + start: formatStart(event), + end: formatEnd(event), + isAllDay: event.allDay ?? false, + location: formatLocation(event), + isOnlineMeeting: formatIsOnlineMeeting(event), + onlineMeetingProvider: formatOnlineMeetingProvider(event), + attendees: formatAttendees(event), + recurrence: formatRecurrence(event), + sensitivity: formatSensitivity(event), + showAs: event.availability, + }; +} diff --git a/packages/providers/src/calendars/microsoft-calendar/events/parse.ts b/packages/providers/src/calendars/microsoft-calendar/events/parse.ts new file mode 100644 index 00000000..94112366 --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/events/parse.ts @@ -0,0 +1,213 @@ +import type { + Event as MicrosoftEvent, + Attendee as MicrosoftEventAttendee, + ResponseStatus as MicrosoftEventAttendeeResponseStatus, +} from "@microsoft/microsoft-graph-types"; +import { Temporal } from "temporal-polyfill"; + +import type { + Attendee, + AttendeeStatus, + Calendar, + CalendarEvent, +} from "../../../interfaces"; +import { parseConference } from "../conferences"; +import { parseRecurrence } from "../recurrence/parse"; +import { parseDateTime, parseTimeZone } from "../utils"; + +function parseDate(date: string) { + return Temporal.PlainDate.from(date); +} + +interface ParseMicrosoftEventOptions { + calendar: Calendar; + event: MicrosoftEvent; +} + +function isOrganizer(event: MicrosoftEvent) { + if (!event.attendees) { + return false; + } + + return event.attendees.some( + (attendee) => attendee.status?.response === "organizer", + ); +} + +function hasOtherAttendees(event: MicrosoftEvent) { + if (!event.attendees || event.attendees.length === 0) { + return false; + } + + return isOrganizer(event) && event.attendees.length > 1; +} + +function parseResponseStatus( + event: MicrosoftEvent, +): AttendeeStatus | undefined { + if (!event.attendees || event.attendees.length === 0) { + return undefined; + } + + if (!hasOtherAttendees(event)) { + return undefined; + } + + if (!event.responseStatus) { + return undefined; + } + + return parseAttendeeStatus(event.responseStatus.response); +} + +function parseResponse(event: MicrosoftEvent) { + const status = parseResponseStatus(event); + + if (!status) { + return undefined; + } + + return { + status, + }; +} + +function parseAttendees(event: MicrosoftEvent) { + return event.attendees?.map(parseAttendee) ?? []; +} + +function parseVisibility(event: MicrosoftEvent) { + if (!event.sensitivity) { + return undefined; + } + + switch (event.sensitivity) { + case "normal": + return "default"; + case "personal": + return "private"; + case "private": + return "private"; + case "confidential": + return "confidential"; + default: + return "default"; + } +} + +function parseCreatedAt(event: MicrosoftEvent) { + if (!event.createdDateTime) { + return undefined; + } + + return Temporal.Instant.from(event.createdDateTime); +} + +function parseUpdatedAt(event: MicrosoftEvent) { + if (!event.lastModifiedDateTime) { + return undefined; + } + + return Temporal.Instant.from(event.lastModifiedDateTime); +} + +function parseOriginalStartTimeZone(event: MicrosoftEvent) { + return { + raw: event.originalStartTimeZone, + parsed: event.originalStartTimeZone + ? parseTimeZone(event.originalStartTimeZone) + : undefined, + }; +} + +function parseOriginalEndTimeZone(event: MicrosoftEvent) { + return { + raw: event.originalEndTimeZone, + parsed: event.originalEndTimeZone + ? parseTimeZone(event.originalEndTimeZone) + : undefined, + }; +} + +function parseMetadata(event: MicrosoftEvent) { + return { + originalStartTimeZone: parseOriginalStartTimeZone(event), + originalEndTimeZone: parseOriginalEndTimeZone(event), + onlineMeeting: event.onlineMeeting, + }; +} + +function parseStart(event: MicrosoftEvent) { + if (event.isAllDay) { + return parseDate(event.start!.dateTime!); + } + + return parseDateTime(event.start!.dateTime!, event.start!.timeZone!); +} + +function parseEnd(event: MicrosoftEvent) { + if (event.isAllDay) { + return parseDate(event.end!.dateTime!); + } + + return parseDateTime(event.end!.dateTime!, event.end!.timeZone!); +} + +export function parseEvent({ + calendar, + event, +}: ParseMicrosoftEventOptions): CalendarEvent { + return { + id: event.id!, + title: event.subject!, + description: event.bodyPreview ?? undefined, + start: parseStart(event), + end: parseEnd(event), + allDay: event.isAllDay ?? false, + location: event.location?.displayName ?? undefined, + availability: event.showAs === "free" ? "free" : "busy", + attendees: parseAttendees(event), + url: event.webLink ?? undefined, + // @ts-expect-error -- type from Graph API package is incorrect + etag: event["@odata.etag"], + visibility: parseVisibility(event), + calendar: { + id: calendar.id, + provider: calendar.provider, + }, + readOnly: calendar.readOnly, + conference: parseConference(event), + response: parseResponse(event), + recurrence: parseRecurrence(event), + recurringEventId: event.seriesMasterId ?? undefined, + createdAt: parseCreatedAt(event), + updatedAt: parseUpdatedAt(event), + metadata: parseMetadata(event), + }; +} + +function parseAttendeeStatus( + status: MicrosoftEventAttendeeResponseStatus["response"], +): AttendeeStatus { + switch (status) { + case "notResponded": + return "unknown"; + case "accepted": + return "accepted"; + case "tentativelyAccepted": + return "tentative"; + case "declined": + return "declined"; + default: + return "unknown"; + } +} + +export function parseAttendee(attendee: MicrosoftEventAttendee): Attendee { + return { + email: attendee.emailAddress!.address!, + name: attendee.emailAddress?.name ?? undefined, + status: parseAttendeeStatus(attendee.status?.response), + type: attendee.type!, + }; +} diff --git a/packages/providers/src/calendars/microsoft-calendar/freebusy.ts b/packages/providers/src/calendars/microsoft-calendar/freebusy.ts index 1998ab40..60c0eb12 100644 --- a/packages/providers/src/calendars/microsoft-calendar/freebusy.ts +++ b/packages/providers/src/calendars/microsoft-calendar/freebusy.ts @@ -4,15 +4,15 @@ import { parseDateTime } from "./utils"; export function parseScheduleItemStatus(status: ScheduleItem["status"]) { // TODO: Handle additional statuses - if (status === "busy" || status === "oof") { - return "busy"; + switch (status) { + case "busy": + case "oof": + return "busy"; + case "free": + return "free"; + default: + return "unknown"; } - - if (status === "free") { - return "free"; - } - - return "unknown"; } export function parseScheduleItem(item: ScheduleItem) { diff --git a/packages/providers/src/calendars/microsoft-calendar/recurrence/format.ts b/packages/providers/src/calendars/microsoft-calendar/recurrence/format.ts new file mode 100644 index 00000000..b7040309 --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/recurrence/format.ts @@ -0,0 +1,159 @@ +import type { + DayOfWeek, + PatternedRecurrence, + RecurrencePattern, + RecurrenceRange, + WeekIndex, +} from "@microsoft/microsoft-graph-types"; +import { Temporal } from "temporal-polyfill"; + +import type { CreateEventInput, UpdateEventInput } from "@repo/schemas"; + +import type { Recurrence, Weekday } from "../../../interfaces"; + +const WEEKDAY_REVERSE_MAP: Record = { + SU: "sunday", + MO: "monday", + TU: "tuesday", + WE: "wednesday", + TH: "thursday", + FR: "friday", + SA: "saturday", +}; + +const WEEK_INDEX_REVERSE_MAP: Record = { + 1: "first", + 2: "second", + 3: "third", + 4: "fourth", + [-1]: "last", +}; + +function formatDate( + date: Temporal.PlainDate | Temporal.ZonedDateTime | Temporal.Instant, +) { + if (date instanceof Temporal.PlainDate) { + return date.toString(); + } + + if (date instanceof Temporal.ZonedDateTime) { + return date.toPlainDate().toString(); + } + + return date.toZonedDateTimeISO("UTC").toPlainDate().toString(); +} + +function formatRecurrencePatternType( + recurrence: Recurrence, +): RecurrencePattern["type"] | undefined { + if (!recurrence.freq) { + return undefined; + } + + switch (recurrence.freq) { + case "DAILY": + return "daily"; + case "WEEKLY": + return "weekly"; + case "MONTHLY": + if (recurrence.bySetPos && recurrence.byDay) { + return "relativeMonthly"; + } + + return "absoluteMonthly"; + case "YEARLY": + if (recurrence.bySetPos && recurrence.byDay) { + return "relativeYearly"; + } + + return "absoluteYearly"; + default: + return undefined; + } +} + +function formatDaysOfWeek(byDay: Weekday[] | undefined) { + if (!byDay || byDay.length === 0) { + return undefined; + } + + return byDay.map((day) => WEEKDAY_REVERSE_MAP[day]); +} + +function formatWeekIndex(bySetPos: number[] | undefined) { + if (!bySetPos || bySetPos.length === 0) { + return undefined; + } + + const pos = bySetPos[0]!; + + return WEEK_INDEX_REVERSE_MAP[pos]; +} + +function formatRecurrenceRange( + recurrence: Recurrence, + startDate: string, +): RecurrenceRange { + if (recurrence.count) { + return { + type: "numbered", + startDate, + numberOfOccurrences: recurrence.count, + }; + } + + if (recurrence.until) { + return { + type: "endDate", + startDate, + endDate: formatDate(recurrence.until), + }; + } + + return { + type: "noEnd", + startDate, + }; +} + +export function formatRecurrence(event: CreateEventInput | UpdateEventInput) { + const recurrence = event.recurrence; + + if (!recurrence || !recurrence.freq) { + return undefined; + } + + const patternType = formatRecurrencePatternType(recurrence); + + if (!patternType) { + return undefined; + } + + const pattern: RecurrencePattern = { + type: patternType, + interval: recurrence.interval ?? 1, + ...(recurrence.byDay + ? { daysOfWeek: formatDaysOfWeek(recurrence.byDay) } + : {}), + ...(recurrence.byMonthDay && recurrence.byMonthDay.length > 0 + ? { dayOfMonth: recurrence.byMonthDay[0] } + : {}), + // TODO: handle string months + ...(recurrence.byMonth && recurrence.byMonth.length > 0 + ? { + month: recurrence.byMonth[0], + } + : {}), + ...(recurrence.bySetPos + ? { index: formatWeekIndex(recurrence.bySetPos) } + : {}), + ...(recurrence.wkst + ? { firstDayOfWeek: WEEKDAY_REVERSE_MAP[recurrence.wkst] } + : {}), + }; + + return { + pattern, + range: formatRecurrenceRange(recurrence, formatDate(event.start)), + }; +} diff --git a/packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts b/packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts new file mode 100644 index 00000000..4839801a --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts @@ -0,0 +1,165 @@ +import type { + DayOfWeek, + PatternedRecurrence, + RecurrencePattern, + RecurrenceRange, + WeekIndex, +} from "@microsoft/microsoft-graph-types"; +import { Temporal } from "temporal-polyfill"; + +import type { Frequency, Recurrence, Weekday } from "../../../interfaces"; +import { MicrosoftEvent } from "../interfaces"; +import { parseTimeZone } from "../utils"; + +const WEEKDAY_MAP: Record = { + sunday: "SU", + monday: "MO", + tuesday: "TU", + wednesday: "WE", + thursday: "TH", + friday: "FR", + saturday: "SA", +}; + +const WEEK_INDEX_MAP: Record = { + first: 1, + second: 2, + third: 3, + fourth: 4, + last: -1, +}; + +function parseFrequency(pattern: RecurrencePattern): { + freq?: Frequency; + bySetPos?: number[]; +} { + switch (pattern.type) { + case "daily": + return { freq: "DAILY" }; + case "weekly": + return { freq: "WEEKLY" }; + case "absoluteMonthly": + return { freq: "MONTHLY" }; + case "relativeMonthly": + if (pattern.index) { + return { + freq: "MONTHLY", + bySetPos: [WEEK_INDEX_MAP[pattern.index]], + }; + } + + return { + freq: "MONTHLY", + }; + case "absoluteYearly": + return { freq: "YEARLY" }; + case "relativeYearly": + if (pattern.index) { + return { + freq: "YEARLY", + bySetPos: [WEEK_INDEX_MAP[pattern.index]], + }; + } + + return { freq: "YEARLY" }; + default: + return {}; + } +} + +function parseDaysOfWeek(pattern: RecurrencePattern) { + if (!pattern.daysOfWeek || pattern.daysOfWeek.length === 0) { + return undefined; + } + + return pattern.daysOfWeek.map((day) => WEEKDAY_MAP[day]); +} + +function parseRecurrenceTimeZone(range: RecurrenceRange, timeZone?: string) { + if (range.recurrenceTimeZone) { + return parseTimeZone(range.recurrenceTimeZone) ?? "UTC"; + } + + if (timeZone) { + return parseTimeZone(timeZone) ?? "UTC"; + } + + return "UTC"; +} + +function parseRecurrenceRange(range: RecurrenceRange, timeZone?: string) { + const result: Pick = {}; + + if (range.type === "numbered" && range.numberOfOccurrences) { + result.count = range.numberOfOccurrences; + } + + if (range.type === "endDate" && range.endDate) { + const plainDate = Temporal.PlainDate.from(range.endDate); + + result.until = plainDate.toZonedDateTime({ + timeZone: parseRecurrenceTimeZone(range, timeZone), + plainTime: Temporal.PlainTime.from({ hour: 23, minute: 59, second: 59 }), + }); + } + + return result; +} + +export function parseRecurrence(event: MicrosoftEvent): Recurrence | undefined { + const recurrence = event.recurrence; + const timeZone = event.start?.timeZone ?? undefined; + + if (!recurrence?.pattern || !recurrence?.range) { + return undefined; + } + + const { freq, bySetPos } = parseFrequency(recurrence.pattern); + + if (!freq && !bySetPos) { + return undefined; + } + + return { + freq, + interval: parseInterval(recurrence.pattern), + byDay: parseDaysOfWeek(recurrence.pattern), + byMonthDay: parseByMonthDay(recurrence.pattern), + byMonth: parseByMonth(recurrence.pattern), + bySetPos, + wkst: parseWkst(recurrence.pattern), + ...parseRecurrenceRange(recurrence.range, timeZone), + }; +} + +function parseInterval(pattern: RecurrencePattern) { + if (pattern.interval && pattern.interval > 1) { + return pattern.interval; + } + + return undefined; +} + +function parseByMonthDay(pattern: RecurrencePattern) { + if (!pattern.dayOfMonth) { + return undefined; + } + + return [pattern.dayOfMonth]; +} + +function parseByMonth(pattern: RecurrencePattern) { + if (!pattern.month) { + return undefined; + } + + return [pattern.month]; +} + +function parseWkst(pattern: RecurrencePattern) { + if (!pattern.firstDayOfWeek) { + return undefined; + } + + return WEEKDAY_MAP[pattern.firstDayOfWeek]; +} diff --git a/packages/providers/src/calendars/microsoft-calendar/utils.ts b/packages/providers/src/calendars/microsoft-calendar/utils.ts index ab89eba1..3d7f53d2 100644 --- a/packages/providers/src/calendars/microsoft-calendar/utils.ts +++ b/packages/providers/src/calendars/microsoft-calendar/utils.ts @@ -28,3 +28,24 @@ export function parseDateTime(dateTime: string, timeZone: string) { parseTimeZone(timeZone) ?? "UTC", ); } + +export function calendarPath(calendarId: string) { + return calendarId === "primary" + ? "/me/calendar" + : `/me/calendars/${calendarId}`; +} + +export function eventResponseStatusPath( + status: "accepted" | "tentative" | "declined", +): "accept" | "tentativelyAccept" | "decline" { + switch (status) { + case "accepted": + return "accept"; + case "tentative": + return "tentativelyAccept"; + case "declined": + return "decline"; + default: + throw new Error("Invalid status"); + } +} diff --git a/packages/providers/src/calendars/utils/meetings.ts b/packages/providers/src/calendars/utils/meetings.ts deleted file mode 100644 index a3b5c4b3..00000000 --- a/packages/providers/src/calendars/utils/meetings.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CalendarEvent } from "../../interfaces"; - -type Meeting = Omit & - Required>; - -export function isMeeting(event: CalendarEvent): event is Meeting { - return !!event.attendees && event.attendees.length > 1; -} diff --git a/packages/providers/src/interfaces/events.ts b/packages/providers/src/interfaces/events.ts index cb424e02..635332eb 100644 --- a/packages/providers/src/interfaces/events.ts +++ b/packages/providers/src/interfaces/events.ts @@ -170,3 +170,6 @@ export interface Recurrence { rscale?: RScale; skip?: "OMIT" | "BACKWARD" | "FORWARD"; } + +export type Meeting = Omit & + Required>; diff --git a/packages/providers/src/lib/events.ts b/packages/providers/src/lib/events.ts new file mode 100644 index 00000000..2ac845ed --- /dev/null +++ b/packages/providers/src/lib/events.ts @@ -0,0 +1,5 @@ +import type { CalendarEvent, Meeting } from "../interfaces/events"; + +export function isMeeting(event: CalendarEvent): event is Meeting { + return !!event.attendees && event.attendees.length > 1; +} diff --git a/packages/providers/src/lib/index.ts b/packages/providers/src/lib/index.ts index 4cdba995..48f2138b 100644 --- a/packages/providers/src/lib/index.ts +++ b/packages/providers/src/lib/index.ts @@ -1,3 +1,4 @@ export * from "./recurrences/export"; export * from "./recurrences/parse"; +export * from "./events"; export { COLORS } from "../calendars/colors";