From b70d1a19ae7a61cfc3ceb13caeb99a5434c705b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 01:11:25 +0000 Subject: [PATCH 1/4] refactor(sync): share instance ids and role capabilities Google and Apple minted the same recurring-instance suffix and copied the same access-role capability table. Move parseAppleInstanceId next to the mint helper so the writer is not also a parser. Co-authored-by: tyler --- .../providers/apple/apple-calendar.adapter.ts | 38 +---------- .../apple/apple-event-writer.adapter.test.ts | 2 +- .../apple/apple-event-writer.adapter.ts | 45 ++----------- .../src/providers/apple/apple-instance-id.ts | 34 ++++++---- .../providers/calendar-role-capabilities.ts | 43 ++++++++++++ .../google/google-calendar.adapter.ts | 40 +----------- .../providers/google/google-instance-id.ts | 65 +++---------------- .../src/providers/recurring-instance-id.ts | 64 ++++++++++++++++++ 8 files changed, 148 insertions(+), 183 deletions(-) create mode 100644 packages/sync/src/providers/calendar-role-capabilities.ts create mode 100644 packages/sync/src/providers/recurring-instance-id.ts diff --git a/packages/sync/src/providers/apple/apple-calendar.adapter.ts b/packages/sync/src/providers/apple/apple-calendar.adapter.ts index 446cb20b49..6b2bbf41cc 100644 --- a/packages/sync/src/providers/apple/apple-calendar.adapter.ts +++ b/packages/sync/src/providers/apple/apple-calendar.adapter.ts @@ -1,7 +1,4 @@ -import { - type CalendarAccessRole, - type SyncCalendarCapabilities, -} from "@core/types/sync/connection.contracts"; +import { type CalendarAccessRole } from "@core/types/sync/connection.contracts"; import { type CaldavClient, CaldavClientError, @@ -10,6 +7,7 @@ import { type DiscoveredCaldavCalendar, discoverCalendars as discoverCaldavCalendars, } from "@sync/providers/apple/caldav-client"; +import { capabilitiesForAccessRole } from "@sync/providers/calendar-role-capabilities"; import { type CalendarDiscovery, type DiscoveredCalendar, @@ -25,36 +23,6 @@ export type AppleCalendarClientFactory = ( const defaultClientFactory: AppleCalendarClientFactory = (username, password) => createCaldavClient({ username, password }); -const CAPABILITIES_BY_ROLE: Record< - CalendarAccessRole, - SyncCalendarCapabilities -> = { - owner: { - canReadEvents: true, - canWriteEvents: true, - canReadBusy: true, - canInviteAttendees: true, - }, - editor: { - canReadEvents: true, - canWriteEvents: true, - canReadBusy: true, - canInviteAttendees: true, - }, - viewer: { - canReadEvents: true, - canWriteEvents: false, - canReadBusy: true, - canInviteAttendees: false, - }, - busyOnly: { - canReadEvents: false, - canWriteEvents: false, - canReadBusy: true, - canInviteAttendees: false, - }, -}; - // Apple iCloud implementation of the calendar-discovery port. The access token // custody hands in is the app-specific password; the account email is bound // when the adapter is constructed for a connection. @@ -134,7 +102,7 @@ function mapCalendar(calendar: DiscoveredCaldavCalendar): DiscoveredCalendar { primary: false, active: true, accessRole, - capabilities: CAPABILITIES_BY_ROLE[accessRole], + capabilities: capabilitiesForAccessRole(accessRole), createsGoogleMeet: false, }; } diff --git a/packages/sync/src/providers/apple/apple-event-writer.adapter.test.ts b/packages/sync/src/providers/apple/apple-event-writer.adapter.test.ts index 06211baa8a..83fab63648 100644 --- a/packages/sync/src/providers/apple/apple-event-writer.adapter.test.ts +++ b/packages/sync/src/providers/apple/apple-event-writer.adapter.test.ts @@ -5,8 +5,8 @@ import { type AppleEventWriterApi, appendExdateToMaster, eventResourceHref, - parseAppleInstanceId, } from "@sync/providers/apple/apple-event-writer.adapter"; +import { parseAppleInstanceId } from "@sync/providers/apple/apple-instance-id"; import { type CaldavResponse } from "@sync/providers/apple/caldav-client"; const CALENDAR = "https://caldav.icloud.com/123/calendars/home/"; diff --git a/packages/sync/src/providers/apple/apple-event-writer.adapter.ts b/packages/sync/src/providers/apple/apple-event-writer.adapter.ts index 21734e403f..ed7562b687 100644 --- a/packages/sync/src/providers/apple/apple-event-writer.adapter.ts +++ b/packages/sync/src/providers/apple/apple-event-writer.adapter.ts @@ -10,7 +10,10 @@ import { serializeAppleEventInstance, serializeAppleEventPatch, } from "@sync/providers/apple/apple-event.serializer"; -import { appleInstanceEventId } from "@sync/providers/apple/apple-instance-id"; +import { + appleInstanceEventId, + parseAppleInstanceId, +} from "@sync/providers/apple/apple-instance-id"; import { type CaldavClient, type CaldavResponse, @@ -451,46 +454,6 @@ class CaldavAppleEventWriterApi implements AppleEventWriterApi { } } -interface ParsedAppleInstanceId { - readonly seriesUid: string; - readonly originalStartAt: string; - readonly scheduleKind: "timed" | "allDay"; -} - -export function parseAppleInstanceId( - providerEventId: string, -): ParsedAppleInstanceId | null { - const timedMatch = /^(.+)_(\d{8}T\d{6}Z)$/.exec(providerEventId); - if (timedMatch) { - const suffix = timedMatch[2]!; - const instant = dayjs.utc(suffix, RFC5545, true); - if (!instant.isValid()) return null; - return { - seriesUid: timedMatch[1]!, - originalStartAt: instant.toDate().toISOString(), - scheduleKind: "timed", - }; - } - - const allDayMatch = /^(.+)_(\d{8})$/.exec(providerEventId); - if (allDayMatch) { - const suffix = allDayMatch[2]!; - const instant = dayjs.utc( - suffix, - dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT, - true, - ); - if (!instant.isValid()) return null; - return { - seriesUid: allDayMatch[1]!, - originalStartAt: instant.toDate().toISOString(), - scheduleKind: "allDay", - }; - } - - return null; -} - export function eventResourceHref(calendarUrl: string, uid: string): string { const base = calendarUrl.endsWith("/") ? calendarUrl : `${calendarUrl}/`; return `${base}${uid}.ics`; diff --git a/packages/sync/src/providers/apple/apple-instance-id.ts b/packages/sync/src/providers/apple/apple-instance-id.ts index 6b10dc3bf8..f9c9de0bbd 100644 --- a/packages/sync/src/providers/apple/apple-instance-id.ts +++ b/packages/sync/src/providers/apple/apple-instance-id.ts @@ -1,19 +1,29 @@ -import dayjs from "@core/util/date/dayjs"; +import { + parseRecurringInstanceEventId, + recurringInstanceEventId, +} from "@sync/providers/recurring-instance-id"; // Apple recurring-instance ids mirror Google's `{seriesId}_{originalStart}` // suffix so sparse cancellations and reader href mappings stay aligned with // Compass projection recurrenceIds. iCloud shares one UID across master and // exception VEVENTs in a resource; the suffix disambiguates instances. -export function appleInstanceEventId( - seriesProviderEventId: string, - originalStartAt: string, - scheduleKind: "timed" | "allDay", -): string { - const instant = dayjs.utc(originalStartAt); - const suffix = - scheduleKind === "allDay" - ? instant.format(dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT) - : instant.format(dayjs.DateFormat.RFC5545); - return `${seriesProviderEventId}_${suffix}`; +export const appleInstanceEventId = recurringInstanceEventId; + +export interface ParsedAppleInstanceId { + readonly seriesUid: string; + readonly originalStartAt: string; + readonly scheduleKind: "timed" | "allDay"; +} + +export function parseAppleInstanceId( + providerEventId: string, +): ParsedAppleInstanceId | null { + const parsed = parseRecurringInstanceEventId(providerEventId); + if (!parsed) return null; + return { + seriesUid: parsed.seriesProviderId, + originalStartAt: parsed.recurrenceId, + scheduleKind: parsed.scheduleKind, + }; } diff --git a/packages/sync/src/providers/calendar-role-capabilities.ts b/packages/sync/src/providers/calendar-role-capabilities.ts new file mode 100644 index 0000000000..8c8c066f3a --- /dev/null +++ b/packages/sync/src/providers/calendar-role-capabilities.ts @@ -0,0 +1,43 @@ +import { + type CalendarAccessRole, + type SyncCalendarCapabilities, +} from "@core/types/sync/connection.contracts"; + +// Operational capabilities implied by each access role. Invite ability follows +// write access — providers expose no separate per-calendar attendee-invite flag. + +const CAPABILITIES_BY_ROLE: Record< + CalendarAccessRole, + SyncCalendarCapabilities +> = { + owner: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + editor: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + viewer: { + canReadEvents: true, + canWriteEvents: false, + canReadBusy: true, + canInviteAttendees: false, + }, + busyOnly: { + canReadEvents: false, + canWriteEvents: false, + canReadBusy: true, + canInviteAttendees: false, + }, +}; + +export function capabilitiesForAccessRole( + role: CalendarAccessRole, +): SyncCalendarCapabilities { + return CAPABILITIES_BY_ROLE[role]; +} diff --git a/packages/sync/src/providers/google/google-calendar.adapter.ts b/packages/sync/src/providers/google/google-calendar.adapter.ts index edd9950724..ca9f1fa1b8 100644 --- a/packages/sync/src/providers/google/google-calendar.adapter.ts +++ b/packages/sync/src/providers/google/google-calendar.adapter.ts @@ -5,10 +5,8 @@ import { type gSchema$Calendar, type gSchema$CalendarListEntry, } from "@core/types/gcal"; -import { - type CalendarAccessRole, - type SyncCalendarCapabilities, -} from "@core/types/sync/connection.contracts"; +import { type CalendarAccessRole } from "@core/types/sync/connection.contracts"; +import { capabilitiesForAccessRole } from "@sync/providers/calendar-role-capabilities"; import { googleFailureCause, googleStatus, @@ -174,38 +172,6 @@ const ACCESS_ROLE_BY_GOOGLE: Record = { freeBusyReader: "busyOnly", }; -// Operational capabilities implied by each role. Invite ability follows write -// access — Google exposes no separate per-calendar attendee-invite flag. -const CAPABILITIES_BY_ROLE: Record< - CalendarAccessRole, - SyncCalendarCapabilities -> = { - owner: { - canReadEvents: true, - canWriteEvents: true, - canReadBusy: true, - canInviteAttendees: true, - }, - editor: { - canReadEvents: true, - canWriteEvents: true, - canReadBusy: true, - canInviteAttendees: true, - }, - viewer: { - canReadEvents: true, - canWriteEvents: false, - canReadBusy: true, - canInviteAttendees: false, - }, - busyOnly: { - canReadEvents: false, - canWriteEvents: false, - canReadBusy: true, - canInviteAttendees: false, - }, -}; - // Map one Google calendar-list entry to provider-neutral facts. An entry // without an id is unusable (it cannot be keyed or persisted), so it is dropped. async function mapCalendar( @@ -229,7 +195,7 @@ async function mapCalendar( // from before the hide, not a signal Google is still showing it. active: item.deleted !== true && item.hidden !== true, accessRole, - capabilities: CAPABILITIES_BY_ROLE[accessRole], + capabilities: capabilitiesForAccessRole(accessRole), createsGoogleMeet: calendarCreatesGoogleMeet( item.conferenceProperties?.allowedConferenceSolutionTypes, ), diff --git a/packages/sync/src/providers/google/google-instance-id.ts b/packages/sync/src/providers/google/google-instance-id.ts index c5a6fb4dc9..84a447984d 100644 --- a/packages/sync/src/providers/google/google-instance-id.ts +++ b/packages/sync/src/providers/google/google-instance-id.ts @@ -1,4 +1,8 @@ -import dayjs from "@core/util/date/dayjs"; +import { + type ParsedRecurringInstanceId, + parseRecurringInstanceEventId, + recurringInstanceEventId, +} from "@sync/providers/recurring-instance-id"; // Google instance ids are `{seriesId}_{originalStart}`: all-day YYYYMMDD, // timed YYYYMMDDTHHMMSSZ (always UTC). Compass mints these when addressing an @@ -7,60 +11,7 @@ import dayjs from "@core/util/date/dayjs"; // must stay byte-identical so a sparse cancellation reconstructs the same // recurrenceId the series projection uses. -export function googleInstanceEventId( - seriesProviderEventId: string, - originalStartAt: string, - scheduleKind: "timed" | "allDay", -): string { - const instant = dayjs.utc(originalStartAt); - const suffix = - scheduleKind === "allDay" - ? instant.format(dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT) - : instant.format(dayjs.DateFormat.RFC5545); - return `${seriesProviderEventId}_${suffix}`; -} +export type ParsedGoogleInstanceId = ParsedRecurringInstanceId; -export interface ParsedGoogleInstanceId { - readonly seriesProviderId: string; - // Compass recurrence identity: UTC ISO with milliseconds (Date#toISOString). - readonly recurrenceId: string; - readonly scheduleKind: "timed" | "allDay"; -} - -const TIMED_INSTANCE_ID = /^(.*)_(\d{8}T\d{6}Z)$/; -const ALL_DAY_INSTANCE_ID = /^(.*)_(\d{8})$/; - -export function parseGoogleInstanceEventId( - providerEventId: string, -): ParsedGoogleInstanceId | null { - const timed = TIMED_INSTANCE_ID.exec(providerEventId); - if (timed) { - return parseSuffix(timed[1], timed[2], "timed", dayjs.DateFormat.RFC5545); - } - const allDay = ALL_DAY_INSTANCE_ID.exec(providerEventId); - if (allDay) { - return parseSuffix( - allDay[1], - allDay[2], - "allDay", - dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT, - ); - } - return null; -} - -function parseSuffix( - seriesProviderId: string | undefined, - suffix: string | undefined, - scheduleKind: "timed" | "allDay", - format: string, -): ParsedGoogleInstanceId | null { - if (!seriesProviderId || !suffix) return null; - const instant = dayjs.utc(suffix, format, true); - if (!instant.isValid()) return null; - return { - seriesProviderId, - recurrenceId: instant.toDate().toISOString(), - scheduleKind, - }; -} +export const googleInstanceEventId = recurringInstanceEventId; +export const parseGoogleInstanceEventId = parseRecurringInstanceEventId; diff --git a/packages/sync/src/providers/recurring-instance-id.ts b/packages/sync/src/providers/recurring-instance-id.ts new file mode 100644 index 0000000000..e83127288c --- /dev/null +++ b/packages/sync/src/providers/recurring-instance-id.ts @@ -0,0 +1,64 @@ +import dayjs from "@core/util/date/dayjs"; + +// Recurring-instance ids are `{seriesId}_{originalStart}`: all-day YYYYMMDD, +// timed YYYYMMDDTHHMMSSZ (always UTC). Google mints this form on the wire; +// Apple mirrors it so sparse cancellations and reader href mappings stay +// aligned with Compass projection recurrenceIds. + +export interface ParsedRecurringInstanceId { + readonly seriesProviderId: string; + // Compass recurrence identity: UTC ISO with milliseconds (Date#toISOString). + readonly recurrenceId: string; + readonly scheduleKind: "timed" | "allDay"; +} + +const TIMED_INSTANCE_ID = /^(.*)_(\d{8}T\d{6}Z)$/; +const ALL_DAY_INSTANCE_ID = /^(.*)_(\d{8})$/; + +export function recurringInstanceEventId( + seriesProviderEventId: string, + originalStartAt: string, + scheduleKind: "timed" | "allDay", +): string { + const instant = dayjs.utc(originalStartAt); + const suffix = + scheduleKind === "allDay" + ? instant.format(dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT) + : instant.format(dayjs.DateFormat.RFC5545); + return `${seriesProviderEventId}_${suffix}`; +} + +export function parseRecurringInstanceEventId( + providerEventId: string, +): ParsedRecurringInstanceId | null { + const timed = TIMED_INSTANCE_ID.exec(providerEventId); + if (timed) { + return parseSuffix(timed[1], timed[2], "timed", dayjs.DateFormat.RFC5545); + } + const allDay = ALL_DAY_INSTANCE_ID.exec(providerEventId); + if (allDay) { + return parseSuffix( + allDay[1], + allDay[2], + "allDay", + dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT, + ); + } + return null; +} + +function parseSuffix( + seriesProviderId: string | undefined, + suffix: string | undefined, + scheduleKind: "timed" | "allDay", + format: string, +): ParsedRecurringInstanceId | null { + if (!seriesProviderId || !suffix) return null; + const instant = dayjs.utc(suffix, format, true); + if (!instant.isValid()) return null; + return { + seriesProviderId, + recurrenceId: instant.toDate().toISOString(), + scheduleKind, + }; +} From 16f7cda7e5549c44a6e5ef14889ccd8a9b080e76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 01:11:28 +0000 Subject: [PATCH 2/4] refactor(sync): share microsoft graph request helper Calendar, event, people, and notification adapters each repeated bearer auth, the 30s timeout, JSON parse, and the { response: { status, data } } error shape. One helper keeps that contract in one place. Co-authored-by: tyler --- .../microsoft/microsoft-calendar.adapter.ts | 23 ++-- .../microsoft-event-reader.adapter.ts | 27 ++--- .../microsoft-event-writer.adapter.ts | 104 ++++-------------- .../microsoft/microsoft-graph-request.test.ts | 76 +++++++++++++ .../microsoft/microsoft-graph-request.ts | 70 ++++++++++++ .../microsoft-notifications.adapter.ts | 58 +++------- .../microsoft/microsoft-people.adapter.ts | 34 ++---- 7 files changed, 204 insertions(+), 188 deletions(-) create mode 100644 packages/sync/src/providers/microsoft/microsoft-graph-request.test.ts create mode 100644 packages/sync/src/providers/microsoft/microsoft-graph-request.ts diff --git a/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts index 41e3247af4..51c05a2c11 100644 --- a/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts @@ -6,10 +6,10 @@ import { microsoftFailureCause, microsoftStatus, } from "@sync/providers/microsoft/microsoft-error"; +import { microsoftGraphRequest } from "@sync/providers/microsoft/microsoft-graph-request"; import { MICROSOFT_CALENDAR_LIST_SELECT, MICROSOFT_GRAPH_BASE_URL, - MICROSOFT_REQUEST_TIMEOUT_MS, } from "@sync/providers/microsoft/microsoft-http.constants"; import { type CalendarDiscovery, @@ -147,23 +147,14 @@ class FetchMicrosoftCalendarListApi implements MicrosoftCalendarListApi { const url = params.nextLink ?? `${MICROSOFT_GRAPH_BASE_URL}/me/calendars?$select=${MICROSOFT_CALENDAR_LIST_SELECT}`; - const response = await fetch(url, { - headers: { Authorization: `Bearer ${this.#accessToken}` }, - signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), - }); - - const data = (await response.json()) as { + const data = await microsoftGraphRequest<{ value?: MicrosoftGraphCalendar[]; "@odata.nextLink"?: string; - error?: { message?: string }; - }; - - if (!response.ok) { - throw Object.assign( - new Error(data.error?.message ?? "microsoft_calendar_list_failed"), - { response: { status: response.status, data } }, - ); - } + }>({ + accessToken: this.#accessToken, + url, + fallbackError: "microsoft_calendar_list_failed", + }); return { items: data.value ?? [], diff --git a/packages/sync/src/providers/microsoft/microsoft-event-reader.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-event-reader.adapter.ts index a37ec901c7..dd97f06cde 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event-reader.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event-reader.adapter.ts @@ -10,11 +10,11 @@ import { type GraphEvent, normalizeMicrosoftEvent, } from "@sync/providers/microsoft/microsoft-event.normalizer"; +import { microsoftGraphRequest } from "@sync/providers/microsoft/microsoft-graph-request"; import { MICROSOFT_EVENT_PAGE_SIZE, MICROSOFT_EVENT_SELECT, MICROSOFT_GRAPH_BASE_URL, - MICROSOFT_REQUEST_TIMEOUT_MS, } from "@sync/providers/microsoft/microsoft-http.constants"; import { ProviderEventError } from "@sync/providers/provider-event.port"; import { @@ -184,28 +184,19 @@ class FetchMicrosoftEventListApi implements MicrosoftEventListApi { params: Parameters[0], ): Promise { const url = resolveRequestUrl(params); - const response = await fetch(url, { + const data = await microsoftGraphRequest<{ + value?: GraphEventDeltaItem[]; + "@odata.nextLink"?: string; + "@odata.deltaLink"?: string; + }>({ + accessToken: this.#accessToken, + url, headers: { - Authorization: `Bearer ${this.#accessToken}`, Prefer: `odata.maxpagesize=${MICROSOFT_EVENT_PAGE_SIZE}, outlook.timezone="UTC"`, }, - signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), + fallbackError: "microsoft_event_delta_failed", }); - const data = (await response.json()) as { - value?: GraphEventDeltaItem[]; - "@odata.nextLink"?: string; - "@odata.deltaLink"?: string; - error?: { code?: string; message?: string }; - }; - - if (!response.ok) { - throw Object.assign( - new Error(data.error?.message ?? "microsoft_event_delta_failed"), - { response: { status: response.status, data } }, - ); - } - return { items: data.value ?? [], nextLink: data["@odata.nextLink"] ?? null, diff --git a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts index af6fbbc38a..90bc7aa60f 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts @@ -11,10 +11,10 @@ import { mapConference, normalizeMicrosoftEvent, } from "@sync/providers/microsoft/microsoft-event.normalizer"; +import { microsoftGraphRequest } from "@sync/providers/microsoft/microsoft-graph-request"; import { MICROSOFT_EVENT_SELECT, MICROSOFT_GRAPH_BASE_URL, - MICROSOFT_REQUEST_TIMEOUT_MS, } from "@sync/providers/microsoft/microsoft-http.constants"; import { type GraphCalendarMeetingSettings, @@ -519,109 +519,43 @@ class FetchMicrosoftEventWriteApi implements MicrosoftEventWriteApi { ifMatch: string | null = null, ): Promise { const headers: Record = { - Authorization: `Bearer ${this.#accessToken}`, Prefer: 'outlook.timezone="UTC"', }; if (ifMatch) headers["If-Match"] = ifMatch; - if (body !== undefined) headers["Content-Type"] = "application/json"; - const response = await fetch(url, { + const data = await microsoftGraphRequest({ + accessToken: this.#accessToken, + url, method, headers, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), + body, + fallbackError: "microsoft_event_write_failed", + emptyOk: method === "DELETE", }); - - if (method === "DELETE") { - if (response.ok) return {} as GraphEvent; - const deleteError = await parseErrorBody(response); - throw Object.assign( - new Error(deleteError.message ?? "microsoft_event_write_failed"), - { response: { status: response.status, data: deleteError.data } }, - ); - } - - const data = (await response.json()) as GraphEvent & { - error?: { code?: string; message?: string }; - }; - - if (!response.ok) { - throw Object.assign( - new Error(data.error?.message ?? "microsoft_event_write_failed"), - { response: { status: response.status, data } }, - ); - } - - return data; + return data ?? ({} as GraphEvent); } async #requestCollection( method: "GET", url: string, ): Promise { - const headers: Record = { - Authorization: `Bearer ${this.#accessToken}`, - Prefer: 'outlook.timezone="UTC"', - }; - - const response = await fetch(url, { + const data = await microsoftGraphRequest<{ value?: GraphEvent[] }>({ + accessToken: this.#accessToken, + url, method, - headers, - signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), + headers: { Prefer: 'outlook.timezone="UTC"' }, + fallbackError: "microsoft_event_write_failed", }); - - const data = (await response.json()) as { - value?: GraphEvent[]; - error?: { code?: string; message?: string }; - }; - - if (!response.ok) { - throw Object.assign( - new Error(data.error?.message ?? "microsoft_event_write_failed"), - { response: { status: response.status, data } }, - ); - } - return data.value ?? []; } - async #requestJson(method: "GET", url: string): Promise { - const headers: Record = { - Authorization: `Bearer ${this.#accessToken}`, - Prefer: 'outlook.timezone="UTC"', - }; - - const response = await fetch(url, { + #requestJson(method: "GET", url: string): Promise { + return microsoftGraphRequest({ + accessToken: this.#accessToken, + url, method, - headers, - signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), + headers: { Prefer: 'outlook.timezone="UTC"' }, + fallbackError: "microsoft_event_write_failed", }); - - const data = (await response.json()) as T & { - error?: { code?: string; message?: string }; - }; - - if (!response.ok) { - throw Object.assign( - new Error(data.error?.message ?? "microsoft_event_write_failed"), - { response: { status: response.status, data } }, - ); - } - - return data; - } -} - -async function parseErrorBody(response: Response): Promise<{ - message?: string; - data?: unknown; -}> { - try { - const data = (await response.json()) as { - error?: { message?: string }; - }; - return { message: data.error?.message, data }; - } catch { - return {}; } } diff --git a/packages/sync/src/providers/microsoft/microsoft-graph-request.test.ts b/packages/sync/src/providers/microsoft/microsoft-graph-request.test.ts new file mode 100644 index 0000000000..fac30acc44 --- /dev/null +++ b/packages/sync/src/providers/microsoft/microsoft-graph-request.test.ts @@ -0,0 +1,76 @@ +import { microsoftStatus } from "@sync/providers/microsoft/microsoft-error"; +import { microsoftGraphRequest } from "@sync/providers/microsoft/microsoft-graph-request"; +import { afterEach, describe, expect, it, mock } from "bun:test"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + mock.restore(); +}); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("microsoftGraphRequest", () => { + it("sends a bearer token and returns JSON on success", async () => { + const fetchMock = mock(() => + Promise.resolve(jsonResponse(200, { id: "cal-1" })), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const data = await microsoftGraphRequest<{ id: string }>({ + accessToken: "token-1", + url: "https://graph.microsoft.com/v1.0/me/calendars", + fallbackError: "microsoft_calendar_list_failed", + }); + + expect(data).toEqual({ id: "cal-1" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("GET"); + expect(init.headers).toEqual({ + Authorization: "Bearer token-1", + }); + }); + + it("throws the Graph status shape classifiers already read", async () => { + globalThis.fetch = mock(() => + Promise.resolve(jsonResponse(401, { error: { message: "Expired" } })), + ) as unknown as typeof fetch; + + try { + await microsoftGraphRequest({ + accessToken: "token-1", + url: "https://graph.microsoft.com/v1.0/me/events", + fallbackError: "microsoft_event_delta_failed", + }); + expect.unreachable("expected the request to reject"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Expired"); + expect(microsoftStatus(error)).toBe(401); + } + }); + + it("skips the JSON body on a successful emptyOk delete", async () => { + const fetchMock = mock(() => + Promise.resolve(new Response(null, { status: 204 })), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + microsoftGraphRequest({ + accessToken: "token-1", + url: "https://graph.microsoft.com/v1.0/subscriptions/sub-1", + method: "DELETE", + fallbackError: "microsoft_subscription_delete_failed", + emptyOk: true, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/sync/src/providers/microsoft/microsoft-graph-request.ts b/packages/sync/src/providers/microsoft/microsoft-graph-request.ts new file mode 100644 index 0000000000..64e3031274 --- /dev/null +++ b/packages/sync/src/providers/microsoft/microsoft-graph-request.ts @@ -0,0 +1,70 @@ +import { MICROSOFT_REQUEST_TIMEOUT_MS } from "@sync/providers/microsoft/microsoft-http.constants"; + +export interface MicrosoftGraphRequestInit { + readonly accessToken: string; + readonly url: string; + readonly method?: string; + readonly headers?: Record; + readonly body?: unknown; + readonly fallbackError: string; + /** Successful responses skip the JSON body (Graph DELETE). */ + readonly emptyOk?: boolean; +} + +export async function microsoftGraphRequest( + init: MicrosoftGraphRequestInit, +): Promise { + const headers: Record = { + Authorization: `Bearer ${init.accessToken}`, + ...init.headers, + }; + const hasBody = init.body !== undefined; + if (hasBody && headers["Content-Type"] === undefined) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(init.url, { + method: init.method ?? "GET", + headers, + ...(hasBody ? { body: JSON.stringify(init.body) } : {}), + signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), + }); + + if (init.emptyOk) { + if (response.ok) return undefined as T; + throw microsoftGraphHttpError( + response.status, + await parseJsonOrEmpty(response), + init.fallbackError, + ); + } + + const data = (await response.json()) as T & { + error?: { message?: string }; + }; + if (!response.ok) { + throw microsoftGraphHttpError(response.status, data, init.fallbackError); + } + return data; +} + +function microsoftGraphHttpError( + status: number, + data: unknown, + fallbackError: string, +): Error { + const message = + (data as { error?: { message?: string } } | undefined)?.error?.message ?? + fallbackError; + return Object.assign(new Error(message), { + response: { status, data }, + }); +} + +async function parseJsonOrEmpty(response: Response): Promise { + try { + return await response.json(); + } catch { + return {}; + } +} diff --git a/packages/sync/src/providers/microsoft/microsoft-notifications.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-notifications.adapter.ts index 69a6896a25..0c43237f30 100644 --- a/packages/sync/src/providers/microsoft/microsoft-notifications.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-notifications.adapter.ts @@ -4,10 +4,8 @@ import { microsoftFailureCause, microsoftStatus, } from "@sync/providers/microsoft/microsoft-error"; -import { - MICROSOFT_GRAPH_BASE_URL, - MICROSOFT_REQUEST_TIMEOUT_MS, -} from "@sync/providers/microsoft/microsoft-http.constants"; +import { microsoftGraphRequest } from "@sync/providers/microsoft/microsoft-graph-request"; +import { MICROSOFT_GRAPH_BASE_URL } from "@sync/providers/microsoft/microsoft-http.constants"; import { type NotificationChannel, type NotificationParseResult, @@ -146,51 +144,23 @@ class FetchMicrosoftSubscriptionsApi implements MicrosoftSubscriptionsApi { async createSubscription( body: MicrosoftSubscriptionCreateBody, ): Promise { - const response = await fetch(`${MICROSOFT_GRAPH_BASE_URL}/subscriptions`, { + return microsoftGraphRequest({ + accessToken: this.accessToken, + url: `${MICROSOFT_GRAPH_BASE_URL}/subscriptions`, method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), + body, + fallbackError: "microsoft_subscription_create_failed", }); - - const data = (await response.json()) as GraphSubscription & { - error?: { code?: string; message?: string }; - }; - - if (!response.ok) { - throw Object.assign( - new Error( - data.error?.message ?? "microsoft_subscription_create_failed", - ), - { response: { status: response.status, data } }, - ); - } - - return data; } async deleteSubscription(subscriptionId: string): Promise { - const response = await fetch( - `${MICROSOFT_GRAPH_BASE_URL}/subscriptions/${encodeURIComponent(subscriptionId)}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${this.accessToken}` }, - signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), - }, - ); - - if (response.ok) return; - - const data = (await response.json().catch(() => ({}))) as { - error?: { code?: string; message?: string }; - }; - throw Object.assign( - new Error(data.error?.message ?? "microsoft_subscription_delete_failed"), - { response: { status: response.status, data } }, - ); + await microsoftGraphRequest({ + accessToken: this.accessToken, + url: `${MICROSOFT_GRAPH_BASE_URL}/subscriptions/${encodeURIComponent(subscriptionId)}`, + method: "DELETE", + fallbackError: "microsoft_subscription_delete_failed", + emptyOk: true, + }); } } diff --git a/packages/sync/src/providers/microsoft/microsoft-people.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-people.adapter.ts index 82d2f25a7a..28a9d2c21b 100644 --- a/packages/sync/src/providers/microsoft/microsoft-people.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-people.adapter.ts @@ -6,10 +6,8 @@ import { microsoftFailureCause, microsoftStatus, } from "@sync/providers/microsoft/microsoft-error"; -import { - MICROSOFT_GRAPH_BASE_URL, - MICROSOFT_REQUEST_TIMEOUT_MS, -} from "@sync/providers/microsoft/microsoft-http.constants"; +import { microsoftGraphRequest } from "@sync/providers/microsoft/microsoft-graph-request"; +import { MICROSOFT_GRAPH_BASE_URL } from "@sync/providers/microsoft/microsoft-http.constants"; import { rankContactSuggestions, toContactSuggestion, @@ -100,28 +98,14 @@ class FetchMicrosoftPeopleApi implements MicrosoftPeopleApi { $select: params.select, $top: String(params.top), }); - const response = await fetch( - `${MICROSOFT_GRAPH_BASE_URL}/me/people?${query}`, - { - headers: { - Authorization: `Bearer ${this.#accessToken}`, - ConsistencyLevel: "eventual", - }, - signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), - }, - ); - - const data = (await response.json()) as { + const data = await microsoftGraphRequest<{ value?: GraphPersonMatch[]; - error?: { code?: string; message?: string }; - }; - - if (!response.ok) { - throw Object.assign( - new Error(data.error?.message ?? "microsoft_people_search_failed"), - { response: { status: response.status, data } }, - ); - } + }>({ + accessToken: this.#accessToken, + url: `${MICROSOFT_GRAPH_BASE_URL}/me/people?${query}`, + headers: { ConsistencyLevel: "eventual" }, + fallbackError: "microsoft_people_search_failed", + }); return { value: data.value ?? [] }; } From 8f55f3bcd1226953414b75f62b38bc71d6b3d1c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 01:11:31 +0000 Subject: [PATCH 3/4] refactor(web): share public booking slot helpers and guest-action markup Book and reschedule duplicated month-slot query, prefetch, and next-available walks. The guest pages also copied the same alert and sticky footer class, and two thin wrappers only forwarded copy-link props. Co-authored-by: tyler --- docs/features/booking.md | 2 +- .../services/public-booking.service.ts | 29 +- .../web/src/booking/PublicBookingAlert.tsx | 22 ++ .../booking/PublicBookingConfirmationView.tsx | 15 +- .../booking/PublicBookingCopyCancelUrl.tsx | 17 -- .../PublicBookingCopyRescheduleUrl.tsx | 17 -- .../web/src/booking/PublicBookingLayout.tsx | 3 + .../web/src/booking/PublicBookingPage.tsx | 25 +- .../booking/PublicBookingReschedulePage.tsx | 23 +- .../web/src/booking/public-booking.query.ts | 285 +++++++++--------- 10 files changed, 216 insertions(+), 222 deletions(-) create mode 100644 packages/web/src/booking/PublicBookingAlert.tsx delete mode 100644 packages/web/src/booking/PublicBookingCopyCancelUrl.tsx delete mode 100644 packages/web/src/booking/PublicBookingCopyRescheduleUrl.tsx diff --git a/docs/features/booking.md b/docs/features/booking.md index 5e52b26a1e..3ba8b114b0 100644 --- a/docs/features/booking.md +++ b/docs/features/booking.md @@ -432,7 +432,7 @@ Guest reschedule is **in scope for v1.3**, not v1 / v1.1. | Calendar application port | `packages/backend/src/booking/services/calendar-booking.port.ts` (`updateBookingEvent`), `services/calendar-booking.service.ts` | | Sync busy occupancy | `packages/sync/src/domain/occurrence-projection.ts`, `busy-query.service.ts`, `booking-occupancy-facts.ts` | | Host Settings UI | `packages/web/src/booking/BookingSettingsSection.tsx`, `packages/web/src/components/Settings/SettingsModal.tsx` | -| Public guest UI | `packages/web/src/booking/PublicBookingPage.tsx`, `PublicBookingConfirmedPage.tsx`, `PublicBookingCancelPage.tsx`, `PublicBookingReschedulePage.tsx`, `PublicBookingCopyRescheduleUrl.tsx`, `PublicBookingEditDetailsForm.tsx` | +| Public guest UI | `packages/web/src/booking/PublicBookingPage.tsx`, `PublicBookingConfirmedPage.tsx`, `PublicBookingCancelPage.tsx`, `PublicBookingReschedulePage.tsx`, `PublicBookingCopyGuestAction.tsx`, `PublicBookingEditDetailsForm.tsx` | | Public web API client | `packages/web/src/api/public-booking.api.ts` | | E2e | `e2e/booking/`, `e2e/booking/public-booking-reschedule.spec.ts`, `e2e/accessibility/booking-a11y.spec.ts` | diff --git a/packages/backend/src/booking/services/public-booking.service.ts b/packages/backend/src/booking/services/public-booking.service.ts index fee19fc5d7..35957415dc 100644 --- a/packages/backend/src/booking/services/public-booking.service.ts +++ b/packages/backend/src/booking/services/public-booking.service.ts @@ -142,6 +142,11 @@ const buildGuestActionUrl = ( CONFIG.FRONTEND_URL, ).href; +const guestActionUrls = (reservationId: string, token: string) => ({ + cancelUrl: buildGuestActionUrl("cancel", reservationId, token), + rescheduleUrl: buildGuestActionUrl("reschedule", reservationId, token), +}); + const assertGuestEmail = (email: string): void => { if (!isGuestEmail(email)) { throw bookingError("INVALID_INPUT", "Invalid guest email"); @@ -590,13 +595,7 @@ export class PublicBookingService { ); const cancelToken = generateCancelToken(); const reservationId = mongoService.objectId(); - const cancelUrl = buildGuestActionUrl( - "cancel", - reservationId.toString(), - cancelToken, - ); - const rescheduleUrl = buildGuestActionUrl( - "reschedule", + const { cancelUrl, rescheduleUrl } = guestActionUrls( reservationId.toString(), cancelToken, ); @@ -737,13 +736,7 @@ export class PublicBookingService { const guestName = input.name ?? reservation.guestName; const notes = nextGuestNotes(input.notes, reservation.notes); const hostDisplayName = await getHostDisplayName(page.userId); - const cancelUrl = buildGuestActionUrl( - "cancel", - reservationId.toString(), - input.token, - ); - const rescheduleUrl = buildGuestActionUrl( - "reschedule", + const { cancelUrl, rescheduleUrl } = guestActionUrls( reservationId.toString(), input.token, ); @@ -796,13 +789,7 @@ export class PublicBookingService { const slotStart = new Date(input.slotStart); const slotEnd = slotEndForStart(slotStart, input.durationMinutes); const hostDisplayName = await getHostDisplayName(page.userId); - const cancelUrl = buildGuestActionUrl( - "cancel", - reservationId.toString(), - input.token, - ); - const rescheduleUrl = buildGuestActionUrl( - "reschedule", + const { cancelUrl, rescheduleUrl } = guestActionUrls( reservationId.toString(), input.token, ); diff --git a/packages/web/src/booking/PublicBookingAlert.tsx b/packages/web/src/booking/PublicBookingAlert.tsx new file mode 100644 index 0000000000..f872b6191c --- /dev/null +++ b/packages/web/src/booking/PublicBookingAlert.tsx @@ -0,0 +1,22 @@ +import { type Ref } from "react"; + +interface PublicBookingAlertProps { + message: string; + alertRef: Ref; +} + +export function PublicBookingAlert({ + message, + alertRef, +}: PublicBookingAlertProps) { + return ( +

+ {message} +

+ ); +} diff --git a/packages/web/src/booking/PublicBookingConfirmationView.tsx b/packages/web/src/booking/PublicBookingConfirmationView.tsx index 927de263fb..6da289c96f 100644 --- a/packages/web/src/booking/PublicBookingConfirmationView.tsx +++ b/packages/web/src/booking/PublicBookingConfirmationView.tsx @@ -4,8 +4,7 @@ import { BOOKING_CONFERENCE_INVITE_COPY, resolveBookingConference, } from "@web/booking/booking-conference.copy"; -import { PublicBookingCopyCancelUrl } from "@web/booking/PublicBookingCopyCancelUrl"; -import { PublicBookingCopyRescheduleUrl } from "@web/booking/PublicBookingCopyRescheduleUrl"; +import { PublicBookingCopyGuestAction } from "@web/booking/PublicBookingCopyGuestAction"; import { PublicBookingLayout } from "@web/booking/PublicBookingLayout"; import { PublicBookingSlotSummary } from "@web/booking/PublicBookingSlotSummary"; import { PUBLIC_BOOKING_HEADING_CLASS } from "@web/booking/PublicBookingStatusMessage"; @@ -103,10 +102,18 @@ export function PublicBookingConfirmationView({ aria-label="Booking actions" > {cancelUrl ? ( - + ) : null} {rescheduleUrl ? ( - + ) : null} ) : null} diff --git a/packages/web/src/booking/PublicBookingCopyCancelUrl.tsx b/packages/web/src/booking/PublicBookingCopyCancelUrl.tsx deleted file mode 100644 index 15469cde94..0000000000 --- a/packages/web/src/booking/PublicBookingCopyCancelUrl.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PublicBookingCopyGuestAction } from "@web/booking/PublicBookingCopyGuestAction"; - -interface PublicBookingCopyCancelUrlProps { - cancelUrl: string; -} - -export function PublicBookingCopyCancelUrl({ - cancelUrl, -}: PublicBookingCopyCancelUrlProps) { - return ( - - ); -} diff --git a/packages/web/src/booking/PublicBookingCopyRescheduleUrl.tsx b/packages/web/src/booking/PublicBookingCopyRescheduleUrl.tsx deleted file mode 100644 index b842b22194..0000000000 --- a/packages/web/src/booking/PublicBookingCopyRescheduleUrl.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PublicBookingCopyGuestAction } from "@web/booking/PublicBookingCopyGuestAction"; - -interface PublicBookingCopyRescheduleUrlProps { - rescheduleUrl: string; -} - -export function PublicBookingCopyRescheduleUrl({ - rescheduleUrl, -}: PublicBookingCopyRescheduleUrlProps) { - return ( - - ); -} diff --git a/packages/web/src/booking/PublicBookingLayout.tsx b/packages/web/src/booking/PublicBookingLayout.tsx index 87a645c3e2..c76744c909 100644 --- a/packages/web/src/booking/PublicBookingLayout.tsx +++ b/packages/web/src/booking/PublicBookingLayout.tsx @@ -1,5 +1,8 @@ import { type PropsWithChildren } from "react"; +export const PUBLIC_BOOKING_STICKY_STEP_CLASS = + "sticky bottom-0 z-10 -mx-4 border-border border-t bg-background px-4 py-3 sm:static sm:mx-0 sm:border-0 sm:px-0 sm:py-0"; + interface PublicBookingLayoutProps extends PropsWithChildren { wide?: boolean; } diff --git a/packages/web/src/booking/PublicBookingPage.tsx b/packages/web/src/booking/PublicBookingPage.tsx index 25b379c3f4..23faa7467c 100644 --- a/packages/web/src/booking/PublicBookingPage.tsx +++ b/packages/web/src/booking/PublicBookingPage.tsx @@ -5,9 +5,13 @@ import { formatBookingDurationWithConference, resolveBookingConference, } from "@web/booking/booking-conference.copy"; +import { PublicBookingAlert } from "@web/booking/PublicBookingAlert"; import { PublicBookingDetailsStep } from "@web/booking/PublicBookingDetailsStep"; import { PublicBookingGuestForm } from "@web/booking/PublicBookingGuestForm"; -import { PublicBookingLayout } from "@web/booking/PublicBookingLayout"; +import { + PUBLIC_BOOKING_STICKY_STEP_CLASS, + PublicBookingLayout, +} from "@web/booking/PublicBookingLayout"; import { PublicBookingPicker } from "@web/booking/PublicBookingPicker"; import { PublicBookingSkipLink } from "@web/booking/PublicBookingSkipLink"; import { @@ -24,9 +28,6 @@ import { } from "@web/booking/use-booking-heading-focus"; import { usePublicBookingFlow } from "@web/booking/use-public-booking-flow"; -const STICKY_STEP_CLASS_NAME = - "sticky bottom-0 z-10 -mx-4 border-border border-t bg-background px-4 py-3 sm:static sm:mx-0 sm:border-0 sm:px-0 sm:py-0"; - export function PublicBookingPage() { const { username } = useParams({ from: "/book/$username" }); const flow = usePublicBookingFlow(); @@ -133,18 +134,14 @@ export function PublicBookingPage() { {flow.alertMessage ? ( -

- {flow.alertMessage} -

+ ) : null} {flow.showDetailsStep && flow.selectedSlotStart ? ( -
+
{flow.showConflictForm ? ( -
+
{flow.alertMessage ? ( -

- {flow.alertMessage} -

+ ) : null} {flow.selectedSlotStart ? ( -
+