From d3c893003a57a431513daf2d4f8d5ee459b12b90 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Sat, 25 Jul 2026 12:04:57 +0200 Subject: [PATCH 1/7] wip --- .../microsoft-calendar/calendars/index.ts | 10 +- .../calendars/{utils.ts => parse.ts} | 6 +- .../conferences/utils.ts => conferences.ts} | 8 +- .../microsoft-calendar/events/format.ts | 220 ++++++ .../microsoft-calendar/events/index.ts | 198 ++++- .../microsoft-calendar/events/parse.ts | 209 ++++++ .../microsoft-calendar/events/utils.ts | 698 ------------------ .../microsoft-calendar/freebusy/index.ts | 8 +- .../freebusy/{utils.ts => parse.ts} | 0 .../microsoft-calendar/recurrence/format.ts | 322 ++++++++ .../microsoft-calendar/recurrence/parse.ts | 100 +++ .../src/calendars/microsoft-calendar/utils.ts | 41 - packages/providers/src/interfaces/events.ts | 6 + .../utils/meetings.ts => lib/events.ts} | 4 +- packages/providers/src/lib/index.ts | 1 + 15 files changed, 1051 insertions(+), 780 deletions(-) rename packages/providers/src/calendars/microsoft-calendar/calendars/{utils.ts => parse.ts} (79%) rename packages/providers/src/calendars/microsoft-calendar/{events/conferences/utils.ts => conferences.ts} (91%) create mode 100644 packages/providers/src/calendars/microsoft-calendar/events/format.ts create mode 100644 packages/providers/src/calendars/microsoft-calendar/events/parse.ts delete mode 100644 packages/providers/src/calendars/microsoft-calendar/events/utils.ts rename packages/providers/src/calendars/microsoft-calendar/freebusy/{utils.ts => parse.ts} (100%) create mode 100644 packages/providers/src/calendars/microsoft-calendar/recurrence/format.ts create mode 100644 packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts rename packages/providers/src/{calendars/utils/meetings.ts => lib/events.ts} (50%) diff --git a/packages/providers/src/calendars/microsoft-calendar/calendars/index.ts b/packages/providers/src/calendars/microsoft-calendar/calendars/index.ts index 72810268..d328fb05 100644 --- a/packages/providers/src/calendars/microsoft-calendar/calendars/index.ts +++ b/packages/providers/src/calendars/microsoft-calendar/calendars/index.ts @@ -13,7 +13,7 @@ import type { CalendarProviderCalendarsUpdateOptions, } from "../../../interfaces/providers"; import { ProviderError } from "../../../lib/provider-error"; -import { parseMicrosoftCalendar } from "./utils"; +import { parseCalendar } from "./parse"; export class MicrosoftCalendarCalendars { constructor( @@ -44,7 +44,7 @@ export class MicrosoftCalendarCalendars { response: CalendarCollectionResponse, ): Promise => { const calendars = (response.value ?? []).map((calendar) => - parseMicrosoftCalendar({ + parseCalendar({ calendar, providerAccountId: this.providerAccountId, }), @@ -98,7 +98,7 @@ export class MicrosoftCalendarCalendars { select, }); - return parseMicrosoftCalendar({ + return parseCalendar({ calendar, providerAccountId: this.providerAccountId, }); @@ -116,7 +116,7 @@ export class MicrosoftCalendarCalendars { }, }); - return parseMicrosoftCalendar({ + return parseCalendar({ calendar: createdCalendar, providerAccountId: this.providerAccountId, }); @@ -133,7 +133,7 @@ export class MicrosoftCalendarCalendars { calendar: { name: calendar.name }, }); - return parseMicrosoftCalendar({ + return parseCalendar({ calendar: updatedCalendar, providerAccountId: this.providerAccountId, }); diff --git a/packages/providers/src/calendars/microsoft-calendar/calendars/utils.ts b/packages/providers/src/calendars/microsoft-calendar/calendars/parse.ts similarity index 79% rename from packages/providers/src/calendars/microsoft-calendar/calendars/utils.ts rename to packages/providers/src/calendars/microsoft-calendar/calendars/parse.ts index f74745a0..22512e23 100644 --- a/packages/providers/src/calendars/microsoft-calendar/calendars/utils.ts +++ b/packages/providers/src/calendars/microsoft-calendar/calendars/parse.ts @@ -2,15 +2,15 @@ import type { Calendar as MicrosoftCalendar } from "@analog/microsoft-calendar"; 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!, diff --git a/packages/providers/src/calendars/microsoft-calendar/events/conferences/utils.ts b/packages/providers/src/calendars/microsoft-calendar/conferences.ts similarity index 91% rename from packages/providers/src/calendars/microsoft-calendar/events/conferences/utils.ts rename to packages/providers/src/calendars/microsoft-calendar/conferences.ts index 3c6f211c..c78b5db3 100644 --- a/packages/providers/src/calendars/microsoft-calendar/events/conferences/utils.ts +++ b/packages/providers/src/calendars/microsoft-calendar/conferences.ts @@ -1,9 +1,9 @@ import { detectMeetingLink } from "@analog/meeting-links"; import type { Event as MicrosoftEvent } from "@analog/microsoft-calendar"; -import type { Conference } from "../../../../interfaces"; +import type { Conference } from "../../interfaces"; -export function toMicrosoftConferenceData(conference: Conference) { +export function formatConference(conference: Conference) { if (conference.type !== "create") { return undefined; } @@ -62,9 +62,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) { 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..0b902020 --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/events/format.ts @@ -0,0 +1,220 @@ +import type { + Attendee as MicrosoftEventAttendee, + Event as MicrosoftEvent, +} from "@analog/microsoft-calendar"; +import { Temporal } from "temporal-polyfill"; + +import type { + CreateEventInput, + MicrosoftEventMetadata, + UpdateEventPatch, +} from "@repo/schemas"; + +import type { Attendee } from "../../../interfaces"; +import { formatConference } from "../conferences"; +import { formatRecurrence, formatRecurrencePatch } 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.toPlainDateTime().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.toPlainDateTime().toString(), + timeZone: + originalTimeZone?.parsed === value.timeZoneId + ? originalTimeZone?.raw + : value.timeZoneId, + }; +} + +function formatMetadata( + metadata: CreateEventInput["metadata"], +): MicrosoftEventMetadata { + if (!metadata) return {}; + if ("originalStartTimeZone" in metadata) return metadata; + if ("originalEndTimeZone" in metadata) return metadata; + if ("onlineMeeting" in metadata) return metadata; + if ("recurrenceTimeZone" in metadata) return metadata; + return {}; +} + +function formatBody(event: CreateEventInput) { + if (!event.description) { + return {}; + } + + return { body: { contentType: "text" as const, content: event.description } }; +} + +function formatStart(event: CreateEventInput) { + const metadata = formatMetadata(event.metadata); + + return formatDate({ + value: event.start, + originalTimeZone: metadata.originalStartTimeZone, + }); +} + +function formatEnd(event: CreateEventInput) { + const metadata = formatMetadata(event.metadata); + + return formatDate({ + value: event.end, + originalTimeZone: metadata.originalEndTimeZone, + }); +} + +function formatLocation(event: CreateEventInput) { + if (!event.location) { + return {}; + } + + return { location: { displayName: event.location } }; +} + +function formatSensitivity( + visibility: CreateEventInput["visibility"], +): MicrosoftEvent["sensitivity"] { + if (visibility === "default") return "normal"; + if (visibility === "public") return "normal"; + return visibility; +} + +function formatAttendee(attendee: Attendee): MicrosoftEventAttendee { + return { + emailAddress: { + address: attendee.email, + name: attendee.name, + }, + type: attendee.type, + }; +} + +function formatAttendees(event: CreateEventInput) { + return event.attendees?.map(formatAttendee); +} + +function formatEventRecurrence(event: CreateEventInput) { + if (!event.recurrence) { + return {}; + } + + const metadata = formatMetadata(event.metadata); + + return { + recurrence: formatRecurrence({ + recurrence: event.recurrence, + start: event.start, + recurrenceTimeZone: metadata.recurrenceTimeZone, + }), + }; +} + +export function formatEvent(event: CreateEventInput): MicrosoftEvent { + return { + subject: event.title, + ...formatBody(event), + start: formatStart(event), + end: formatEnd(event), + isAllDay: event.allDay ?? false, + ...formatLocation(event), + ...(event.conference ? formatConference(event.conference) : {}), + showAs: event.availability, + sensitivity: formatSensitivity(event.visibility), + attendees: formatAttendees(event), + ...formatEventRecurrence(event), + }; +} + +interface FormatEventPatchOptions { + // Resolved master start for recurrence serialization; Graph requires + // range.startDate to match the master event's start date, which a sparse + // patch does not necessarily carry. + startForRecurrence?: + | Temporal.PlainDate + | Temporal.Instant + | Temporal.ZonedDateTime; +} + +export function formatEventPatch( + event: UpdateEventPatch, + options: FormatEventPatchOptions = {}, +): MicrosoftEvent { + const metadata = formatMetadata(event.metadata); + + return { + ...(event.title !== undefined ? { subject: event.title } : {}), + ...(event.description !== undefined + ? { + body: { + contentType: "text" as const, + content: event.description ?? "", + }, + } + : {}), + ...(event.start !== undefined + ? { + start: formatDate({ + value: event.start, + originalTimeZone: metadata.originalStartTimeZone, + }), + } + : {}), + ...(event.end !== undefined + ? { + end: formatDate({ + value: event.end, + originalTimeZone: metadata.originalEndTimeZone, + }), + } + : {}), + ...(event.allDay !== undefined ? { isAllDay: event.allDay } : {}), + ...(event.location !== undefined + ? { location: { displayName: event.location } } + : {}), + ...(event.availability !== undefined ? { showAs: event.availability } : {}), + ...(event.visibility !== undefined + ? { sensitivity: formatSensitivity(event.visibility) } + : {}), + ...(event.attendees !== undefined + ? { attendees: event.attendees.map(formatAttendee) } + : {}), + // Graph has no conference field to null out: clearing demotes the online + // meeting via isOnlineMeeting=false with the provider reset to "unknown". + ...(event.conference === null + ? { isOnlineMeeting: false, onlineMeetingProvider: "unknown" as const } + : event.conference + ? formatConference(event.conference) + : {}), + ...formatRecurrencePatch( + event.recurrence, + options.startForRecurrence ?? event.start, + metadata.recurrenceTimeZone, + ), + }; +} diff --git a/packages/providers/src/calendars/microsoft-calendar/events/index.ts b/packages/providers/src/calendars/microsoft-calendar/events/index.ts index e9076357..54e1dcda 100644 --- a/packages/providers/src/calendars/microsoft-calendar/events/index.ts +++ b/packages/providers/src/calendars/microsoft-calendar/events/index.ts @@ -1,3 +1,4 @@ +import { APIError } from "@analog/microsoft-calendar"; import type { DefaultCalendarCalendarViewDeltaInput, DefaultCalendarCreateEventInput, @@ -19,21 +20,42 @@ import type { CalendarProviderEventsDeleteOptions, CalendarProviderEventsGetOptions, CalendarProviderEventsListOptions, + CalendarProviderEventsMoveOptions, CalendarProviderEventsRespondOptions, CalendarProviderEventsUpdateOptions, CalendarProviderSyncOptions, CalendarProviderSyncResult, } from "../../../interfaces/providers"; import { ProviderError } from "../../../lib/provider-error"; -import { - parseMicrosoftEvent, - toMicrosoftEvent, - toMicrosoftEventPatch, -} from "./utils"; +import { formatEvent, formatEventPatch } from "./format"; +import { parseEvent } from "./parse"; const MAX_EVENTS_PER_CALENDAR = 250; const TEXT_BODY_PREFERENCE = 'outlook.body-content-type="text"'; +// Graph owns these properties; they are rejected or silently ignored when they +// are posted back, so a copied event must not carry them over. +const SERVER_OWNED_EVENT_FIELDS = new Set([ + "id", + "changeKey", + "iCalUId", + "webLink", + "createdDateTime", + "lastModifiedDateTime", + "seriesMasterId", + "onlineMeeting", + "onlineMeetingUrl", +]); + +function stripServerOwnedFields(event: MicrosoftEvent) { + return Object.fromEntries( + Object.entries(event).filter( + ([key]) => + !key.startsWith("@odata") && !SERVER_OWNED_EVENT_FIELDS.has(key), + ), + ) as MicrosoftEvent; +} + export class MicrosoftCalendarEvents implements CalendarProviderEvents { constructor(private readonly client: MicrosoftCalendar) {} @@ -103,7 +125,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { }); const events = (response.value ?? []).map((event) => - parseMicrosoftEvent({ event, calendar }), + parseEvent({ event, calendar }), ); if (!response["@odata.nextLink"]) { @@ -119,7 +141,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { }); const events = (page.value ?? []).map((event) => - parseMicrosoftEvent({ event, calendar }), + parseEvent({ event, calendar }), ); if (!page["@odata.nextLink"]) { @@ -156,7 +178,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { timeMax, timeZone, }: CalendarProviderSyncOptions): Promise { - return this.withErrorHandler("events.sync", async () => { + const runSync = async (token: string | undefined) => { const startTime = timeMin?.withTimeZone("UTC").toInstant().toString(); const endTime = timeMax?.withTimeZone("UTC").toInstant().toString(); @@ -170,7 +192,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { const changes: CalendarEventSyncItem[] = []; do { - const link = pageToken ?? initialSyncToken; + const link = pageToken ?? token; let response: DeltaCollectionResponse; @@ -218,7 +240,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { changes.push({ status: "updated", - event: parseMicrosoftEvent({ + event: parseEvent({ event: item, calendar, }), @@ -229,14 +251,95 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { syncToken = response["@odata.deltaLink"] ?? undefined; } while (pageToken); + // The delta feed only carries the changed instances, so pull in the series + // masters they reference unless the feed already returned them. + const changedEventIds = new Set(); + const recurringEventIds = new Set(); + + for (const change of changes) { + if (change.status === "deleted") { + continue; + } + + changedEventIds.add(change.event.id); + + if (change.event.recurringEventId) { + recurringEventIds.add(change.event.recurringEventId); + } + } + + const recurringMasterEvents = await Promise.all( + Array.from(recurringEventIds) + .filter((eventId) => !changedEventIds.has(eventId)) + .map((eventId) => this.get({ calendar, eventId, timeZone })), + ); + + const recurringChanges: CalendarEventSyncItem[] = + recurringMasterEvents.map((event) => ({ + status: "updated", + event, + })); + + changes.push(...recurringChanges); + return { changes, syncToken, - status: "incremental", }; + }; + + return this.withErrorHandler("events.sync", async () => { + try { + const result = await runSync(initialSyncToken); + + return { ...result, status: "incremental" }; + } catch (error) { + if (!this.isFullSyncRequiredError(error)) { + throw error; + } + + // A full sync needs an explicit window; without one the retry can only + // fail with the generic guard below, which would hide the resync + // signal the caller has to act on. + if (!timeMin || !timeMax) { + throw error; + } + + const result = await runSync(undefined); + + // 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" }; + } }); } + // Graph expires and invalidates delta tokens; the 410 status and the sync + // state error codes both mean the delta link is unusable and the calendar has + // to be synced from scratch. + private isFullSyncRequiredError(error: unknown): boolean { + if (!(error instanceof APIError)) { + return false; + } + + if (error.status === 410) { + return true; + } + + return ( + error.error?.error.code === "syncStateNotFound" || + error.error?.error.code === "resyncRequired" + ); + } + async get({ calendar, eventId, timeZone }: CalendarProviderEventsGetOptions) { return this.withErrorHandler("events.get", async () => { const headers = { @@ -249,7 +352,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { headers, }); - return parseMicrosoftEvent({ + return parseEvent({ event, calendar, }); @@ -268,11 +371,11 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { const createdEvent = await this.eventsFor(calendar.id).create({ userId: "me", - event: toMicrosoftEvent(event), + event: formatEvent(event), headers: { Prefer: TEXT_BODY_PREFERENCE }, }); - return parseMicrosoftEvent({ + return parseEvent({ event: createdEvent, calendar, }); @@ -309,7 +412,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { const updatedEvent = await this.eventsFor(calendar.id).update({ userId: "me", eventId, - event: toMicrosoftEventPatch(event, { startForRecurrence }), + event: formatEventPatch(event, { startForRecurrence }), ...(event.etag ? { ifMatch: event.etag } : {}), headers: { Prefer: TEXT_BODY_PREFERENCE }, }); @@ -326,7 +429,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { return this.get({ calendar, eventId }); } - return parseMicrosoftEvent({ + return parseEvent({ event: updatedEvent, calendar, }); @@ -339,8 +442,32 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { sendUpdate, }: CalendarProviderEventsDeleteOptions) { await this.withErrorHandler("events.delete", async () => { - if (!sendUpdate) { - throw new Error("Microsoft Calendar does not support sendUpdate=false"); + // Cancelling is what notifies the attendees, but Graph only allows it on + // meetings the user organizes; event ids are calendar-independent, so the + // action is addressed through /me/events like the respond actions. + if (sendUpdate) { + try { + await this.client.users.events.cancel({ userId: "me", eventId }); + + return; + } catch (error) { + // Graph rejects the action with a client error when the event is not + // an organized meeting: fall back to deleting the event. Auth, + // throttling, server and network failures are transient and must + // surface instead of silently deleting without notifying anyone. + const status = error instanceof APIError ? error.status : undefined; + + if ( + status === undefined || + status < 400 || + status >= 500 || + status === 401 || + status === 408 || + status === 429 + ) { + throw error; + } + } } await this.eventsFor(calendarId).delete({ @@ -351,9 +478,38 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { }); } - async move() { - return this.withErrorHandler("events.move", () => { - throw new Error("Moving Microsoft Calendar events is not supported"); + async move({ + sourceCalendar, + destinationCalendar, + eventId, + etag, + }: CalendarProviderEventsMoveOptions) { + return this.withErrorHandler("events.move", async () => { + // Graph cannot move an event between calendars, so copy it into the + // destination calendar and delete the original. The source is read in UTC + // so the copy keeps the absolute times of the original. + const sourceEvent = await this.eventsFor(sourceCalendar.id).get({ + userId: "me", + eventId, + headers: { Prefer: 'outlook.timezone="UTC"' }, + }); + + const createdEvent = await this.eventsFor(destinationCalendar.id).create({ + userId: "me", + event: stripServerOwnedFields(sourceEvent), + headers: { Prefer: TEXT_BODY_PREFERENCE }, + }); + + await this.eventsFor(sourceCalendar.id).delete({ + userId: "me", + eventId, + ...(etag ? { ifMatch: etag } : {}), + }); + + return parseEvent({ + event: createdEvent, + calendar: destinationCalendar, + }); }); } 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..386d5ddf --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/events/parse.ts @@ -0,0 +1,209 @@ +import type { + Attendee as MicrosoftEventAttendee, + Event as MicrosoftEvent, + ResponseStatus as MicrosoftEventAttendeeResponseStatus, +} from "@analog/microsoft-calendar"; +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 ParseEventOptions { + calendar: Calendar; + event: MicrosoftEvent; +} + +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!); +} + +function parseVisibility( + sensitivity: MicrosoftEvent["sensitivity"], +): CalendarEvent["visibility"] { + if (sensitivity === "normal") return "default"; + if (sensitivity === "personal") return "private"; + return sensitivity; +} + +function parseAttendees(event: MicrosoftEvent) { + return event.attendees?.map(parseAttendee) ?? []; +} + +function parseResponseStatus( + event: MicrosoftEvent, +): AttendeeStatus | undefined { + return event.responseStatus?.response + ? parseAttendeeStatus(event.responseStatus.response) + : undefined; +} + +function parseResponse(event: MicrosoftEvent) { + const status = parseResponseStatus(event); + + if (!status) { + return {}; + } + + return { response: { status } }; +} + +function parseCreatedAt(event: MicrosoftEvent) { + if (!event.createdDateTime) { + return {}; + } + + return { createdAt: Temporal.Instant.from(event.createdDateTime) }; +} + +function parseUpdatedAt(event: MicrosoftEvent) { + if (!event.lastModifiedDateTime) { + return {}; + } + + return { updatedAt: Temporal.Instant.from(event.lastModifiedDateTime) }; +} + +function parseOriginalStartTimeZone(event: MicrosoftEvent) { + if (!event.originalStartTimeZone) { + return {}; + } + + return { + originalStartTimeZone: { + raw: event.originalStartTimeZone, + parsed: parseTimeZone(event.originalStartTimeZone), + }, + }; +} + +function parseOriginalEndTimeZone(event: MicrosoftEvent) { + if (!event.originalEndTimeZone) { + return {}; + } + + return { + originalEndTimeZone: { + raw: event.originalEndTimeZone, + parsed: parseTimeZone(event.originalEndTimeZone), + }, + }; +} + +function parseRecurrenceTimeZone(event: MicrosoftEvent) { + if (!event.recurrence?.range?.recurrenceTimeZone) { + return {}; + } + + return { recurrenceTimeZone: event.recurrence.range.recurrenceTimeZone }; +} + +function parseEventRecurrence(event: MicrosoftEvent) { + const recurrence = event.recurrence + ? parseRecurrence(event.recurrence) + : undefined; + + if (!recurrence) { + return {}; + } + + return { recurrence }; +} + +function parseMetadata(event: MicrosoftEvent) { + return { + ...parseOriginalStartTimeZone(event), + ...parseOriginalEndTimeZone(event), + onlineMeeting: event.onlineMeeting, + ...parseRecurrenceTimeZone(event), + }; +} + +export function parseEvent({ + calendar, + event, +}: ParseEventOptions): CalendarEvent { + if (!event.start || !event.end) { + throw new Error("Event start or end is missing"); + } + + return { + id: event.id!, + title: event.subject!, + description: event.body?.content ?? undefined, + start: parseStart(event), + end: parseEnd(event), + allDay: event.isAllDay ?? false, + location: event.location?.displayName ?? undefined, + availability: event.showAs === "free" ? "free" : "busy", + visibility: parseVisibility(event.sensitivity), + attendees: parseAttendees(event), + url: event.webLink ?? undefined, + etag: event["@odata.etag"], + calendar: { + id: calendar.id, + provider: calendar.provider, + }, + readOnly: calendar.readOnly, + conference: parseConference(event), + recurringEventId: event.seriesMasterId ?? undefined, + ...parseEventRecurrence(event), + ...parseResponse(event), + ...parseCreatedAt(event), + ...parseUpdatedAt(event), + metadata: parseMetadata(event), + } as CalendarEvent; +} + +function parseAttendeeStatus( + 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 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/events/utils.ts b/packages/providers/src/calendars/microsoft-calendar/events/utils.ts deleted file mode 100644 index 8a7f0341..00000000 --- a/packages/providers/src/calendars/microsoft-calendar/events/utils.ts +++ /dev/null @@ -1,698 +0,0 @@ -import type { - Attendee as MicrosoftEventAttendee, - DayOfWeek as MicrosoftDayOfWeek, - Event as MicrosoftEvent, - ResponseStatus as MicrosoftEventAttendeeResponseStatus, - PatternedRecurrence, - RecurrencePattern, - RecurrenceRange, - WeekIndex, -} from "@analog/microsoft-calendar"; -import { Temporal } from "temporal-polyfill"; - -import type { - CreateEventInput, - MicrosoftEventMetadata, - UpdateEventPatch, -} from "@repo/schemas"; -import { toPlainDate, toZonedDateTime } from "@repo/temporal"; - -import type { - Attendee, - AttendeeStatus, - Calendar, - CalendarEvent, - Recurrence, - Weekday, -} from "../../../interfaces"; -import { parseDateTime, parseTimeZone, toMicrosoftDate } from "../utils"; -import { - parseMicrosoftConference, - toMicrosoftConferenceData, -} from "./conferences/utils"; - -function parseDate(date: string) { - return Temporal.PlainDate.from(date); -} - -const WEEKDAY_TO_MICROSOFT_DAY: Record = { - MO: "monday", - TU: "tuesday", - WE: "wednesday", - TH: "thursday", - FR: "friday", - SA: "saturday", - SU: "sunday", -}; - -const MICROSOFT_DAY_TO_WEEKDAY: Record = { - monday: "MO", - tuesday: "TU", - wednesday: "WE", - thursday: "TH", - friday: "FR", - saturday: "SA", - sunday: "SU", -}; - -const WEEK_INDEX_TO_SET_POS: Record = { - first: 1, - second: 2, - third: 3, - fourth: 4, - last: -1, -}; - -// ISO dayOfWeek is 1 (Monday) through 7 (Sunday). -const ISO_DAY_TO_MICROSOFT_DAY: MicrosoftDayOfWeek[] = [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", -]; - -export class RecurrenceConversionError extends Error { - constructor(message: string) { - super(`Cannot convert recurrence for Microsoft Calendar: ${message}`); - this.name = "RecurrenceConversionError"; - } -} - -function toSetPosWeekIndex(recurrence: Recurrence): WeekIndex { - const bySetPos = recurrence.bySetPos; - - if (!bySetPos || bySetPos.length !== 1) { - throw new RecurrenceConversionError( - "bySetPos must contain exactly one of 1, 2, 3, 4, or -1", - ); - } - - switch (bySetPos[0]) { - case 1: - return "first"; - case 2: - return "second"; - case 3: - return "third"; - case 4: - return "fourth"; - case -1: - return "last"; - default: - throw new RecurrenceConversionError( - `bySetPos value ${bySetPos[0]} has no Microsoft equivalent (supported: 1, 2, 3, 4, -1)`, - ); - } -} - -function toMicrosoftDaysOfWeek(byDay: Weekday[]): MicrosoftDayOfWeek[] { - return byDay.map((day) => WEEKDAY_TO_MICROSOFT_DAY[day]); -} - -function toMicrosoftDayOfWeek(dayOfWeek: number): MicrosoftDayOfWeek { - const day = ISO_DAY_TO_MICROSOFT_DAY[dayOfWeek - 1]; - - if (!day) { - throw new RecurrenceConversionError(`invalid ISO weekday ${dayOfWeek}`); - } - - return day; -} - -function assertConvertibleRecurrence(recurrence: Recurrence) { - const unsupported: string[] = []; - - if (recurrence.byYearDay?.length) unsupported.push("byYearDay"); - if (recurrence.byWeekNo?.length) unsupported.push("byWeekNo"); - if (recurrence.byHour?.length) unsupported.push("byHour"); - if (recurrence.byMinute?.length) unsupported.push("byMinute"); - if (recurrence.bySecond?.length) unsupported.push("bySecond"); - if (recurrence.rDate?.length) unsupported.push("rDate"); - if (recurrence.exDate?.length) unsupported.push("exDate"); - - if (recurrence.rscale && recurrence.rscale !== "GREGORIAN") { - unsupported.push(`rscale=${recurrence.rscale}`); - } - - if (recurrence.skip && recurrence.skip !== "OMIT") { - unsupported.push(`skip=${recurrence.skip}`); - } - - if (unsupported.length > 0) { - throw new RecurrenceConversionError( - `unsupported rule parts: ${unsupported.join(", ")}`, - ); - } - - if (recurrence.count !== undefined && recurrence.until !== undefined) { - throw new RecurrenceConversionError( - "count and until are mutually exclusive", - ); - } - - if (recurrence.freq === "WEEKLY" && recurrence.bySetPos?.length) { - throw new RecurrenceConversionError("bySetPos is not supported for WEEKLY"); - } - - if ( - recurrence.until !== undefined && - !(recurrence.until instanceof Temporal.PlainDate) - ) { - throw new RecurrenceConversionError( - "Microsoft recurrence only supports date-valued until", - ); - } -} - -function toMicrosoftRecurrencePattern( - recurrence: Recurrence, - start: Temporal.ZonedDateTime, -): RecurrencePattern { - const interval = recurrence.interval ?? 1; - - if (recurrence.byMonth && recurrence.freq !== "YEARLY") { - throw new RecurrenceConversionError( - `byMonth is only supported for YEARLY, got ${recurrence.freq}`, - ); - } - - if ( - recurrence.byMonthDay && - recurrence.freq !== "MONTHLY" && - recurrence.freq !== "YEARLY" - ) { - throw new RecurrenceConversionError( - `byMonthDay is only supported for MONTHLY and YEARLY, got ${recurrence.freq}`, - ); - } - - switch (recurrence.freq) { - case "DAILY": { - if (recurrence.byDay?.length) { - throw new RecurrenceConversionError("byDay is not supported for DAILY"); - } - - return { type: "daily", interval }; - } - case "WEEKLY": { - return { - type: "weekly", - interval, - daysOfWeek: recurrence.byDay?.length - ? toMicrosoftDaysOfWeek(recurrence.byDay) - : [toMicrosoftDayOfWeek(start.dayOfWeek)], - firstDayOfWeek: WEEKDAY_TO_MICROSOFT_DAY[recurrence.wkst ?? "MO"], - }; - } - case "MONTHLY": { - if (recurrence.byDay?.length) { - return { - type: "relativeMonthly", - interval, - daysOfWeek: toMicrosoftDaysOfWeek(recurrence.byDay), - index: toSetPosWeekIndex(recurrence), - }; - } - - if (recurrence.byMonthDay && recurrence.byMonthDay.length !== 1) { - throw new RecurrenceConversionError( - "byMonthDay must contain exactly one day for MONTHLY", - ); - } - - const dayOfMonth = recurrence.byMonthDay?.[0] ?? start.day; - - // RFC 5545 skips months without this day, but Outlook substitutes the - // month's last day, silently changing the rule's meaning. - if (dayOfMonth > 28) { - throw new RecurrenceConversionError( - `MONTHLY on day ${dayOfMonth} means "last day" in short months on Outlook, unlike the RFC rule`, - ); - } - - return { type: "absoluteMonthly", interval, dayOfMonth }; - } - case "YEARLY": { - if (recurrence.byMonth && recurrence.byMonth.length !== 1) { - throw new RecurrenceConversionError( - "byMonth must contain exactly one month for YEARLY", - ); - } - - const month = recurrence.byMonth?.[0] ?? start.month; - - if (recurrence.byDay?.length) { - return { - type: "relativeYearly", - interval, - month, - daysOfWeek: toMicrosoftDaysOfWeek(recurrence.byDay), - index: toSetPosWeekIndex(recurrence), - }; - } - - if (recurrence.byMonthDay && recurrence.byMonthDay.length !== 1) { - throw new RecurrenceConversionError( - "byMonthDay must contain exactly one day for YEARLY", - ); - } - - const dayOfMonth = recurrence.byMonthDay?.[0] ?? start.day; - - // Same Outlook substitution as MONTHLY: Feb 29 and days beyond a fixed - // month's length roll to the month's last day instead of skipping. - const stableDays = - month === 2 ? 28 : [4, 6, 9, 11].includes(month) ? 30 : 31; - - if (dayOfMonth > stableDays) { - throw new RecurrenceConversionError( - `YEARLY on month ${month}, day ${dayOfMonth} does not occur every year and rolls to the month's last day on Outlook`, - ); - } - - return { type: "absoluteYearly", interval, month, dayOfMonth }; - } - default: { - throw new RecurrenceConversionError( - `frequency ${recurrence.freq ?? "(none)"} is not supported`, - ); - } - } -} - -interface ToMicrosoftRecurrenceOptions { - recurrence: Recurrence; - // The series master's start, never a selected occurrence's: Graph requires - // range.startDate to match the master event's start date. - start: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime; - recurrenceTimeZone?: string; -} - -export function toMicrosoftRecurrence({ - recurrence, - start, - recurrenceTimeZone, -}: ToMicrosoftRecurrenceOptions): PatternedRecurrence { - assertConvertibleRecurrence(recurrence); - - // Graph gets the zone verbatim (it may be a Windows name); Temporal - // conversions need the IANA equivalent. - const timeZone = - recurrenceTimeZone ?? - (start instanceof Temporal.ZonedDateTime ? start.timeZoneId : "UTC"); - const conversionTimeZone = parseTimeZone(timeZone) ?? "UTC"; - - // Graph expects range dates and pattern defaults (weekday, day of month) - // in recurrenceTimeZone, not the zone the event happened to be parsed in - // (events.get parses in the requested Prefer time zone, typically UTC). - const startDate = toPlainDate(start, { timeZone: conversionTimeZone }); - - const range: RecurrenceRange = { - startDate: startDate.toString(), - recurrenceTimeZone: timeZone, - ...(recurrence.count !== undefined - ? { type: "numbered", numberOfOccurrences: recurrence.count } - : recurrence.until !== undefined - ? { - type: "endDate", - endDate: toPlainDate(recurrence.until, { - timeZone: conversionTimeZone, - }).toString(), - } - : { type: "noEnd" }), - }; - - return { - pattern: toMicrosoftRecurrencePattern( - recurrence, - toZonedDateTime(start, { timeZone: conversionTimeZone }), - ), - range, - }; -} - -export function parseMicrosoftRecurrence( - recurrence: PatternedRecurrence, -): Recurrence | undefined { - const { pattern, range } = recurrence; - - if (!pattern?.type) { - return undefined; - } - - const shared: Recurrence = { - ...(pattern.interval !== undefined ? { interval: pattern.interval } : {}), - ...(range?.type === "numbered" && range.numberOfOccurrences !== undefined - ? { count: range.numberOfOccurrences } - : {}), - // Graph's endDate is inclusive, matching RFC 5545 UNTIL for date values. - ...(range?.type === "endDate" && range.endDate - ? { until: Temporal.PlainDate.from(range.endDate) } - : {}), - }; - - const byDay = pattern.daysOfWeek?.map((day) => MICROSOFT_DAY_TO_WEEKDAY[day]); - const wkst = pattern.firstDayOfWeek - ? MICROSOFT_DAY_TO_WEEKDAY[pattern.firstDayOfWeek] - : undefined; - - switch (pattern.type) { - case "daily": - return { ...shared, freq: "DAILY" }; - case "weekly": - return { - ...shared, - freq: "WEEKLY", - ...(byDay?.length ? { byDay } : {}), - // Graph defaults firstDayOfWeek to sunday, unlike RFC 5545's implicit - // WKST=MO, so a missing field must parse as an explicit SU. - wkst: wkst ?? "SU", - }; - case "absoluteMonthly": - return { - ...shared, - freq: "MONTHLY", - ...(pattern.dayOfMonth !== undefined - ? { byMonthDay: [pattern.dayOfMonth] } - : {}), - }; - case "relativeMonthly": - return { - ...shared, - freq: "MONTHLY", - ...(byDay?.length ? { byDay } : {}), - bySetPos: [WEEK_INDEX_TO_SET_POS[pattern.index ?? "first"]], - }; - case "absoluteYearly": - return { - ...shared, - freq: "YEARLY", - ...(pattern.month !== undefined ? { byMonth: [pattern.month] } : {}), - ...(pattern.dayOfMonth !== undefined - ? { byMonthDay: [pattern.dayOfMonth] } - : {}), - }; - case "relativeYearly": - return { - ...shared, - freq: "YEARLY", - ...(pattern.month !== undefined ? { byMonth: [pattern.month] } : {}), - ...(byDay?.length ? { byDay } : {}), - bySetPos: [WEEK_INDEX_TO_SET_POS[pattern.index ?? "first"]], - }; - default: - return undefined; - } -} - -interface ParseMicrosoftEventOptions { - calendar: Calendar; - event: MicrosoftEvent; -} - -function parseResponseStatus( - event: MicrosoftEvent, -): AttendeeStatus | undefined { - return event.responseStatus?.response - ? parseMicrosoftAttendeeStatus(event.responseStatus.response) - : undefined; -} - -function parseMicrosoftVisibility( - sensitivity: MicrosoftEvent["sensitivity"], -): CalendarEvent["visibility"] { - if (sensitivity === "normal") return "default"; - if (sensitivity === "personal") return "private"; - return sensitivity; -} - -function toMicrosoftSensitivity( - visibility: CreateEventInput["visibility"], -): MicrosoftEvent["sensitivity"] { - if (visibility === "default") return "normal"; - if (visibility === "public") return "normal"; - return visibility; -} - -function toMicrosoftAttendee(attendee: Attendee): MicrosoftEventAttendee { - return { - emailAddress: { - address: attendee.email, - name: attendee.name, - }, - type: attendee.type, - }; -} - -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); - const recurrence = event.recurrence - ? parseMicrosoftRecurrence(event.recurrence) - : undefined; - - return { - id: event.id!, - title: event.subject!, - description: event.body?.content ?? 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", - visibility: parseMicrosoftVisibility(event.sensitivity), - attendees: event.attendees?.map(parseMicrosoftAttendee) ?? [], - url: event.webLink ?? undefined, - etag: event["@odata.etag"], - calendar: { - id: calendar.id, - provider: calendar.provider, - }, - readOnly: calendar.readOnly, - conference: parseMicrosoftConference(event), - recurringEventId: event.seriesMasterId ?? undefined, - ...(recurrence ? { recurrence } : {}), - ...(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, - ...(event.recurrence?.range?.recurrenceTimeZone - ? { recurrenceTimeZone: event.recurrence.range.recurrenceTimeZone } - : {}), - }, - } as CalendarEvent; -} - -export function toMicrosoftEvent(event: CreateEventInput): MicrosoftEvent { - const metadata = toMicrosoftMetadata(event.metadata); - - 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, - sensitivity: toMicrosoftSensitivity(event.visibility), - attendees: event.attendees?.map(toMicrosoftAttendee), - ...(event.recurrence - ? { - recurrence: toMicrosoftRecurrence({ - recurrence: event.recurrence, - start: event.start, - recurrenceTimeZone: metadata?.recurrenceTimeZone, - }), - } - : {}), - }; -} - -interface ToMicrosoftEventPatchOptions { - // Resolved master start for recurrence serialization; Graph requires - // range.startDate to match the master event's start date, which a sparse - // patch does not necessarily carry. - startForRecurrence?: - | Temporal.PlainDate - | Temporal.Instant - | Temporal.ZonedDateTime; -} - -function toMicrosoftMetadata( - metadata: CreateEventInput["metadata"], -): MicrosoftEventMetadata { - if (!metadata) return {}; - if ("originalStartTimeZone" in metadata) return metadata; - if ("originalEndTimeZone" in metadata) return metadata; - if ("onlineMeeting" in metadata) return metadata; - if ("recurrenceTimeZone" in metadata) return metadata; - return {}; -} - -function toMicrosoftRecurrencePatch( - recurrence: UpdateEventPatch["recurrence"], - start: - | Temporal.PlainDate - | Temporal.Instant - | Temporal.ZonedDateTime - | undefined, - recurrenceTimeZone?: string, -) { - if (recurrence === undefined) return {}; - if (recurrence === null) return { recurrence: null }; - - if (!start) { - throw new RecurrenceConversionError( - "a recurrence change requires the event start to anchor range.startDate", - ); - } - - return { - recurrence: toMicrosoftRecurrence({ - recurrence, - start, - recurrenceTimeZone, - }), - }; -} - -export function toMicrosoftEventPatch( - event: UpdateEventPatch, - options: ToMicrosoftEventPatchOptions = {}, -): MicrosoftEvent { - const metadata = toMicrosoftMetadata(event.metadata); - - const recurrenceStart = options.startForRecurrence ?? event.start; - - return { - ...(event.title !== undefined ? { subject: event.title } : {}), - ...(event.description !== undefined - ? { - body: { contentType: "text", content: event.description ?? "" }, - } - : {}), - ...(event.start !== undefined - ? { - start: toMicrosoftDate({ - value: event.start, - originalTimeZone: metadata?.originalStartTimeZone, - }), - } - : {}), - ...(event.end !== undefined - ? { - end: toMicrosoftDate({ - value: event.end, - originalTimeZone: metadata?.originalEndTimeZone, - }), - } - : {}), - ...(event.allDay !== undefined ? { isAllDay: event.allDay } : {}), - ...(event.location !== undefined - ? { location: { displayName: event.location } } - : {}), - ...(event.availability !== undefined ? { showAs: event.availability } : {}), - ...(event.visibility !== undefined - ? { sensitivity: toMicrosoftSensitivity(event.visibility) } - : {}), - ...(event.attendees !== undefined - ? { attendees: event.attendees.map(toMicrosoftAttendee) } - : {}), - // Graph has no conference field to null out: clearing demotes the online - // meeting via isOnlineMeeting=false with the provider reset to "unknown". - ...(event.conference === null - ? { isOnlineMeeting: false, onlineMeetingProvider: "unknown" as const } - : event.conference - ? toMicrosoftConferenceData(event.conference) - : {}), - ...toMicrosoftRecurrencePatch( - event.recurrence, - recurrenceStart, - metadata.recurrenceTimeZone, - ), - }; -} - -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/freebusy/index.ts b/packages/providers/src/calendars/microsoft-calendar/freebusy/index.ts index 9944be26..64bf2220 100644 --- a/packages/providers/src/calendars/microsoft-calendar/freebusy/index.ts +++ b/packages/providers/src/calendars/microsoft-calendar/freebusy/index.ts @@ -10,8 +10,8 @@ import type { CalendarProviderFreeBusyQueryOptions, } from "../../../interfaces/providers"; import { ProviderError } from "../../../lib/provider-error"; -import { toMicrosoftDate } from "../utils"; -import { parseScheduleItem } from "./utils"; +import { formatDate } from "../events/format"; +import { parseScheduleItem } from "./parse"; const MAX_SCHEDULES_PER_REQUEST = 20; @@ -72,8 +72,8 @@ export class MicrosoftCalendarFreeBusy implements CalendarProviderFreeBusy { await this.client.users.calendar.getSchedule({ userId: "me", schedules: batch, - startTime: toMicrosoftDate({ value: timeMin }), - endTime: toMicrosoftDate({ value: timeMax }), + startTime: formatDate({ value: timeMin }), + endTime: formatDate({ value: timeMax }), }), ), ), diff --git a/packages/providers/src/calendars/microsoft-calendar/freebusy/utils.ts b/packages/providers/src/calendars/microsoft-calendar/freebusy/parse.ts similarity index 100% rename from packages/providers/src/calendars/microsoft-calendar/freebusy/utils.ts rename to packages/providers/src/calendars/microsoft-calendar/freebusy/parse.ts 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..d7fd4fa2 --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/recurrence/format.ts @@ -0,0 +1,322 @@ +import type { + DayOfWeek as MicrosoftDayOfWeek, + PatternedRecurrence, + RecurrencePattern, + RecurrenceRange, + WeekIndex, +} from "@analog/microsoft-calendar"; +import { Temporal } from "temporal-polyfill"; + +import type { UpdateEventPatch } from "@repo/schemas"; +import { toPlainDate, toZonedDateTime } from "@repo/temporal"; + +import type { Recurrence, Weekday } from "../../../interfaces"; +import { parseTimeZone } from "../utils"; + +const WEEKDAY_REVERSE_MAP: Record = { + MO: "monday", + TU: "tuesday", + WE: "wednesday", + TH: "thursday", + FR: "friday", + SA: "saturday", + SU: "sunday", +}; + +// ISO dayOfWeek is 1 (Monday) through 7 (Sunday). +const ISO_DAY_MAP: MicrosoftDayOfWeek[] = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +]; + +export class RecurrenceConversionError extends Error { + constructor(message: string) { + super(`Cannot convert recurrence for Microsoft Calendar: ${message}`); + this.name = "RecurrenceConversionError"; + } +} + +function formatWeekIndex(recurrence: Recurrence): WeekIndex { + const bySetPos = recurrence.bySetPos; + + if (!bySetPos || bySetPos.length !== 1) { + throw new RecurrenceConversionError( + "bySetPos must contain exactly one of 1, 2, 3, 4, or -1", + ); + } + + switch (bySetPos[0]) { + case 1: + return "first"; + case 2: + return "second"; + case 3: + return "third"; + case 4: + return "fourth"; + case -1: + return "last"; + default: + throw new RecurrenceConversionError( + `bySetPos value ${bySetPos[0]} has no Microsoft equivalent (supported: 1, 2, 3, 4, -1)`, + ); + } +} + +function formatDaysOfWeek(byDay: Weekday[]): MicrosoftDayOfWeek[] { + return byDay.map((day) => WEEKDAY_REVERSE_MAP[day]); +} + +function formatDayOfWeek(dayOfWeek: number): MicrosoftDayOfWeek { + const day = ISO_DAY_MAP[dayOfWeek - 1]; + + if (!day) { + throw new RecurrenceConversionError(`invalid ISO weekday ${dayOfWeek}`); + } + + return day; +} + +function assertConvertibleRecurrence(recurrence: Recurrence) { + const unsupported: string[] = []; + + if (recurrence.byYearDay?.length) unsupported.push("byYearDay"); + if (recurrence.byWeekNo?.length) unsupported.push("byWeekNo"); + if (recurrence.byHour?.length) unsupported.push("byHour"); + if (recurrence.byMinute?.length) unsupported.push("byMinute"); + if (recurrence.bySecond?.length) unsupported.push("bySecond"); + if (recurrence.rDate?.length) unsupported.push("rDate"); + if (recurrence.exDate?.length) unsupported.push("exDate"); + + if (recurrence.rscale && recurrence.rscale !== "GREGORIAN") { + unsupported.push(`rscale=${recurrence.rscale}`); + } + + if (recurrence.skip && recurrence.skip !== "OMIT") { + unsupported.push(`skip=${recurrence.skip}`); + } + + if (unsupported.length > 0) { + throw new RecurrenceConversionError( + `unsupported rule parts: ${unsupported.join(", ")}`, + ); + } + + if (recurrence.count !== undefined && recurrence.until !== undefined) { + throw new RecurrenceConversionError( + "count and until are mutually exclusive", + ); + } + + if (recurrence.freq === "WEEKLY" && recurrence.bySetPos?.length) { + throw new RecurrenceConversionError("bySetPos is not supported for WEEKLY"); + } + + if ( + recurrence.until !== undefined && + !(recurrence.until instanceof Temporal.PlainDate) + ) { + throw new RecurrenceConversionError( + "Microsoft recurrence only supports date-valued until", + ); + } +} + +function formatRecurrencePattern( + recurrence: Recurrence, + start: Temporal.ZonedDateTime, +): RecurrencePattern { + const interval = recurrence.interval ?? 1; + + if (recurrence.byMonth && recurrence.freq !== "YEARLY") { + throw new RecurrenceConversionError( + `byMonth is only supported for YEARLY, got ${recurrence.freq}`, + ); + } + + if ( + recurrence.byMonthDay && + recurrence.freq !== "MONTHLY" && + recurrence.freq !== "YEARLY" + ) { + throw new RecurrenceConversionError( + `byMonthDay is only supported for MONTHLY and YEARLY, got ${recurrence.freq}`, + ); + } + + switch (recurrence.freq) { + case "DAILY": { + if (recurrence.byDay?.length) { + throw new RecurrenceConversionError("byDay is not supported for DAILY"); + } + + return { type: "daily", interval }; + } + case "WEEKLY": { + return { + type: "weekly", + interval, + daysOfWeek: recurrence.byDay?.length + ? formatDaysOfWeek(recurrence.byDay) + : [formatDayOfWeek(start.dayOfWeek)], + firstDayOfWeek: WEEKDAY_REVERSE_MAP[recurrence.wkst ?? "MO"], + }; + } + case "MONTHLY": { + if (recurrence.byDay?.length) { + return { + type: "relativeMonthly", + interval, + daysOfWeek: formatDaysOfWeek(recurrence.byDay), + index: formatWeekIndex(recurrence), + }; + } + + if (recurrence.byMonthDay && recurrence.byMonthDay.length !== 1) { + throw new RecurrenceConversionError( + "byMonthDay must contain exactly one day for MONTHLY", + ); + } + + const dayOfMonth = recurrence.byMonthDay?.[0] ?? start.day; + + // RFC 5545 skips months without this day, but Outlook substitutes the + // month's last day, silently changing the rule's meaning. + if (dayOfMonth > 28) { + throw new RecurrenceConversionError( + `MONTHLY on day ${dayOfMonth} means "last day" in short months on Outlook, unlike the RFC rule`, + ); + } + + return { type: "absoluteMonthly", interval, dayOfMonth }; + } + case "YEARLY": { + if (recurrence.byMonth && recurrence.byMonth.length !== 1) { + throw new RecurrenceConversionError( + "byMonth must contain exactly one month for YEARLY", + ); + } + + const month = recurrence.byMonth?.[0] ?? start.month; + + if (recurrence.byDay?.length) { + return { + type: "relativeYearly", + interval, + month, + daysOfWeek: formatDaysOfWeek(recurrence.byDay), + index: formatWeekIndex(recurrence), + }; + } + + if (recurrence.byMonthDay && recurrence.byMonthDay.length !== 1) { + throw new RecurrenceConversionError( + "byMonthDay must contain exactly one day for YEARLY", + ); + } + + const dayOfMonth = recurrence.byMonthDay?.[0] ?? start.day; + + // Same Outlook substitution as MONTHLY: Feb 29 and days beyond a fixed + // month's length roll to the month's last day instead of skipping. + const stableDays = + month === 2 ? 28 : [4, 6, 9, 11].includes(month) ? 30 : 31; + + if (dayOfMonth > stableDays) { + throw new RecurrenceConversionError( + `YEARLY on month ${month}, day ${dayOfMonth} does not occur every year and rolls to the month's last day on Outlook`, + ); + } + + return { type: "absoluteYearly", interval, month, dayOfMonth }; + } + default: { + throw new RecurrenceConversionError( + `frequency ${recurrence.freq ?? "(none)"} is not supported`, + ); + } + } +} + +interface FormatRecurrenceOptions { + recurrence: Recurrence; + // The series master's start, never a selected occurrence's: Graph requires + // range.startDate to match the master event's start date. + start: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime; + recurrenceTimeZone?: string; +} + +export function formatRecurrence({ + recurrence, + start, + recurrenceTimeZone, +}: FormatRecurrenceOptions): PatternedRecurrence { + assertConvertibleRecurrence(recurrence); + + // Graph gets the zone verbatim (it may be a Windows name); Temporal + // conversions need the IANA equivalent. + const timeZone = + recurrenceTimeZone ?? + (start instanceof Temporal.ZonedDateTime ? start.timeZoneId : "UTC"); + const conversionTimeZone = parseTimeZone(timeZone) ?? "UTC"; + + // Graph expects range dates and pattern defaults (weekday, day of month) + // in recurrenceTimeZone, not the zone the event happened to be parsed in + // (events.get parses in the requested Prefer time zone, typically UTC). + const startDate = toPlainDate(start, { timeZone: conversionTimeZone }); + + const range: RecurrenceRange = { + startDate: startDate.toString(), + recurrenceTimeZone: timeZone, + ...(recurrence.count !== undefined + ? { type: "numbered", numberOfOccurrences: recurrence.count } + : recurrence.until !== undefined + ? { + type: "endDate", + endDate: toPlainDate(recurrence.until, { + timeZone: conversionTimeZone, + }).toString(), + } + : { type: "noEnd" }), + }; + + return { + pattern: formatRecurrencePattern( + recurrence, + toZonedDateTime(start, { timeZone: conversionTimeZone }), + ), + range, + }; +} + +export function formatRecurrencePatch( + recurrence: UpdateEventPatch["recurrence"], + start: + | Temporal.PlainDate + | Temporal.Instant + | Temporal.ZonedDateTime + | undefined, + recurrenceTimeZone?: string, +) { + if (recurrence === undefined) return {}; + if (recurrence === null) return { recurrence: null }; + + if (!start) { + throw new RecurrenceConversionError( + "a recurrence change requires the event start to anchor range.startDate", + ); + } + + return { + recurrence: formatRecurrence({ + recurrence, + start, + recurrenceTimeZone, + }), + }; +} 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..4497442d --- /dev/null +++ b/packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts @@ -0,0 +1,100 @@ +import type { + DayOfWeek as MicrosoftDayOfWeek, + PatternedRecurrence, + WeekIndex, +} from "@analog/microsoft-calendar"; +import { Temporal } from "temporal-polyfill"; + +import type { Recurrence, Weekday } from "../../../interfaces"; + +const WEEKDAY_MAP: Record = { + monday: "MO", + tuesday: "TU", + wednesday: "WE", + thursday: "TH", + friday: "FR", + saturday: "SA", + sunday: "SU", +}; + +const WEEK_INDEX_MAP: Record = { + first: 1, + second: 2, + third: 3, + fourth: 4, + last: -1, +}; + +export function parseRecurrence( + recurrence: PatternedRecurrence, +): Recurrence | undefined { + const { pattern, range } = recurrence; + + if (!pattern?.type) { + return undefined; + } + + const shared: Recurrence = { + ...(pattern.interval !== undefined ? { interval: pattern.interval } : {}), + ...(range?.type === "numbered" && range.numberOfOccurrences !== undefined + ? { count: range.numberOfOccurrences } + : {}), + // Graph's endDate is inclusive, matching RFC 5545 UNTIL for date values. + ...(range?.type === "endDate" && range.endDate + ? { until: Temporal.PlainDate.from(range.endDate) } + : {}), + }; + + const byDay = pattern.daysOfWeek?.map((day) => WEEKDAY_MAP[day]); + const wkst = pattern.firstDayOfWeek + ? WEEKDAY_MAP[pattern.firstDayOfWeek] + : undefined; + + switch (pattern.type) { + case "daily": + return { ...shared, freq: "DAILY" }; + case "weekly": + return { + ...shared, + freq: "WEEKLY", + ...(byDay?.length ? { byDay } : {}), + // Graph defaults firstDayOfWeek to sunday, unlike RFC 5545's implicit + // WKST=MO, so a missing field must parse as an explicit SU. + wkst: wkst ?? "SU", + }; + case "absoluteMonthly": + return { + ...shared, + freq: "MONTHLY", + ...(pattern.dayOfMonth !== undefined + ? { byMonthDay: [pattern.dayOfMonth] } + : {}), + }; + case "relativeMonthly": + return { + ...shared, + freq: "MONTHLY", + ...(byDay?.length ? { byDay } : {}), + bySetPos: [WEEK_INDEX_MAP[pattern.index ?? "first"]], + }; + case "absoluteYearly": + return { + ...shared, + freq: "YEARLY", + ...(pattern.month !== undefined ? { byMonth: [pattern.month] } : {}), + ...(pattern.dayOfMonth !== undefined + ? { byMonthDay: [pattern.dayOfMonth] } + : {}), + }; + case "relativeYearly": + return { + ...shared, + freq: "YEARLY", + ...(pattern.month !== undefined ? { byMonth: [pattern.month] } : {}), + ...(byDay?.length ? { byDay } : {}), + bySetPos: [WEEK_INDEX_MAP[pattern.index ?? "first"]], + }; + default: + return undefined; + } +} diff --git a/packages/providers/src/calendars/microsoft-calendar/utils.ts b/packages/providers/src/calendars/microsoft-calendar/utils.ts index ad0655c0..ab89eba1 100644 --- a/packages/providers/src/calendars/microsoft-calendar/utils.ts +++ b/packages/providers/src/calendars/microsoft-calendar/utils.ts @@ -28,44 +28,3 @@ export function parseDateTime(dateTime: string, timeZone: string) { parseTimeZone(timeZone) ?? "UTC", ); } - -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.toPlainDateTime().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.toPlainDateTime().toString(), - timeZone: - originalTimeZone?.parsed === value.timeZoneId - ? originalTimeZone?.raw - : value.timeZoneId, - }; -} diff --git a/packages/providers/src/interfaces/events.ts b/packages/providers/src/interfaces/events.ts index d78bba1d..0a5899ca 100644 --- a/packages/providers/src/interfaces/events.ts +++ b/packages/providers/src/interfaces/events.ts @@ -237,3 +237,9 @@ export interface Recurrence { rscale?: RScale; skip?: "OMIT" | "BACKWARD" | "FORWARD"; } + +// CalendarEvent is a union over the allDay/start/end shapes, which `Omit` would +// collapse into a single `allDay: boolean` object, so the required `attendees` +// is intersected onto the union instead. +export type Meeting = CalendarEvent & + Required>; diff --git a/packages/providers/src/calendars/utils/meetings.ts b/packages/providers/src/lib/events.ts similarity index 50% rename from packages/providers/src/calendars/utils/meetings.ts rename to packages/providers/src/lib/events.ts index d32de326..2ac845ed 100644 --- a/packages/providers/src/calendars/utils/meetings.ts +++ b/packages/providers/src/lib/events.ts @@ -1,6 +1,4 @@ -import type { Attendee, CalendarEvent } from "../../interfaces"; - -type Meeting = CalendarEvent & { attendees: Attendee[] }; +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"; From 44780fada9e3f1824b6a3c4162590cfc3cd066ac Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Sat, 25 Jul 2026 13:17:49 +0200 Subject: [PATCH 2/7] wip --- .../src/groups/calendar/events/interfaces.ts | 2 +- .../src/groups/events/interfaces.ts | 2 +- packages/microsoft-calendar/src/interfaces.ts | 91 ++++++++++++++----- .../calendars/events/interfaces.ts | 2 +- .../src/users/calendar/events/interfaces.ts | 2 +- .../src/users/calendars/events/interfaces.ts | 2 +- .../src/users/events/interfaces.ts | 2 +- .../microsoft-calendar/events/format.ts | 2 +- .../microsoft-calendar/events/parse.ts | 18 ++-- .../microsoft-calendar/freebusy/parse.ts | 4 +- .../microsoft-calendar/recurrence/parse.ts | 10 +- 11 files changed, 86 insertions(+), 51 deletions(-) diff --git a/packages/microsoft-calendar/src/groups/calendar/events/interfaces.ts b/packages/microsoft-calendar/src/groups/calendar/events/interfaces.ts index 8e10d624..a2003214 100644 --- a/packages/microsoft-calendar/src/groups/calendar/events/interfaces.ts +++ b/packages/microsoft-calendar/src/groups/calendar/events/interfaces.ts @@ -72,7 +72,7 @@ export type GroupCalendarGetEventResponse = Event; export interface GroupCalendarUpdateEventInput extends MicrosoftCalendarRequestOptions { groupId: string; eventId: string; - event: Event; + event: Partial; } export type GroupCalendarUpdateEventResponse = Event; diff --git a/packages/microsoft-calendar/src/groups/events/interfaces.ts b/packages/microsoft-calendar/src/groups/events/interfaces.ts index c85ae9fa..a2b82acf 100644 --- a/packages/microsoft-calendar/src/groups/events/interfaces.ts +++ b/packages/microsoft-calendar/src/groups/events/interfaces.ts @@ -72,7 +72,7 @@ export type GroupGetEventResponse = Event; export interface GroupUpdateEventInput extends MicrosoftCalendarRequestOptions { groupId: string; eventId: string; - event: Event; + event: Partial; } export type GroupUpdateEventResponse = Event; diff --git a/packages/microsoft-calendar/src/interfaces.ts b/packages/microsoft-calendar/src/interfaces.ts index cfe76744..1c6af7bc 100644 --- a/packages/microsoft-calendar/src/interfaces.ts +++ b/packages/microsoft-calendar/src/interfaces.ts @@ -149,9 +149,12 @@ export type CalendarRoleType = | "delegateWithPrivateEventAccess" | "custom"; +// Graph's OpenAPI marks every field optional. The docs only define dateTime and +// timeZone; both are expected when the type is present. +// https://learn.microsoft.com/en-us/graph/api/resources/datetimetimezone export interface DateTimeTimeZone { - dateTime?: string; - timeZone?: string | null; + dateTime: string; + timeZone: string; [key: string]: unknown; } @@ -175,13 +178,17 @@ export interface Entity { [key: string]: unknown; } +// Graph's OpenAPI marks every field optional. Calendar event docs always +// expect start and end on GET/create; PATCH sends Partial with only +// changed fields. +// https://learn.microsoft.com/en-us/graph/api/resources/event export interface Event extends OutlookItem { allowNewTimeProposals?: boolean | null; attendees?: Attendee[]; body?: ItemBody; bodyPreview?: string | null; cancelledOccurrences?: string[]; - end?: DateTimeTimeZone; + end: DateTimeTimeZone; hasAttachments?: boolean | null; hideAttendees?: boolean | null; iCalUId?: string | null; @@ -210,7 +217,7 @@ export interface Event extends OutlookItem { sensitivity?: Sensitivity; seriesMasterId?: string | null; showAs?: FreeBusyStatus; - start?: DateTimeTimeZone; + start: DateTimeTimeZone; subject?: string | null; transactionId?: string | null; type?: EventType; @@ -292,8 +299,12 @@ export type LocationUniqueIdType = | "private" | "bing"; +// Graph's OpenAPI marks every field optional. Create docs require id and +// value for each property in multiValueExtendedProperties. +// https://learn.microsoft.com/en-us/graph/api/multivaluelegacyextendedproperty-post-multivalueextendedproperties?view=graph-rest-1.0 export interface MultiValueLegacyExtendedProperty extends Entity { - value?: (string | null)[]; + id: string; + value: (string | null)[]; [key: string]: unknown; } @@ -330,17 +341,24 @@ export interface OutlookItem extends Entity { [key: string]: unknown; } +// Graph's OpenAPI marks both as optional (the type is shared with access +// reviews, which omit pattern for one-time reviews). For calendar events, +// both are required when recurrence is set. +// https://learn.microsoft.com/en-us/graph/outlook-schedule-recurring-events export interface PatternedRecurrence { - pattern?: RecurrencePattern; - range?: RecurrenceRange; + pattern: RecurrencePattern; + range: RecurrenceRange; [key: string]: unknown; } +// Graph's OpenAPI marks every field optional. The docs only define number and +// type; both are expected on a phone entry. +// https://learn.microsoft.com/en-us/graph/api/resources/phone?view=graph-rest-1.0 export interface Phone { language?: string | null; - number?: string | null; + number: string; region?: string | null; - type?: PhoneType; + type: PhoneType; [key: string]: unknown; } @@ -365,19 +383,25 @@ export interface PhysicalAddress { [key: string]: unknown; } +// Graph's OpenAPI marks emailAddress optional. The docs only define +// emailAddress; it is expected on a recipient. +// https://learn.microsoft.com/en-us/graph/api/resources/recipient export interface Recipient { - emailAddress?: EmailAddress; + emailAddress: EmailAddress; [key: string]: unknown; } +// Graph's OpenAPI marks every field optional. Docs require type and interval +// for every pattern; other fields are required only for specific types. +// https://learn.microsoft.com/en-us/graph/api/resources/recurrencepattern export interface RecurrencePattern { dayOfMonth?: number; daysOfWeek?: DayOfWeek[]; firstDayOfWeek?: DayOfWeek; index?: WeekIndex; - interval?: number; + interval: number; month?: number; - type?: RecurrencePatternType; + type: RecurrencePatternType; [key: string]: unknown; } @@ -389,12 +413,15 @@ export type RecurrencePatternType = | "absoluteYearly" | "relativeYearly"; +// Graph's OpenAPI marks every field optional. Docs require type and startDate +// for every range; endDate / numberOfOccurrences depend on type. +// https://learn.microsoft.com/en-us/graph/api/resources/recurrencerange export interface RecurrenceRange { endDate?: string | null; numberOfOccurrences?: number; recurrenceTimeZone?: string | null; - startDate?: string | null; - type?: RecurrenceRangeType; + startDate: string; + type: RecurrenceRangeType; [key: string]: unknown; } @@ -424,38 +451,54 @@ export interface ScheduleInformation { [key: string]: unknown; } +// Graph's OpenAPI marks every field optional. Docs mark only isPrivate, +// location, and subject as optional; start, end, and status are expected. +// https://learn.microsoft.com/en-us/graph/api/resources/scheduleitem export interface ScheduleItem { - end?: DateTimeTimeZone; + end: DateTimeTimeZone; isPrivate?: boolean | null; location?: string | null; - start?: DateTimeTimeZone; - status?: FreeBusyStatus; + start: DateTimeTimeZone; + status: FreeBusyStatus; subject?: string | null; [key: string]: unknown; } export type Sensitivity = "normal" | "personal" | "private" | "confidential"; +// Graph's OpenAPI marks id/value optional (id via Entity). Create docs require +// both id and value for each property in the collection. +// https://learn.microsoft.com/en-us/graph/api/singlevaluelegacyextendedproperty-post-singlevalueextendedproperties?view=graph-rest-1.0 export interface SingleValueLegacyExtendedProperty extends Entity { - value?: string | null; + id: string; + value: string; [key: string]: unknown; } +// Graph's OpenAPI marks every field optional. The docs only define start and +// end; both are expected on a time slot. +// https://learn.microsoft.com/en-us/graph/api/resources/timeslot export interface TimeSlot { - end?: DateTimeTimeZone; - start?: DateTimeTimeZone; + end: DateTimeTimeZone; + start: DateTimeTimeZone; [key: string]: unknown; } +// Graph's OpenAPI marks name optional. Docs only define name; it is expected +// when the type is present. +// https://learn.microsoft.com/en-us/graph/api/resources/timezonebase export interface TimeZoneBase { - name?: string | null; + name: string; [key: string]: unknown; } +// Graph's OpenAPI marks every field optional. createUploadSession always +// returns uploadUrl, expirationDateTime, and nextExpectedRanges. +// https://learn.microsoft.com/en-us/graph/api/attachment-createuploadsession?view=graph-rest-1.0 export interface UploadSession { - expirationDateTime?: string | null; - nextExpectedRanges?: (string | null)[]; - uploadUrl?: string | null; + expirationDateTime: string; + nextExpectedRanges: string[]; + uploadUrl: string; [key: string]: unknown; } diff --git a/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/interfaces.ts b/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/interfaces.ts index 6ba5d1c9..02cc8616 100644 --- a/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/interfaces.ts @@ -87,7 +87,7 @@ export interface CalendarGroupCalendarUpdateEventInput extends MicrosoftCalendar calendarGroupId: string; calendarId: string; eventId: string; - event: Event; + event: Partial; } export type CalendarGroupCalendarUpdateEventResponse = Event; diff --git a/packages/microsoft-calendar/src/users/calendar/events/interfaces.ts b/packages/microsoft-calendar/src/users/calendar/events/interfaces.ts index e55dea2c..474ec18e 100644 --- a/packages/microsoft-calendar/src/users/calendar/events/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar/events/interfaces.ts @@ -72,7 +72,7 @@ export type DefaultCalendarGetEventResponse = Event; export interface DefaultCalendarUpdateEventInput extends MicrosoftCalendarRequestOptions { userId: string; eventId: string; - event: Event; + event: Partial; } export type DefaultCalendarUpdateEventResponse = Event; diff --git a/packages/microsoft-calendar/src/users/calendars/events/interfaces.ts b/packages/microsoft-calendar/src/users/calendars/events/interfaces.ts index b1420f14..ed83a5eb 100644 --- a/packages/microsoft-calendar/src/users/calendars/events/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendars/events/interfaces.ts @@ -79,7 +79,7 @@ export interface CalendarUpdateEventInput extends MicrosoftCalendarRequestOption userId: string; calendarId: string; eventId: string; - event: Event; + event: Partial; } export type CalendarUpdateEventResponse = Event; diff --git a/packages/microsoft-calendar/src/users/events/interfaces.ts b/packages/microsoft-calendar/src/users/events/interfaces.ts index 17ee56ce..b44f6bb6 100644 --- a/packages/microsoft-calendar/src/users/events/interfaces.ts +++ b/packages/microsoft-calendar/src/users/events/interfaces.ts @@ -72,7 +72,7 @@ export type GetEventResponse = Event; export interface UpdateEventInput extends MicrosoftCalendarRequestOptions { userId: string; eventId: string; - event: Event; + event: Partial; } export type UpdateEventResponse = Event; diff --git a/packages/providers/src/calendars/microsoft-calendar/events/format.ts b/packages/providers/src/calendars/microsoft-calendar/events/format.ts index 0b902020..e18988bd 100644 --- a/packages/providers/src/calendars/microsoft-calendar/events/format.ts +++ b/packages/providers/src/calendars/microsoft-calendar/events/format.ts @@ -164,7 +164,7 @@ interface FormatEventPatchOptions { export function formatEventPatch( event: UpdateEventPatch, options: FormatEventPatchOptions = {}, -): MicrosoftEvent { +): Partial { const metadata = formatMetadata(event.metadata); return { diff --git a/packages/providers/src/calendars/microsoft-calendar/events/parse.ts b/packages/providers/src/calendars/microsoft-calendar/events/parse.ts index 386d5ddf..1bf5dac2 100644 --- a/packages/providers/src/calendars/microsoft-calendar/events/parse.ts +++ b/packages/providers/src/calendars/microsoft-calendar/events/parse.ts @@ -26,18 +26,18 @@ interface ParseEventOptions { function parseStart(event: MicrosoftEvent) { if (event.isAllDay) { - return parseDate(event.start!.dateTime!); + return parseDate(event.start.dateTime); } - return parseDateTime(event.start!.dateTime!, event.start!.timeZone!); + return parseDateTime(event.start.dateTime, event.start.timeZone); } function parseEnd(event: MicrosoftEvent) { if (event.isAllDay) { - return parseDate(event.end!.dateTime!); + return parseDate(event.end.dateTime); } - return parseDateTime(event.end!.dateTime!, event.end!.timeZone!); + return parseDateTime(event.end.dateTime, event.end.timeZone); } function parseVisibility( @@ -113,7 +113,7 @@ function parseOriginalEndTimeZone(event: MicrosoftEvent) { } function parseRecurrenceTimeZone(event: MicrosoftEvent) { - if (!event.recurrence?.range?.recurrenceTimeZone) { + if (!event.recurrence?.range.recurrenceTimeZone) { return {}; } @@ -145,10 +145,6 @@ export function parseEvent({ calendar, event, }: ParseEventOptions): CalendarEvent { - if (!event.start || !event.end) { - throw new Error("Event start or end is missing"); - } - return { id: event.id!, title: event.subject!, @@ -201,8 +197,8 @@ function parseAttendeeStatus( export function parseAttendee(attendee: MicrosoftEventAttendee): Attendee { return { - email: attendee.emailAddress!.address!, - name: attendee.emailAddress?.name ?? undefined, + 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/parse.ts b/packages/providers/src/calendars/microsoft-calendar/freebusy/parse.ts index 4bcd4fa0..1c095ce4 100644 --- a/packages/providers/src/calendars/microsoft-calendar/freebusy/parse.ts +++ b/packages/providers/src/calendars/microsoft-calendar/freebusy/parse.ts @@ -17,8 +17,8 @@ export function parseScheduleItemStatus(status: ScheduleItem["status"]) { export function parseScheduleItem(item: ScheduleItem) { return { - start: parseDateTime(item.start!.dateTime!, item.start!.timeZone!), - end: parseDateTime(item.end!.dateTime!, item.end!.timeZone!), + start: parseDateTime(item.start.dateTime, item.start.timeZone), + end: parseDateTime(item.end.dateTime, item.end.timeZone), status: parseScheduleItemStatus(item.status), }; } diff --git a/packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts b/packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts index 4497442d..5001a21b 100644 --- a/packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts +++ b/packages/providers/src/calendars/microsoft-calendar/recurrence/parse.ts @@ -30,17 +30,13 @@ export function parseRecurrence( ): Recurrence | undefined { const { pattern, range } = recurrence; - if (!pattern?.type) { - return undefined; - } - const shared: Recurrence = { - ...(pattern.interval !== undefined ? { interval: pattern.interval } : {}), - ...(range?.type === "numbered" && range.numberOfOccurrences !== undefined + interval: pattern.interval, + ...(range.type === "numbered" && range.numberOfOccurrences !== undefined ? { count: range.numberOfOccurrences } : {}), // Graph's endDate is inclusive, matching RFC 5545 UNTIL for date values. - ...(range?.type === "endDate" && range.endDate + ...(range.type === "endDate" && range.endDate ? { until: Temporal.PlainDate.from(range.endDate) } : {}), }; From 14ad35e40b5071f3c44888072dfd07479a1a505d Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Sat, 25 Jul 2026 17:44:18 +0200 Subject: [PATCH 3/7] wip --- .../src/groups/calendar-view/interfaces.ts | 5 +- .../calendar/calendar-view/interfaces.ts | 6 +- .../calendar/events/instances/interfaces.ts | 6 +- .../src/groups/calendar/events/interfaces.ts | 5 +- .../src/groups/events/instances/interfaces.ts | 5 +- .../src/groups/events/interfaces.ts | 5 +- packages/microsoft-calendar/src/interfaces.ts | 7 + .../calendars/calendar-view/interfaces.ts | 3 +- .../calendars/events/instances/interfaces.ts | 3 +- .../calendars/events/interfaces.ts | 6 +- .../src/users/calendar-view/interfaces.ts | 5 +- .../calendar/calendar-view/interfaces.ts | 6 +- .../calendar/events/instances/interfaces.ts | 6 +- .../src/users/calendar/events/interfaces.ts | 5 +- .../calendars/calendar-view/interfaces.ts | 5 +- .../calendars/events/instances/interfaces.ts | 5 +- .../src/users/calendars/events/interfaces.ts | 5 +- .../src/users/events/instances/interfaces.ts | 5 +- .../src/users/events/interfaces.ts | 3 +- .../google-calendar/calendars/index.ts | 10 +- .../calendars/{utils.ts => parse.ts} | 6 +- .../conferences/utils.ts => conferences.ts} | 77 ++- .../google-calendar/events/format.ts | 297 +++++++++ .../calendars/google-calendar/events/index.ts | 45 +- .../calendars/google-calendar/events/parse.ts | 261 ++++++++ .../calendars/google-calendar/events/utils.ts | 607 ------------------ .../google-calendar/freebusy/index.ts | 4 +- .../freebusy/{utils.ts => parse.ts} | 2 +- .../microsoft-calendar/events/index.ts | 7 +- .../providers/src/conferencing/google-meet.ts | 8 +- 30 files changed, 744 insertions(+), 676 deletions(-) rename packages/providers/src/calendars/google-calendar/calendars/{utils.ts => parse.ts} (80%) rename packages/providers/src/calendars/google-calendar/{events/conferences/utils.ts => conferences.ts} (70%) create mode 100644 packages/providers/src/calendars/google-calendar/events/format.ts create mode 100644 packages/providers/src/calendars/google-calendar/events/parse.ts delete mode 100644 packages/providers/src/calendars/google-calendar/events/utils.ts rename packages/providers/src/calendars/google-calendar/freebusy/{utils.ts => parse.ts} (95%) diff --git a/packages/microsoft-calendar/src/groups/calendar-view/interfaces.ts b/packages/microsoft-calendar/src/groups/calendar-view/interfaces.ts index 143a6030..e3b9cf6b 100644 --- a/packages/microsoft-calendar/src/groups/calendar-view/interfaces.ts +++ b/packages/microsoft-calendar/src/groups/calendar-view/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -35,4 +36,6 @@ export interface GroupCalendarViewDeltaInput extends MicrosoftCalendarRequestOpt expand?: string[]; } -export type GroupCalendarViewDeltaResponse = DeltaCollectionResponse; +export type GroupCalendarViewDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/groups/calendar/calendar-view/interfaces.ts b/packages/microsoft-calendar/src/groups/calendar/calendar-view/interfaces.ts index 62ae2330..6305a84a 100644 --- a/packages/microsoft-calendar/src/groups/calendar/calendar-view/interfaces.ts +++ b/packages/microsoft-calendar/src/groups/calendar/calendar-view/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -35,5 +36,6 @@ export interface GroupCalendarCalendarViewDeltaInput extends MicrosoftCalendarRe expand?: string[]; } -export type GroupCalendarCalendarViewDeltaResponse = - DeltaCollectionResponse; +export type GroupCalendarCalendarViewDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/groups/calendar/events/instances/interfaces.ts b/packages/microsoft-calendar/src/groups/calendar/events/instances/interfaces.ts index 1dd463df..2a913bf9 100644 --- a/packages/microsoft-calendar/src/groups/calendar/events/instances/interfaces.ts +++ b/packages/microsoft-calendar/src/groups/calendar/events/instances/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -37,5 +38,6 @@ export interface GroupCalendarEventInstanceDeltaInput extends MicrosoftCalendarR expand?: string[]; } -export type GroupCalendarEventInstanceDeltaResponse = - DeltaCollectionResponse; +export type GroupCalendarEventInstanceDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/groups/calendar/events/interfaces.ts b/packages/microsoft-calendar/src/groups/calendar/events/interfaces.ts index a2003214..4e20f0a4 100644 --- a/packages/microsoft-calendar/src/groups/calendar/events/interfaces.ts +++ b/packages/microsoft-calendar/src/groups/calendar/events/interfaces.ts @@ -2,6 +2,7 @@ import type { Calendar, DateTimeTimeZone, DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -52,7 +53,9 @@ export interface GroupCalendarEventDeltaInput extends MicrosoftCalendarRequestOp expand?: string[]; } -export type GroupCalendarEventDeltaResponse = DeltaCollectionResponse; +export type GroupCalendarEventDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; export interface GroupCalendarDeleteEventInput extends MicrosoftCalendarRequestOptions { groupId: string; diff --git a/packages/microsoft-calendar/src/groups/events/instances/interfaces.ts b/packages/microsoft-calendar/src/groups/events/instances/interfaces.ts index c640a086..5fe941ca 100644 --- a/packages/microsoft-calendar/src/groups/events/instances/interfaces.ts +++ b/packages/microsoft-calendar/src/groups/events/instances/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -37,4 +38,6 @@ export interface GroupEventInstanceDeltaInput extends MicrosoftCalendarRequestOp expand?: string[]; } -export type GroupEventInstanceDeltaResponse = DeltaCollectionResponse; +export type GroupEventInstanceDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/groups/events/interfaces.ts b/packages/microsoft-calendar/src/groups/events/interfaces.ts index a2b82acf..4f57c05a 100644 --- a/packages/microsoft-calendar/src/groups/events/interfaces.ts +++ b/packages/microsoft-calendar/src/groups/events/interfaces.ts @@ -2,6 +2,7 @@ import type { Calendar, DateTimeTimeZone, DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -52,7 +53,9 @@ export interface GroupEventDeltaInput extends MicrosoftCalendarRequestOptions { expand?: string[]; } -export type GroupEventDeltaResponse = DeltaCollectionResponse; +export type GroupEventDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; export interface GroupDeleteEventInput extends MicrosoftCalendarRequestOptions { groupId: string; diff --git a/packages/microsoft-calendar/src/interfaces.ts b/packages/microsoft-calendar/src/interfaces.ts index 1c6af7bc..e95283f5 100644 --- a/packages/microsoft-calendar/src/interfaces.ts +++ b/packages/microsoft-calendar/src/interfaces.ts @@ -27,6 +27,13 @@ export interface DeltaCollectionResponse extends CollectionResponse { "@odata.deltaLink"?: string | null; } +export interface DeltaRemovedEvent { + id: string; + "@removed": { + reason: "deleted"; + }; +} + export type ODataCountResponse = number; export interface Attachment extends Entity { diff --git a/packages/microsoft-calendar/src/users/calendar-groups/calendars/calendar-view/interfaces.ts b/packages/microsoft-calendar/src/users/calendar-groups/calendars/calendar-view/interfaces.ts index c810265b..830b72bc 100644 --- a/packages/microsoft-calendar/src/users/calendar-groups/calendars/calendar-view/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar-groups/calendars/calendar-view/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -41,4 +42,4 @@ export interface CalendarGroupCalendarCalendarViewDeltaInput extends MicrosoftCa } export type CalendarGroupCalendarCalendarViewDeltaResponse = - DeltaCollectionResponse; + DeltaCollectionResponse; diff --git a/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/instances/interfaces.ts b/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/instances/interfaces.ts index fc79e90b..72d7dc8e 100644 --- a/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/instances/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/instances/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -43,4 +44,4 @@ export interface CalendarGroupCalendarEventInstanceDeltaInput extends MicrosoftC } export type CalendarGroupCalendarEventInstanceDeltaResponse = - DeltaCollectionResponse; + DeltaCollectionResponse; diff --git a/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/interfaces.ts b/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/interfaces.ts index 02cc8616..2c82323c 100644 --- a/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar-groups/calendars/events/interfaces.ts @@ -2,6 +2,7 @@ import type { Calendar, DateTimeTimeZone, DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -60,8 +61,9 @@ export interface CalendarGroupCalendarEventDeltaInput extends MicrosoftCalendarR expand?: string[]; } -export type CalendarGroupCalendarEventDeltaResponse = - DeltaCollectionResponse; +export type CalendarGroupCalendarEventDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; export interface CalendarGroupCalendarDeleteEventInput extends MicrosoftCalendarRequestOptions { userId: string; diff --git a/packages/microsoft-calendar/src/users/calendar-view/interfaces.ts b/packages/microsoft-calendar/src/users/calendar-view/interfaces.ts index 741330e4..a3864e08 100644 --- a/packages/microsoft-calendar/src/users/calendar-view/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar-view/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -35,4 +36,6 @@ export interface CalendarViewDeltaInput extends MicrosoftCalendarRequestOptions expand?: string[]; } -export type CalendarViewDeltaResponse = DeltaCollectionResponse; +export type CalendarViewDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/users/calendar/calendar-view/interfaces.ts b/packages/microsoft-calendar/src/users/calendar/calendar-view/interfaces.ts index 6f3db933..88f4f2a1 100644 --- a/packages/microsoft-calendar/src/users/calendar/calendar-view/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar/calendar-view/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -35,5 +36,6 @@ export interface DefaultCalendarCalendarViewDeltaInput extends MicrosoftCalendar expand?: string[]; } -export type DefaultCalendarCalendarViewDeltaResponse = - DeltaCollectionResponse; +export type DefaultCalendarCalendarViewDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/users/calendar/events/instances/interfaces.ts b/packages/microsoft-calendar/src/users/calendar/events/instances/interfaces.ts index c17aecca..00caab6e 100644 --- a/packages/microsoft-calendar/src/users/calendar/events/instances/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar/events/instances/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -37,5 +38,6 @@ export interface DefaultCalendarEventInstanceDeltaInput extends MicrosoftCalenda expand?: string[]; } -export type DefaultCalendarEventInstanceDeltaResponse = - DeltaCollectionResponse; +export type DefaultCalendarEventInstanceDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/users/calendar/events/interfaces.ts b/packages/microsoft-calendar/src/users/calendar/events/interfaces.ts index 474ec18e..1343f996 100644 --- a/packages/microsoft-calendar/src/users/calendar/events/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendar/events/interfaces.ts @@ -2,6 +2,7 @@ import type { Calendar, DateTimeTimeZone, DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -52,7 +53,9 @@ export interface DefaultCalendarEventDeltaInput extends MicrosoftCalendarRequest expand?: string[]; } -export type DefaultCalendarEventDeltaResponse = DeltaCollectionResponse; +export type DefaultCalendarEventDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; export interface DefaultCalendarDeleteEventInput extends MicrosoftCalendarRequestOptions { userId: string; diff --git a/packages/microsoft-calendar/src/users/calendars/calendar-view/interfaces.ts b/packages/microsoft-calendar/src/users/calendars/calendar-view/interfaces.ts index 26ab4b13..1e8c4505 100644 --- a/packages/microsoft-calendar/src/users/calendars/calendar-view/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendars/calendar-view/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -37,4 +38,6 @@ export interface CalendarCalendarViewDeltaInput extends MicrosoftCalendarRequest expand?: string[]; } -export type CalendarCalendarViewDeltaResponse = DeltaCollectionResponse; +export type CalendarCalendarViewDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/users/calendars/events/instances/interfaces.ts b/packages/microsoft-calendar/src/users/calendars/events/instances/interfaces.ts index a6a66732..3732c6da 100644 --- a/packages/microsoft-calendar/src/users/calendars/events/instances/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendars/events/instances/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -39,4 +40,6 @@ export interface CalendarEventInstanceDeltaInput extends MicrosoftCalendarReques expand?: string[]; } -export type CalendarEventInstanceDeltaResponse = DeltaCollectionResponse; +export type CalendarEventInstanceDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/users/calendars/events/interfaces.ts b/packages/microsoft-calendar/src/users/calendars/events/interfaces.ts index ed83a5eb..8d22d1b8 100644 --- a/packages/microsoft-calendar/src/users/calendars/events/interfaces.ts +++ b/packages/microsoft-calendar/src/users/calendars/events/interfaces.ts @@ -2,6 +2,7 @@ import type { Calendar, DateTimeTimeZone, DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -56,7 +57,9 @@ export interface CalendarEventDeltaInput extends MicrosoftCalendarRequestOptions expand?: string[]; } -export type CalendarEventDeltaResponse = DeltaCollectionResponse; +export type CalendarEventDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; export interface CalendarDeleteEventInput extends MicrosoftCalendarRequestOptions { userId: string; diff --git a/packages/microsoft-calendar/src/users/events/instances/interfaces.ts b/packages/microsoft-calendar/src/users/events/instances/interfaces.ts index 25b24d3e..e7355219 100644 --- a/packages/microsoft-calendar/src/users/events/instances/interfaces.ts +++ b/packages/microsoft-calendar/src/users/events/instances/interfaces.ts @@ -1,5 +1,6 @@ import type { DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -37,4 +38,6 @@ export interface InstanceDeltaInput extends MicrosoftCalendarRequestOptions { expand?: string[]; } -export type InstanceDeltaResponse = DeltaCollectionResponse; +export type InstanceDeltaResponse = DeltaCollectionResponse< + Event | DeltaRemovedEvent +>; diff --git a/packages/microsoft-calendar/src/users/events/interfaces.ts b/packages/microsoft-calendar/src/users/events/interfaces.ts index b44f6bb6..0292c39d 100644 --- a/packages/microsoft-calendar/src/users/events/interfaces.ts +++ b/packages/microsoft-calendar/src/users/events/interfaces.ts @@ -2,6 +2,7 @@ import type { Calendar, DateTimeTimeZone, DeltaCollectionResponse, + DeltaRemovedEvent, Event, EventCollectionResponse, MicrosoftCalendarRequestOptions, @@ -52,7 +53,7 @@ export interface DeltaInput extends MicrosoftCalendarRequestOptions { expand?: string[]; } -export type DeltaResponse = DeltaCollectionResponse; +export type DeltaResponse = DeltaCollectionResponse; export interface DeleteEventInput extends MicrosoftCalendarRequestOptions { userId: string; diff --git a/packages/providers/src/calendars/google-calendar/calendars/index.ts b/packages/providers/src/calendars/google-calendar/calendars/index.ts index 7deb2672..0f44f6c5 100644 --- a/packages/providers/src/calendars/google-calendar/calendars/index.ts +++ b/packages/providers/src/calendars/google-calendar/calendars/index.ts @@ -8,7 +8,7 @@ import type { CalendarProviderCalendarsUpdateOptions, } from "../../../interfaces/providers"; import { ProviderError } from "../../../lib/provider-error"; -import { parseGoogleCalendarCalendarListEntry } from "./utils"; +import { parseCalendar } from "./parse"; export class GoogleCalendarCalendars { constructor( @@ -21,7 +21,7 @@ export class GoogleCalendarCalendars { const { items } = await this.client.calendarList.list({}); return items.map((calendar) => - parseGoogleCalendarCalendarListEntry({ + parseCalendar({ providerAccountId: this.providerAccountId, entry: calendar, }), @@ -35,7 +35,7 @@ export class GoogleCalendarCalendars { return this.withErrorHandler("calendars.get", async () => { const calendar = await this.client.calendarList.get({ calendarId }); - return parseGoogleCalendarCalendarListEntry({ + return parseCalendar({ providerAccountId: this.providerAccountId, entry: calendar, }); @@ -52,7 +52,7 @@ export class GoogleCalendarCalendars { timeZone: calendar.timeZone, }); - return parseGoogleCalendarCalendarListEntry({ + return parseCalendar({ providerAccountId: this.providerAccountId, entry: createdCalendar, }); @@ -69,7 +69,7 @@ export class GoogleCalendarCalendars { summary: calendar.name, }); - return parseGoogleCalendarCalendarListEntry({ + return parseCalendar({ providerAccountId: this.providerAccountId, entry: updatedCalendar, }); diff --git a/packages/providers/src/calendars/google-calendar/calendars/utils.ts b/packages/providers/src/calendars/google-calendar/calendars/parse.ts similarity index 80% rename from packages/providers/src/calendars/google-calendar/calendars/utils.ts rename to packages/providers/src/calendars/google-calendar/calendars/parse.ts index bc4b76b2..0fe51e5b 100644 --- a/packages/providers/src/calendars/google-calendar/calendars/utils.ts +++ b/packages/providers/src/calendars/google-calendar/calendars/parse.ts @@ -1,15 +1,15 @@ import type { Calendar } from "../../../interfaces"; import type { GoogleCalendarCalendarListEntry } from "../interfaces"; -interface ParsedGoogleCalendarCalendarListEntryOptions { +interface ParseCalendarOptions { providerAccountId: string; entry: GoogleCalendarCalendarListEntry; } -export function parseGoogleCalendarCalendarListEntry({ +export function parseCalendar({ providerAccountId, entry, -}: ParsedGoogleCalendarCalendarListEntryOptions): Calendar { +}: ParseCalendarOptions): Calendar { return { id: entry.id!, name: entry.summaryOverride ?? entry.summary!, diff --git a/packages/providers/src/calendars/google-calendar/events/conferences/utils.ts b/packages/providers/src/calendars/google-calendar/conferences.ts similarity index 70% rename from packages/providers/src/calendars/google-calendar/events/conferences/utils.ts rename to packages/providers/src/calendars/google-calendar/conferences.ts index 81a56319..e33fb8ab 100644 --- a/packages/providers/src/calendars/google-calendar/events/conferences/utils.ts +++ b/packages/providers/src/calendars/google-calendar/conferences.ts @@ -1,11 +1,13 @@ import type { ConferenceDataInput, CreateConferenceRequest, + EntryPoint, + EntryPointInput, } from "@analog/google-calendar"; import { detectMeetingLink } from "@analog/meeting-links"; -import type { Conference } from "../../../../interfaces"; -import type { GoogleCalendarEvent } from "../../interfaces"; +import type { Conference } from "../../interfaces"; +import type { GoogleCalendarEvent } from "./interfaces"; function parseConferenceRequestStatus(status?: string) { if (status === "pending" || status === "success" || status === "failure") { @@ -123,7 +125,7 @@ function isCreatingConferenceRequest( return createRequest.status?.statusCode !== "success"; } -export function parseConferenceData( +export function parseConference( event: GoogleCalendarEvent, ): Conference | undefined { if (isCreatingConferenceRequest(event.conferenceData?.createRequest)) { @@ -212,7 +214,7 @@ export function parseConferenceData( }; } -export function toConferenceData( +export function formatConference( conference: Conference, ): ConferenceDataInput | undefined { if (conference.type === "conference") { @@ -228,3 +230,70 @@ export function toConferenceData( }, }; } + +function formatEntryPointInput(entryPoint: EntryPoint): EntryPointInput { + return { + accessCode: entryPoint.accessCode, + entryPointFeatures: entryPoint.entryPointFeatures, + entryPointType: entryPoint.entryPointType!, + label: entryPoint.label, + meetingCode: entryPoint.meetingCode, + passcode: entryPoint.passcode, + password: entryPoint.password, + pin: entryPoint.pin, + regionCode: entryPoint.regionCode, + uri: entryPoint.uri!, + }; +} + +export function formatConferenceInput( + conferenceData: GoogleCalendarEvent["conferenceData"], +): ConferenceDataInput | undefined { + if (!conferenceData) { + return undefined; + } + + // A createRequest with status "success" is a completed conference (see + // isCreatingConferenceRequest above), so copy it instead of re-echoing the + // request. + if ( + (!conferenceData.createRequest || + conferenceData.createRequest.status?.statusCode === "success") && + conferenceData.entryPoints?.length + ) { + const [entryPoint, ...entryPoints] = conferenceData.entryPoints; + + return { + ...conferenceData, + conferenceSolution: { + iconUri: conferenceData.conferenceSolution?.iconUri, + key: { + type: conferenceData.conferenceSolution!.key!.type!, + }, + name: conferenceData.conferenceSolution?.name, + }, + entryPoints: [ + formatEntryPointInput(entryPoint!), + ...entryPoints.map(formatEntryPointInput), + ], + }; + } + + if (!conferenceData.createRequest) { + return undefined; + } + + // Re-sending the same requestId is an idempotent no-op that keeps a + // conferenceData body present; omitting it would clear the conference + // because updates always send conferenceDataVersion=1. + return { + createRequest: { + requestId: conferenceData.createRequest.requestId!, + ...(conferenceData.createRequest.conferenceSolutionKey?.type && { + conferenceSolutionKey: { + type: conferenceData.createRequest.conferenceSolutionKey.type, + }, + }), + }, + }; +} diff --git a/packages/providers/src/calendars/google-calendar/events/format.ts b/packages/providers/src/calendars/google-calendar/events/format.ts new file mode 100644 index 00000000..78e3ea9d --- /dev/null +++ b/packages/providers/src/calendars/google-calendar/events/format.ts @@ -0,0 +1,297 @@ +import type { + ConferenceDataInput, + EventAttendee, + EventAttendeeInput, + EventDateTime, + EventInput, +} from "@analog/google-calendar"; +import { Temporal } from "temporal-polyfill"; + +import type { CreateEventInput, UpdateEventPatch } from "@repo/schemas"; + +import type { Attendee, AttendeeStatus } from "../../../interfaces"; +import { toRecurrenceProperties } from "../../../lib/recurrences/export"; +import { formatConference, formatConferenceInput } from "../conferences"; +import type { + GoogleCalendarDate, + GoogleCalendarDateTime, + GoogleCalendarEvent, + GoogleCalendarEventAttendeeResponseStatus, +} from "../interfaces"; + +function formatAttendeeInput(attendee: EventAttendee): EventAttendeeInput { + return { + additionalGuests: attendee.additionalGuests, + comment: attendee.comment, + displayName: attendee.displayName, + email: attendee.email!, + optional: attendee.optional, + resource: attendee.resource, + responseStatus: attendee.responseStatus, + }; +} + +function formatReminders( + reminders: GoogleCalendarEvent["reminders"], +): EventInput["reminders"] { + if (!reminders) { + return undefined; + } + + return { + overrides: reminders.overrides?.map((reminder) => ({ + ...reminder, + method: reminder.method!, + minutes: reminder.minutes!, + })), + useDefault: reminders.useDefault, + }; +} + +export function formatEventInput(event: GoogleCalendarEvent) { + if (event.eventType && event.eventType !== "default") { + throw new Error( + `Google Calendar ${event.eventType} events cannot be updated`, + ); + } + + return { + anyoneCanAddSelf: event.anyoneCanAddSelf, + attachments: event.attachments?.map((attachment) => ({ + fileUrl: attachment.fileUrl!, + iconLink: attachment.iconLink, + mimeType: attachment.mimeType, + title: attachment.title, + })), + attendees: event.attendees?.map(formatAttendeeInput), + attendeesOmitted: event.attendeesOmitted, + conferenceData: formatConferenceInput(event.conferenceData), + description: event.description, + end: event.end!, + eventType: "default" as const, + extendedProperties: event.extendedProperties, + guestsCanInviteOthers: event.guestsCanInviteOthers, + guestsCanModify: event.guestsCanModify, + guestsCanSeeOtherGuests: event.guestsCanSeeOtherGuests, + location: event.location, + originalStartTime: event.originalStartTime, + recurrence: event.recurrence, + reminders: formatReminders(event.reminders), + sequence: event.sequence, + source: event.source, + start: event.start!, + status: event.status, + summary: event.summary, + transparency: event.transparency, + visibility: event.visibility, + ...(event.eventLabelId + ? { eventLabelId: event.eventLabelId, eventLabelVersion: 1 as const } + : { colorId: event.colorId }), + }; +} + +export function formatDate( + value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, +): GoogleCalendarDate | GoogleCalendarDateTime { + if (value instanceof Temporal.PlainDate) { + return { + date: value.toString(), + }; + } + + if (value instanceof Temporal.Instant) { + return { + dateTime: value.toString(), + }; + } + + return { + dateTime: value.toString({ timeZoneName: "never", offset: "auto" }), + timeZone: value.timeZoneId, + }; +} + +export function formatAttendee(attendee: Attendee): EventAttendeeInput { + return { + email: attendee.email, + displayName: attendee.name, + ...(attendee.type === "optional" ? { optional: true } : {}), + ...(attendee.type === "resource" ? { resource: true } : {}), + responseStatus: formatAttendeeStatus(attendee.status), + comment: attendee.comment, + additionalGuests: attendee.additionalGuests, + }; +} + +function formatRecurrence(event: CreateEventInput | UpdateEventPatch) { + // TODO: how to handle recurrence when the time zone is changed (i.e. until, rDate, exDate). + if (event.recurrence === null) { + return []; + } + + if (!event.recurrence) { + return undefined; + } + + return toRecurrenceProperties(event.recurrence); +} + +function formatAttendees(event: CreateEventInput | UpdateEventPatch) { + if (!event.attendees) { + return undefined; + } + + return event.attendees.map(formatAttendee); +} + +function formatEventConference(event: CreateEventInput | UpdateEventPatch) { + if (event.conference === null) { + return null; + } + + if (!event.conference) { + return undefined; + } + + return formatConference(event.conference); +} + +function formatTransparency( + event: CreateEventInput | UpdateEventPatch, +): "opaque" | "transparent" | undefined { + if (!event.availability) { + return undefined; + } + + if (event.availability === "free") { + return "transparent"; + } + + return "opaque"; +} + +export function formatEvent(event: CreateEventInput) { + if (event.color) { + throw new Error("Google Calendar event colors are not supported"); + } + + return { + id: event.id, + summary: event.title, + description: event.description, + location: event.location, + visibility: event.visibility, + start: formatDate(event.start), + end: formatDate(event.end), + transparency: formatTransparency(event), + attendees: formatAttendees(event), + conferenceData: event.conference + ? formatConference(event.conference) + : undefined, + // Should always be 1 to ensure conference data is retained for all event modification requests. + conferenceDataVersion: 1 as const, + // TODO: how to handle recurrence when the time zone is changed (i.e. until, rDate, exDate). + recurrence: formatRecurrence(event), + }; +} + +interface EventUpdateOverrides { + attendees?: EventAttendeeInput[]; + calendarId: string; + conferenceData?: ConferenceDataInput | null; + conferenceDataVersion: 1; + description?: string | null; + end: EventDateTime; + location?: string | null; + recurrence?: string[]; + start: EventDateTime; + summary?: string; + transparency?: "opaque" | "transparent"; + visibility?: "confidential" | "default" | "private" | "public"; +} + +export function formatEventPatch( + event: UpdateEventPatch, + existingEvent: GoogleCalendarEvent, +): EventUpdateOverrides { + if (event.color) { + throw new Error("Google Calendar event colors are not supported"); + } + + return { + calendarId: event.calendar.id, + // Should always be 1 to ensure conference data is retained for all event modification requests. + conferenceDataVersion: 1, + start: event.start ? formatDate(event.start) : existingEvent.start!, + end: event.end ? formatDate(event.end) : existingEvent.end!, + // TODO: how to handle recurrence when the time zone is changed (i.e. until, rDate, exDate). + recurrence: + event.recurrence !== undefined + ? formatRecurrence(event) + : existingEvent.recurrence, + ...(event.title !== undefined ? { summary: event.title } : {}), + // A null patch value is sent as an explicit null in the PUT body to clear + // the field (recurrence clears as [] instead — the insert type has no null). + ...(event.description !== undefined + ? { description: event.description } + : {}), + ...(event.location !== undefined ? { location: event.location } : {}), + ...(event.visibility !== undefined ? { visibility: event.visibility } : {}), + ...(event.availability !== undefined + ? { transparency: formatTransparency(event) } + : {}), + ...(event.attendees !== undefined + ? { attendees: formatAttendees(event) } + : {}), + // A "conference"-shaped patch is display-only and maps to no input: skip + // the key so the echoed conferenceData survives the full-replace PUT. + ...(event.conference === null || event.conference?.type === "create" + ? { conferenceData: formatEventConference(event) } + : {}), + }; +} + +export function formatAttendeeStatus( + status: AttendeeStatus, +): GoogleCalendarEventAttendeeResponseStatus { + if (status === "unknown") { + return "needsAction"; + } + + return status; +} + +export function attendeesWithSelfResponse( + attendees: EventAttendee[] | undefined, + status: AttendeeStatus, + comment?: string | null, +) { + if (!attendees) { + throw new Error("Event has no attendees"); + } + + const attendee = attendees.find((attendee) => attendee.self); + + if (!attendee) { + throw new Error("User is not an attendee"); + } + + const input = formatAttendeeInput(attendee); + + if (comment === undefined) { + return [ + { + ...input, + responseStatus: formatAttendeeStatus(status), + }, + ]; + } + + return [ + { + ...input, + comment, + responseStatus: formatAttendeeStatus(status), + }, + ]; +} diff --git a/packages/providers/src/calendars/google-calendar/events/index.ts b/packages/providers/src/calendars/google-calendar/events/index.ts index 6fb0cfea..95995259 100644 --- a/packages/providers/src/calendars/google-calendar/events/index.ts +++ b/packages/providers/src/calendars/google-calendar/events/index.ts @@ -24,14 +24,13 @@ import type { import { ProviderError } from "../../../lib/provider-error"; import { attendeesWithSelfResponse, - createEventParams, - parseGoogleCalendarEventDate, - parseGoogleCalendarEvent, - toGoogleCalendarAttendee, - toGoogleCalendarAttendeeResponseStatus, - toGoogleCalendarEventInput, - updateEventParams, -} from "./utils"; + formatAttendee, + formatAttendeeStatus, + formatEvent, + formatEventInput, + formatEventPatch, +} from "./format"; +import { parseEvent, parseEventDate } from "./parse"; const MAX_EVENTS_PER_CALENDAR = 250; @@ -57,7 +56,7 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { }); const events = (items ?? []).map((event) => - parseGoogleCalendarEvent({ + parseEvent({ calendar, event, defaultTimeZone: timeZone ?? "UTC", @@ -136,9 +135,7 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { event: { id: event.id!, recurringEventId: event.recurringEventId, - originalStartTime: parseGoogleCalendarEventDate( - event.originalStartTime!, - ), + originalStartTime: parseEventDate(event.originalStartTime!), calendar: { id: calendar.id, provider: calendar.provider, @@ -163,7 +160,7 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { changes.push({ status: "updated", - event: parseGoogleCalendarEvent({ + event: parseEvent({ calendar, event, defaultTimeZone: timeZone, @@ -239,7 +236,7 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { eventId, }); - return parseGoogleCalendarEvent({ + return parseEvent({ calendar, event, defaultTimeZone: timeZone ?? "UTC", @@ -256,11 +253,11 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { try { const createdEvent = await this.client.events.insert({ calendarId: calendar.id, - ...createEventParams(event), + ...formatEvent(event), sendUpdates: sendUpdate ? "all" : "none", }); - return parseGoogleCalendarEvent({ + return parseEvent({ calendar, event: createdEvent, }); @@ -302,8 +299,8 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { const updatedEvent = await this.client.events.update({ eventId, - ...toGoogleCalendarEventInput(existingEvent), - ...updateEventParams(event, existingEvent), + ...formatEventInput(existingEvent), + ...formatEventPatch(event, existingEvent), ...(response ? event.attendees === undefined ? { @@ -320,15 +317,13 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { attendees: event.attendees.map((attendee) => attendee.email === selfEmail ? { - ...toGoogleCalendarAttendee(attendee), + ...formatAttendee(attendee), ...(response.comment !== undefined ? { comment: response.comment } : {}), - responseStatus: toGoogleCalendarAttendeeResponseStatus( - response.status, - ), + responseStatus: formatAttendeeStatus(response.status), } - : toGoogleCalendarAttendee(attendee), + : formatAttendee(attendee), ), } : {}), @@ -339,7 +334,7 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { headers: { "If-Match": event.etag ?? existingEvent.etag }, }); - return parseGoogleCalendarEvent({ + return parseEvent({ calendar, event: updatedEvent, }); @@ -378,7 +373,7 @@ export class GoogleCalendarEvents implements CalendarProviderEvents { sendUpdates: sendUpdate ? "all" : "none", }); - return parseGoogleCalendarEvent({ + return parseEvent({ calendar: destinationCalendar, event, }); diff --git a/packages/providers/src/calendars/google-calendar/events/parse.ts b/packages/providers/src/calendars/google-calendar/events/parse.ts new file mode 100644 index 00000000..4b952188 --- /dev/null +++ b/packages/providers/src/calendars/google-calendar/events/parse.ts @@ -0,0 +1,261 @@ +import type { EventDateTime } from "@analog/google-calendar"; +import { Temporal } from "temporal-polyfill"; + +import type { + Attendee, + AttendeeStatus, + Calendar, + CalendarEvent, +} from "../../../interfaces"; +import { parseTextRecurrence } from "../../../lib/recurrences/parse"; +import { parseConference } from "../conferences"; +import type { + GoogleCalendarDate, + GoogleCalendarDateTime, + GoogleCalendarEvent, + GoogleCalendarEventAttendee, + GoogleCalendarEventAttendeeResponseStatus, +} from "../interfaces"; + +const GMT_OFFSET = + /^GMT(?[+-])(?\d{1,2})(?::?(?[0-5]\d))?$/; + +function parseTimeZone(timeZone: string) { + // Normalize Google-style GMT offsets to IANA or UTC-compatible time zones + if (!timeZone) { + return timeZone; + } + + if (timeZone === "GMT") { + return "UTC"; + } + + const match = GMT_OFFSET.exec(timeZone); + + if (!match?.groups) { + return timeZone; + } + + const { sign, hours, minutes } = match.groups; + + if (!sign || !hours) { + return timeZone; + } + + // If minutes are provided and not 00, fall back to a UTC offset which Temporal supports. + const hh = hours.padStart(2, "0"); + const mm = minutes && minutes !== "00" ? minutes : "00"; + + return `${sign}${hh}:${mm}`; +} + +function parseDate({ date }: GoogleCalendarDate) { + return Temporal.PlainDate.from(date); +} + +function parseDateTime({ dateTime, timeZone }: GoogleCalendarDateTime) { + const instant = Temporal.Instant.from(dateTime); + + if (!timeZone) { + return instant; + } + + return instant.toZonedDateTimeISO(parseTimeZone(timeZone)); +} + +export function parseEventDate(value: EventDateTime) { + if (value.date) { + return parseDate({ date: value.date }); + } + + return parseDateTime({ + dateTime: value.dateTime!, + timeZone: value.timeZone, + }); +} + +interface ParseEventOptions { + calendar: Calendar; + event: GoogleCalendarEvent; + defaultTimeZone?: string; +} + +function parseStart(event: GoogleCalendarEvent) { + if (!event.start?.dateTime) { + return parseDate(event.start as GoogleCalendarDate); + } + + return parseDateTime(event.start as GoogleCalendarDateTime); +} + +function parseEnd(event: GoogleCalendarEvent) { + if (!event.start?.dateTime) { + return parseDate(event.end as GoogleCalendarDate); + } + + return parseDateTime(event.end as GoogleCalendarDateTime); +} + +function parseAttendees(event: GoogleCalendarEvent) { + if (!event.attendees) { + return []; + } + + const attendees = event.attendees.map(parseAttendee); + const organizer = attendees.find((attendee) => attendee.organizer); + + if (!organizer) { + return attendees; + } + + if (attendees[0] === organizer) { + return attendees; + } + + return [organizer, ...attendees.filter((attendee) => attendee !== organizer)]; +} + +function parseResponse(event: GoogleCalendarEvent) { + const selfAttendee = event.attendees?.find((a) => a.self); + + if (!selfAttendee) { + return {}; + } + + return { + response: { + status: parseAttendeeStatus(selfAttendee.responseStatus ?? "needsAction"), + comment: selfAttendee.comment, + }, + }; +} + +function parseEventRecurrence( + event: GoogleCalendarEvent, + defaultTimeZone: string, +) { + const recurrence = event.recurrence + ? parseTextRecurrence({ + lines: event.recurrence, + defaultTimeZone: parseTimeZone( + event.start?.timeZone ?? defaultTimeZone, + ), + }) + : undefined; + + if (!recurrence) { + return {}; + } + + return { recurrence }; +} + +function parseCreatedAt(event: GoogleCalendarEvent) { + if (!event.created) { + return {}; + } + + return { createdAt: Temporal.Instant.from(event.created) }; +} + +function parseUpdatedAt(event: GoogleCalendarEvent) { + if (!event.updated) { + return {}; + } + + return { updatedAt: Temporal.Instant.from(event.updated) }; +} + +function parseMetadata(event: GoogleCalendarEvent) { + return { + ...(event.recurrence ? { originalRecurrence: event.recurrence } : {}), + ...(event.recurringEventId + ? { recurringEventId: event.recurringEventId } + : {}), + }; +} + +export function parseEvent({ + calendar, + event, + defaultTimeZone = "UTC", +}: ParseEventOptions): CalendarEvent { + return { + // ID should always be present if not defined Google Calendar will generate one + id: event.id!, + title: event.summary!, + description: event.description, + start: parseStart(event), + end: parseEnd(event), + allDay: !event.start?.dateTime, + location: event.location, + status: event.status, + availability: event.transparency === "transparent" ? "free" : "busy", + attendees: parseAttendees(event), + url: event.htmlLink, + etag: event.etag, + visibility: event.visibility as + | "default" + | "public" + | "private" + | "confidential" + | undefined, + calendar: { + id: calendar.id, + provider: calendar.provider, + }, + readOnly: + calendar.readOnly || + [ + "birthday", + "focusTime", + "fromGmail", + "outOfOffice", + "workingLocation", + ].includes(event.eventType ?? ""), + conference: parseConference(event), + ...parseResponse(event), + ...parseEventRecurrence(event, defaultTimeZone), + ...parseCreatedAt(event), + ...parseUpdatedAt(event), + recurringEventId: event.recurringEventId, + metadata: parseMetadata(event), + } as CalendarEvent; +} + +function parseAttendeeStatus( + status: GoogleCalendarEventAttendeeResponseStatus, +): AttendeeStatus { + if (status === "needsAction") { + return "unknown"; + } + + return status; +} + +function parseAttendeeType( + attendee: GoogleCalendarEventAttendee, +): "required" | "optional" | "resource" { + if (attendee.resource) { + return "resource"; + } + + if (attendee.optional) { + return "optional"; + } + + return "required"; +} + +export function parseAttendee(attendee: GoogleCalendarEventAttendee): Attendee { + return { + id: attendee.id, + email: attendee.email!, + name: attendee.displayName, + status: parseAttendeeStatus(attendee.responseStatus ?? "needsAction"), + type: parseAttendeeType(attendee), + comment: attendee.comment, + organizer: attendee.organizer, + additionalGuests: attendee.additionalGuests, + }; +} diff --git a/packages/providers/src/calendars/google-calendar/events/utils.ts b/packages/providers/src/calendars/google-calendar/events/utils.ts deleted file mode 100644 index aea22e44..00000000 --- a/packages/providers/src/calendars/google-calendar/events/utils.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { Temporal } from "temporal-polyfill"; - -import type { - ConferenceDataInput, - EntryPoint, - EntryPointInput, - EventAttendee, - EventAttendeeInput, - EventDateTime, - EventInput, -} from "@analog/google-calendar"; -import type { CreateEventInput, UpdateEventPatch } from "@repo/schemas"; - -import type { - Attendee, - AttendeeStatus, - Calendar, - CalendarEvent, - Recurrence, -} from "../../../interfaces"; -import { toRecurrenceProperties } from "../../../lib/recurrences/export"; -import { parseTextRecurrence } from "../../../lib/recurrences/parse"; -import type { - GoogleCalendarDate, - GoogleCalendarDateTime, - GoogleCalendarEvent, - GoogleCalendarEventAttendee, - GoogleCalendarEventAttendeeResponseStatus, -} from "../interfaces"; -import { parseConferenceData, toConferenceData } from "./conferences/utils"; - -function toGoogleCalendarEntryPointInput( - entryPoint: EntryPoint, -): EntryPointInput { - return { - accessCode: entryPoint.accessCode, - entryPointFeatures: entryPoint.entryPointFeatures, - entryPointType: entryPoint.entryPointType!, - label: entryPoint.label, - meetingCode: entryPoint.meetingCode, - passcode: entryPoint.passcode, - password: entryPoint.password, - pin: entryPoint.pin, - regionCode: entryPoint.regionCode, - uri: entryPoint.uri!, - }; -} - -function toGoogleCalendarConferenceDataInput( - conferenceData: GoogleCalendarEvent["conferenceData"], -): ConferenceDataInput | undefined { - if (!conferenceData) { - return undefined; - } - - // A createRequest with status "success" is a completed conference (see - // isCreatingConferenceRequest in ./conferences/utils.ts), so copy it instead - // of re-echoing the request. - if ( - (!conferenceData.createRequest || - conferenceData.createRequest.status?.statusCode === "success") && - conferenceData.entryPoints?.length - ) { - const [entryPoint, ...entryPoints] = conferenceData.entryPoints; - - return { - ...conferenceData, - conferenceSolution: { - iconUri: conferenceData.conferenceSolution?.iconUri, - key: { - type: conferenceData.conferenceSolution!.key!.type!, - }, - name: conferenceData.conferenceSolution?.name, - }, - entryPoints: [ - toGoogleCalendarEntryPointInput(entryPoint!), - ...entryPoints.map(toGoogleCalendarEntryPointInput), - ], - }; - } - - if (!conferenceData.createRequest) { - return undefined; - } - - // Re-sending the same requestId is an idempotent no-op that keeps a - // conferenceData body present; omitting it would clear the conference - // because updates always send conferenceDataVersion=1. - return { - createRequest: { - requestId: conferenceData.createRequest.requestId!, - ...(conferenceData.createRequest.conferenceSolutionKey?.type && { - conferenceSolutionKey: { - type: conferenceData.createRequest.conferenceSolutionKey.type, - }, - }), - }, - }; -} - -function toGoogleCalendarAttendeeInput( - attendee: EventAttendee, -): EventAttendeeInput { - return { - additionalGuests: attendee.additionalGuests, - comment: attendee.comment, - displayName: attendee.displayName, - email: attendee.email!, - optional: attendee.optional, - resource: attendee.resource, - responseStatus: attendee.responseStatus, - }; -} - -function toGoogleCalendarRemindersInput( - reminders: GoogleCalendarEvent["reminders"], -): EventInput["reminders"] { - if (!reminders) { - return undefined; - } - - return { - overrides: reminders.overrides?.map((reminder) => ({ - ...reminder, - method: reminder.method!, - minutes: reminder.minutes!, - })), - useDefault: reminders.useDefault, - }; -} - -export function toGoogleCalendarEventInput(event: GoogleCalendarEvent) { - if (event.eventType && event.eventType !== "default") { - throw new Error( - `Google Calendar ${event.eventType} events cannot be updated`, - ); - } - - return { - anyoneCanAddSelf: event.anyoneCanAddSelf, - attachments: event.attachments?.map((attachment) => ({ - fileUrl: attachment.fileUrl!, - iconLink: attachment.iconLink, - mimeType: attachment.mimeType, - title: attachment.title, - })), - attendees: event.attendees?.map(toGoogleCalendarAttendeeInput), - attendeesOmitted: event.attendeesOmitted, - conferenceData: toGoogleCalendarConferenceDataInput(event.conferenceData), - description: event.description, - end: event.end!, - eventType: "default" as const, - extendedProperties: event.extendedProperties, - guestsCanInviteOthers: event.guestsCanInviteOthers, - guestsCanModify: event.guestsCanModify, - guestsCanSeeOtherGuests: event.guestsCanSeeOtherGuests, - location: event.location, - originalStartTime: event.originalStartTime, - recurrence: event.recurrence, - reminders: toGoogleCalendarRemindersInput(event.reminders), - sequence: event.sequence, - source: event.source, - start: event.start!, - status: event.status, - summary: event.summary, - transparency: event.transparency, - visibility: event.visibility, - ...(event.eventLabelId - ? { eventLabelId: event.eventLabelId, eventLabelVersion: 1 as const } - : { colorId: event.colorId }), - }; -} - -export function toGoogleCalendarDate( - value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, -): GoogleCalendarDate | GoogleCalendarDateTime { - if (value instanceof Temporal.PlainDate) { - return { - date: value.toString(), - }; - } - - if (value instanceof Temporal.Instant) { - return { - dateTime: value.toString(), - }; - } - - return { - dateTime: value.toString({ timeZoneName: "never", offset: "auto" }), - timeZone: value.timeZoneId, - }; -} - -function parseDate({ date }: GoogleCalendarDate) { - return Temporal.PlainDate.from(date); -} - -function normalizeGoogleTimeZone(timeZone: string) { - // Normalize Google-style GMT offsets to IANA or UTC-compatible time zones - if (!timeZone) { - return timeZone; - } - - if (timeZone === "GMT") { - return "UTC"; - } - - const match = /^GMT([+-])(\d{1,2})(?::?([0-5]\d))?$/.exec(timeZone); - - if (!match) { - return timeZone; - } - - const [, sign, hoursStr, minutesStr] = match; - - if (!sign || !hoursStr) { - return timeZone; - } - - // If minutes are provided and not 00, fall back to a UTC offset which Temporal supports. - const hh = hoursStr.padStart(2, "0"); - const mm = minutesStr && minutesStr !== "00" ? minutesStr : "00"; - - return `${sign}${hh}:${mm}`; -} - -function parseDateTime({ dateTime, timeZone }: GoogleCalendarDateTime) { - const instant = Temporal.Instant.from(dateTime); - - if (!timeZone) { - return instant; - } - - const normalized = normalizeGoogleTimeZone(timeZone); - return instant.toZonedDateTimeISO(normalized); -} - -export function parseGoogleCalendarEventDate(value: EventDateTime) { - if (value.date) { - return parseDate({ date: value.date }); - } - - return parseDateTime({ - dateTime: value.dateTime!, - timeZone: value.timeZone, - }); -} - -function parseResponseStatus(event: GoogleCalendarEvent) { - const selfAttendee = event.attendees?.find((a) => a.self); - - if (!selfAttendee) { - return undefined; - } - - return { - status: parseGoogleCalendarAttendeeStatus( - selfAttendee.responseStatus ?? "needsAction", - ), - comment: selfAttendee.comment, - }; -} - -function parseRecurrence( - event: GoogleCalendarEvent, - timeZone: string, -): Recurrence | undefined { - if (!event.recurrence) { - return undefined; - } - - return parseTextRecurrence({ - lines: event.recurrence, - defaultTimeZone: normalizeGoogleTimeZone(timeZone), - }); -} - -interface ParsedGoogleCalendarEventOptions { - calendar: Calendar; - event: GoogleCalendarEvent; - defaultTimeZone?: string; -} - -export function parseGoogleCalendarEvent({ - calendar, - event, - defaultTimeZone = "UTC", -}: ParsedGoogleCalendarEventOptions): CalendarEvent { - const isAllDay = !event.start?.dateTime; - const response = parseResponseStatus(event); - const recurrence = parseRecurrence( - event, - event.start?.timeZone ?? defaultTimeZone, - ); - - return { - // ID should always be present if not defined Google Calendar will generate one - id: event.id!, - title: event.summary!, - description: event.description, - start: isAllDay - ? parseDate(event.start as GoogleCalendarDate) - : parseDateTime(event.start as GoogleCalendarDateTime), - end: isAllDay - ? parseDate(event.end as GoogleCalendarDate) - : parseDateTime(event.end as GoogleCalendarDateTime), - allDay: isAllDay, - location: event.location, - status: event.status, - availability: event.transparency === "transparent" ? "free" : "busy", - attendees: event.attendees - ? parseGoogleCalendarAttendeeList(event.attendees) - : [], - url: event.htmlLink, - etag: event.etag, - visibility: event.visibility as - | "default" - | "public" - | "private" - | "confidential" - | undefined, - calendar: { - id: calendar.id, - provider: calendar.provider, - }, - readOnly: - calendar.readOnly || - [ - "birthday", - "focusTime", - "fromGmail", - "outOfOffice", - "workingLocation", - ].includes(event.eventType ?? ""), - conference: parseConferenceData(event), - ...(response ? { response } : {}), - ...(recurrence ? { recurrence } : {}), - ...(event.created - ? { createdAt: Temporal.Instant.from(event.created) } - : {}), - ...(event.updated - ? { updatedAt: Temporal.Instant.from(event.updated) } - : {}), - recurringEventId: event.recurringEventId, - metadata: { - ...(event.recurrence ? { originalRecurrence: event.recurrence } : {}), - ...(event.recurringEventId - ? { recurringEventId: event.recurringEventId } - : {}), - }, - } as CalendarEvent; -} - -export function toGoogleCalendarAttendee( - attendee: Attendee, -): EventAttendeeInput { - return { - email: attendee.email, - displayName: attendee.name, - ...(attendee.type === "optional" ? { optional: true } : {}), - ...(attendee.type === "resource" ? { resource: true } : {}), - responseStatus: toGoogleCalendarAttendeeResponseStatus(attendee.status), - comment: attendee.comment, - additionalGuests: attendee.additionalGuests, - }; -} - -function toGoogleCalendarAttendees( - attendees: Attendee[], -): EventAttendeeInput[] { - return attendees.map(toGoogleCalendarAttendee); -} - -function recurrences(event: CreateEventInput | UpdateEventPatch) { - // TODO: how to handle recurrence when the time zone is changed (i.e. until, rDate, exDate). - if (event.recurrence === null) { - return []; - } - - if (!event.recurrence) { - return undefined; - } - - return toRecurrenceProperties(event.recurrence); -} - -function attendees(event: CreateEventInput | UpdateEventPatch) { - if (!event.attendees) { - return undefined; - } - - return toGoogleCalendarAttendees(event.attendees); -} - -function conference(event: CreateEventInput | UpdateEventPatch) { - if (event.conference === null) { - return null; - } - - if (!event.conference) { - return undefined; - } - - return toConferenceData(event.conference); -} - -function availability( - event: CreateEventInput | UpdateEventPatch, -): "opaque" | "transparent" | undefined { - if (!event.availability) { - return undefined; - } - - if (event.availability === "free") { - return "transparent"; - } - - return "opaque"; -} - -export function createEventParams(event: CreateEventInput) { - if (event.color) { - throw new Error("Google Calendar event colors are not supported"); - } - - return { - id: event.id, - summary: event.title, - description: event.description, - location: event.location, - visibility: event.visibility, - start: toGoogleCalendarDate(event.start), - end: toGoogleCalendarDate(event.end), - transparency: availability(event), - attendees: attendees(event), - conferenceData: event.conference - ? toConferenceData(event.conference) - : undefined, - // Should always be 1 to ensure conference data is retained for all event modification requests. - conferenceDataVersion: 1 as const, - // TODO: how to handle recurrence when the time zone is changed (i.e. until, rDate, exDate). - recurrence: recurrences(event), - }; -} - -interface GoogleCalendarEventUpdateOverrides { - attendees?: EventAttendeeInput[]; - calendarId: string; - conferenceData?: ConferenceDataInput | null; - conferenceDataVersion: 1; - description?: string | null; - end: EventDateTime; - location?: string | null; - recurrence?: string[]; - start: EventDateTime; - summary?: string; - transparency?: "opaque" | "transparent"; - visibility?: "confidential" | "default" | "private" | "public"; -} - -export function updateEventParams( - event: UpdateEventPatch, - existingEvent: GoogleCalendarEvent, -): GoogleCalendarEventUpdateOverrides { - if (event.color) { - throw new Error("Google Calendar event colors are not supported"); - } - - return { - calendarId: event.calendar.id, - // Should always be 1 to ensure conference data is retained for all event modification requests. - conferenceDataVersion: 1, - start: event.start - ? toGoogleCalendarDate(event.start) - : existingEvent.start!, - end: event.end ? toGoogleCalendarDate(event.end) : existingEvent.end!, - // TODO: how to handle recurrence when the time zone is changed (i.e. until, rDate, exDate). - recurrence: - event.recurrence !== undefined - ? recurrences(event) - : existingEvent.recurrence, - ...(event.title !== undefined ? { summary: event.title } : {}), - // A null patch value is sent as an explicit null in the PUT body to clear - // the field (recurrence clears as [] instead — the insert type has no null). - ...(event.description !== undefined - ? { description: event.description } - : {}), - ...(event.location !== undefined ? { location: event.location } : {}), - ...(event.visibility !== undefined ? { visibility: event.visibility } : {}), - ...(event.availability !== undefined - ? { transparency: availability(event) } - : {}), - ...(event.attendees !== undefined ? { attendees: attendees(event) } : {}), - // A "conference"-shaped patch is display-only and maps to no input: skip - // the key so the echoed conferenceData survives the full-replace PUT. - ...(event.conference === null || event.conference?.type === "create" - ? { conferenceData: conference(event) } - : {}), - }; -} - -export function toGoogleCalendarAttendeeResponseStatus( - status: AttendeeStatus, -): GoogleCalendarEventAttendeeResponseStatus { - if (status === "unknown") { - return "needsAction"; - } - - return status; -} - -export function attendeesWithSelfResponse( - attendees: EventAttendee[] | undefined, - status: AttendeeStatus, - comment?: string | null, -) { - if (!attendees) { - throw new Error("Event has no attendees"); - } - - const attendee = attendees.find((attendee) => attendee.self); - - if (!attendee) { - throw new Error("User is not an attendee"); - } - - const input = toGoogleCalendarAttendeeInput(attendee); - - if (comment === undefined) { - return [ - { - ...input, - responseStatus: toGoogleCalendarAttendeeResponseStatus(status), - }, - ]; - } - - return [ - { - ...input, - comment, - responseStatus: toGoogleCalendarAttendeeResponseStatus(status), - }, - ]; -} - -function parseGoogleCalendarAttendeeStatus( - status: GoogleCalendarEventAttendeeResponseStatus, -): AttendeeStatus { - if (status === "needsAction") { - return "unknown"; - } - - return status; -} - -function parseGoogleCalendarAttendeeType( - attendee: GoogleCalendarEventAttendee, -): "required" | "optional" | "resource" { - if (attendee.resource) { - return "resource"; - } - - if (attendee.optional) { - return "optional"; - } - - return "required"; -} - -export function parseGoogleCalendarAttendee( - attendee: GoogleCalendarEventAttendee, -): Attendee { - return { - id: attendee.id, - email: attendee.email!, - name: attendee.displayName, - status: parseGoogleCalendarAttendeeStatus( - attendee.responseStatus ?? "needsAction", - ), - type: parseGoogleCalendarAttendeeType(attendee), - comment: attendee.comment, - organizer: attendee.organizer, - additionalGuests: attendee.additionalGuests, - }; -} - -export function parseGoogleCalendarAttendeeList( - attendees: GoogleCalendarEventAttendee[], -): Attendee[] { - const mappedAttendees = attendees.map(parseGoogleCalendarAttendee); - - // Find the organizer and move to index 0 if it exists - const organizerIndex = mappedAttendees.findIndex( - (attendee) => attendee.organizer, - ); - - if (organizerIndex > 0) { - const organizer = mappedAttendees[organizerIndex]!; - - mappedAttendees.splice(organizerIndex, 1); - mappedAttendees.unshift(organizer); - } - - return mappedAttendees; -} diff --git a/packages/providers/src/calendars/google-calendar/freebusy/index.ts b/packages/providers/src/calendars/google-calendar/freebusy/index.ts index 1f4d36d3..f74ae2c2 100644 --- a/packages/providers/src/calendars/google-calendar/freebusy/index.ts +++ b/packages/providers/src/calendars/google-calendar/freebusy/index.ts @@ -5,7 +5,7 @@ import type { CalendarProviderFreeBusyQueryOptions, } from "../../../interfaces/providers"; import { ProviderError } from "../../../lib/provider-error"; -import { parseGoogleCalendarFreeBusy } from "./utils"; +import { parseFreeBusy } from "./parse"; export class GoogleCalendarFreeBusy implements CalendarProviderFreeBusy { constructor(private readonly client: GoogleCalendar) {} @@ -23,7 +23,7 @@ export class GoogleCalendarFreeBusy implements CalendarProviderFreeBusy { items: schedules.map((id) => ({ id })), }); - return parseGoogleCalendarFreeBusy(response); + return parseFreeBusy(response); }); } diff --git a/packages/providers/src/calendars/google-calendar/freebusy/utils.ts b/packages/providers/src/calendars/google-calendar/freebusy/parse.ts similarity index 95% rename from packages/providers/src/calendars/google-calendar/freebusy/utils.ts rename to packages/providers/src/calendars/google-calendar/freebusy/parse.ts index c4204e76..1132add5 100644 --- a/packages/providers/src/calendars/google-calendar/freebusy/utils.ts +++ b/packages/providers/src/calendars/google-calendar/freebusy/parse.ts @@ -3,7 +3,7 @@ import { Temporal } from "temporal-polyfill"; import type { CalendarFreeBusy } from "../../../interfaces"; import type { GoogleCalendarFreeBusyResponse } from "../interfaces"; -export function parseGoogleCalendarFreeBusy( +export function parseFreeBusy( response: GoogleCalendarFreeBusyResponse, ): CalendarFreeBusy[] { return Object.entries(response.calendars).map( diff --git a/packages/providers/src/calendars/microsoft-calendar/events/index.ts b/packages/providers/src/calendars/microsoft-calendar/events/index.ts index 54e1dcda..53a681b2 100644 --- a/packages/providers/src/calendars/microsoft-calendar/events/index.ts +++ b/packages/providers/src/calendars/microsoft-calendar/events/index.ts @@ -8,6 +8,7 @@ import type { DefaultCalendarListEventInput, DefaultCalendarUpdateEventInput, DeltaCollectionResponse, + DeltaRemovedEvent, Event as MicrosoftEvent, ListMoreInput, MicrosoftCalendar, @@ -194,7 +195,9 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { do { const link = pageToken ?? token; - let response: DeltaCollectionResponse; + let response: DeltaCollectionResponse< + MicrosoftEvent | DeltaRemovedEvent + >; if (link) { response = await this.calendarViewFor(calendar.id).deltaMore({ @@ -223,7 +226,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { continue; } - if (item["@removed"]) { + if ("@removed" in item) { changes.push({ status: "deleted", event: { diff --git a/packages/providers/src/conferencing/google-meet.ts b/packages/providers/src/conferencing/google-meet.ts index c7a464dc..8c20e355 100644 --- a/packages/providers/src/conferencing/google-meet.ts +++ b/packages/providers/src/conferencing/google-meet.ts @@ -1,7 +1,7 @@ import { GoogleCalendar } from "@analog/google-calendar"; -import { parseConferenceData } from "../calendars/google-calendar/events/conferences/utils"; -import { toGoogleCalendarEventInput } from "../calendars/google-calendar/events/utils"; +import { parseConference } from "../calendars/google-calendar/conferences"; +import { formatEventInput } from "../calendars/google-calendar/events/format"; import type { Conference, ConferencingProvider } from "../interfaces"; import type { ConferencingProviderCreateConferenceOptions } from "../interfaces/providers"; import { ProviderError } from "../lib/provider-error"; @@ -38,7 +38,7 @@ export class GoogleMeetProvider implements ConferencingProvider { const updatedEvent = await this.client.events.update({ calendarId, eventId, - ...toGoogleCalendarEventInput(existingEvent), + ...formatEventInput(existingEvent), conferenceData: { createRequest: { requestId: crypto.randomUUID(), @@ -56,7 +56,7 @@ export class GoogleMeetProvider implements ConferencingProvider { throw new Error("Failed to create conference data"); } - return parseConferenceData(updatedEvent)!; + return parseConference(updatedEvent)!; }); } From 6dd7e5e1d997c16c9ca8988b70092bf24e67e3ec Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Mon, 3 Aug 2026 11:15:37 +0200 Subject: [PATCH 4/7] wip --- apps/app/src/components/ai.tsx | 16 +++++++-------- .../components/prompt-kit/response-stream.tsx | 6 ++---- .../components/sign-in-with-google-button.tsx | 2 +- apps/app/src/components/ui/field.tsx | 2 +- apps/app/src/lib/trpc/query-client.ts | 2 +- apps/app/src/routes/login.tsx | 2 +- .../solar-terminator-map.tsx | 4 ++-- .../ai-input/hooks/use-expanding-input.ts | 4 ++-- .../fields/attendees/attendee-list.tsx | 11 ++-------- .../event-form/fields/description-field.tsx | 2 +- .../recurrences/fields/day-of-week-field.tsx | 2 +- .../event-form/utils/transform/input.ts | 10 +++++----- .../tabs/accounts/connected-accounts-list.tsx | 4 ++-- .../tabs/accounts/default-calendar-picker.tsx | 4 ++-- apps/web/src/hooks/calendar/use-events.ts | 4 ---- apps/web/src/lib/utils/events.ts | 2 +- packages/api/src/routers/calendars.ts | 8 ++++---- packages/api/src/routers/events.ts | 20 +++++++++---------- packages/api/src/routers/tasks.ts | 2 +- packages/api/src/trpc.ts | 10 +++++----- packages/api/src/utils/index.ts | 2 +- .../api/src/utils/maps/routes/directions.ts | 4 +--- packages/auth/src/utils/account-linking.ts | 4 ++-- .../microsoft-calendar/conferences.ts | 2 +- .../microsoft-calendar/events/format.ts | 2 +- .../microsoft-calendar/events/index.ts | 6 +++--- .../src/calendars/microsoft-calendar/utils.ts | 2 +- packages/providers/src/index.ts | 7 ------- packages/schemas/src/places.ts | 4 ++-- 29 files changed, 63 insertions(+), 87 deletions(-) diff --git a/apps/app/src/components/ai.tsx b/apps/app/src/components/ai.tsx index 8f0bd1e4..a072e85f 100644 --- a/apps/app/src/components/ai.tsx +++ b/apps/app/src/components/ai.tsx @@ -43,8 +43,6 @@ const renderToolPart = ( part: ChatMessagePart, index: number, ): React.ReactNode => { - if (!part.type?.startsWith("tool-")) return null; - return ; }; @@ -52,7 +50,7 @@ export function MessageComponent({ message, isLastMessage, }: MessageComponentProps) { - const isAssistant = message?.role === "assistant"; + const isAssistant = message.role === "assistant"; return (
- {message?.parts - .filter((part) => part.type?.startsWith("tool-")) + {message.parts + .filter((part) => part.type.startsWith("tool-")) .map((part, index) => renderToolPart(part, index))}
- {message?.parts + {message.parts .filter((part) => part.type === "text") - .map((part) => (part.type === "text" ? part.text : "")) + .map((part) => part.text) .join("")} @@ -104,7 +102,7 @@ export function MessageComponent({ ) : (
- {message?.parts + {message.parts .map((part) => (part.type === "text" ? part.text : "")) .join("")} @@ -193,7 +191,7 @@ function ThreadChatbot() {
)} - {messages?.map((message, index) => { + {messages.map((message, index) => { const isLastMessage = index === messages.length - 1; return ( diff --git a/apps/app/src/components/prompt-kit/response-stream.tsx b/apps/app/src/components/prompt-kit/response-stream.tsx index 8aa4daec..dae0dffd 100644 --- a/apps/app/src/components/prompt-kit/response-stream.tsx +++ b/apps/app/src/components/prompt-kit/response-stream.tsx @@ -76,8 +76,6 @@ function useTextStream({ if (modeRef.current === "typewriter") { if (normalizedSpeed < 25) return 1; return Math.max(1, Math.round((normalizedSpeed - 25) / 10)); - } else if (modeRef.current === "fade") { - return 1; } return 1; @@ -234,7 +232,7 @@ function useTextStream({ if (typeof textStream === "string") { processStringTypewriter(textStream); - } else if (textStream) { + } else { processAsyncIterable(textStream); } }, [textStream, reset, processStringTypewriter, processAsyncIterable]); @@ -388,7 +386,7 @@ function ResponseStream({ } }; - const Container = as as keyof React.JSX.IntrinsicElements; + const Container = as; return {renderContent()}; } diff --git a/apps/app/src/components/sign-in-with-google-button.tsx b/apps/app/src/components/sign-in-with-google-button.tsx index 0be73121..45c68d83 100644 --- a/apps/app/src/components/sign-in-with-google-button.tsx +++ b/apps/app/src/components/sign-in-with-google-button.tsx @@ -22,7 +22,7 @@ export function SignInWithGoogleButton() { return data; }, onError: (error) => { - toast.error(error.message ?? "An unknown error occurred"); + toast.error(error.message); }, }); diff --git a/apps/app/src/components/ui/field.tsx b/apps/app/src/components/ui/field.tsx index 2d4e7f88..64516e36 100644 --- a/apps/app/src/components/ui/field.tsx +++ b/apps/app/src/components/ui/field.tsx @@ -194,7 +194,7 @@ function FieldError({ ...new Map(errors.map((error) => [error?.message, error])).values(), ]; - if (uniqueErrors?.length == 1) { + if (uniqueErrors.length === 1) { return uniqueErrors[0]?.message; } diff --git a/apps/app/src/lib/trpc/query-client.ts b/apps/app/src/lib/trpc/query-client.ts index 30592e68..db3033e8 100644 --- a/apps/app/src/lib/trpc/query-client.ts +++ b/apps/app/src/lib/trpc/query-client.ts @@ -29,7 +29,7 @@ export function makeQueryClient() { }, queryCache: new QueryCache({ onError: (error) => { - console.error(error.message ?? "Something went wrong"); + console.error(error.message); }, }), }); diff --git a/apps/app/src/routes/login.tsx b/apps/app/src/routes/login.tsx index 489f8eda..d51ee7bc 100644 --- a/apps/app/src/routes/login.tsx +++ b/apps/app/src/routes/login.tsx @@ -61,7 +61,7 @@ function Login() { toast.success("Magic link printed to the server logs"); }, onError: (error) => { - toast.error(error.message ?? "An unknown error occurred"); + toast.error(error.message); }, }); diff --git a/apps/web/src/components/calendar/timeline/header/solar-terminator-map/solar-terminator-map.tsx b/apps/web/src/components/calendar/timeline/header/solar-terminator-map/solar-terminator-map.tsx index 0445ced8..5355f5dd 100644 --- a/apps/web/src/components/calendar/timeline/header/solar-terminator-map/solar-terminator-map.tsx +++ b/apps/web/src/components/calendar/timeline/header/solar-terminator-map/solar-terminator-map.tsx @@ -127,7 +127,7 @@ export function SolarTerminatorMap({ if (terminator.length > 0) { // Go to right edge at same latitude dayCoords.push([180, terminator[terminator.length - 1]?.[1] || 0]); - if ((sunDeclination ?? 0) >= 0) { + if (sunDeclination >= 0) { // Close across the northern boundary when the sun is over the northern hemisphere dayCoords.push([180, 90]); dayCoords.push([-180, 90]); @@ -156,7 +156,7 @@ export function SolarTerminatorMap({ ); }); - return lineGenerator(dayCoords) + "Z" || ""; + return lineGenerator(dayCoords) + "Z"; }, [terminator, projection, sunDeclination]); return ( diff --git a/apps/web/src/components/event-form/ai-input/hooks/use-expanding-input.ts b/apps/web/src/components/event-form/ai-input/hooks/use-expanding-input.ts index 475b3a25..71e6f945 100644 --- a/apps/web/src/components/event-form/ai-input/hooks/use-expanding-input.ts +++ b/apps/web/src/components/event-form/ai-input/hooks/use-expanding-input.ts @@ -11,8 +11,8 @@ export const useExpandingInput = (value: string) => { const placeCursorAtEnd = useCallback(() => { if (!textareaRef.current) return; textareaRef.current.focus(); - textareaRef.current.selectionStart = textareaRef.current.value.length ?? 0; - textareaRef.current.selectionEnd = textareaRef.current.value.length ?? 0; + textareaRef.current.selectionStart = textareaRef.current.value.length; + textareaRef.current.selectionEnd = textareaRef.current.value.length; }, []); useUpdateEffect(() => { diff --git a/apps/web/src/components/event-form/fields/attendees/attendee-list.tsx b/apps/web/src/components/event-form/fields/attendees/attendee-list.tsx index 8fe570a1..c40b84f5 100644 --- a/apps/web/src/components/event-form/fields/attendees/attendee-list.tsx +++ b/apps/web/src/components/event-form/fields/attendees/attendee-list.tsx @@ -37,10 +37,7 @@ interface AttendeeAvatarProps { function AttendeeAvatar({ name, email, className }: AttendeeAvatarProps) { const initials = React.useMemo(() => { - const initials = - name?.trim().length && name?.trim().length > 0 - ? name?.charAt(0) - : email.charAt(0); + const initials = name?.trim() ? name.charAt(0) : email.charAt(0); return initials.toUpperCase(); }, [name, email]); @@ -70,7 +67,7 @@ function AttendeeInfo({ organizer, className, }: AttendeeInfoProps) { - const showName = name?.trim()?.length && name?.trim().length > 0; + const showName = name?.trim().length && name.trim().length > 0; return (
@@ -125,10 +122,6 @@ function AttendeeStatusIcon({ status, className }: AttendeeStatusProps) { } }; - if (!Icon) { - return null; - } - return (
) => { - onChange?.(e.target.value); + onChange(e.target.value); }, [onChange], ); diff --git a/apps/web/src/components/event-form/recurrences/fields/day-of-week-field.tsx b/apps/web/src/components/event-form/recurrences/fields/day-of-week-field.tsx index 35834786..beb781f8 100644 --- a/apps/web/src/components/event-form/recurrences/fields/day-of-week-field.tsx +++ b/apps/web/src/components/event-form/recurrences/fields/day-of-week-field.tsx @@ -32,7 +32,7 @@ export function DayOfWeekField({ value, onValueChange }: DayOfWeekFieldProps) { onValueChange(v as Weekday[])} className="w-full" > diff --git a/apps/web/src/components/event-form/utils/transform/input.ts b/apps/web/src/components/event-form/utils/transform/input.ts index a27b2892..fb8de4eb 100644 --- a/apps/web/src/components/event-form/utils/transform/input.ts +++ b/apps/web/src/components/event-form/utils/transform/input.ts @@ -5,7 +5,7 @@ import type { FormValues, } from "@/components/event-form/utils/schema"; import type { Calendar, CalendarEvent, DraftEvent } from "@/lib/interfaces"; -import { createEventId, isDraftEvent } from "@/lib/utils/calendar"; +import { isDraftEvent } from "@/lib/utils/calendar"; interface ParseDateTimeOptions { defaultTimeZone: string; @@ -32,7 +32,7 @@ export function parseAttendees( return ( event.attendees?.map((attendee) => ({ id: attendee.id, - email: attendee.email ?? "", + email: attendee.email, status: attendee.status, type: attendee.type, name: attendee.name ?? "", @@ -60,7 +60,7 @@ export function parseDraftEvent({ const end = parseDateTime(event.end, { defaultTimeZone: timeZone }); return { - id: event?.id ?? createEventId(), + id: event.id, type: "draft", title: event.title ?? "", start, @@ -73,7 +73,7 @@ export function parseDraftEvent({ recurringEventId: event.recurringEventId, attendees: parseAttendees(event), response: event.response, - calendar: event?.calendar ?? { + calendar: event.calendar ?? { id: defaultCalendar.id, provider: defaultCalendar.provider, }, @@ -103,7 +103,7 @@ export function parseCalendarEvent({ end, location: event.location ?? "", description: event.description ?? "", - allDay: event.allDay ?? false, + allDay: event.allDay, availability: event.availability ?? "busy", recurrence: event.recurrence, recurringEventId: event.recurringEventId, diff --git a/apps/web/src/components/settings-dialog/tabs/accounts/connected-accounts-list.tsx b/apps/web/src/components/settings-dialog/tabs/accounts/connected-accounts-list.tsx index 52b0f6a2..d030307d 100644 --- a/apps/web/src/components/settings-dialog/tabs/accounts/connected-accounts-list.tsx +++ b/apps/web/src/components/settings-dialog/tabs/accounts/connected-accounts-list.tsx @@ -32,7 +32,7 @@ export function ConnectedAccountsList() { ); } - if (data?.accounts.length === 0) { + if (data.accounts.length === 0) { return (

No accounts connected yet.

@@ -43,7 +43,7 @@ export function ConnectedAccountsList() { return (
    - {data?.accounts.map((account) => ( + {data.accounts.map((account) => ( diff --git a/apps/web/src/components/settings-dialog/tabs/accounts/default-calendar-picker.tsx b/apps/web/src/components/settings-dialog/tabs/accounts/default-calendar-picker.tsx index 0f1e533c..fedd8c38 100644 --- a/apps/web/src/components/settings-dialog/tabs/accounts/default-calendar-picker.tsx +++ b/apps/web/src/components/settings-dialog/tabs/accounts/default-calendar-picker.tsx @@ -72,7 +72,7 @@ export function DefaultCalendarPicker() { - - {data?.accounts.map((account) => ( + {data.accounts.map((account) => ( {account.name} diff --git a/apps/web/src/hooks/calendar/use-events.ts b/apps/web/src/hooks/calendar/use-events.ts index de2cd910..efeec0a1 100644 --- a/apps/web/src/hooks/calendar/use-events.ts +++ b/apps/web/src/hooks/calendar/use-events.ts @@ -35,10 +35,6 @@ export function useSelectDisplayItems() { return React.useCallback( (data: RouterOutputs["events"]["list"]) => { - if (!data.events) { - return []; - } - return data.events.map((event) => createEventDisplayItem(event, defaultTimeZone), ); diff --git a/apps/web/src/lib/utils/events.ts b/apps/web/src/lib/utils/events.ts index 2c17a7d1..505d8bbd 100644 --- a/apps/web/src/lib/utils/events.ts +++ b/apps/web/src/lib/utils/events.ts @@ -3,7 +3,7 @@ import { Conference } from "@repo/providers/interfaces"; import type { Attendee, CalendarEvent } from "@/lib/interfaces"; export function isUserOnlyAttendee(attendees: Attendee[]): boolean { - if (!attendees || attendees.length === 0) { + if (attendees.length === 0) { return true; } diff --git a/packages/api/src/routers/calendars.ts b/packages/api/src/routers/calendars.ts index c6a357a6..a9973c75 100644 --- a/packages/api/src/routers/calendars.ts +++ b/packages/api/src/routers/calendars.ts @@ -19,7 +19,7 @@ export const calendarsRouter = createTRPCRouter({ ({ account }) => account.accountId === input.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.provider.accountId}`, @@ -129,7 +129,7 @@ export const calendarsRouter = createTRPCRouter({ ({ account }) => account.accountId === input.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.provider.accountId}`, @@ -159,7 +159,7 @@ export const calendarsRouter = createTRPCRouter({ ({ account }) => account.accountId === input.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.provider.accountId}`, @@ -208,7 +208,7 @@ export const calendarsRouter = createTRPCRouter({ ({ account }) => account.accountId === input.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.provider.accountId}`, diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts index 01b5d7dd..7817b7c8 100644 --- a/packages/api/src/routers/events.ts +++ b/packages/api/src/routers/events.ts @@ -96,7 +96,7 @@ export const eventsRouter = createTRPCRouter({ account.accountId === input.calendar.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.calendar.provider.accountId}`, @@ -148,7 +148,7 @@ export const eventsRouter = createTRPCRouter({ account.accountId === input.calendar.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.calendar.provider.accountId}`, @@ -177,7 +177,7 @@ export const eventsRouter = createTRPCRouter({ create: calendarProcedure .input( createEventInputSchema.extend({ - sendUpdate: z.boolean().optional().default(true), + sendUpdate: z.boolean().default(true), }), ) .mutation(async ({ ctx, input }) => { @@ -186,7 +186,7 @@ export const eventsRouter = createTRPCRouter({ account.accountId === input.calendar.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.calendar.provider.accountId}`, @@ -216,7 +216,7 @@ export const eventsRouter = createTRPCRouter({ .input( z.object({ data: patchEventInputSchema, - sendUpdate: z.boolean().optional().default(true), + sendUpdate: z.boolean().default(true), move: z .object({ source: z.object({ @@ -247,7 +247,7 @@ export const eventsRouter = createTRPCRouter({ account.accountId === data.calendar.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${data.calendar.provider.accountId}`, @@ -416,7 +416,7 @@ export const eventsRouter = createTRPCRouter({ }), }), eventId: z.string(), - sendUpdate: z.boolean().optional().default(true), + sendUpdate: z.boolean().default(true), }), ) .mutation(async ({ ctx, input }) => { @@ -425,7 +425,7 @@ export const eventsRouter = createTRPCRouter({ account.accountId === input.calendar.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.calendar.provider.accountId}`, @@ -458,7 +458,7 @@ export const eventsRouter = createTRPCRouter({ }), }), eventId: z.string(), - sendUpdate: z.boolean().optional().default(true), + sendUpdate: z.boolean().default(true), }), ) .mutation(async ({ ctx, input }) => { @@ -549,7 +549,7 @@ export const eventsRouter = createTRPCRouter({ account.accountId === input.calendar.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Calendar client not found for providerAccountId: ${input.calendar.provider.accountId}`, diff --git a/packages/api/src/routers/tasks.ts b/packages/api/src/routers/tasks.ts index 9b03181d..5fff559f 100644 --- a/packages/api/src/routers/tasks.ts +++ b/packages/api/src/routers/tasks.ts @@ -12,7 +12,7 @@ export const tasksRouter = createTRPCRouter({ ({ account }) => account.accountId === input.provider.accountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Task client not found for providerAccountId: ${input.provider.accountId}`, diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index f5fb0be4..9b445c81 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -42,10 +42,10 @@ export const createTRPCContext = async (opts: { ...opts, db, redis, - session: opts.session?.session, - user: opts.session?.user, + session: opts.session.session, + user: opts.session.user, rateLimit: { - id: opts.session?.user?.id ?? getIp(opts.headers), + id: opts.session.user.id, }, }; } @@ -61,7 +61,7 @@ export const createTRPCContext = async (opts: { session: session?.session, user: session?.user, rateLimit: { - id: session?.user?.id ?? getIp(opts.headers), + id: session?.user.id ?? getIp(opts.headers), }, }; }; @@ -128,7 +128,7 @@ export const protectedProcedure = t.procedure ctx: { ...ctx, session: ctx.session!, - user: ctx.user!, + user: ctx.user, }, }); }); diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 5dc1b635..6da282a2 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -16,7 +16,7 @@ export function findProviderOrThrow( ({ account }) => account.accountId === providerAccountId, ); - if (!provider?.client) { + if (!provider) { throw new TRPCError({ code: "NOT_FOUND", message: `Could not find provider for providerAccountId: ${providerAccountId}`, diff --git a/packages/api/src/utils/maps/routes/directions.ts b/packages/api/src/utils/maps/routes/directions.ts index 758463a7..16c2bfb5 100644 --- a/packages/api/src/utils/maps/routes/directions.ts +++ b/packages/api/src/utils/maps/routes/directions.ts @@ -290,9 +290,7 @@ export async function directions(input: DirectionsInput) { ...(leg.duration ? { duration: { - trafficAware: leg.duration - ? parseDuration(leg.duration) - : undefined, + trafficAware: parseDuration(leg.duration), static: leg.staticDuration ? parseDuration(leg.staticDuration) : undefined, diff --git a/packages/auth/src/utils/account-linking.ts b/packages/auth/src/utils/account-linking.ts index e015c5bf..cc938538 100644 --- a/packages/auth/src/utils/account-linking.ts +++ b/packages/auth/src/utils/account-linking.ts @@ -68,7 +68,7 @@ export const handleUnlinkAccount = createAuthMiddleware(async (ctx) => { } const defaultAccount = await db.query.account.findFirst({ - where: (table, { eq }) => eq(table.id, user!.defaultAccountId), + where: (table, { eq }) => eq(table.id, user.defaultAccountId), }); if (defaultAccount?.accountId !== ctx.body?.accountId) { @@ -76,7 +76,7 @@ export const handleUnlinkAccount = createAuthMiddleware(async (ctx) => { } const newDefaultAccount = await db.query.account.findFirst({ - where: (table, { eq }) => eq(table.userId, user!.id), + where: (table, { eq }) => eq(table.userId, user.id), }); if (!newDefaultAccount) { diff --git a/packages/providers/src/calendars/microsoft-calendar/conferences.ts b/packages/providers/src/calendars/microsoft-calendar/conferences.ts index c78b5db3..8b83cd71 100644 --- a/packages/providers/src/calendars/microsoft-calendar/conferences.ts +++ b/packages/providers/src/calendars/microsoft-calendar/conferences.ts @@ -91,7 +91,7 @@ export function parseConference(event: MicrosoftEvent): Conference | undefined { }, meetingCode: event.onlineMeeting?.conferenceId ?? undefined, }, - ...(phoneNumbers && phoneNumbers.length > 0 + ...(phoneNumbers.length > 0 ? { phone: phoneNumbers.map((number) => ({ joinUrl: { diff --git a/packages/providers/src/calendars/microsoft-calendar/events/format.ts b/packages/providers/src/calendars/microsoft-calendar/events/format.ts index e18988bd..2fc52530 100644 --- a/packages/providers/src/calendars/microsoft-calendar/events/format.ts +++ b/packages/providers/src/calendars/microsoft-calendar/events/format.ts @@ -47,7 +47,7 @@ export function formatDate({ value, originalTimeZone }: FormatDateOptions) { dateTime: value.toPlainDateTime().toString(), timeZone: originalTimeZone?.parsed === value.timeZoneId - ? originalTimeZone?.raw + ? originalTimeZone.raw : value.timeZoneId, }; } diff --git a/packages/providers/src/calendars/microsoft-calendar/events/index.ts b/packages/providers/src/calendars/microsoft-calendar/events/index.ts index 53a681b2..3e3956f4 100644 --- a/packages/providers/src/calendars/microsoft-calendar/events/index.ts +++ b/packages/providers/src/calendars/microsoft-calendar/events/index.ts @@ -111,7 +111,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { const endTime = timeMax.withTimeZone("UTC").toInstant().toString(); const headers = { - Prefer: `outlook.timezone="${timeZone ?? "UTC"}", ${TEXT_BODY_PREFERENCE}`, + Prefer: `outlook.timezone="${timeZone}", ${TEXT_BODY_PREFERENCE}`, }; const listPages = async (nextLink?: string): Promise => { @@ -184,7 +184,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { const endTime = timeMax?.withTimeZone("UTC").toInstant().toString(); const headers = { - Prefer: `outlook.timezone="${timeZone ?? "UTC"}", ${TEXT_BODY_PREFERENCE}`, + Prefer: `outlook.timezone="${timeZone}", ${TEXT_BODY_PREFERENCE}`, }; let syncToken: string | undefined; @@ -222,7 +222,7 @@ export class MicrosoftCalendarEvents implements CalendarProviderEvents { } for (const item of response.value ?? []) { - if (!item?.id) { + if (!item.id) { continue; } diff --git a/packages/providers/src/calendars/microsoft-calendar/utils.ts b/packages/providers/src/calendars/microsoft-calendar/utils.ts index ab89eba1..ec57efbd 100644 --- a/packages/providers/src/calendars/microsoft-calendar/utils.ts +++ b/packages/providers/src/calendars/microsoft-calendar/utils.ts @@ -3,7 +3,7 @@ import { Temporal } from "temporal-polyfill"; import { mapWindowsToIanaTimeZone } from "./windows-timezones"; export function isValidTimeZone(timeZone: string) { - if (!Intl || !Intl.DateTimeFormat().resolvedOptions().timeZone) { + if (!Intl.DateTimeFormat().resolvedOptions().timeZone) { throw new Error("Time zones are not available in this environment"); } diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 9ee100d2..1ab2f5ed 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -108,13 +108,6 @@ export function accountToConferencingProvider( const Provider = supportedConferencingProviders[providerId]; - if (!Provider) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Conferencing provider not supported: '${providerId}' for account '${activeAccount.providerId}' (providerAccountId: ${activeAccount.accountId})`, - }); - } - return new Provider({ accessToken: activeAccount.accessToken, providerAccountId: activeAccount.accountId, diff --git a/packages/schemas/src/places.ts b/packages/schemas/src/places.ts index 2de51e15..3ead35b7 100644 --- a/packages/schemas/src/places.ts +++ b/packages/schemas/src/places.ts @@ -2,8 +2,8 @@ import * as z from "zod"; export const autocompleteInputSchema = z.object({ input: z.string().min(1).max(256), - languageCode: z.string().optional().default("en"), - limit: z.number().int().min(1).max(10).optional().default(5), + languageCode: z.string().default("en"), + limit: z.number().int().min(1).max(10).default(5), }); export const placeResultSchema = z.object({ From b753a0e208434b4169428df83293c25a0e6057b7 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Mon, 3 Aug 2026 21:37:19 +0200 Subject: [PATCH 5/7] wip --- apps/web/src/app/api/[...trpc]/route.ts | 5 ++-- .../create-event/create-queue-provider.tsx | 14 +++++----- .../flows/create-event/create-queue.ts | 2 +- .../delete-event/delete-queue-provider.tsx | 14 +++++----- .../flows/delete-event/delete-queue.ts | 4 +-- .../event-form/event-form-state-provider.tsx | 12 +++++---- .../update-event/update-queue-provider.tsx | 14 +++++----- .../flows/update-event/update-queue.ts | 4 +-- .../calendar-picker/calendar-picker.tsx | 26 ++++++++++--------- .../calendar/timeline/header/timezone.tsx | 7 ++--- .../components/calendar/timeline/timeline.tsx | 6 ++--- .../command-bar/window-stack-provider.tsx | 7 ++--- apps/web/src/components/date-picker.tsx | 14 +++++----- .../event-form/fields/calendar-field.tsx | 16 ++++++------ .../event-form/fields/date/date-input.tsx | 14 +++++----- .../fields/date/use-time-suggestions.ts | 16 +++++++----- .../recurrences/recurrence-dialog.tsx | 4 +-- .../recurrences/recurrence-field.tsx | 6 ++--- .../hooks/calendar/use-event-collection.ts | 12 +++++---- apps/web/src/hooks/calendar/use-events.ts | 7 +++-- .../web/src/hooks/calendar/use-grid-layout.ts | 16 +++++++----- apps/web/src/hooks/use-sidebar-resize.ts | 7 ++--- apps/web/src/lib/db.ts | 21 ++++++++------- apps/web/src/lib/utils/events.ts | 6 ++--- apps/web/src/store/calendar-store.ts | 5 ++-- .../google-calendar/src/colors/interfaces.ts | 2 +- packages/microsoft-calendar/src/interfaces.ts | 13 +++++----- packages/providers/src/lib/events.ts | 2 +- .../providers/src/lib/recurrences/parse.ts | 2 +- packages/providers/src/tasks/google-tasks.ts | 3 +-- 30 files changed, 148 insertions(+), 133 deletions(-) diff --git a/apps/web/src/app/api/[...trpc]/route.ts b/apps/web/src/app/api/[...trpc]/route.ts index 6ba37876..e71d9fba 100644 --- a/apps/web/src/app/api/[...trpc]/route.ts +++ b/apps/web/src/app/api/[...trpc]/route.ts @@ -2,14 +2,13 @@ import { createOpenApiFetchHandler } from "trpc-to-openapi"; import { appRouter, createContext } from "@repo/api"; -const handler = (req: Request) => { - return createOpenApiFetchHandler({ +const handler = (req: Request) => + createOpenApiFetchHandler({ endpoint: "/api/v1", req, router: appRouter, createContext: () => createContext({ headers: req.headers }), }); -}; export { handler as GET, diff --git a/apps/web/src/components/calendar/flows/create-event/create-queue-provider.tsx b/apps/web/src/components/calendar/flows/create-event/create-queue-provider.tsx index a1a5b731..1bd50874 100644 --- a/apps/web/src/components/calendar/flows/create-event/create-queue-provider.tsx +++ b/apps/web/src/components/calendar/flows/create-event/create-queue-provider.tsx @@ -38,12 +38,14 @@ export function CreateQueueProvider({ children }: CreateQueueProviderProps) { [createMutation, removeOptimisticAction], ); - const logic = React.useMemo(() => { - return createCreateQueueMachine({ - createEvent, - removeOptimisticAction, - }); - }, [createEvent, removeOptimisticAction]); + const logic = React.useMemo( + () => + createCreateQueueMachine({ + createEvent, + removeOptimisticAction, + }), + [createEvent, removeOptimisticAction], + ); return ( diff --git a/apps/web/src/components/calendar/flows/create-event/create-queue.ts b/apps/web/src/components/calendar/flows/create-event/create-queue.ts index 59a31f2b..d09a4d90 100644 --- a/apps/web/src/components/calendar/flows/create-event/create-queue.ts +++ b/apps/web/src/components/calendar/flows/create-event/create-queue.ts @@ -20,7 +20,7 @@ export interface CreateQueueItem { } export function hasAttendees(event: CalendarEvent) { - return !!event.attendees && event.attendees.length > 0; + return (event.attendees?.length ?? 0) > 0; } export type CreateEvent = (item: CreateQueueItem) => Promise; diff --git a/apps/web/src/components/calendar/flows/delete-event/delete-queue-provider.tsx b/apps/web/src/components/calendar/flows/delete-event/delete-queue-provider.tsx index 0a7ce159..2c658729 100644 --- a/apps/web/src/components/calendar/flows/delete-event/delete-queue-provider.tsx +++ b/apps/web/src/components/calendar/flows/delete-event/delete-queue-provider.tsx @@ -55,12 +55,14 @@ export function DeleteQueueProvider({ children }: DeleteQueueProviderProps) { [deleteMutation, removeOptimisticAction], ); - const logic = React.useMemo(() => { - return createDeleteQueueMachine({ - deleteEvent, - removeOptimisticAction, - }); - }, [deleteEvent, removeOptimisticAction]); + const logic = React.useMemo( + () => + createDeleteQueueMachine({ + deleteEvent, + removeOptimisticAction, + }), + [deleteEvent, removeOptimisticAction], + ); return ( diff --git a/apps/web/src/components/calendar/flows/delete-event/delete-queue.ts b/apps/web/src/components/calendar/flows/delete-event/delete-queue.ts index 540e57b2..2c4fd146 100644 --- a/apps/web/src/components/calendar/flows/delete-event/delete-queue.ts +++ b/apps/web/src/components/calendar/flows/delete-event/delete-queue.ts @@ -20,11 +20,11 @@ export interface DeleteQueueItem { } export function isRecurring(event: CalendarEvent) { - return !!event.recurringEventId; + return Boolean(event.recurringEventId); } export function hasAttendees(event: CalendarEvent) { - return !!event.attendees && event.attendees.length > 0; + return (event.attendees?.length ?? 0) > 0; } export type DeleteEvent = (item: DeleteQueueItem) => Promise; diff --git a/apps/web/src/components/calendar/flows/event-form/event-form-state-provider.tsx b/apps/web/src/components/calendar/flows/event-form/event-form-state-provider.tsx index c0f773c6..bd8fb343 100644 --- a/apps/web/src/components/calendar/flows/event-form/event-form-state-provider.tsx +++ b/apps/web/src/components/calendar/flows/event-form/event-form-state-provider.tsx @@ -26,11 +26,13 @@ export function EventFormStateProvider({ return item; }, []); - const logic = React.useMemo(() => { - return createEventFormMachine({ - updateEvent, - }); - }, [updateEvent]); + const logic = React.useMemo( + () => + createEventFormMachine({ + updateEvent, + }), + [updateEvent], + ); return ( diff --git a/apps/web/src/components/calendar/flows/update-event/update-queue-provider.tsx b/apps/web/src/components/calendar/flows/update-event/update-queue-provider.tsx index 2dfb2584..0c4f52b9 100644 --- a/apps/web/src/components/calendar/flows/update-event/update-queue-provider.tsx +++ b/apps/web/src/components/calendar/flows/update-event/update-queue-provider.tsx @@ -101,12 +101,14 @@ export function UpdateQueueProvider({ children }: UpdateQueueProviderProps) { [updateMutation, removeOptimisticAction], ); - const logic = React.useMemo(() => { - return createUpdateQueueMachine({ - updateEvent, - removeOptimisticAction, - }); - }, [updateEvent, removeOptimisticAction]); + const logic = React.useMemo( + () => + createUpdateQueueMachine({ + updateEvent, + removeOptimisticAction, + }), + [updateEvent, removeOptimisticAction], + ); return ( diff --git a/apps/web/src/components/calendar/flows/update-event/update-queue.ts b/apps/web/src/components/calendar/flows/update-event/update-queue.ts index 7ea58e6b..c3debdde 100644 --- a/apps/web/src/components/calendar/flows/update-event/update-queue.ts +++ b/apps/web/src/components/calendar/flows/update-event/update-queue.ts @@ -40,11 +40,11 @@ export interface UpdateQueueItem { } export function isRecurring(event: CalendarEvent) { - return !!event.recurringEventId; + return Boolean(event.recurringEventId); } export function hasAttendees(event: CalendarEvent) { - return !!event.attendees && event.attendees.length > 0; + return (event.attendees?.length ?? 0) > 0; } export type UpdateEvent = (item: UpdateQueueItem) => Promise; diff --git a/apps/web/src/components/calendar/header/calendar-picker/calendar-picker.tsx b/apps/web/src/components/calendar/header/calendar-picker/calendar-picker.tsx index 880e1806..be8f3318 100644 --- a/apps/web/src/components/calendar/header/calendar-picker/calendar-picker.tsx +++ b/apps/web/src/components/calendar/header/calendar-picker/calendar-picker.tsx @@ -87,18 +87,20 @@ function CalendarPickerContent() { const calendarPreferences = useCalendarStore((s) => s.calendarPreferences); const { isActionMenuOpen } = useCalendarPickerContext(); - const visibleCalendars = React.useMemo(() => { - return data?.accounts - .flatMap((account) => account.calendars) - .filter((calendar) => { - const preference = getCalendarPreference( - calendarPreferences, - calendar.provider.accountId, - calendar.id, - ); - return !preference?.hidden; - }); - }, [data, calendarPreferences]); + const visibleCalendars = React.useMemo( + () => + data?.accounts + .flatMap((account) => account.calendars) + .filter((calendar) => { + const preference = getCalendarPreference( + calendarPreferences, + calendar.provider.accountId, + calendar.id, + ); + return !preference?.hidden; + }), + [data, calendarPreferences], + ); if (!data) { return null; diff --git a/apps/web/src/components/calendar/timeline/header/timezone.tsx b/apps/web/src/components/calendar/timeline/header/timezone.tsx index bc3c2e10..29aef70b 100644 --- a/apps/web/src/components/calendar/timeline/header/timezone.tsx +++ b/apps/web/src/components/calendar/timeline/header/timezone.tsx @@ -64,9 +64,10 @@ function TimeDisplay({ className, timeZoneId }: TimeDisplayProps) { const use12Hour = useCalendarStore((s) => s.calendarSettings.use12Hour); const locale = useCalendarStore((s) => s.calendarSettings.locale); - const time = React.useMemo(() => { - return currentTime.withTimeZone(timeZoneId); - }, [currentTime, timeZoneId]); + const time = React.useMemo( + () => currentTime.withTimeZone(timeZoneId), + [currentTime, timeZoneId], + ); return (
    diff --git a/apps/web/src/components/calendar/timeline/timeline.tsx b/apps/web/src/components/calendar/timeline/timeline.tsx index c986408e..3e56f349 100644 --- a/apps/web/src/components/calendar/timeline/timeline.tsx +++ b/apps/web/src/components/calendar/timeline/timeline.tsx @@ -41,9 +41,9 @@ function useHours(timeZone: string) { const start = startOfDay(date, { timeZone: defaultTimeZone }); - const hours = HOURS.map((time) => { - return start.add({ hours: time.hour }).withTimeZone(timeZone); - }); + const hours = HOURS.map((time) => + start.add({ hours: time.hour }).withTimeZone(timeZone), + ); return hours.map((hour) => ({ label: formatTime({ diff --git a/apps/web/src/components/command-bar/window-stack-provider.tsx b/apps/web/src/components/command-bar/window-stack-provider.tsx index 4196c804..80455072 100644 --- a/apps/web/src/components/command-bar/window-stack-provider.tsx +++ b/apps/web/src/components/command-bar/window-stack-provider.tsx @@ -49,9 +49,10 @@ export function WindowStackProvider({ children }: WindowStackProviderProps) { ]; }, [selectedEvents]); - const windows = React.useMemo(() => { - return [...eventWindows, ...stack]; - }, [eventWindows, stack]); + const windows = React.useMemo( + () => [...eventWindows, ...stack], + [eventWindows, stack], + ); const [activeWindowId, setActiveWindowId] = React.useState( () => windows[0]?.id ?? null, diff --git a/apps/web/src/components/date-picker.tsx b/apps/web/src/components/date-picker.tsx index 9a1a529b..11eb179b 100644 --- a/apps/web/src/components/date-picker.tsx +++ b/apps/web/src/components/date-picker.tsx @@ -14,12 +14,14 @@ export function DatePicker() { const displayedDays = useDisplayedDays(); - const displayedMonth = React.useMemo(() => { - return Temporal.PlainYearMonth.from({ - year: currentDate.year, - month: currentDate.month, - }); - }, [currentDate.year, currentDate.month]); + const displayedMonth = React.useMemo( + () => + Temporal.PlainYearMonth.from({ + year: currentDate.year, + month: currentDate.month, + }), + [currentDate.year, currentDate.month], + ); const calendarRef = React.useRef(null); diff --git a/apps/web/src/components/event-form/fields/calendar-field.tsx b/apps/web/src/components/event-form/fields/calendar-field.tsx index 43958af9..e174125b 100644 --- a/apps/web/src/components/event-form/fields/calendar-field.tsx +++ b/apps/web/src/components/event-form/fields/calendar-field.tsx @@ -53,9 +53,7 @@ export function CalendarField({ const trpc = useTRPC(); const { data } = useQuery(trpc.calendars.list.queryOptions()); - const items = React.useMemo(() => { - return data?.accounts ?? []; - }, [data]); + const items = React.useMemo(() => data?.accounts ?? [], [data]); const onSelect = React.useCallback( (calendar: Calendar) => { @@ -65,11 +63,13 @@ export function CalendarField({ [onChange, onBlur], ); - const selected = React.useMemo(() => { - return data?.accounts - .flatMap((item) => item.calendars) - .find((item) => item.id === value.id); - }, [data, value]); + const selected = React.useMemo( + () => + data?.accounts + .flatMap((item) => item.calendars) + .find((item) => item.id === value.id), + [data, value], + ); return ( diff --git a/apps/web/src/components/event-form/fields/date/date-input.tsx b/apps/web/src/components/event-form/fields/date/date-input.tsx index 3974ff52..486a2084 100644 --- a/apps/web/src/components/event-form/fields/date/date-input.tsx +++ b/apps/web/src/components/event-form/fields/date/date-input.tsx @@ -89,9 +89,10 @@ export function DateInput({ return value.toPlainDate(); }, [value, allDay, start]); - const defaultMonth = React.useMemo(() => { - return value.toPlainDate().toPlainYearMonth(); - }, [value]); + const defaultMonth = React.useMemo( + () => value.toPlainDate().toPlainYearMonth(), + [value], + ); const [displayedMonth, setDisplayedMonth] = React.useState(date.toPlainYearMonth()); @@ -296,9 +297,10 @@ function TemporalCalendar({ onMonthChange, min, }: TemporalCalendarProps) { - const legacySelected = React.useMemo(() => { - return toDate(selected, { timeZone }); - }, [selected, timeZone]); + const legacySelected = React.useMemo( + () => toDate(selected, { timeZone }), + [selected, timeZone], + ); const legacyOnSelect = React.useCallback( (date: Date) => { diff --git a/apps/web/src/components/event-form/fields/date/use-time-suggestions.ts b/apps/web/src/components/event-form/fields/date/use-time-suggestions.ts index 53f35f74..4585fa19 100644 --- a/apps/web/src/components/event-form/fields/date/use-time-suggestions.ts +++ b/apps/web/src/components/event-form/fields/date/use-time-suggestions.ts @@ -75,13 +75,15 @@ export function useTimeSuggestionsList() { const settings = useCalendarSettings(); const defaultTimeZone = useDefaultTimeZone(); - return React.useMemo(() => { - return generateList({ - locale: settings.locale, - timeZone: defaultTimeZone, - use12Hour: settings.use12Hour, - }); - }, [settings.locale, defaultTimeZone, settings.use12Hour]); + return React.useMemo( + () => + generateList({ + locale: settings.locale, + timeZone: defaultTimeZone, + use12Hour: settings.use12Hour, + }), + [settings.locale, defaultTimeZone, settings.use12Hour], + ); } export function useTimeSuggestions(searchValue: string) { diff --git a/apps/web/src/components/event-form/recurrences/recurrence-dialog.tsx b/apps/web/src/components/event-form/recurrences/recurrence-dialog.tsx index e97fe9d3..64d4dcb6 100644 --- a/apps/web/src/components/event-form/recurrences/recurrence-dialog.tsx +++ b/apps/web/src/components/event-form/recurrences/recurrence-dialog.tsx @@ -110,9 +110,7 @@ export function RecurrenceDialog({ }, }); - const min = React.useMemo(() => { - return toDate(start); - }, [start]); + const min = React.useMemo(() => toDate(start), [start]); return ( diff --git a/apps/web/src/components/event-form/recurrences/recurrence-field.tsx b/apps/web/src/components/event-form/recurrences/recurrence-field.tsx index c88e4440..60ef6d72 100644 --- a/apps/web/src/components/event-form/recurrences/recurrence-field.tsx +++ b/apps/web/src/components/event-form/recurrences/recurrence-field.tsx @@ -54,9 +54,7 @@ export function RecurrenceField({ return baseEvent?.recurrence; }, [value, baseEvent?.recurrence]); - const timeZone = React.useMemo(() => { - return date.timeZoneId; - }, [date]); + const timeZone = React.useMemo(() => date.timeZoneId, [date]); const recurrence = useRecurrence({ recurrence: displayRecurrence ?? undefined, @@ -75,7 +73,7 @@ export function RecurrenceField({