diff --git a/packages/lib/CalendarService.ts b/packages/lib/CalendarService.ts index 27a818e2620f46..c34a04a599658f 100644 --- a/packages/lib/CalendarService.ts +++ b/packages/lib/CalendarService.ts @@ -17,18 +17,22 @@ import type { TeamMember, } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import ICAL from "ical.js"; import type { Attendee, DateArray, DurationObject } from "ics"; import { createEvent } from "ics"; -import type { DAVAccount, DAVCalendar, DAVObject } from "tsdav"; +import type { DAVAccount, DAVCalendar, DAVObject, DAVClient } from "tsdav"; import { createAccount, createCalendarObject, + createDAVClient, deleteCalendarObject, fetchCalendarObjects, fetchCalendars, getBasicAuthHeaders, updateCalendarObject, + DAVNamespace, } from "tsdav"; import { v4 as uuidv4 } from "uuid"; import { getLocation, getRichDescription } from "./CalEventParser"; @@ -392,6 +396,7 @@ export default abstract class BaseCalendarService implements Calendar { protected integrationName = ""; private log: typeof logger; private credential: CredentialPayload; + private client?: DAVClient; constructor(credential: CredentialPayload, integrationName: string, url?: string) { this.integrationName = integrationName; @@ -463,23 +468,47 @@ export default abstract class BaseCalendarService implements Calendar { : undefined; // We create the event directly on iCal + // 1. If a specific destination calendar is requested, use it. + // 2. Else prefer the calendar identified as CALDAV:schedule-default-calendar-URL (RFC 6638 Section 9.2). + // 3. Else fall back to the first available calendar and log for debuggability. + let targetCalendars: IntegrationCalendar[]; + + if (mainHostDestinationCalendar?.externalId) { + targetCalendars = calendars.filter((c) => c.externalId === mainHostDestinationCalendar.externalId); + } else { + const defaultCalendar = calendars.find((c) => c.isDefault); + if (defaultCalendar) { + targetCalendars = [defaultCalendar]; + this.log.debug(`CalDAV: Using schedule-default-calendar-URL calendar: ${defaultCalendar.externalId}`); + } else { + // RFC does not guarantee PROPFIND response order — log the fallback choice + targetCalendars = calendars.slice(0, 1); + this.log.warn( + `CalDAV: No default calendar found via schedule-default-calendar-URL. ` + + `Falling back to first available calendar: ${targetCalendars[0]?.externalId ?? "none"}` + ); + } + } + + if (targetCalendars.length === 0) { + // TODO: consider adding ErrorCode.NoTargetCalendarsFound for a more specific error + throw new ErrorWithCode( + ErrorCode.InternalServerError, + "No target calendars found to create CalDAV calendar entry" + ); + } + const responses = await Promise.all( - calendars - .filter((c) => - mainHostDestinationCalendar?.externalId - ? c.externalId === mainHostDestinationCalendar.externalId - : true - ) - .map((calendar) => - createCalendarObject({ - calendar: { - url: calendar.externalId, - }, - filename: `${uid}.ics`, - iCalString: injectScheduleAgent(iCalStringWithTimezone), - headers: this.headers, - }) - ) + targetCalendars.map((calendar) => + createCalendarObject({ + calendar: { + url: calendar.externalId, + }, + filename: `${uid}.ics`, + iCalString: injectScheduleAgent(iCalStringWithTimezone), + headers: this.headers, + }) + ) ); if (responses.some((r) => !r.ok)) { @@ -560,7 +589,7 @@ export default abstract class BaseCalendarService implements Calendar { if (response.status >= 200 && response.status < 300) { return { uid, - type: this.credentials.type, + type: this.credential.type, id: typeof calendarEvent.uid === "string" ? calendarEvent.uid : "-1", password: "", url: calendarEvent.url, @@ -665,7 +694,7 @@ export default abstract class BaseCalendarService implements Calendar { const userId = this.getUserId(selectedCalendars); // we use the userId from selectedCalendars to fetch the user's timeZone from the database primarily for all-day events without any timezone information - const userTimeZone = userId ? await this.getUserTimezoneFromDB(userId) : "Europe/London"; + const userTimeZone = userId ? (await this.getUserTimezoneFromDB(userId)) ?? "Europe/London" : "Europe/London"; const events: { start: string; end: string }[] = []; objects.forEach((object) => { if (!object || object.data == null || JSON.stringify(object.data) == "{}") return; @@ -817,27 +846,38 @@ export default abstract class BaseCalendarService implements Calendar { async listCalendars(event?: CalendarEvent): Promise { try { - const account = await this.getAccount(); + const client = await this.getClient(); - const calendars = (await fetchCalendars({ - account, - headers: this.headers, - })) /** @url https://github.com/natelindev/tsdav/pull/139 */ as (Omit & { + const calendars = (await client.fetchCalendars()) /** @url https://github.com/natelindev/tsdav/pull/139 */ as (Omit< + DAVCalendar, + "displayName" + > & { displayName?: string | Record; })[]; + // Attempt to resolve the scheduling default calendar URL from the principal. + // This implements RFC 6638 Section 9.2: default calendar should be identified + // via the CALDAV:schedule-default-calendar-URL property. + const defaultCalendarUrl = await this.resolveDefaultCalendarUrl(client); + return calendars.reduce((newCalendars, calendar) => { if (!calendar.components?.includes("VEVENT")) return newCalendars; const [mainHostDestinationCalendar] = event?.destinationCalendar ?? []; + + const isDefault = defaultCalendarUrl + ? calendar.url === defaultCalendarUrl + : !!(calendar as any).props?.["schedule-default-calendar-URL"]; // fallback: check on the calendar object itself + newCalendars.push({ externalId: calendar.url, /** @url https://github.com/calcom/cal.diy/issues/7186 */ name: typeof calendar.displayName === "string" ? calendar.displayName : "", + isDefault, primary: mainHostDestinationCalendar?.externalId ? mainHostDestinationCalendar.externalId === calendar.url : false, integration: this.integrationName, - email: this.credentials.username ?? "", + email: this.credentials["username"] ?? "", }); return newCalendars; }, []); @@ -1015,9 +1055,73 @@ export default abstract class BaseCalendarService implements Calendar { account: { serverUrl: this.url, accountType: DEFAULT_CALENDAR_TYPE, - credentials: this.credentials, + credentials: { + username: this.credentials["username"], + password: this.credentials["password"], + }, }, headers: this.headers, }); } + + private async getClient(): Promise { + if (this.client) return this.client; + this.client = await createDAVClient({ + serverUrl: this.url, + credentials: { + username: this.credentials["username"], + password: this.credentials["password"], + }, + authMethod: "Basic", + defaultAccountType: "caldav", + }); + return this.client; + } + + /** + * Resolves the scheduling default calendar URL from the principal properties. + * RFC 6638 Section 9.2: Checking schedule-default-calendar-URL property on the Inbox. + */ + private async resolveDefaultCalendarUrl(client: DAVClient): Promise { + try { + const principalUrl = await client.fetchPrincipalUrl(); + + // Priority 1: CALDAV:schedule-default-calendar-URL (RFC 6638 Section 9.2) + const principalProps = await client.propfind({ + url: principalUrl, + props: [{ + name: "schedule-default-calendar-URL", + namespace: DAVNamespace.CALDAV, + }], + depth: "0", + }); + + const defaultUrl = (principalProps?.[0] as any)?.props?.["schedule-default-calendar-URL"]?.href; + if (defaultUrl) { + this.log.debug(`CalDAV: Found default calendar via schedule-default-calendar-URL: ${defaultUrl}`); + return defaultUrl; + } + + // Priority 2: CALDAV:calendar-user-address-set (Fallback) + const addressSetProps = await client.propfind({ + url: principalUrl, + props: [{ + name: "calendar-user-address-set", + namespace: DAVNamespace.CALDAV, + }], + depth: "0", + }); + + const fallbackUrl = (addressSetProps?.[0] as any)?.props?.["calendar-user-address-set"]?.href; + if (fallbackUrl) { + this.log.debug(`CalDAV: Found default calendar via calendar-user-address-set: ${fallbackUrl}`); + return fallbackUrl; + } + + return undefined; + } catch (e) { + this.log.warn("CalDAV: Could not resolve default calendar URL from inbox/principal", e); + return undefined; + } + } } diff --git a/packages/lib/__tests__/CalendarService.caldav-defaults.test.ts b/packages/lib/__tests__/CalendarService.caldav-defaults.test.ts new file mode 100644 index 00000000000000..9668b4a985f1b6 --- /dev/null +++ b/packages/lib/__tests__/CalendarService.caldav-defaults.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import BaseCalendarService from "../CalendarService"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; + +// Mock the dependencies +vi.mock("../crypto", () => ({ + symmetricDecrypt: vi.fn().mockReturnValue(JSON.stringify({ + username: "user", + password: "pass", + url: "https://caldav.example.com" + })), +})); + +vi.mock("./logger", () => ({ + default: { + getSubLogger: vi.fn().mockReturnThis(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("tsdav", async () => { + const actual = await vi.importActual("tsdav"); + return { + ...actual, + createDAVClient: vi.fn(), + fetchCalendarObjects: vi.fn(), + createCalendarObject: vi.fn(), + }; +}); + +// Concrete class for testing the abstract BaseCalendarService +class TestCalendarService extends BaseCalendarService { + constructor(credential: any) { + super(credential, "test-caldav"); + } + getAccountEmail() { return "test@example.com"; } +} + +describe("BaseCalendarService - CalDAV Default Calendar Selection", () => { + const mockCredential = { key: "mock-key", user: { email: "owner@example.com" } }; + let service: TestCalendarService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new TestCalendarService(mockCredential); + }); + + it("should resolve default calendar using CALDAV:schedule-default-calendar-URL", async () => { + const { createDAVClient } = await import("tsdav"); + const mockClient = { + fetchCalendars: vi.fn().mockResolvedValue([ + { url: "https://caldav.example.com/cal1/", components: ["VEVENT"], displayName: "Calendar 1" }, + { url: "https://caldav.example.com/default/", components: ["VEVENT"], displayName: "Default Calendar" }, + ]), + fetchPrincipalUrl: vi.fn().mockResolvedValue("https://caldav.example.com/principal/"), + propfind: vi.fn().mockResolvedValue([ + { + props: { + "schedule-default-calendar-URL": { href: "https://caldav.example.com/default/" } + } + } + ]), + }; + (createDAVClient as any).mockResolvedValue(mockClient); + + const calendars = await service.listCalendars(); + + expect(calendars).toHaveLength(2); + expect(calendars.find(c => c.externalId.includes("default"))?.isDefault).toBe(true); + expect(calendars.find(c => c.externalId.includes("cal1"))?.isDefault).toBe(false); + }); + + it("should fall back to calendar-user-address-set if schedule-default-calendar-URL is missing", async () => { + const { createDAVClient } = await import("tsdav"); + const mockClient = { + fetchCalendars: vi.fn().mockResolvedValue([ + { url: "https://caldav.example.com/fallback/", components: ["VEVENT"] }, + ]), + fetchPrincipalUrl: vi.fn().mockResolvedValue("https://caldav.example.com/principal/"), + propfind: vi.fn() + .mockResolvedValueOnce([]) // First call for schedule-default-calendar-URL returns nothing + .mockResolvedValueOnce([ // Second call for fallback + { + props: { + "calendar-user-address-set": { href: "https://caldav.example.com/fallback/" } + } + } + ]), + }; + (createDAVClient as any).mockResolvedValue(mockClient); + + const calendars = await service.listCalendars(); + expect(calendars[0].isDefault).toBe(true); + }); + + it("should throw ErrorWithCode.InternalServerError when no target calendars are found", async () => { + const { createDAVClient } = await import("tsdav"); + const mockClient = { + fetchCalendars: vi.fn().mockResolvedValue([]), // No calendars returned + fetchPrincipalUrl: vi.fn().mockResolvedValue("url"), + propfind: vi.fn().mockResolvedValue([]), + }; + (createDAVClient as any).mockResolvedValue(mockClient); + + const event: any = { + startTime: "2024-01-01T10:00:00Z", + endTime: "2024-01-01T11:00:00Z", + organizer: { timeZone: "UTC", email: "m@e.com" }, + attendees: [], + }; + + await expect(service.createEvent(event, 1)).rejects.toThrow( + new ErrorWithCode(ErrorCode.InternalServerError, "No target calendars found to create CalDAV calendar entry") + ); + }); +}); diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 2d025fbfd13343..5c6e9207a019bb 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -1,99 +1,80 @@ -import type { calendar_v3 } from "@googleapis/calendar"; -import type { Dayjs } from "dayjs"; -import type { TFunction } from "i18next"; -import type { Time } from "ical.js"; -import type { Frequency } from "rrule"; -import type z from "zod"; - -import type { bookingResponse } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; -import type { TimeFormat } from "@calcom/lib/timeFormat"; -import type { - BookingSeat, - DestinationCalendar, - Prisma, - SelectedCalendar as _SelectedCalendar, -} from "@calcom/prisma/client"; -import type { SchedulingType } from "@calcom/prisma/enums"; -import type { CredentialForCalendarService } from "@calcom/types/Credential"; - -import type { Ensure } from "./utils"; - -export type { VideoCallData } from "./VideoApiAdapter"; - -type PaymentInfo = { - link?: string | null; - reason?: string | null; - id?: string | null; - paymentOption?: string | null; - amount?: number; - currency?: string; -}; - export type Person = { - name: string; + name?: string; email: string; - timeZone: string; - language: { translate: TFunction; locale: string }; - username?: string; - usernameInOrg?: string; - id?: number; - bookingId?: number | null; - locale?: string | null; - timeFormat?: TimeFormat; - bookingSeat?: BookingSeat | null; - phoneNumber?: string | null; + timeZone?: string; }; -export type TeamMember = { - id?: number; - name: string; - email: string; - phoneNumber?: string | null; - timeZone: string; - language: { translate: TFunction; locale: string }; +export type Calendar = { + listCalendars(event?: CalendarEvent): Promise; + createEvent(event: CalendarServiceEvent, credentialId: number): Promise; + updateEvent(uid: string, event: CalendarEvent): Promise; + deleteEvent(uid: string, event: CalendarEvent): Promise; + getAvailability(params: GetAvailabilityParams): Promise; + getAccountEmail(): string; }; -export type EventBusyDate = { - start: Date | string; - end: Date | string; - source?: string | null; - timeZone?: string; +export type IntegrationCalendar = { + id: string; + externalId: string; + name: string; + primary?: boolean; + readOnly?: boolean; + email?: string; + integration: string; + userId?: number | null; + isDefault?: boolean; }; -export type EventBusyDetails = EventBusyDate & { - title?: string; - source: string; - userId?: number | null; +export type CalendarEvent = { + type: string; + title: string; + description?: string; + startTime: string; + endTime: string; + organizer: Person; + attendees: Person[]; + location?: string; + uid?: string; + destinationCalendar?: IntegrationCalendar[]; + team?: { + members: Person[]; + }; + hideCalendarEventDetails?: boolean; + additionalInformation?: string; }; -export type AdditionalInfo = Record & { calWarnings?: string[] }; +export type CalendarServiceEvent = CalendarEvent; export type NewCalendarEventType = { uid: string; id: string; - thirdPartyRecurringEventId?: string | null; type: string; - password: string; - url: string; - additionalInfo: AdditionalInfo; - iCalUID?: string | null; - location?: string | null; - hangoutLink?: string | null; - conferenceData?: ConferenceData; - delegatedToId?: string | null; + password?: string; + url?: string; + additionalInfo?: Record; +}; + +export type EventBusyDate = { + start: string; + end: string; +}; + +export type GetAvailabilityParams = { + dateFrom: string; + dateTo: string; + selectedCalendars: IntegrationCalendar[]; }; export type CalendarEventType = { uid: string; - etag: string; - /** This is the actual caldav event url, not the location url. */ + etag?: string; url: string; summary: string; description: string; location: string; sequence: number; - startDate: Date | Dayjs; - endDate: Date | Dayjs; + startDate: string | Date; + endDate: string | Date; duration: { weeks: number; days: number; @@ -102,231 +83,10 @@ export type CalendarEventType = { seconds: number; isNegative: boolean; }; - organizer: string; - attendees: unknown[][]; - recurrenceId: Time; - timezone: string | object; -}; - -export type BatchResponse = { - responses: SubResponse[]; -}; - -export type SubResponse = { - body: { - value: { - showAs: "free" | "tentative" | "away" | "busy" | "workingElsewhere"; - start: { dateTime: string }; - end: { dateTime: string }; - }[]; - }; -}; - -export interface ConferenceData { - createRequest?: calendar_v3.Schema$CreateConferenceRequest; -} - -export interface RecurringEvent { - dtstart?: Date | undefined; - interval: number; - count: number; - freq: Frequency; - until?: Date | undefined; - tzid?: string | undefined; -} - -export type { IntervalLimit, IntervalLimitUnit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; - -export type AppsStatus = { - appName: string; - type: (typeof App)["type"]; - success: number; - failures: number; - errors: string[]; - warnings?: string[]; -}; - -export type CalEventResponses = Record< - string, - { - label: string; - value: z.infer; - isHidden?: boolean; - } ->; - -export interface ExistingRecurringEvent { - recurringEventId: string; -} - -// If modifying this interface, probably should update builders/calendarEvent files -export interface CalendarEvent { - // Instead of sending this per event. - // TODO: Links sent in email should be validated and automatically redirected to org domain or regular app. It would be a much cleaner way. Maybe use existing /api/link endpoint - bookerUrl?: string; - hashedLink?: string | null; - type: string; - title: string; - startTime: string; - endTime: string; organizer: Person; attendees: Person[]; - length?: number | null; - additionalNotes?: string | null; - customInputs?: Prisma.JsonObject | null; - description?: string | null; - team?: { - name: string; - members: TeamMember[]; - id: number; - }; - location?: string | null; - conferenceCredentialId?: number; - conferenceData?: ConferenceData; - additionalInformation?: AdditionalInformation; - uid?: string | null; - existingRecurringEvent?: ExistingRecurringEvent | null; - bookingId?: number; - videoCallData?: VideoCallData; - paymentInfo?: PaymentInfo | null; - requiresConfirmation?: boolean | null; - destinationCalendar?: DestinationCalendar[] | null; - cancellationReason?: string | null; - rejectionReason?: string | null; - hideCalendarNotes?: boolean; - hideCalendarEventDetails?: boolean; - recurrence?: string; - recurringEvent?: RecurringEvent | null; - eventTypeId?: number | null; - appsStatus?: AppsStatus[]; - seatsShowAttendees?: boolean | null; - seatsShowAvailabilityCount?: boolean | null; - attendeeSeatId?: string; - seatsPerTimeSlot?: number | null; - schedulingType?: SchedulingType | null; - iCalUID?: string | null; - iCalSequence?: number | null; - hideOrganizerEmail?: boolean; - disableCancelling?: boolean; - disableRescheduling?: boolean; - - // It has responses to all the fields(system + user) - responses?: CalEventResponses | null; - - // It just has responses to only the user fields. It allows to easily iterate over to show only user fields - userFieldsResponses?: CalEventResponses | null; - platformClientId?: string | null; - platformRescheduleUrl?: string | null; - platformCancelUrl?: string | null; - platformBookingUrl?: string | null; - hideBranding?: boolean; - oneTimePassword?: string | null; - delegationCredentialId?: string | null; - customReplyToEmail?: string | null; - rescheduledBy?: string; - organizationId?: number | null; - hasOrganizerChanged?: boolean; - assignmentReason?: { - category: string; // Translated label like "Routed", "Reassigned", etc. - details?: string | null; // The detailed reason string - } | null; -} - -export interface EntryPoint { - entryPointType?: string; - uri?: string; - label?: string; - pin?: string; - accessCode?: string; - meetingCode?: string; - passcode?: string; - password?: string; -} - -export interface AdditionalInformation { - conferenceData?: ConferenceData; - entryPoints?: EntryPoint[]; - hangoutLink?: string; -} - -export interface IntegrationCalendar extends Ensure, "externalId" | "integration"> { - primary?: boolean; - name?: string; - readOnly?: boolean; - // For displaying the connected email address - email?: string; - primaryEmail?: string | null; - credentialId?: number | null; - integrationTitle?: string; - integration: string; - customCalendarReminder?: DestinationCalendar["customCalendarReminder"]; -} - -/** - * Mode for calendar fetch operations to control caching behavior: - * - "slots": For getting actual calendar availability (uses cache when available) - * - "overlay": For getting overlay calendar availability (does not use cache) - * - "booking": For booking confirmation (does not use cache) - * - "none": For operations that don't use getAvailability (e.g., deleteEvent, listCalendars) - */ -export type CalendarFetchMode = "slots" | "overlay" | "booking" | "none"; - -/** - * Parameters for getAvailability and getAvailabilityWithTimeZones methods - */ -export interface GetAvailabilityParams { - dateFrom: string; - dateTo: string; - selectedCalendars: IntegrationCalendar[]; - mode: CalendarFetchMode; - fallbackToPrimary?: boolean; -} - -/** - * null is to refer to user-level SelectedCalendar - */ -export type SelectedCalendarEventTypeIds = (number | null)[]; - -export interface CalendarServiceEvent extends CalendarEvent { - calendarDescription: string; -} - -export interface Calendar { - getCredentialId?(): number; - createEvent( - event: CalendarServiceEvent, - credentialId: number, - externalCalendarId?: string - ): Promise; - - updateEvent( - uid: string, - event: CalendarServiceEvent, - externalCalendarId?: string | null - ): Promise; - - deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise; - - getAvailability(params: GetAvailabilityParams): Promise; - - // for OOO calibration (only google calendar for now) - getAvailabilityWithTimeZones?(params: GetAvailabilityParams): Promise; - - fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise; - - listCalendars(event?: CalendarEvent): Promise; - - testDelegationCredentialSetup?(): Promise; -} - -/** - * @see [How to inference class type that implements an interface](https://stackoverflow.com/a/64765554/6297100) - */ -type Class = new (...args: Args) => I; - -export type CalendarClass = Class; + recurrenceId: string | null; + timezone: string; +}; -export type SelectedCalendar = Pick< - _SelectedCalendar, - "userId" | "integration" | "externalId" | "credentialId" ->; +export type TeamMember = Person;