diff --git a/packages/lib/CalendarService.test.ts b/packages/lib/CalendarService.test.ts index 55e1f8d7af..1e78920013 100644 --- a/packages/lib/CalendarService.test.ts +++ b/packages/lib/CalendarService.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { createEvent as createIcsEvent } from "ics"; import { createCalendarObject, updateCalendarObject } from "tsdav"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("ics", () => ({ createEvent: vi.fn(), @@ -41,7 +41,6 @@ vi.mock("./CalEventParser", () => ({ })); import type { CalendarServiceEvent } from "@calcom/types/Calendar"; - import BaseCalendarService from "./CalendarService"; const createMockEvent = (overrides: Partial = {}): CalendarServiceEvent => ({ @@ -102,6 +101,359 @@ class TestCalendarService extends BaseCalendarService { } } +describe("CalendarService - UID Consistency", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should use event.uid when provided, not generate a new UUID", async () => { + const service = new TestCalendarService(); + const bookingUid = "booking-uid-from-database-abc123"; + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:${bookingUid}\r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ uid: bookingUid }); + const result = await service.createEvent(event, 1); + + expect(result.uid).toBe(bookingUid); + expect(result.id).toBe(bookingUid); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + expect(calledArg.filename).toBe(`${bookingUid}.ics`); + + const icsCallArg = vi.mocked(createIcsEvent).mock.calls[0][0]; + expect(icsCallArg.uid).toBe(bookingUid); + }); + + it("should generate a new UUID when event.uid is not provided", async () => { + const service = new TestCalendarService(); + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:generated-uuid\r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ uid: undefined }); + const result = await service.createEvent(event, 1); + + expect(result.uid).toBeTruthy(); + expect(typeof result.uid).toBe("string"); + expect(result.uid.length).toBeGreaterThan(0); + }); + + it("should use the same uid for CalDAV filename and ics UID property", async () => { + const service = new TestCalendarService(); + const bookingUid = "consistent-uid-xyz789"; + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:${bookingUid}\r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ uid: bookingUid }); + await service.createEvent(event, 1); + + const icsCallArg = vi.mocked(createIcsEvent).mock.calls[0][0]; + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + + expect(icsCallArg.uid).toBe(bookingUid); + expect(calledArg.filename).toBe(`${bookingUid}.ics`); + expect(icsCallArg.uid).toBe(calledArg.filename?.replace(".ics", "")); + }); +}); + +describe("CalendarService - VTIMEZONE Generation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should include VTIMEZONE block in created CalDAV event", async () => { + const service = new TestCalendarService(); + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T15:00:00Z", + endTime: "2023-06-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + expect(iCalString).toContain("BEGIN:VTIMEZONE"); + expect(iCalString).toContain("END:VTIMEZONE"); + expect(iCalString).toContain("TZID:America/Chicago"); + }); + + it("should use TZID in DTSTART instead of UTC Z suffix", async () => { + const service = new TestCalendarService(); + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T15:00:00Z", + endTime: "2023-06-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + expect(iCalString).toContain("DTSTART;TZID=America/Chicago:"); + const unfolded = iCalString.replace(/\r?\n[ \t]/g, ""); + expect(unfolded).not.toMatch(/^DTSTART:[0-9]{8}T[0-9]{6}Z/m); + }); + + it("should convert UTC time to local time correctly for America/Chicago", async () => { + const service = new TestCalendarService(); + // 2023-01-15T15:00:00Z = 2023-01-15T09:00:00 in America/Chicago (UTC-6 in January) + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230115T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-01-15T15:00:00Z", + endTime: "2023-01-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + const unfolded = iCalString.replace(/\r?\n[ \t]/g, ""); + + expect(unfolded).toContain("DTSTART;TZID=America/Chicago:20230115T090000"); + }); + + it("should place VTIMEZONE block before BEGIN:VEVENT", async () => { + const service = new TestCalendarService(); + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T15:00:00Z", + endTime: "2023-06-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + const vtimezoneIdx = iCalString.indexOf("BEGIN:VTIMEZONE"); + const veventIdx = iCalString.indexOf("BEGIN:VEVENT"); + + expect(vtimezoneIdx).toBeGreaterThan(-1); + expect(veventIdx).toBeGreaterThan(-1); + expect(vtimezoneIdx).toBeLessThan(veventIdx); + }); + + it("should produce valid 8-digit DTSTART dates in VTIMEZONE blocks", async () => { + const service = new TestCalendarService(); + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T120000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T12:00:00Z", + endTime: "2023-06-15T13:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/New_York", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + const vtimezoneBlock = iCalString.slice( + iCalString.indexOf("BEGIN:VTIMEZONE"), + iCalString.indexOf("END:VTIMEZONE") + 13 + ); + const dtStartMatches = vtimezoneBlock.match(/DTSTART:(\d+)T/g); + expect(dtStartMatches).not.toBeNull(); + for (const match of dtStartMatches ?? []) { + const dateStr = match.replace("DTSTART:", "").replace("T", ""); + expect(dateStr).toHaveLength(8); // YYYYMMDD = 8 chars + } + }); + + it("should use pre-transition local time for VTIMEZONE DTSTART (RFC 5545)", async () => { + const service = new TestCalendarService(); + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T120000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T12:00:00Z", + endTime: "2023-06-15T13:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/New_York", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + const vtimezoneBlock = iCalString.slice( + iCalString.indexOf("BEGIN:VTIMEZONE"), + iCalString.indexOf("END:VTIMEZONE") + 13 + ); + + // America/New_York 2023: spring forward March 12 at 2:00 AM EST, + // fall back November 5 at 2:00 AM EDT. + // RFC 5545 requires DTSTART to use pre-transition local time (02:00), + // not the post-transition time (03:00 for spring forward, 01:00 for fall back). + const daylightBlock = vtimezoneBlock.slice( + vtimezoneBlock.indexOf("BEGIN:DAYLIGHT"), + vtimezoneBlock.indexOf("END:DAYLIGHT") + ); + expect(daylightBlock).toContain("DTSTART:20230312T020000"); + + const standardBlock = vtimezoneBlock.slice( + vtimezoneBlock.indexOf("BEGIN:STANDARD"), + vtimezoneBlock.indexOf("END:STANDARD") + ); + expect(standardBlock).toContain("DTSTART:20231105T020000"); + }); + + it("should use correct DST rules for Southern Hemisphere (Australia/Sydney)", async () => { + const service = new TestCalendarService(); + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230115T020000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-01-15T02:00:00Z", + endTime: "2023-01-15T03:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "Australia/Sydney", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + expect(iCalString).toContain("BEGIN:VTIMEZONE"); + expect(iCalString).toContain("TZID:Australia/Sydney"); + const vtimezoneBlock = iCalString.slice( + iCalString.indexOf("BEGIN:VTIMEZONE"), + iCalString.indexOf("END:VTIMEZONE") + 13 + ); + expect(vtimezoneBlock).toContain("BEGIN:DAYLIGHT"); + expect(vtimezoneBlock).toContain("BEGIN:STANDARD"); + }); + + it("should apply timezone fix to updateEvent as well", async () => { + const service = new TestCalendarService(); + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + (service as unknown as Record).getEventsByUID = vi.fn().mockResolvedValue([ + { + uid: "test-uid", + url: "https://caldav.example.com/calendar/test.ics", + etag: '"etag123"', + }, + ]); + + const event = createMockEvent({ + uid: "test-uid", + startTime: "2023-06-15T15:00:00Z", + endTime: "2023-06-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.testUpdateEvent("test-uid", event, "https://caldav.example.com/calendar/"); + + const calledArg = vi.mocked(updateCalendarObject).mock.calls[0][0]; + const data = calledArg.calendarObject.data; + + expect(data).toContain("BEGIN:VTIMEZONE"); + expect(data).toContain("TZID:America/Chicago"); + expect(data).toContain("DTSTART;TZID=America/Chicago:"); + }); +}); + describe("CalendarService - SCHEDULE-AGENT injection", () => { beforeEach(() => { vi.clearAllMocks(); @@ -366,7 +718,7 @@ describe("CalendarService - SCHEDULE-AGENT injection", () => { it("should preserve other iCal properties unchanged", async () => { const service = new TestCalendarService(); - const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cal.com//NONSGML//EN\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDTSTART:20230101T100000Z\r\nDTEND:20230101T110000Z\r\nEND:VCALENDAR`; + const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cal.com//NONSGML//EN\r\nBEGIN:VEVENT\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDTSTART:20230101T100000Z\r\nDTEND:20230101T110000Z\r\nEND:VEVENT\r\nEND:VCALENDAR`; vi.mocked(createIcsEvent).mockReturnValue({ error: null as unknown as Error, value: mockIcsOutput, @@ -381,8 +733,6 @@ describe("CalendarService - SCHEDULE-AGENT injection", () => { expect(iCalString).toContain("VERSION:2.0"); expect(iCalString).toContain("PRODID:-//Cal.com//NONSGML//EN"); - expect(iCalString).toContain("DTSTART:20230101T100000Z"); - expect(iCalString).toContain("DTEND:20230101T110000Z"); }); it("should handle empty iCalString gracefully", async () => { diff --git a/packages/lib/CalendarService.ts b/packages/lib/CalendarService.ts index 1dbd875235..a0c3b8c183 100644 --- a/packages/lib/CalendarService.ts +++ b/packages/lib/CalendarService.ts @@ -1,5 +1,22 @@ /* eslint-disable @typescript-eslint/triple-slash-reference */ /// + +import process from "node:process"; +import dayjs from "@calcom/dayjs"; +import sanitizeCalendarObject from "@calcom/lib/sanitizeCalendarObject"; +import type { + Person as AttendeeInCalendarEvent, + Calendar, + CalendarEvent, + CalendarEventType, + CalendarServiceEvent, + EventBusyDate, + GetAvailabilityParams, + IntegrationCalendar, + NewCalendarEventType, + TeamMember, +} from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; import ICAL from "ical.js"; import type { Attendee, DateArray, DurationObject } from "ics"; import { createEvent } from "ics"; @@ -14,23 +31,6 @@ import { updateCalendarObject, } from "tsdav"; import { v4 as uuidv4 } from "uuid"; - -import dayjs from "@calcom/dayjs"; -import sanitizeCalendarObject from "@calcom/lib/sanitizeCalendarObject"; -import type { Person as AttendeeInCalendarEvent } from "@calcom/types/Calendar"; -import type { - Calendar, - CalendarServiceEvent, - CalendarEvent, - CalendarEventType, - EventBusyDate, - GetAvailabilityParams, - IntegrationCalendar, - NewCalendarEventType, - TeamMember, -} from "@calcom/types/Calendar"; -import type { CredentialPayload } from "@calcom/types/Credential"; - import { getLocation, getRichDescription } from "./CalEventParser"; import { symmetricDecrypt } from "./crypto"; import logger from "./logger"; @@ -172,6 +172,216 @@ const injectScheduleAgent = (iCalString: string): string => { return result; }; +/** + * Finds the DST transition moment for a given year by binary-searching + * when the UTC offset changes between two months. + */ +const findDSTTransition = ( + timezone: string, + year: number, + fromMonth: number, + toMonth: number +): ReturnType | null => { + let low = dayjs.utc(new Date(year, fromMonth, 1)).valueOf(); + let high = dayjs.utc(new Date(year, toMonth, 1)).valueOf(); + const lowOffset = dayjs(low).tz(timezone).utcOffset(); + const highOffset = dayjs(high).tz(timezone).utcOffset(); + + if (lowOffset === highOffset) return null; + + while (high - low > 60 * 1000) { + const mid = Math.floor((low + high) / 2); + const midOffset = dayjs(mid).tz(timezone).utcOffset(); + if (midOffset === lowOffset) { + low = mid; + } else { + high = mid; + } + } + + // RFC 5545 requires DTSTART to be the local time interpreted with the + // pre-transition offset (TZOFFSETFROM). Apply the pre-transition offset + // to the UTC transition moment to get this local time. + const preTransitionOffsetMs = lowOffset * 60 * 1000; + return dayjs.utc(high + preTransitionOffsetMs); +}; + +/** Formats a transition moment as iCal DTSTART (e.g., 20260308T020000). */ +const formatTransitionDtstart = (transition: ReturnType): string => { + const year = String(transition.year()); + const month = String(transition.month() + 1).padStart(2, "0"); + const day = String(transition.date()).padStart(2, "0"); + const hour = String(transition.hour()).padStart(2, "0"); + const minute = String(transition.minute()).padStart(2, "0"); + return `${year}${month}${day}T${hour}${minute}00`; +}; + +/** Generates BYDAY RRULE value (e.g., "2SU") for nth weekday occurrence in month. */ +const getBydayRule = (d: ReturnType): string => { + const dayNames = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + const dayOfWeek = dayNames[d.day()]; + const dayOfMonth = d.date(); + const weekNum = Math.ceil(dayOfMonth / 7); + const daysInMonth = d.daysInMonth(); + if (dayOfMonth > daysInMonth - 7) { + return `-1${dayOfWeek}`; + } + return `${weekNum}${dayOfWeek}`; +}; + +/** + * Builds a VTIMEZONE component for the given IANA timezone. + * Uses the event's year to compute actual DST transitions (not 1970), + * ensuring correct BYDAY/BYMONTH for zones that changed rules post-1970. + * RFC 5545 ยง3.6.5 requires VTIMEZONE when DTSTART uses TZID. + */ +const buildVTimezone = (timezone: string, eventStart: string): string => { + const eventYear = dayjs(eventStart).tz(timezone).year(); + // Construct dates from scratch in the target timezone to get correct offsets. + // Using .month() on an existing dayjs object may not recalculate the tz offset. + const winterMoment = dayjs.tz(`${eventYear}-01-15T12:00:00`, timezone); + const summerMoment = dayjs.tz(`${eventYear}-07-15T12:00:00`, timezone); + + const formatOffset = (d: ReturnType): string => { + const offsetMinutes = d.utcOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const abs = Math.abs(offsetMinutes); + const hours = String(Math.floor(abs / 60)).padStart(2, "0"); + const mins = String(abs % 60).padStart(2, "0"); + return `${sign}${hours}${mins}`; + }; + + const standardOffset = formatOffset(winterMoment); + const daylightOffset = formatOffset(summerMoment); + const hasDST = standardOffset !== daylightOffset; + + const lines: string[] = ["BEGIN:VTIMEZONE", `TZID:${timezone}`]; + + if (hasDST) { + const springTransition = findDSTTransition(timezone, eventYear, 0, 6); + const fallTransition = findDSTTransition(timezone, eventYear, 6, 11); + + const winterUtcOffset = winterMoment.utcOffset(); + const summerUtcOffset = summerMoment.utcOffset(); + const trueStandardOffset = winterUtcOffset < summerUtcOffset ? standardOffset : daylightOffset; + const trueDaylightOffset = winterUtcOffset < summerUtcOffset ? daylightOffset : standardOffset; + const springIsDaylight = summerUtcOffset > winterUtcOffset; + + if (springTransition) { + const dtstart = formatTransitionDtstart(springTransition); + const byday = getBydayRule(springTransition); + const bymonth = springTransition.month() + 1; + + if (springIsDaylight) { + lines.push( + "BEGIN:DAYLIGHT", + `TZOFFSETFROM:${trueStandardOffset}`, + `TZOFFSETTO:${trueDaylightOffset}`, + "TZNAME:DST", + `DTSTART:${dtstart}`, + `RRULE:FREQ=YEARLY;BYMONTH=${bymonth};BYDAY=${byday}`, + "END:DAYLIGHT" + ); + } else { + lines.push( + "BEGIN:STANDARD", + `TZOFFSETFROM:${trueDaylightOffset}`, + `TZOFFSETTO:${trueStandardOffset}`, + "TZNAME:ST", + `DTSTART:${dtstart}`, + `RRULE:FREQ=YEARLY;BYMONTH=${bymonth};BYDAY=${byday}`, + "END:STANDARD" + ); + } + } else { + lines.push( + "BEGIN:DAYLIGHT", + `TZOFFSETFROM:${trueStandardOffset}`, + `TZOFFSETTO:${trueDaylightOffset}`, + "TZNAME:DST", + "DTSTART:19700101T000000", + "END:DAYLIGHT" + ); + } + + if (fallTransition) { + const dtstart = formatTransitionDtstart(fallTransition); + const byday = getBydayRule(fallTransition); + const bymonth = fallTransition.month() + 1; + + if (springIsDaylight) { + lines.push( + "BEGIN:STANDARD", + `TZOFFSETFROM:${trueDaylightOffset}`, + `TZOFFSETTO:${trueStandardOffset}`, + "TZNAME:ST", + `DTSTART:${dtstart}`, + `RRULE:FREQ=YEARLY;BYMONTH=${bymonth};BYDAY=${byday}`, + "END:STANDARD" + ); + } else { + lines.push( + "BEGIN:DAYLIGHT", + `TZOFFSETFROM:${trueStandardOffset}`, + `TZOFFSETTO:${trueDaylightOffset}`, + "TZNAME:DST", + `DTSTART:${dtstart}`, + `RRULE:FREQ=YEARLY;BYMONTH=${bymonth};BYDAY=${byday}`, + "END:DAYLIGHT" + ); + } + } else { + lines.push( + "BEGIN:STANDARD", + `TZOFFSETFROM:${trueDaylightOffset}`, + `TZOFFSETTO:${trueStandardOffset}`, + "TZNAME:ST", + "DTSTART:19700101T000000", + "END:STANDARD" + ); + } + } else { + lines.push( + "BEGIN:STANDARD", + `TZOFFSETFROM:${standardOffset}`, + `TZOFFSETTO:${standardOffset}`, + `TZNAME:${timezone}`, + "DTSTART:19700101T000000", + "END:STANDARD" + ); + } + + lines.push("END:VTIMEZONE"); + return lines.join("\r\n"); +}; + +/** + * Injects a VTIMEZONE block and rewrites DTSTART/DTEND from UTC to timezone-local. + */ +const injectVTimezone = ( + iCalString: string, + timezone: string, + startTime: string, + endTime: string +): string => { + const formatLocalDateTime = (isoString: string, tz: string): string => { + const local = dayjs(isoString).tz(tz); + return local.format("YYYYMMDDTHHmmss"); + }; + + const localStart = formatLocalDateTime(startTime, timezone); + const localEnd = formatLocalDateTime(endTime, timezone); + + let result = iCalString + .replace(/^DTSTART:[^\r\n]+/m, `DTSTART;TZID=${timezone}:${localStart}`) + .replace(/^DTEND:[^\r\n]+/m, `DTEND;TZID=${timezone}:${localEnd}`); + + const vtimezone = buildVTimezone(timezone, startTime); + result = result.replace(/^BEGIN:VEVENT/m, `${vtimezone}\r\nBEGIN:VEVENT`); + + return result; +}; + const mapAttendees = (attendees: AttendeeInCalendarEvent[] | TeamMember[]): Attendee[] => attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); @@ -217,7 +427,7 @@ export default abstract class BaseCalendarService implements Calendar { async createEvent(event: CalendarServiceEvent, credentialId: number): Promise { try { const calendars = await this.listCalendars(event); - const uid = uuidv4(); + const uid = event.uid || uuidv4(); // We create local ICS files const { error, value: iCalString } = createEvent({ @@ -242,6 +452,11 @@ export default abstract class BaseCalendarService implements Calendar { if (error || !iCalString) throw new Error(`Error creating iCalString:=> ${error?.message} : ${error?.name} `); + // Inject VTIMEZONE and rewrite DTSTART/DTEND to organizer's local timezone. + // Without this, CalDAV servers send scheduling emails with UTC times. + const timezone = event.organizer.timeZone; + const iCalStringWithTimezone = injectVTimezone(iCalString, timezone, event.startTime, event.endTime); + const mainHostDestinationCalendar = event.destinationCalendar ? (event.destinationCalendar.find((cal) => cal.credentialId === credentialId) ?? event.destinationCalendar[0]) @@ -261,7 +476,7 @@ export default abstract class BaseCalendarService implements Calendar { url: calendar.externalId, }, filename: `${uid}.ics`, - iCalString: injectScheduleAgent(iCalString), + iCalString: injectScheduleAgent(iCalStringWithTimezone), headers: this.headers, }) ) @@ -320,6 +535,12 @@ export default abstract class BaseCalendarService implements Calendar { additionalInfo: {}, }; } + // Apply timezone fix to updated events as well + const updateTimezone = event.organizer.timeZone; + const iCalStringWithTimezone = iCalString + ? injectVTimezone(iCalString, updateTimezone, event.startTime, event.endTime) + : ""; + let calendarEvent: CalendarEventType; const eventsToUpdate = events.filter((e) => e.uid === uid); return Promise.all( @@ -328,7 +549,7 @@ export default abstract class BaseCalendarService implements Calendar { return updateCalendarObject({ calendarObject: { url: calendarEvent.url, - data: injectScheduleAgent(iCalString ?? ""), + data: injectScheduleAgent(iCalStringWithTimezone ?? ""), etag: calendarEvent?.etag, }, headers: this.headers, @@ -695,10 +916,7 @@ export default abstract class BaseCalendarService implements Calendar { (promise): promise is PromiseFulfilledResult<(DAVObject | undefined)[]> => promise.status === "fulfilled" ); - const flatResult = fulfilledPromises - .map((promise) => promise.value) - .flat() - .filter((obj) => obj !== null); + const flatResult = fulfilledPromises.flatMap((promise) => promise.value).filter((obj) => obj !== null); return flatResult as DAVObject[]; }