diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts index a3bccec5b6..cae6f09849 100644 --- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -1,4 +1,5 @@ import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants"; +import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries"; import { type EventBusyDate, getBusyCalendarTimes, @@ -51,7 +52,10 @@ export class CalendarsService { .filter((credential) => !!credential); } - async getCalendars(userId: number, ensureDefaultSelectedCalendars = false) { + async getCalendars( + userId: number, + ensureDefaultSelectedCalendars = false + ): Promise { const cachedResult = await this.calendarsCacheService.getConnectedAndDestinationCalendarsCache(userId); if (cachedResult && !ensureDefaultSelectedCalendars) { @@ -79,6 +83,26 @@ export class CalendarsService { return result; } + /** + * Delegates to getCalendars() (which is cached by CalendarsCacheService) because the + * upstream platform-libraries API only supports fetching all credentials at once — + * a targeted single-credential query is tracked as a future optimisation. + */ + async getCalendarsForConnection( + userId: number, + credentialId: number + ): Promise { + const full = await this.getCalendars(userId); + const conn = full.connectedCalendars.find((c) => c.credentialId === credentialId); + if (!conn) { + throw new NotFoundException("Calendar connection not found"); + } + return { + ...full, + connectedCalendars: [conn], + }; + } + async getBusyTimes( calendarsToLoad: Calendar[], userId: User["id"], diff --git a/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts b/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts index 669b2168b1..c8d8e5f28c 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts @@ -1,11 +1,14 @@ +import { Module } from "@nestjs/common"; import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository"; import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; -import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service"; import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service"; import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service"; import { AppsRepository } from "@/modules/apps/apps.repository"; import { CalUnifiedCalendarsController } from "@/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller"; import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service"; +import { UnifiedCalendarService } from "@/modules/cal-unified-calendars/services/unified-calendar.service"; +import { UnifiedCalendarsFreebusyService } from "@/modules/cal-unified-calendars/services/unified-calendars-freebusy.service"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; @@ -13,13 +16,14 @@ import { RedisModule } from "@/modules/redis/redis.module"; import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersRepository } from "@/modules/users/users.repository"; -import { Module } from "@nestjs/common"; @Module({ imports: [TokensModule, RedisModule], providers: [ GCalService, GoogleCalendarService, + UnifiedCalendarService, + UnifiedCalendarsFreebusyService, AppsRepository, BookingReferencesRepository_2024_08_13, CredentialsRepository, diff --git a/apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.spec.ts b/apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.spec.ts new file mode 100644 index 0000000000..1b1627a329 --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.spec.ts @@ -0,0 +1,559 @@ +/** + * Virtual mocks are required because UnifiedCalendarService (imported transitively + * for DI token resolution) has runtime imports from @calcom/platform-libraries and + * @calcom/platform-libraries/app-store whose transitive dependencies (prisma, DB, + * Google APIs) cannot be resolved in Jest. + * The service is fully mocked via useValue so these packages are never executed. + */ +jest.mock( + "@calcom/platform-libraries/app-store", + () => ({ + DelegationCredentialRepository: { + findByIdIncludeSensitiveServiceAccountKey: jest.fn().mockResolvedValue(null), + }, + OAuth2UniversalSchema: { parse: jest.fn((v: unknown) => v) }, + }), + { virtual: true } +); +jest.mock( + "@calcom/platform-libraries", + () => ({ + getBusyCalendarTimes: jest.fn(), + getConnectedDestinationCalendarsAndEnsureDefaultsInDb: jest.fn(), + credentialForCalendarServiceSelect: {}, + }), + { virtual: true } +); + +import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { BadRequestException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { CalUnifiedCalendarsController } from "./cal-unified-calendars.controller"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UnifiedCalendarService } from "@/modules/cal-unified-calendars/services/unified-calendar.service"; + +describe("CalUnifiedCalendarsController", () => { + let controller: CalUnifiedCalendarsController; + let mockUnifiedCalendarService: Record; + + const userId = 42; + + beforeEach(async () => { + mockUnifiedCalendarService = { + getConnections: jest.fn(), + getEventDetails: jest.fn(), + updateEventDetails: jest.fn(), + listEvents: jest.fn(), + createEvent: jest.fn(), + deleteEvent: jest.fn(), + getFreeBusy: jest.fn(), + listConnectionEvents: jest.fn(), + createConnectionEvent: jest.fn(), + getConnectionEvent: jest.fn(), + updateConnectionEvent: jest.fn(), + deleteConnectionEvent: jest.fn(), + getConnectionFreeBusy: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [CalUnifiedCalendarsController], + providers: [{ provide: UnifiedCalendarService, useValue: mockUnifiedCalendarService }], + }) + .overrideGuard(ApiAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionsGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(CalUnifiedCalendarsController); + }); + + describe("listConnections", () => { + it("should return connections from unified calendar service", async () => { + const connections = [ + { connectionId: "1", type: "google" as const, email: "user@gmail.com" }, + { connectionId: "2", type: "office365" as const, email: "user@outlook.com" }, + ]; + mockUnifiedCalendarService.getConnections.mockResolvedValue(connections); + + const result = await controller.listConnections(userId); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(result.data.connections).toEqual(connections); + expect(mockUnifiedCalendarService.getConnections).toHaveBeenCalledWith(userId); + }); + + it("should return empty connections array when user has no calendars", async () => { + mockUnifiedCalendarService.getConnections.mockResolvedValue([]); + + const result = await controller.listConnections(userId); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(result.data.connections).toEqual([]); + }); + + it("should strip credential key even if service accidentally includes it (defense-in-depth)", async () => { + const connectionsWithKey = [ + { + connectionId: "1", + type: "google" as const, + email: "user@gmail.com", + key: { access_token: "SECRET_TOKEN", refresh_token: "SECRET_REFRESH" }, + }, + ]; + mockUnifiedCalendarService.getConnections.mockResolvedValue(connectionsWithKey); + + const result = await controller.listConnections(userId); + const conn = result.data.connections[0]; + const serialized = JSON.stringify(result); + + expect(conn).toHaveProperty("connectionId", "1"); + expect(conn).toHaveProperty("type", "google"); + expect(conn).toHaveProperty("email", "user@gmail.com"); + expect(conn).not.toHaveProperty("key"); + expect(serialized).not.toContain("SECRET_TOKEN"); + expect(serialized).not.toContain("SECRET_REFRESH"); + expect(Object.keys(conn)).toEqual(["connectionId", "type", "email"]); + }); + }); + + describe("listConnectionEvents", () => { + it("should list events for a valid connection", async () => { + const transformedEvents = [ + { + eventId: "event-1", + status: "confirmed", + title: "Meeting", + start: { time: "2024-01-15T10:00:00Z", timeZone: "UTC" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "UTC" }, + }, + ]; + mockUnifiedCalendarService.listConnectionEvents.mockResolvedValue(transformedEvents); + + // ParseConnectionIdPipe transforms string "10" to number 10 before it reaches the controller + const result = await controller.listConnectionEvents(10, userId, { + from: "2024-01-01", + to: "2024-01-31", + }); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(result.data).toHaveLength(1); + expect(mockUnifiedCalendarService.listConnectionEvents).toHaveBeenCalledWith( + userId, + 10, + "primary", + "2024-01-01T00:00:00.000Z", + "2024-01-31T23:59:59.999Z" + ); + }); + + it("should pass ISO datetime strings directly without modification", async () => { + mockUnifiedCalendarService.listConnectionEvents.mockResolvedValue([]); + + await controller.listConnectionEvents(10, userId, { + from: "2024-01-01T09:00:00Z", + to: "2024-01-31T17:00:00Z", + }); + + expect(mockUnifiedCalendarService.listConnectionEvents).toHaveBeenCalledWith( + userId, + 10, + "primary", + "2024-01-01T09:00:00Z", + "2024-01-31T17:00:00Z" + ); + }); + + it("should use provided calendarId when specified", async () => { + mockUnifiedCalendarService.listConnectionEvents.mockResolvedValue([]); + + await controller.listConnectionEvents(10, userId, { + from: "2024-01-01", + to: "2024-01-31", + calendarId: "custom@group.calendar.google.com", + }); + + expect(mockUnifiedCalendarService.listConnectionEvents).toHaveBeenCalledWith( + userId, + 10, + "custom@group.calendar.google.com", + expect.any(String), + expect.any(String) + ); + }); + }); + + describe("createConnectionEvent", () => { + const body = { + title: "New Event", + start: { time: "2024-01-15T10:00:00Z", timeZone: "America/New_York" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "America/New_York" }, + }; + + it("should create event for a valid connection", async () => { + const transformedEvent = { + eventId: "new-event-id", + status: "confirmed", + title: "New Event", + start: { time: "2024-01-15T10:00:00Z", timeZone: "America/New_York" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "America/New_York" }, + }; + mockUnifiedCalendarService.createConnectionEvent.mockResolvedValue(transformedEvent); + + const result = await controller.createConnectionEvent(10, userId, body); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(result.data).toBeDefined(); + expect(mockUnifiedCalendarService.createConnectionEvent).toHaveBeenCalledWith( + userId, + 10, + "primary", + body + ); + }); + + it("should use custom calendarId when provided", async () => { + const transformedEvent = { + eventId: "new-event-id", + status: "confirmed", + title: "New Event", + start: { time: "2024-01-15T10:00:00Z", timeZone: "America/New_York" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "America/New_York" }, + }; + mockUnifiedCalendarService.createConnectionEvent.mockResolvedValue(transformedEvent); + + await controller.createConnectionEvent(10, userId, body, "custom@group.calendar.google.com"); + + expect(mockUnifiedCalendarService.createConnectionEvent).toHaveBeenCalledWith( + userId, + 10, + "custom@group.calendar.google.com", + body + ); + }); + }); + + describe("getConnectionEvent", () => { + it("should get a single event by ID", async () => { + const transformedEvent = { + eventId: "event-1", + status: "confirmed", + title: "Test", + start: { time: "2024-01-15T10:00:00Z", timeZone: "UTC" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "UTC" }, + }; + mockUnifiedCalendarService.getConnectionEvent.mockResolvedValue(transformedEvent); + + const result = await controller.getConnectionEvent(10, "event-1", userId); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(mockUnifiedCalendarService.getConnectionEvent).toHaveBeenCalledWith( + userId, + 10, + "primary", + "event-1" + ); + }); + + it("should use custom calendarId when provided", async () => { + mockUnifiedCalendarService.getConnectionEvent.mockResolvedValue({ + eventId: "e", + start: { time: "2024-01-15T10:00:00Z", timeZone: "UTC" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "UTC" }, + }); + + await controller.getConnectionEvent(10, "event-1", userId, "custom@calendar"); + + expect(mockUnifiedCalendarService.getConnectionEvent).toHaveBeenCalledWith( + userId, + 10, + "custom@calendar", + "event-1" + ); + }); + }); + + describe("updateConnectionEvent", () => { + it("should update event for a valid connection", async () => { + const updateData = { title: "Updated Title" }; + const transformedEvent = { + eventId: "event-1", + title: "Updated Title", + start: { time: "2024-01-15T10:00:00Z", timeZone: "UTC" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "UTC" }, + }; + mockUnifiedCalendarService.updateConnectionEvent.mockResolvedValue(transformedEvent); + + const result = await controller.updateConnectionEvent(10, "event-1", userId, updateData); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(mockUnifiedCalendarService.updateConnectionEvent).toHaveBeenCalledWith( + userId, + 10, + "primary", + "event-1", + updateData + ); + }); + }); + + describe("deleteConnectionEvent", () => { + it("should delete event for a valid connection", async () => { + mockUnifiedCalendarService.deleteConnectionEvent.mockResolvedValue(undefined); + + await controller.deleteConnectionEvent(10, "event-1", userId); + + expect(mockUnifiedCalendarService.deleteConnectionEvent).toHaveBeenCalledWith( + userId, + 10, + "primary", + "event-1" + ); + }); + }); + + describe("getConnectionFreeBusy", () => { + it("should return busy times for a connection", async () => { + const busyData = [{ start: new Date("2024-01-15T10:00:00Z"), end: new Date("2024-01-15T11:00:00Z") }]; + mockUnifiedCalendarService.getConnectionFreeBusy.mockResolvedValue(busyData); + + const result = await controller.getConnectionFreeBusy(10, userId, { + from: "2024-01-01T00:00:00Z", + to: "2024-01-31T23:59:59Z", + timeZone: "America/New_York", + }); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(result.data).toEqual(busyData); + expect(mockUnifiedCalendarService.getConnectionFreeBusy).toHaveBeenCalledWith( + userId, + 10, + "2024-01-01T00:00:00Z", + "2024-01-31T23:59:59Z", + "America/New_York" + ); + }); + + it("should default timezone to UTC when not provided", async () => { + mockUnifiedCalendarService.getConnectionFreeBusy.mockResolvedValue([]); + + await controller.getConnectionFreeBusy(10, userId, { + from: "2024-01-01T00:00:00Z", + to: "2024-01-31T23:59:59Z", + }); + + expect(mockUnifiedCalendarService.getConnectionFreeBusy).toHaveBeenCalledWith( + userId, + 10, + "2024-01-01T00:00:00Z", + "2024-01-31T23:59:59Z", + "UTC" + ); + }); + }); + + describe("getCalendarEventDetails (user-scoped)", () => { + it("should return event details for Google Calendar", async () => { + const transformedEvent = { + eventId: "event-uid", + status: "confirmed", + title: "Test Meeting", + start: { time: "2024-01-15T10:00:00Z", timeZone: "UTC" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "UTC" }, + }; + mockUnifiedCalendarService.getEventDetails.mockResolvedValue(transformedEvent); + + const result = await controller.getCalendarEventDetails(GOOGLE_CALENDAR, "event-uid"); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(result.data).toBeDefined(); + expect(mockUnifiedCalendarService.getEventDetails).toHaveBeenCalledWith(GOOGLE_CALENDAR, "event-uid"); + }); + + it("should propagate BadRequestException for non-Google calendar", async () => { + mockUnifiedCalendarService.getEventDetails.mockRejectedValue( + new BadRequestException("Meeting details is currently only available for Google Calendar.") + ); + + await expect(controller.getCalendarEventDetails("office365", "event-uid")).rejects.toThrow( + BadRequestException + ); + }); + }); + + describe("updateCalendarEvent (user-scoped)", () => { + it("should update event for Google Calendar", async () => { + const updateData = { title: "Updated" }; + const transformedEvent = { + eventId: "event-uid", + title: "Updated", + start: { time: "2024-01-15T10:00:00Z", timeZone: "UTC" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "UTC" }, + }; + mockUnifiedCalendarService.updateEventDetails.mockResolvedValue(transformedEvent); + + const result = await controller.updateCalendarEvent(GOOGLE_CALENDAR, "event-uid", updateData); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(mockUnifiedCalendarService.updateEventDetails).toHaveBeenCalledWith( + GOOGLE_CALENDAR, + "event-uid", + updateData + ); + }); + + it("should propagate BadRequestException for non-Google calendar", async () => { + mockUnifiedCalendarService.updateEventDetails.mockRejectedValue( + new BadRequestException("Event updates is currently only available for Google Calendar.") + ); + + await expect(controller.updateCalendarEvent("office365", "event-uid", { title: "t" })).rejects.toThrow( + BadRequestException + ); + }); + }); + + describe("listCalendarEvents (user-scoped)", () => { + it("should list events for Google Calendar", async () => { + mockUnifiedCalendarService.listEvents.mockResolvedValue([]); + + const result = await controller.listCalendarEvents(GOOGLE_CALENDAR, userId, { + from: "2024-01-01", + to: "2024-01-31", + }); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(result.data).toEqual([]); + }); + + it("should propagate BadRequestException for non-Google calendar", async () => { + mockUnifiedCalendarService.listEvents.mockRejectedValue( + new BadRequestException("List events is currently only available for Google Calendar.") + ); + + await expect( + controller.listCalendarEvents("office365", userId, { + from: "2024-01-01", + to: "2024-01-31", + }) + ).rejects.toThrow(BadRequestException); + }); + }); + + describe("createCalendarEvent (user-scoped)", () => { + const body = { + title: "New Event", + start: { time: "2024-01-15T10:00:00Z", timeZone: "America/New_York" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "America/New_York" }, + }; + + it("should create event for Google Calendar", async () => { + const transformedEvent = { + eventId: "new-id", + title: "New Event", + start: { time: "2024-01-15T10:00:00Z", timeZone: "America/New_York" }, + end: { time: "2024-01-15T11:00:00Z", timeZone: "America/New_York" }, + }; + mockUnifiedCalendarService.createEvent.mockResolvedValue(transformedEvent); + + const result = await controller.createCalendarEvent(GOOGLE_CALENDAR, userId, body); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(mockUnifiedCalendarService.createEvent).toHaveBeenCalledWith( + GOOGLE_CALENDAR, + userId, + "primary", + body + ); + }); + + it("should propagate BadRequestException for non-Google calendar", async () => { + mockUnifiedCalendarService.createEvent.mockRejectedValue( + new BadRequestException("Create event is currently only available for Google Calendar.") + ); + + await expect(controller.createCalendarEvent("apple", userId, body)).rejects.toThrow( + BadRequestException + ); + }); + }); + + describe("deleteCalendarEvent (user-scoped)", () => { + it("should delete event for Google Calendar", async () => { + mockUnifiedCalendarService.deleteEvent.mockResolvedValue(undefined); + + await controller.deleteCalendarEvent(GOOGLE_CALENDAR, "event-uid", userId); + + expect(mockUnifiedCalendarService.deleteEvent).toHaveBeenCalledWith( + GOOGLE_CALENDAR, + userId, + "primary", + "event-uid" + ); + }); + + it("should propagate BadRequestException for non-Google calendar", async () => { + mockUnifiedCalendarService.deleteEvent.mockRejectedValue( + new BadRequestException("Delete event is currently only available for Google Calendar.") + ); + + await expect(controller.deleteCalendarEvent("office365", "event-uid", userId)).rejects.toThrow( + BadRequestException + ); + }); + }); + + describe("getFreeBusy (user-scoped)", () => { + it("should return busy times for Google Calendar", async () => { + const busyData = [{ start: new Date(), end: new Date() }]; + mockUnifiedCalendarService.getFreeBusy.mockResolvedValue(busyData); + + const result = await controller.getFreeBusy(GOOGLE_CALENDAR, userId, { + from: "2024-01-01T00:00:00Z", + to: "2024-01-31T23:59:59Z", + timeZone: "America/New_York", + }); + + expect(result.status).toBe(SUCCESS_STATUS); + expect(result.data).toEqual(busyData); + expect(mockUnifiedCalendarService.getFreeBusy).toHaveBeenCalledWith( + GOOGLE_CALENDAR, + userId, + "2024-01-01T00:00:00Z", + "2024-01-31T23:59:59Z", + "America/New_York" + ); + }); + + it("should default timezone to UTC when not provided", async () => { + mockUnifiedCalendarService.getFreeBusy.mockResolvedValue([]); + + await controller.getFreeBusy(GOOGLE_CALENDAR, userId, { + from: "2024-01-01T00:00:00Z", + to: "2024-01-31T23:59:59Z", + }); + + expect(mockUnifiedCalendarService.getFreeBusy).toHaveBeenCalledWith( + GOOGLE_CALENDAR, + userId, + "2024-01-01T00:00:00Z", + "2024-01-31T23:59:59Z", + "UTC" + ); + }); + + it("should propagate BadRequestException for non-Google calendar", async () => { + mockUnifiedCalendarService.getFreeBusy.mockRejectedValue( + new BadRequestException("Free/busy is currently only available for Google Calendar.") + ); + + await expect( + controller.getFreeBusy("office365", userId, { + from: "2024-01-01", + to: "2024-01-31", + }) + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.ts b/apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.ts index 85b9a0a944..6f66a3f172 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.ts @@ -1,25 +1,37 @@ -import { API_VERSIONS_VALUES } from "@/lib/api-versions"; -import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers"; -import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; -import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; -import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input"; -import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output"; -import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe"; -import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service"; +import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants"; import { + Body, Controller, + Delete, Get, - Param, - UseGuards, HttpCode, HttpStatus, - BadRequestException, + Param, Patch, - Body, + Post, + Query, + UseGuards, } from "@nestjs/common"; -import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger"; +import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger"; +import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { CreateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/create-unified-calendar-event.input"; +import { FreebusyUnifiedInput } from "@/modules/cal-unified-calendars/inputs/freebusy-unified.input"; +import { ListUnifiedCalendarEventsInput } from "@/modules/cal-unified-calendars/inputs/list-unified-calendar-events.input"; +import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input"; +import { + GetUnifiedCalendarEventOutput, + ListUnifiedCalendarEventsOutput, +} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output"; +import { ListConnectionsOutput } from "@/modules/cal-unified-calendars/outputs/list-connections.output"; +import { ParseConnectionIdPipe } from "@/modules/cal-unified-calendars/pipes/parse-connection-id.pipe"; +import { UnifiedCalendarService } from "@/modules/cal-unified-calendars/services/unified-calendar.service"; -import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants"; +const UNIFIED_CALENDAR_PARAM = ["google", "office365", "apple"] as const; @Controller({ path: "/v2/calendars", @@ -27,46 +39,194 @@ import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants"; }) @DocsTags("Cal Unified Calendars") export class CalUnifiedCalendarsController { - constructor(private readonly googleCalendarService: GoogleCalendarService) {} + constructor(private readonly unifiedCalendarService: UnifiedCalendarService) {} - @ApiParam({ - name: "calendar", - enum: [GOOGLE_CALENDAR], - type: String, - }) - @ApiParam({ - name: "eventUid", - description: - "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking", - type: String, - }) - @Get("/:calendar/event/:eventUid") - @Get("/:calendar/events/:eventUid") + @Get("connections") @HttpCode(HttpStatus.OK) @UseGuards(ApiAuthGuard, PermissionsGuard) @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ - summary: "Get meeting details from calendar", - description: "Returns detailed information about a meeting including attendance metrics", + summary: "List calendar connections", + description: + "Returns all calendar connections for the authenticated user (Google, Office 365, Apple). Use connectionId in connection-scoped endpoints. Note: Event CRUD (list/create/get/update/delete events) is currently only supported for Google Calendar connections; other types will return 400.", }) - async getCalendarEventDetails( - @Param("calendar") calendar: string, - @Param("eventUid") eventUid: string - ): Promise { - if (calendar !== GOOGLE_CALENDAR) { - throw new BadRequestException("Meeting details are currently only available for Google Calendar"); - } - - const eventDetails = await this.googleCalendarService.getEventDetails(eventUid); - - const transformedEvent = new GoogleCalendarEventOutputPipe().transform(eventDetails); - + async listConnections(@GetUser("id") userId: number): Promise { + const connections = await this.unifiedCalendarService.getConnections(userId); + // Defense-in-depth: only forward the public fields so that any future service + // regression that accidentally includes credential.key cannot leak to the client. + const safeConnections = connections.map(({ connectionId, type, email }) => ({ connectionId, type, email })); return { status: SUCCESS_STATUS, - data: transformedEvent, + data: { connections: safeConnections }, }; } + @ApiParam({ + name: "connectionId", + description: "Calendar connection ID from GET /connections", + type: String, + }) + @Get("connections/:connectionId/events") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "List events for a connection", + description: + "List events in a date range for a specific calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + }) + @ApiQuery({ name: "from", required: true, type: String }) + @ApiQuery({ name: "to", required: true, type: String }) + @ApiQuery({ name: "timeZone", required: false, type: String }) + @ApiQuery({ name: "calendarId", required: false, type: String }) + async listConnectionEvents( + @Param("connectionId", ParseConnectionIdPipe) credentialId: number, + @GetUser("id") userId: number, + @Query() query: ListUnifiedCalendarEventsInput + ): Promise { + const timeMin = query.from.includes("T") ? query.from : `${query.from}T00:00:00.000Z`; + const timeMax = query.to.includes("T") ? query.to : `${query.to}T23:59:59.999Z`; + const data = await this.unifiedCalendarService.listConnectionEvents( + userId, + credentialId, + query.calendarId ?? "primary", + timeMin, + timeMax + ); + return { status: SUCCESS_STATUS, data }; + } + + @ApiParam({ name: "connectionId", type: String }) + @Post("connections/:connectionId/events") + @HttpCode(HttpStatus.CREATED) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Create event on a connection", + description: + "Create a new event on the specified calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + }) + @ApiQuery({ name: "calendarId", required: false, type: String }) + async createConnectionEvent( + @Param("connectionId", ParseConnectionIdPipe) credentialId: number, + @GetUser("id") userId: number, + @Body() body: CreateUnifiedCalendarEventInput, + @Query("calendarId") calendarId?: string + ): Promise { + const data = await this.unifiedCalendarService.createConnectionEvent( + userId, + credentialId, + calendarId ?? "primary", + body + ); + return { status: SUCCESS_STATUS, data }; + } + + @ApiParam({ name: "connectionId", type: String }) + @ApiParam({ name: "eventId", type: String }) + @Get("connections/:connectionId/events/:eventId") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Get event for a connection", + description: + "Get a single event by ID for the specified calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + }) + @ApiQuery({ name: "calendarId", required: false, type: String }) + async getConnectionEvent( + @Param("connectionId", ParseConnectionIdPipe) credentialId: number, + @Param("eventId") eventId: string, + @GetUser("id") userId: number, + @Query("calendarId") calendarId?: string + ): Promise { + const data = await this.unifiedCalendarService.getConnectionEvent( + userId, + credentialId, + calendarId ?? "primary", + eventId + ); + return { status: SUCCESS_STATUS, data }; + } + + @ApiParam({ name: "connectionId", type: String }) + @ApiParam({ name: "eventId", type: String }) + @Patch("connections/:connectionId/events/:eventId") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Update event for a connection", + description: + "Update an event on the specified calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + }) + @ApiQuery({ name: "calendarId", required: false, type: String }) + async updateConnectionEvent( + @Param("connectionId", ParseConnectionIdPipe) credentialId: number, + @Param("eventId") eventId: string, + @GetUser("id") userId: number, + @Body() updateData: UpdateUnifiedCalendarEventInput, + @Query("calendarId") calendarId?: string + ): Promise { + const data = await this.unifiedCalendarService.updateConnectionEvent( + userId, + credentialId, + calendarId ?? "primary", + eventId, + updateData + ); + return { status: SUCCESS_STATUS, data }; + } + + @ApiParam({ name: "connectionId", type: String }) + @ApiParam({ name: "eventId", type: String }) + @Delete("connections/:connectionId/events/:eventId") + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Delete event for a connection", + description: + "Delete/cancel an event on the specified calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + }) + @ApiQuery({ name: "calendarId", required: false, type: String }) + async deleteConnectionEvent( + @Param("connectionId", ParseConnectionIdPipe) credentialId: number, + @Param("eventId") eventId: string, + @GetUser("id") userId: number, + @Query("calendarId") calendarId?: string + ): Promise { + await this.unifiedCalendarService.deleteConnectionEvent(userId, credentialId, calendarId ?? "primary", eventId); + } + + @ApiParam({ name: "connectionId", type: String }) + @Get("connections/:connectionId/freebusy") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Get free/busy for a connection", + description: "Get busy time slots for the specified calendar connection.", + }) + @ApiQuery({ name: "from", required: true, type: String }) + @ApiQuery({ name: "to", required: true, type: String }) + @ApiQuery({ name: "timeZone", required: false, type: String }) + async getConnectionFreeBusy( + @Param("connectionId", ParseConnectionIdPipe) credentialId: number, + @GetUser("id") userId: number, + @Query() query: FreebusyUnifiedInput + ): Promise { + const timezone = query.timeZone ?? "UTC"; + const data = await this.unifiedCalendarService.getConnectionFreeBusy( + userId, + credentialId, + query.from, + query.to, + timezone + ); + return { status: SUCCESS_STATUS, data }; + } + @ApiParam({ name: "calendar", enum: [GOOGLE_CALENDAR], @@ -78,30 +238,145 @@ export class CalUnifiedCalendarsController { "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking", type: String, }) - @Patch("/:calendar/events/:eventUid") + @Get(["/:calendar/events/:eventUid", "/:calendar/event/:eventUid"]) + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Get meeting details from calendar", + description: + "Returns detailed information about a meeting including attendance metrics. The singular /event/ path is deprecated \u2014 use /events/ (plural) instead. For connection-scoped access use GET /connections/{connectionId}/events/{eventId}.", + }) + async getCalendarEventDetails( + @Param("calendar") calendar: string, + @Param("eventUid") eventUid: string + ): Promise { + const data = await this.unifiedCalendarService.getEventDetails(calendar, eventUid); + return { status: SUCCESS_STATUS, data }; + } + + @ApiParam({ + name: "calendar", + enum: [GOOGLE_CALENDAR], + type: String, + }) + @ApiParam({ + name: "eventUid", + description: + "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking", + type: String, + }) + @Patch(["/:calendar/events/:eventUid", "/:calendar/event/:eventUid"]) @HttpCode(HttpStatus.OK) @UseGuards(ApiAuthGuard, PermissionsGuard) @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Update meeting details in calendar", - description: "Updates event information in the specified calendar provider", + description: + "Updates event information in the specified calendar provider. The singular /event/ path is deprecated \u2014 use /events/ (plural) instead. For connection-scoped access use PATCH /connections/{connectionId}/events/{eventId}.", }) async updateCalendarEvent( @Param("calendar") calendar: string, @Param("eventUid") eventUid: string, @Body() updateData: UpdateUnifiedCalendarEventInput ): Promise { - if (calendar !== GOOGLE_CALENDAR) { - throw new BadRequestException("Event updates are currently only available for Google Calendar"); - } + const data = await this.unifiedCalendarService.updateEventDetails(calendar, eventUid, updateData); + return { status: SUCCESS_STATUS, data }; + } - const updatedEvent = await this.googleCalendarService.updateEventDetails(eventUid, updateData); + @ApiParam({ name: "calendar", enum: UNIFIED_CALENDAR_PARAM, type: String }) + @Get("/:calendar/events") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "List calendar events", + description: + "List events in a date range for the authenticated user's calendar. Currently only Google Calendar is supported.", + }) + @ApiQuery({ name: "from", required: true, type: String }) + @ApiQuery({ name: "to", required: true, type: String }) + @ApiQuery({ name: "timeZone", required: false, type: String }) + @ApiQuery({ name: "calendarId", required: false, type: String }) + async listCalendarEvents( + @Param("calendar") calendar: string, + @GetUser("id") userId: number, + @Query() query: ListUnifiedCalendarEventsInput + ): Promise { + const timeMin = query.from.includes("T") ? query.from : `${query.from}T00:00:00.000Z`; + const timeMax = query.to.includes("T") ? query.to : `${query.to}T23:59:59.999Z`; + const data = await this.unifiedCalendarService.listEvents( + calendar, + userId, + query.calendarId ?? "primary", + timeMin, + timeMax + ); + return { status: SUCCESS_STATUS, data }; + } - const transformedEvent = new GoogleCalendarEventOutputPipe().transform(updatedEvent); + @ApiParam({ name: "calendar", enum: UNIFIED_CALENDAR_PARAM, type: String }) + @Post("/:calendar/events") + @HttpCode(HttpStatus.CREATED) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Create a calendar event", + description: + "Create a new event on the authenticated user's calendar. Currently only Google Calendar is supported.", + }) + async createCalendarEvent( + @Param("calendar") calendar: string, + @GetUser("id") userId: number, + @Body() body: CreateUnifiedCalendarEventInput + ): Promise { + const data = await this.unifiedCalendarService.createEvent(calendar, userId, "primary", body); + return { status: SUCCESS_STATUS, data }; + } - return { - status: SUCCESS_STATUS, - data: transformedEvent, - }; + @ApiParam({ name: "calendar", enum: UNIFIED_CALENDAR_PARAM, type: String }) + @ApiParam({ + name: "eventUid", + description: "The calendar provider's event ID (e.g. Google Calendar event ID)", + type: String, + }) + @Delete("/:calendar/events/:eventUid") + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Delete a calendar event", + description: + "Delete/cancel an event on the authenticated user's calendar. Currently only Google Calendar is supported.", + }) + async deleteCalendarEvent( + @Param("calendar") calendar: string, + @Param("eventUid") eventUid: string, + @GetUser("id") userId: number + ): Promise { + await this.unifiedCalendarService.deleteEvent(calendar, userId, "primary", eventUid); + } + + @ApiParam({ name: "calendar", enum: UNIFIED_CALENDAR_PARAM, type: String }) + @Get("/:calendar/freebusy") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Get free/busy times", + description: + "Get busy time slots for the authenticated user's selected calendars in the given date range. Currently only Google Calendar is supported.", + }) + @ApiQuery({ name: "from", required: true, type: String }) + @ApiQuery({ name: "to", required: true, type: String }) + @ApiQuery({ name: "timeZone", required: false, type: String }) + async getFreeBusy( + @Param("calendar") calendar: string, + @GetUser("id") userId: number, + @Query() query: FreebusyUnifiedInput + ): Promise { + const timezone = query.timeZone ?? "UTC"; + const data = await this.unifiedCalendarService.getFreeBusy(calendar, userId, query.from, query.to, timezone); + return { status: SUCCESS_STATUS, data }; } } diff --git a/apps/api/v2/src/modules/cal-unified-calendars/inputs/create-unified-calendar-event.input.ts b/apps/api/v2/src/modules/cal-unified-calendars/inputs/create-unified-calendar-event.input.ts new file mode 100644 index 0000000000..60cce5ce36 --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/inputs/create-unified-calendar-event.input.ts @@ -0,0 +1,92 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsDefined, + IsEmail, + IsISO8601, + IsOptional, + IsString, + IsTimeZone, + ValidateNested, +} from "class-validator"; + +export class CreateEventDateTimeWithZone { + @IsISO8601() + @ApiProperty({ + type: String, + format: "date-time", + description: "Start or end time in ISO 8601 format", + }) + time!: string; + + @IsTimeZone() + @ApiProperty({ + type: String, + description: "IANA time zone (e.g. America/New_York)", + }) + timeZone!: string; +} + +export class CreateEventAttendee { + @IsEmail() + @ApiProperty({ + type: String, + description: "Email address of the attendee", + }) + email!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + type: String, + description: "Display name of the attendee", + }) + name?: string; +} + +export class CreateUnifiedCalendarEventInput { + @IsString() + @ApiProperty({ + type: String, + description: "Title of the calendar event", + }) + title!: string; + + @IsDefined() + @ValidateNested() + @Type(() => CreateEventDateTimeWithZone) + @ApiProperty({ + type: CreateEventDateTimeWithZone, + description: "Start date and time with time zone", + }) + start!: CreateEventDateTimeWithZone; + + @IsDefined() + @ValidateNested() + @Type(() => CreateEventDateTimeWithZone) + @ApiProperty({ + type: CreateEventDateTimeWithZone, + description: "End date and time with time zone", + }) + end!: CreateEventDateTimeWithZone; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + type: String, + nullable: true, + description: "Description of the event", + }) + description?: string | null; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateEventAttendee) + @ApiPropertyOptional({ + type: [CreateEventAttendee], + description: "List of attendees", + }) + attendees?: CreateEventAttendee[]; +} diff --git a/apps/api/v2/src/modules/cal-unified-calendars/inputs/freebusy-unified.input.ts b/apps/api/v2/src/modules/cal-unified-calendars/inputs/freebusy-unified.input.ts new file mode 100644 index 0000000000..aedb9f068b --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/inputs/freebusy-unified.input.ts @@ -0,0 +1,53 @@ +import { BadRequestException } from "@nestjs/common"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsISO8601, + IsOptional, + IsTimeZone, + Validate, + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator"; + +@ValidatorConstraint({ name: "isAfterFrom", async: false }) +class IsAfterFrom implements ValidatorConstraintInterface { + validate(to: string, args: ValidationArguments) { + const obj = args.object as { from?: string }; + if (!obj.from || !to) return true; + if (new Date(to).getTime() < new Date(obj.from).getTime()) { + throw new BadRequestException("'to' must not be before 'from'"); + } + return true; + } + defaultMessage() { + return "'to' must not be before 'from'"; + } +} + +export class FreebusyUnifiedInput { + @IsISO8601() + @ApiProperty({ + type: String, + description: "Start of the date range (ISO 8601 date or date-time)", + example: "2026-03-10", + }) + from!: string; + + @IsISO8601() + @Validate(IsAfterFrom) + @ApiProperty({ + type: String, + description: "End of the date range (ISO 8601 date or date-time)", + example: "2026-03-10", + }) + to!: string; + + @IsTimeZone() + @IsOptional() + @ApiPropertyOptional({ + type: String, + description: "IANA time zone (e.g. America/New_York)", + }) + timeZone?: string; +} diff --git a/apps/api/v2/src/modules/cal-unified-calendars/inputs/list-unified-calendar-events.input.ts b/apps/api/v2/src/modules/cal-unified-calendars/inputs/list-unified-calendar-events.input.ts new file mode 100644 index 0000000000..2f168e62c9 --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/inputs/list-unified-calendar-events.input.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsISO8601, IsOptional, IsString, IsTimeZone } from "class-validator"; + +export class ListUnifiedCalendarEventsInput { + @IsISO8601() + @ApiProperty({ + type: String, + description: "Start of the date range (ISO 8601 date or date-time)", + example: "2026-03-01", + }) + from!: string; + + @IsISO8601() + @ApiProperty({ + type: String, + description: "End of the date range (ISO 8601 date or date-time)", + example: "2026-03-31", + }) + to!: string; + + @IsTimeZone() + @IsOptional() + @ApiPropertyOptional({ + type: String, + description: "IANA time zone for the request (e.g. America/New_York)", + }) + timeZone?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + type: String, + description: + "Calendar ID. Use 'primary' for the user's primary calendar, or the external ID of a connected calendar.", + default: "primary", + }) + calendarId?: string; +} diff --git a/apps/api/v2/src/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input.ts b/apps/api/v2/src/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input.ts index b2734c59d8..a443bdefc6 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input.ts @@ -1,10 +1,9 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { IsISO8601, IsOptional, IsString, ValidateNested, IsEnum, IsArray } from "class-validator"; - +import { IsArray, IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator"; import { - CalendarEventStatus, CalendarEventResponseStatus, + CalendarEventStatus, } from "../outputs/get-unified-calendar-event.output"; export class UpdateCalendarEventAttendee { diff --git a/apps/api/v2/src/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output.ts b/apps/api/v2/src/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output.ts index 5c484de241..fc12d2aa20 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output.ts @@ -1,9 +1,8 @@ +import { CALENDARS, ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator"; -import { CALENDARS, SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; - export enum CalendarEventStatus { ACCEPTED = "accepted", PENDING = "pending", @@ -445,3 +444,14 @@ export class GetUnifiedCalendarEventOutput { @ApiProperty({ type: UnifiedCalendarEventOutput }) data!: UnifiedCalendarEventOutput; } + +export class ListUnifiedCalendarEventsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => UnifiedCalendarEventOutput) + @ApiProperty({ type: [UnifiedCalendarEventOutput] }) + data!: UnifiedCalendarEventOutput[]; +} diff --git a/apps/api/v2/src/modules/cal-unified-calendars/outputs/list-connections.output.ts b/apps/api/v2/src/modules/cal-unified-calendars/outputs/list-connections.output.ts new file mode 100644 index 0000000000..2e03db17d5 --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/outputs/list-connections.output.ts @@ -0,0 +1,49 @@ +import { CALENDARS, ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator"; + +export class CalendarConnectionItem { + @IsString() + @ApiProperty({ + description: "Stable ID for this calendar connection (use in connection-scoped endpoints)", + example: "123", + }) + connectionId!: string; + + @IsEnum(CALENDARS) + @ApiProperty({ + enum: CALENDARS, + description: "Calendar provider type", + example: "google", + }) + type!: (typeof CALENDARS)[number]; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + description: "Primary email for this connection (null if unavailable)", + example: "user@gmail.com", + nullable: true, + }) + email?: string | null; +} + +export class ListConnectionsData { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CalendarConnectionItem) + @ApiProperty({ type: [CalendarConnectionItem] }) + connections!: CalendarConnectionItem[]; +} + +export class ListConnectionsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @Type(() => ListConnectionsData) + @ApiProperty({ type: ListConnectionsData }) + data!: ListConnectionsData; +} diff --git a/apps/api/v2/src/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe.spec.ts b/apps/api/v2/src/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe.spec.ts index 540fe98f3a..83af54ebe2 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe.spec.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe.spec.ts @@ -1,18 +1,17 @@ -import { - CalendarEventStatus, - CalendarEventResponseStatus, -} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output"; - import { createGoogleCalendarEventFixture, googleEventWithConferenceData, - googleEventWithLocationOnly, googleEventWithHangoutLink, + googleEventWithLocationOnly, } from "./__fixtures__/google-calendar-event.fixture"; import { GoogleCalendarEventOutputPipe, GoogleCalendarEventResponse, } from "./get-calendar-event-details-output-pipe"; +import { + CalendarEventResponseStatus, + CalendarEventStatus, +} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output"; describe("GoogleCalendarEventOutputPipe", () => { let pipe: GoogleCalendarEventOutputPipe; diff --git a/apps/api/v2/src/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe.ts b/apps/api/v2/src/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe.ts index a2823b0115..8d07d9bd56 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe.ts @@ -1,10 +1,10 @@ +import { Injectable, PipeTransform } from "@nestjs/common"; import { CalendarEventResponseStatus, CalendarEventStatus, DateTimeWithZone, UnifiedCalendarEventOutput, } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output"; -import { PipeTransform, Injectable } from "@nestjs/common"; export interface GoogleCalendarEventResponse { kind: string; @@ -27,12 +27,14 @@ export interface GoogleCalendarEventResponse { self?: boolean; }; start: { - dateTime: string; - timeZone: string; + dateTime?: string; + timeZone?: string; + date?: string; }; end: { - dateTime: string; - timeZone: string; + dateTime?: string; + timeZone?: string; + date?: string; }; iCalUID: string; sequence: number; @@ -117,12 +119,22 @@ export class GoogleCalendarEventOutputPipe } private transformDateTimeWithZone(googleDateTime: { - dateTime: string; - timeZone: string; + dateTime?: string; + timeZone?: string; + date?: string; }): DateTimeWithZone { const dateTimeWithZone = new DateTimeWithZone(); - dateTimeWithZone.time = googleDateTime.dateTime; - dateTimeWithZone.timeZone = googleDateTime.timeZone; + if (googleDateTime.dateTime) { + dateTimeWithZone.time = googleDateTime.dateTime; + dateTimeWithZone.timeZone = googleDateTime.timeZone ?? "UTC"; + } else if (googleDateTime.date) { + // All-day event: date-only (e.g. "2026-03-20") → midnight UTC + dateTimeWithZone.time = `${googleDateTime.date}T00:00:00`; + dateTimeWithZone.timeZone = "UTC"; + } else { + dateTimeWithZone.time = ""; + dateTimeWithZone.timeZone = "UTC"; + } return dateTimeWithZone; } diff --git a/apps/api/v2/src/modules/cal-unified-calendars/pipes/google-calendar-event-input-pipe.ts b/apps/api/v2/src/modules/cal-unified-calendars/pipes/google-calendar-event-input-pipe.ts index f3d5ab7707..d2c6c31cd3 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/pipes/google-calendar-event-input-pipe.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/pipes/google-calendar-event-input-pipe.ts @@ -1,14 +1,13 @@ -import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe"; import { Injectable } from "@nestjs/common"; - import { - UpdateUnifiedCalendarEventInput, UpdateDateTimeWithZone, + UpdateUnifiedCalendarEventInput, } from "../inputs/update-unified-calendar-event.input"; import { CalendarEventResponseStatus, CalendarEventStatus, } from "../outputs/get-unified-calendar-event.output"; +import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe"; // Common interfaces for Google Calendar types interface GoogleCalendarDateTime { diff --git a/apps/api/v2/src/modules/cal-unified-calendars/pipes/google-calendar-event-input.pipe.spec.ts b/apps/api/v2/src/modules/cal-unified-calendars/pipes/google-calendar-event-input.pipe.spec.ts index 7706b1f773..c2fb063b3d 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/pipes/google-calendar-event-input.pipe.spec.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/pipes/google-calendar-event-input.pipe.spec.ts @@ -1,6 +1,6 @@ import { - UpdateUnifiedCalendarEventInput, UpdateDateTimeWithZone, + UpdateUnifiedCalendarEventInput, } from "../inputs/update-unified-calendar-event.input"; import { CalendarEventResponseStatus, diff --git a/apps/api/v2/src/modules/cal-unified-calendars/pipes/parse-connection-id.pipe.ts b/apps/api/v2/src/modules/cal-unified-calendars/pipes/parse-connection-id.pipe.ts new file mode 100644 index 0000000000..18eb31aef2 --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/pipes/parse-connection-id.pipe.ts @@ -0,0 +1,12 @@ +import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common"; + +@Injectable() +export class ParseConnectionIdPipe implements PipeTransform { + transform(value: string): number { + const id = parseInt(value, 10); + if (Number.isNaN(id)) { + throw new BadRequestException("Invalid connectionId"); + } + return id; + } +} diff --git a/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.spec.ts b/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.spec.ts new file mode 100644 index 0000000000..9b8d576a7e --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.spec.ts @@ -0,0 +1,461 @@ +/** + * Virtual mocks are required here because: + * - @calcom/platform-libraries/app-store and @calcom/platform-libraries are workspace packages + * whose transitive dependencies (prisma, DB connections) cannot be resolved in the Jest + * unit-test environment. We mock them to isolate GoogleCalendarService logic. + * - googleapis-common and @googleapis/calendar are mocked to verify JWT creation params + * and Calendar construction without hitting real Google APIs. + */ +const mockDelegationFindById = jest.fn().mockResolvedValue(null); +const mockJwtAuthorize = jest.fn().mockResolvedValue(undefined); +const MockJWT = jest.fn().mockImplementation(() => ({ + authorize: mockJwtAuthorize, +})); +const MockCalendar = jest.fn().mockImplementation(() => ({ events: {} })); +jest.mock( + "@calcom/platform-libraries/app-store", + () => ({ + DelegationCredentialRepository: { + findByIdIncludeSensitiveServiceAccountKey: mockDelegationFindById, + }, + OAuth2UniversalSchema: { parse: jest.fn((v: unknown) => v) }, + }), + { virtual: true } +); +jest.mock( + "@calcom/platform-libraries", + () => ({ + getBusyCalendarTimes: jest.fn(), + getConnectedDestinationCalendarsAndEnsureDefaultsInDb: jest.fn(), + credentialForCalendarServiceSelect: {}, + }), + { virtual: true } +); +jest.mock("googleapis-common", () => ({ + JWT: MockJWT, +})); +jest.mock("@googleapis/calendar", () => ({ + calendar_v3: { + Calendar: MockCalendar, + }, +})); + +import { GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants"; +import { BadRequestException, NotFoundException, UnauthorizedException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { GoogleCalendarService } from "./google-calendar.service"; +import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository"; +import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; + +describe("GoogleCalendarService", () => { + let service: GoogleCalendarService; + let mockBookingReferencesRepo: { getBookingReferencesIncludeSensitiveCredentials: jest.Mock }; + let mockGCalService: { getOAuthClient: jest.Mock; redirectUri: string }; + let mockCredentialsRepo: { + findCredentialWithDelegationByTypeAndUserId: jest.Mock; + findCredentialByIdAndUserId: jest.Mock; + }; + + const userId = 42; + const credentialId = 100; + + beforeEach(async () => { + mockBookingReferencesRepo = { + getBookingReferencesIncludeSensitiveCredentials: jest.fn(), + }; + + mockGCalService = { + getOAuthClient: jest.fn().mockResolvedValue({ + setCredentials: jest.fn(), + }), + redirectUri: "http://localhost/callback", + }; + + mockCredentialsRepo = { + findCredentialWithDelegationByTypeAndUserId: jest.fn(), + findCredentialByIdAndUserId: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GoogleCalendarService, + { + provide: BookingReferencesRepository_2024_08_13, + useValue: mockBookingReferencesRepo, + }, + { + provide: GCalService, + useValue: mockGCalService, + }, + { + provide: CredentialsRepository, + useValue: mockCredentialsRepo, + }, + ], + }).compile(); + + service = module.get(GoogleCalendarService); + }); + + describe("getCalendarClientForUser", () => { + it("should throw UnauthorizedException when no credential found", async () => { + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue(null); + + await expect(service.getCalendarClientForUser(userId)).rejects.toThrow(UnauthorizedException); + expect(mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId).toHaveBeenCalledWith( + GOOGLE_CALENDAR_TYPE, + userId + ); + }); + + it("should throw UnauthorizedException when credential is invalid", async () => { + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token" }, + invalid: true, + delegationCredentialId: null, + user: { email: "user@example.com" }, + }); + + await expect(service.getCalendarClientForUser(userId)).rejects.toThrow( + "Google Calendar credentials are invalid. Please reconnect." + ); + }); + + it("should return a calendar instance for valid OAuth credential without delegation", async () => { + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token", refresh_token: "refresh", token_type: "Bearer" }, + invalid: false, + delegationCredentialId: null, + user: { email: "user@example.com" }, + }); + + const calendar = await service.getCalendarClientForUser(userId); + + expect(calendar).toBeDefined(); + expect(mockGCalService.getOAuthClient).toHaveBeenCalledWith("http://localhost/callback"); + }); + + it("should pass delegation credential info when delegationCredentialId is present", async () => { + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token", refresh_token: "refresh", token_type: "Bearer" }, + invalid: false, + delegationCredentialId: "deleg-cred-123", + user: { email: "user@example.com" }, + }); + + // Delegation will fail (no real service account), so it falls back to OAuth + const calendar = await service.getCalendarClientForUser(userId); + + expect(calendar).toBeDefined(); + }); + + it("should use delegation path when service account key is available (getOAuthClient not called)", async () => { + mockDelegationFindById.mockResolvedValueOnce({ + serviceAccountKey: { + client_email: "sa@project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_FOR_TEST\n-----END PRIVATE KEY-----\n", + }, + }); + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: null, + invalid: false, + delegationCredentialId: "deleg-cred-123", + user: { email: "user@example.com" }, + }); + + const calendar = await service.getCalendarClientForUser(userId); + + expect(calendar).toBeDefined(); + expect(mockGCalService.getOAuthClient).not.toHaveBeenCalled(); + }); + + it("should create JWT with correct params for delegation (email, key, scopes, subject)", async () => { + MockJWT.mockClear(); + mockJwtAuthorize.mockClear(); + mockDelegationFindById.mockResolvedValueOnce({ + serviceAccountKey: { + client_email: "sa@project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nTEST_KEY\n-----END PRIVATE KEY-----\n", + }, + }); + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: null, + invalid: false, + delegationCredentialId: "deleg-cred-200", + user: { email: "delegated-user@example.com" }, + }); + + await service.getCalendarClientForUser(userId); + + expect(MockJWT).toHaveBeenCalledWith({ + email: "sa@project.iam.gserviceaccount.com", + key: "-----BEGIN PRIVATE KEY-----\nTEST_KEY\n-----END PRIVATE KEY-----\n", + scopes: ["https://www.googleapis.com/auth/calendar"], + subject: "delegated-user@example.com", + }); + expect(mockJwtAuthorize).toHaveBeenCalled(); + }); + + it("should strip OAuth client ID alias from email before delegation", async () => { + MockJWT.mockClear(); + mockDelegationFindById.mockResolvedValueOnce({ + serviceAccountKey: { + client_email: "sa@project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n", + }, + }); + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: null, + invalid: false, + delegationCredentialId: "deleg-cred-300", + user: { email: "user+abcdefghijklmnopqrstuvwxy@example.com" }, + }); + + await service.getCalendarClientForUser(userId); + + expect(MockJWT).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "user@example.com", + }) + ); + }); + + it("should fall back to OAuth when delegation service account key has missing client_email", async () => { + mockDelegationFindById.mockResolvedValueOnce({ + serviceAccountKey: { + client_email: null, + private_key: "-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n", + }, + }); + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token", refresh_token: "refresh", token_type: "Bearer" }, + invalid: false, + delegationCredentialId: "deleg-cred-400", + user: { email: "user@example.com" }, + }); + + const calendar = await service.getCalendarClientForUser(userId); + + expect(calendar).toBeDefined(); + expect(mockGCalService.getOAuthClient).toHaveBeenCalled(); + }); + + it("should fall back to OAuth when delegation service account key has missing private_key", async () => { + mockDelegationFindById.mockResolvedValueOnce({ + serviceAccountKey: { + client_email: "sa@project.iam.gserviceaccount.com", + private_key: null, + }, + }); + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token", refresh_token: "refresh", token_type: "Bearer" }, + invalid: false, + delegationCredentialId: "deleg-cred-500", + user: { email: "user@example.com" }, + }); + + const calendar = await service.getCalendarClientForUser(userId); + + expect(calendar).toBeDefined(); + expect(mockGCalService.getOAuthClient).toHaveBeenCalled(); + }); + + it("should fall back to OAuth when JWT authorize() throws", async () => { + mockJwtAuthorize.mockRejectedValueOnce(new Error("authorize failed")); + mockDelegationFindById.mockResolvedValueOnce({ + serviceAccountKey: { + client_email: "sa@project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n", + }, + }); + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token", refresh_token: "refresh", token_type: "Bearer" }, + invalid: false, + delegationCredentialId: "deleg-cred-600", + user: { email: "user@example.com" }, + }); + + const calendar = await service.getCalendarClientForUser(userId); + + expect(calendar).toBeDefined(); + expect(mockGCalService.getOAuthClient).toHaveBeenCalled(); + }); + + it("should throw UnauthorizedException when delegation fails and no OAuth key available", async () => { + mockDelegationFindById.mockResolvedValueOnce({ + serviceAccountKey: { + client_email: null, + private_key: null, + }, + }); + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: null, + invalid: false, + delegationCredentialId: "deleg-cred-700", + user: { email: "user@example.com" }, + }); + + await expect(service.getCalendarClientForUser(userId)).rejects.toThrow( + "No valid credentials available for Google Calendar" + ); + }); + + it("should handle missing user email gracefully", async () => { + mockCredentialsRepo.findCredentialWithDelegationByTypeAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token", refresh_token: "refresh", token_type: "Bearer" }, + invalid: false, + delegationCredentialId: null, + user: null, + }); + + const calendar = await service.getCalendarClientForUser(userId); + + expect(calendar).toBeDefined(); + }); + }); + + describe("getCalendarClientByCredentialId", () => { + it("should throw NotFoundException when credential not found", async () => { + mockCredentialsRepo.findCredentialByIdAndUserId.mockResolvedValue(null); + + await expect(service.getCalendarClientByCredentialId(userId, credentialId)).rejects.toThrow( + NotFoundException + ); + expect(mockCredentialsRepo.findCredentialByIdAndUserId).toHaveBeenCalledWith(credentialId, userId); + }); + + it("should throw BadRequestException when credential type is not Google Calendar", async () => { + mockCredentialsRepo.findCredentialByIdAndUserId.mockResolvedValue({ + id: credentialId, + type: "office365_calendar", + key: { access_token: "token" }, + invalid: false, + delegationCredentialId: null, + user: { email: "user@example.com" }, + }); + + await expect(service.getCalendarClientByCredentialId(userId, credentialId)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw UnauthorizedException when credential is invalid", async () => { + mockCredentialsRepo.findCredentialByIdAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token" }, + invalid: true, + delegationCredentialId: null, + user: { email: "user@example.com" }, + }); + + await expect(service.getCalendarClientByCredentialId(userId, credentialId)).rejects.toThrow( + "Calendar credentials are invalid. Please reconnect." + ); + }); + + it("should return a calendar instance for valid Google credential", async () => { + mockCredentialsRepo.findCredentialByIdAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token", refresh_token: "refresh", token_type: "Bearer" }, + invalid: false, + delegationCredentialId: null, + user: { email: "user@example.com" }, + }); + + const calendar = await service.getCalendarClientByCredentialId(userId, credentialId); + + expect(calendar).toBeDefined(); + expect(mockGCalService.getOAuthClient).toHaveBeenCalled(); + }); + + it("should pass delegation info when delegationCredentialId is present", async () => { + mockCredentialsRepo.findCredentialByIdAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: { access_token: "token", refresh_token: "refresh", token_type: "Bearer" }, + invalid: false, + delegationCredentialId: "deleg-cred-456", + user: { email: "user@example.com" }, + }); + + // Delegation will fail (no real service account), falls back to OAuth + const calendar = await service.getCalendarClientByCredentialId(userId, credentialId); + + expect(calendar).toBeDefined(); + }); + + it("should use delegation path with JWT for getCalendarClientByCredentialId", async () => { + MockJWT.mockClear(); + mockJwtAuthorize.mockClear(); + mockDelegationFindById.mockResolvedValueOnce({ + serviceAccountKey: { + client_email: "sa@project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nKEY_FOR_CRED\n-----END PRIVATE KEY-----\n", + }, + }); + mockCredentialsRepo.findCredentialByIdAndUserId.mockResolvedValue({ + id: credentialId, + type: GOOGLE_CALENDAR_TYPE, + key: null, + invalid: false, + delegationCredentialId: "deleg-by-cred-id", + user: { email: "cred-user@example.com" }, + }); + + const calendar = await service.getCalendarClientByCredentialId(userId, credentialId); + + expect(calendar).toBeDefined(); + expect(MockJWT).toHaveBeenCalledWith({ + email: "sa@project.iam.gserviceaccount.com", + key: "-----BEGIN PRIVATE KEY-----\nKEY_FOR_CRED\n-----END PRIVATE KEY-----\n", + scopes: ["https://www.googleapis.com/auth/calendar"], + subject: "cred-user@example.com", + }); + expect(mockJwtAuthorize).toHaveBeenCalled(); + expect(mockGCalService.getOAuthClient).not.toHaveBeenCalled(); + }); + }); + + describe("getEventDetails", () => { + it("should throw NotFoundException when booking reference not found", async () => { + mockBookingReferencesRepo.getBookingReferencesIncludeSensitiveCredentials.mockResolvedValue(null); + + await expect(service.getEventDetails("non-existent-uid")).rejects.toThrow(NotFoundException); + }); + }); + + describe("updateEventDetails", () => { + it("should throw NotFoundException when booking reference not found", async () => { + mockBookingReferencesRepo.getBookingReferencesIncludeSensitiveCredentials.mockResolvedValue(null); + + await expect(service.updateEventDetails("non-existent-uid", { title: "test" })).rejects.toThrow( + NotFoundException + ); + }); + }); +}); diff --git a/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.ts b/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.ts index e12354f353..8f2b718375 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.ts @@ -1,24 +1,33 @@ +import { GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants"; +import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store"; +import type { Prisma } from "@calcom/prisma/client"; +import { calendar_v3 } from "@googleapis/calendar"; +import { + BadRequestException, + ForbiddenException, + HttpException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; +import { JWT } from "googleapis-common"; +import type { CreateUnifiedCalendarEventInput } from "../inputs/create-unified-calendar-event.input"; +import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input"; +import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe"; import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository"; import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service"; import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe"; -import { calendar_v3 } from "@googleapis/calendar"; -import { Injectable, UnauthorizedException } from "@nestjs/common"; -import { NotFoundException } from "@nestjs/common"; -import { Logger } from "@nestjs/common"; -import { JWT } from "googleapis-common"; - -import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store"; -import type { Prisma } from "@calcom/prisma/client"; - -import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input"; -import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; @Injectable() export class GoogleCalendarService { private logger = new Logger("GoogleCalendarService"); constructor( private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13, - private readonly gCalService: GCalService + private readonly gCalService: GCalService, + private readonly credentialsRepository: CredentialsRepository ) {} async getEventDetails(eventUid: string): Promise { @@ -154,4 +163,296 @@ export class GoogleCalendarService { return null; } } + + /** + * Gets an authorized Google Calendar instance for the given user (for user-scoped list/create/delete). + * Tries delegated auth first (if available), then falls back to direct OAuth. + */ + async getCalendarClientForUser(userId: number): Promise { + const credential = await this.credentialsRepository.findCredentialWithDelegationByTypeAndUserId( + GOOGLE_CALENDAR_TYPE, + userId + ); + if (!credential) { + throw new UnauthorizedException("Google Calendar is not connected for this user"); + } + if (credential.invalid) { + throw new UnauthorizedException("Google Calendar credentials are invalid. Please reconnect."); + } + return this.getAuthorizedCalendarInstance( + credential.user?.email ?? undefined, + credential.key, + credential.delegationCredentialId ? { id: credential.delegationCredentialId } : null + ); + } + + /** + * Gets an authorized Google Calendar instance for a specific credential (connection). + * Tries delegated auth first (if available), then falls back to direct OAuth. + */ + async getCalendarClientByCredentialId(userId: number, credentialId: number): Promise { + const credential = await this.credentialsRepository.findCredentialByIdAndUserId(credentialId, userId); + if (!credential) { + throw new NotFoundException("Calendar connection not found"); + } + if (credential.type !== GOOGLE_CALENDAR_TYPE) { + throw new BadRequestException( + "Event operations for this connection are currently only available for Google Calendar" + ); + } + if (credential.invalid) { + throw new UnauthorizedException("Calendar credentials are invalid. Please reconnect."); + } + return this.getAuthorizedCalendarInstance( + credential.user?.email ?? undefined, + credential.key, + credential.delegationCredentialId ? { id: credential.delegationCredentialId } : null + ); + } + + // ─── Shared private helpers (DRY calendar CRUD) ────────────────────── + + private async listEventsWithClient( + calendar: calendar_v3.Calendar, + calendarId: string, + timeMin: string, + timeMax: string + ): Promise { + const effectiveCalendarId = calendarId || "primary"; + try { + const response = await calendar.events.list({ + calendarId: effectiveCalendarId, + timeMin, + timeMax, + singleEvents: true, + orderBy: "startTime", + }); + const items = (response.data.items || []) as GoogleCalendarEventResponse[]; + // Include both timed events (start.dateTime) and all-day events (start.date) + return items.filter( + (e) => e.start?.dateTime != null || e.start?.date != null + ) as GoogleCalendarEventResponse[]; + } catch (error) { + throw this.mapGoogleApiError(error, "Failed to list calendar events"); + } + } + + private async createEventWithClient( + calendar: calendar_v3.Calendar, + calendarId: string, + body: CreateUnifiedCalendarEventInput + ): Promise { + const effectiveCalendarId = calendarId || "primary"; + const requestBody: calendar_v3.Schema$Event = { + summary: body.title, + description: body.description ?? undefined, + start: { + dateTime: body.start.time, + timeZone: body.start.timeZone, + }, + end: { + dateTime: body.end.time, + timeZone: body.end.timeZone, + }, + attendees: body.attendees?.map((a) => ({ + email: a.email, + displayName: a.name, + })), + }; + try { + const response = await calendar.events.insert({ + calendarId: effectiveCalendarId, + requestBody, + sendUpdates: "none", + }); + if (!response.data) { + throw new BadRequestException("Failed to create calendar event"); + } + return response.data as GoogleCalendarEventResponse; + } catch (error) { + if (error instanceof HttpException) throw error; + throw this.mapGoogleApiError(error, "Failed to create calendar event"); + } + } + + private async getEventWithClient( + calendar: calendar_v3.Calendar, + calendarId: string, + eventId: string + ): Promise { + const effectiveCalendarId = calendarId || "primary"; + try { + const event = await calendar.events.get({ + calendarId: effectiveCalendarId, + eventId, + }); + if (!event.data) { + throw new NotFoundException("Event not found"); + } + return event.data as GoogleCalendarEventResponse; + } catch (error) { + if (error instanceof HttpException) throw error; + throw this.mapGoogleApiError(error, "Failed to retrieve event details"); + } + } + + private async updateEventWithClient( + calendar: calendar_v3.Calendar, + calendarId: string, + eventId: string, + updateData: UpdateUnifiedCalendarEventInput + ): Promise { + const effectiveCalendarId = calendarId || "primary"; + const updatePayload = new GoogleCalendarEventInputPipe().transform(updateData); + try { + const event = await calendar.events.patch({ + calendarId: effectiveCalendarId, + eventId, + requestBody: updatePayload, + }); + if (!event.data) { + throw new NotFoundException("Failed to update event"); + } + return event.data as GoogleCalendarEventResponse; + } catch (error) { + if (error instanceof HttpException) throw error; + throw this.mapGoogleApiError(error, "Failed to update event details"); + } + } + + private async deleteEventWithClient( + calendar: calendar_v3.Calendar, + calendarId: string, + eventId: string + ): Promise { + const effectiveCalendarId = calendarId || "primary"; + try { + await calendar.events.delete({ + calendarId: effectiveCalendarId, + eventId, + sendUpdates: "none", + }); + } catch (error) { + throw this.mapGoogleApiError(error, "Failed to delete event"); + } + } + + /** + * Maps a Google API error (GaxiosError) to the appropriate NestJS HttpException + * based on the HTTP status code returned by the Google Calendar API. + */ + private mapGoogleApiError(error: unknown, fallbackMessage: string): HttpException { + const errCode = (error as { code?: string | number })?.code; + const status = + typeof errCode === "number" + ? errCode + : typeof errCode === "string" && !Number.isNaN(Number(errCode)) + ? Number(errCode) + : ((error as { response?: { status?: number } })?.response?.status ?? 0); + if (status === 400) return new BadRequestException(fallbackMessage); + if (status === 401) return new UnauthorizedException(fallbackMessage); + if (status === 403) { + // Google returns 403 for both permission errors and quota/rate-limit errors. + // Check the error reason to distinguish retriable throttling from permanent permission denial. + const reason = (error as { response?: { data?: { error?: { errors?: Array<{ reason?: string }> } } } })?.response + ?.data?.error?.errors?.[0]?.reason; + if (reason === "rateLimitExceeded" || reason === "userRateLimitExceeded") { + return new HttpException(fallbackMessage, 429); + } + return new ForbiddenException(fallbackMessage); + } + if (status === 404) return new NotFoundException(fallbackMessage); + if (status === 429) return new HttpException(fallbackMessage, 429); + return new InternalServerErrorException(fallbackMessage); + } + + // ─── Public user-scoped methods ────────────────────────────────────── + + /** + * Lists events in a date range for the user's Google Calendar. + * Returns both timed events and all-day events. + */ + async listEventsForUser( + userId: number, + calendarId: string, + timeMin: string, + timeMax: string + ): Promise { + const calendar = await this.getCalendarClientForUser(userId); + return this.listEventsWithClient(calendar, calendarId, timeMin, timeMax); + } + + /** + * Creates a new event on the user's Google Calendar. + */ + async createEventForUser( + userId: number, + calendarId: string, + body: CreateUnifiedCalendarEventInput + ): Promise { + const calendar = await this.getCalendarClientForUser(userId); + return this.createEventWithClient(calendar, calendarId, body); + } + + /** + * Deletes/cancels an event on the user's Google Calendar by provider event ID. + */ + async deleteEventForUser(userId: number, calendarId: string, eventId: string): Promise { + const calendar = await this.getCalendarClientForUser(userId); + return this.deleteEventWithClient(calendar, calendarId, eventId); + } + + // ─── Public connection-scoped methods ───────────────────────────────── + + async listEventsForUserByConnectionId( + userId: number, + credentialId: number, + calendarId: string, + timeMin: string, + timeMax: string + ): Promise { + const calendar = await this.getCalendarClientByCredentialId(userId, credentialId); + return this.listEventsWithClient(calendar, calendarId, timeMin, timeMax); + } + + async createEventForUserByConnectionId( + userId: number, + credentialId: number, + calendarId: string, + body: CreateUnifiedCalendarEventInput + ): Promise { + const calendar = await this.getCalendarClientByCredentialId(userId, credentialId); + return this.createEventWithClient(calendar, calendarId, body); + } + + async getEventByConnectionId( + userId: number, + credentialId: number, + calendarId: string, + eventId: string + ): Promise { + const calendar = await this.getCalendarClientByCredentialId(userId, credentialId); + return this.getEventWithClient(calendar, calendarId, eventId); + } + + async updateEventByConnectionId( + userId: number, + credentialId: number, + calendarId: string, + eventId: string, + updateData: UpdateUnifiedCalendarEventInput + ): Promise { + const calendar = await this.getCalendarClientByCredentialId(userId, credentialId); + return this.updateEventWithClient(calendar, calendarId, eventId, updateData); + } + + async deleteEventForUserByConnectionId( + userId: number, + credentialId: number, + calendarId: string, + eventId: string + ): Promise { + const calendar = await this.getCalendarClientByCredentialId(userId, credentialId); + return this.deleteEventWithClient(calendar, calendarId, eventId); + } } diff --git a/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendar.service.ts b/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendar.service.ts new file mode 100644 index 0000000000..f0cfa921c5 --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendar.service.ts @@ -0,0 +1,192 @@ +import { + APPLE_CALENDAR, + APPLE_CALENDAR_TYPE, + GOOGLE_CALENDAR, + GOOGLE_CALENDAR_TYPE, + OFFICE_365_CALENDAR, + OFFICE_365_CALENDAR_TYPE, +} from "@calcom/platform-constants"; +import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import type { CreateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/create-unified-calendar-event.input"; +import type { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input"; +import { + GoogleCalendarEventOutputPipe, + GoogleCalendarEventResponse, +} from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe"; +import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service"; +import { UnifiedCalendarsFreebusyService } from "@/modules/cal-unified-calendars/services/unified-calendars-freebusy.service"; + +type ConnectedCalendarsList = ConnectedDestinationCalendars["connectedCalendars"]; + +/** Integration type at runtime (e.g. google_calendar); not always present on shared type. */ +function getIntegrationType(c: ConnectedCalendarsList[number]): string | undefined { + return (c.integration as { type?: string }).type; +} + +function getPrimaryEmail(c: ConnectedCalendarsList[number]): string | null | undefined { + return (c.primary as { email?: string } | undefined)?.email; +} + +const INTEGRATION_TYPE_TO_API: Record< + string, + typeof GOOGLE_CALENDAR | typeof OFFICE_365_CALENDAR | typeof APPLE_CALENDAR +> = { + [GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR, + [OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR, + [APPLE_CALENDAR_TYPE]: APPLE_CALENDAR, +}; + +@Injectable() +export class UnifiedCalendarService { + private readonly pipe = new GoogleCalendarEventOutputPipe(); + + constructor( + private readonly googleCalendarService: GoogleCalendarService, + private readonly freebusyService: UnifiedCalendarsFreebusyService, + private readonly calendarsService: CalendarsService + ) {} + + // ─── Strategy: validate calendar type ────────────────────────────────── + + private ensureGoogleCalendar(calendar: string, action: string): void { + if (calendar !== GOOGLE_CALENDAR) { + throw new BadRequestException( + `${action} is currently only available for Google Calendar. Office 365 and Apple support is coming soon.` + ); + } + } + + private transformEvent(event: GoogleCalendarEventResponse) { + return this.pipe.transform(event); + } + + private transformEvents(events: GoogleCalendarEventResponse[]) { + return events.map((e) => this.pipe.transform(e)); + } + + // ─── Connections ─────────────────────────────────────────────────────── + + async getConnections( + userId: number + ): Promise> { + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + return connectedCalendars + .filter( + (c) => + getIntegrationType(c) === GOOGLE_CALENDAR_TYPE || + getIntegrationType(c) === OFFICE_365_CALENDAR_TYPE || + getIntegrationType(c) === APPLE_CALENDAR_TYPE + ) + .map((c) => { + const apiType = INTEGRATION_TYPE_TO_API[getIntegrationType(c) ?? ""]; + const email = c.primary?.externalId ?? getPrimaryEmail(c) ?? null; + return { + connectionId: String(c.credentialId), + type: apiType ?? GOOGLE_CALENDAR, + email: email || null, + }; + }); + } + + // ─── User-scoped calendar operations ─────────────────────────────────── + + async getEventDetails(calendar: string, eventUid: string) { + this.ensureGoogleCalendar(calendar, "Meeting details"); + const event = await this.googleCalendarService.getEventDetails(eventUid); + return this.transformEvent(event); + } + + async updateEventDetails(calendar: string, eventUid: string, updateData: UpdateUnifiedCalendarEventInput) { + this.ensureGoogleCalendar(calendar, "Event updates"); + const event = await this.googleCalendarService.updateEventDetails(eventUid, updateData); + return this.transformEvent(event); + } + + async listEvents(calendar: string, userId: number, calendarId: string, timeMin: string, timeMax: string) { + this.ensureGoogleCalendar(calendar, "List events"); + const events = await this.googleCalendarService.listEventsForUser(userId, calendarId, timeMin, timeMax); + return this.transformEvents(events); + } + + async createEvent(calendar: string, userId: number, calendarId: string, body: CreateUnifiedCalendarEventInput) { + this.ensureGoogleCalendar(calendar, "Create event"); + const event = await this.googleCalendarService.createEventForUser(userId, calendarId, body); + return this.transformEvent(event); + } + + async deleteEvent(calendar: string, userId: number, calendarId: string, eventUid: string) { + this.ensureGoogleCalendar(calendar, "Delete event"); + await this.googleCalendarService.deleteEventForUser(userId, calendarId, eventUid); + } + + async getFreeBusy(calendar: string, userId: number, from: string, to: string, timezone: string) { + this.ensureGoogleCalendar(calendar, "Free/busy"); + return this.freebusyService.getBusyTimesForGoogleCalendars(userId, from, to, timezone); + } + + // ─── Connection-scoped operations ────────────────────────────────────── + + async listConnectionEvents( + userId: number, + credentialId: number, + calendarId: string, + timeMin: string, + timeMax: string + ) { + const events = await this.googleCalendarService.listEventsForUserByConnectionId( + userId, + credentialId, + calendarId, + timeMin, + timeMax + ); + return this.transformEvents(events); + } + + async createConnectionEvent( + userId: number, + credentialId: number, + calendarId: string, + body: CreateUnifiedCalendarEventInput + ) { + const event = await this.googleCalendarService.createEventForUserByConnectionId( + userId, + credentialId, + calendarId, + body + ); + return this.transformEvent(event); + } + + async getConnectionEvent(userId: number, credentialId: number, calendarId: string, eventId: string) { + const event = await this.googleCalendarService.getEventByConnectionId(userId, credentialId, calendarId, eventId); + return this.transformEvent(event); + } + + async updateConnectionEvent( + userId: number, + credentialId: number, + calendarId: string, + eventId: string, + updateData: UpdateUnifiedCalendarEventInput + ) { + const event = await this.googleCalendarService.updateEventByConnectionId( + userId, + credentialId, + calendarId, + eventId, + updateData + ); + return this.transformEvent(event); + } + + async deleteConnectionEvent(userId: number, credentialId: number, calendarId: string, eventId: string) { + await this.googleCalendarService.deleteEventForUserByConnectionId(userId, credentialId, calendarId, eventId); + } + + async getConnectionFreeBusy(userId: number, credentialId: number, from: string, to: string, timezone: string) { + return this.freebusyService.getBusyTimesForConnection(userId, credentialId, from, to, timezone); + } +} diff --git a/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.integration.spec.ts b/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.integration.spec.ts new file mode 100644 index 0000000000..276e893bcd --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.integration.spec.ts @@ -0,0 +1,82 @@ +/** + * Integration-style tests that use the real @calcom/platform-libraries package (no virtual mock). + * We import the real ConnectedDestinationCalendars type so that if the package or type shape changes, + * this file will still compile and tests validate our service against the subset of fields we use. + * CalendarsService is still mocked so we don't require DB/Redis. Mock data uses a minimal shape + * (cast to the real type) that matches what UnifiedCalendarsFreebusyService actually reads. + */ + +import { + GOOGLE_CALENDAR_TYPE, +} from "@calcom/platform-constants"; +import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries"; +import { Test, TestingModule } from "@nestjs/testing"; +import { UnifiedCalendarsFreebusyService } from "./unified-calendars-freebusy.service"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; + +describe("UnifiedCalendarsFreebusyService (integration with real platform-libraries types)", () => { + let service: UnifiedCalendarsFreebusyService; + let mockCalendarsService: { + getCalendars: jest.Mock; + getCalendarsForConnection: jest.Mock; + getBusyTimes: jest.Mock; + }; + + const userId = 42; + + beforeEach(async () => { + mockCalendarsService = { + getCalendars: jest.fn(), + getCalendarsForConnection: jest.fn(), + getBusyTimes: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UnifiedCalendarsFreebusyService, + { provide: CalendarsService, useValue: mockCalendarsService }, + ], + }).compile(); + + service = module.get(UnifiedCalendarsFreebusyService); + }); + + it("getBusyTimesForConnection works with real-shaped calendar list and delegates to getBusyTimes", async () => { + const mockConnectionData = { + connectedCalendars: [ + { + credentialId: 100, + integration: { type: GOOGLE_CALENDAR_TYPE }, + primary: { externalId: "user@gmail.com" }, + calendars: [ + { credentialId: 100, externalId: "cal1@group.calendar.google.com", isSelected: true }, + { credentialId: 100, externalId: "cal2@group.calendar.google.com", isSelected: false }, + ], + }, + ], + destinationCalendar: null, + } as unknown as ConnectedDestinationCalendars; + mockCalendarsService.getCalendarsForConnection.mockResolvedValue(mockConnectionData); + mockCalendarsService.getBusyTimes.mockResolvedValue([ + { start: "2025-03-10T09:00:00Z", end: "2025-03-10T10:00:00Z" }, + ]); + + const result = await service.getBusyTimesForConnection( + userId, + 100, + "2025-03-10T00:00:00Z", + "2025-03-11T00:00:00Z", + "UTC" + ); + + expect(result).toEqual([{ start: "2025-03-10T09:00:00Z", end: "2025-03-10T10:00:00Z" }]); + expect(mockCalendarsService.getCalendarsForConnection).toHaveBeenCalledWith(userId, 100); + expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith( + [{ credentialId: 100, externalId: "cal1@group.calendar.google.com" }], + userId, + "2025-03-10T00:00:00Z", + "2025-03-11T00:00:00Z", + "UTC" + ); + }); +}); diff --git a/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.service.spec.ts b/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.service.spec.ts new file mode 100644 index 0000000000..a67b62482e --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.service.spec.ts @@ -0,0 +1,298 @@ +/** + * Virtual mock is required because CalendarsService (imported for the DI token) has + * runtime imports from @calcom/platform-libraries whose transitive dependencies + * (prisma, DB) cannot be resolved in the Jest unit-test environment. + * The integration spec (unified-calendars-freebusy.integration.spec.ts) imports the + * real ConnectedDestinationCalendars type to catch type-shape changes at compile time. + */ +jest.mock( + "@calcom/platform-libraries", + () => ({ + getBusyCalendarTimes: jest.fn(), + getConnectedDestinationCalendarsAndEnsureDefaultsInDb: jest.fn(), + credentialForCalendarServiceSelect: {}, + }), + { virtual: true } +); + +import { + GOOGLE_CALENDAR_TYPE, + OFFICE_365_CALENDAR_TYPE, +} from "@calcom/platform-constants"; +import { NotFoundException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { UnifiedCalendarsFreebusyService } from "./unified-calendars-freebusy.service"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; + +describe("UnifiedCalendarsFreebusyService", () => { + let service: UnifiedCalendarsFreebusyService; + let mockCalendarsService: { + getCalendars: jest.Mock; + getCalendarsForConnection: jest.Mock; + getBusyTimes: jest.Mock; + }; + + const userId = 42; + + beforeEach(async () => { + mockCalendarsService = { + getCalendars: jest.fn(), + getCalendarsForConnection: jest.fn(), + getBusyTimes: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UnifiedCalendarsFreebusyService, + { + provide: CalendarsService, + useValue: mockCalendarsService, + }, + ], + }).compile(); + + service = module.get(UnifiedCalendarsFreebusyService); + }); + + describe("getBusyTimesForConnection", () => { + const from = "2024-01-01T00:00:00Z"; + const to = "2024-01-31T23:59:59Z"; + const timezone = "America/New_York"; + + it("should return busy times for selected calendars in a connection", async () => { + mockCalendarsService.getCalendarsForConnection.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 10, + integration: { type: GOOGLE_CALENDAR_TYPE }, + primary: { externalId: "user@gmail.com" }, + calendars: [ + { credentialId: 10, externalId: "user@gmail.com", isSelected: true }, + { credentialId: 10, externalId: "other@gmail.com", isSelected: false }, + ], + }, + ], + }); + const busyData = [{ start: new Date("2024-01-15T10:00:00Z"), end: new Date("2024-01-15T11:00:00Z") }]; + mockCalendarsService.getBusyTimes.mockResolvedValue(busyData); + + const result = await service.getBusyTimesForConnection(userId, 10, from, to, timezone); + + expect(result).toEqual(busyData); + expect(mockCalendarsService.getCalendarsForConnection).toHaveBeenCalledWith(userId, 10); + expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith( + [{ credentialId: 10, externalId: "user@gmail.com" }], + userId, + from, + to, + timezone + ); + }); + + it("should throw when connection not found", async () => { + mockCalendarsService.getCalendarsForConnection.mockRejectedValue( + new NotFoundException("Calendar connection not found") + ); + + await expect(service.getBusyTimesForConnection(userId, 999, from, to, timezone)).rejects.toThrow( + NotFoundException + ); + }); + + it("should fall back to primary calendar when no calendars are selected", async () => { + mockCalendarsService.getCalendarsForConnection.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 10, + integration: { type: GOOGLE_CALENDAR_TYPE }, + primary: { externalId: "user@gmail.com" }, + calendars: [{ credentialId: 10, externalId: "user@gmail.com", isSelected: false }], + }, + ], + }); + mockCalendarsService.getBusyTimes.mockResolvedValue([]); + + await service.getBusyTimesForConnection(userId, 10, from, to, timezone); + + expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith( + [{ credentialId: 10, externalId: "user@gmail.com" }], + userId, + from, + to, + timezone + ); + }); + + it("should return empty array when no calendars and no primary", async () => { + mockCalendarsService.getCalendarsForConnection.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 10, + integration: { type: GOOGLE_CALENDAR_TYPE }, + primary: undefined, + calendars: [], + }, + ], + }); + + const result = await service.getBusyTimesForConnection(userId, 10, from, to, timezone); + + expect(result).toEqual([]); + expect(mockCalendarsService.getBusyTimes).not.toHaveBeenCalled(); + }); + + it("should handle connection with no calendars array", async () => { + mockCalendarsService.getCalendarsForConnection.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 10, + integration: { type: GOOGLE_CALENDAR_TYPE }, + primary: { externalId: "user@gmail.com" }, + }, + ], + }); + mockCalendarsService.getBusyTimes.mockResolvedValue([]); + + await service.getBusyTimesForConnection(userId, 10, from, to, timezone); + + expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith( + [{ credentialId: 10, externalId: "user@gmail.com" }], + userId, + from, + to, + timezone + ); + }); + }); + + describe("getBusyTimesForGoogleCalendars", () => { + const from = "2024-01-01T00:00:00Z"; + const to = "2024-01-31T23:59:59Z"; + const timezone = "UTC"; + + it("should aggregate selected calendars from all Google connections", async () => { + mockCalendarsService.getCalendars.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 1, + integration: { type: GOOGLE_CALENDAR_TYPE }, + calendars: [ + { credentialId: 1, externalId: "cal1@gmail.com", isSelected: true }, + { credentialId: 1, externalId: "cal2@gmail.com", isSelected: true }, + ], + }, + { + credentialId: 2, + integration: { type: GOOGLE_CALENDAR_TYPE }, + calendars: [{ credentialId: 2, externalId: "cal3@gmail.com", isSelected: true }], + }, + ], + }); + const busyData = [{ start: new Date("2024-01-15T10:00:00Z"), end: new Date("2024-01-15T11:00:00Z") }]; + mockCalendarsService.getBusyTimes.mockResolvedValue(busyData); + + const result = await service.getBusyTimesForGoogleCalendars(userId, from, to, timezone); + + expect(result).toEqual(busyData); + expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith( + [ + { credentialId: 1, externalId: "cal1@gmail.com" }, + { credentialId: 1, externalId: "cal2@gmail.com" }, + { credentialId: 2, externalId: "cal3@gmail.com" }, + ], + userId, + from, + to, + timezone + ); + }); + + it("should only include Google Calendar connections", async () => { + mockCalendarsService.getCalendars.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 1, + integration: { type: GOOGLE_CALENDAR_TYPE }, + calendars: [{ credentialId: 1, externalId: "cal1@gmail.com", isSelected: true }], + }, + { + credentialId: 2, + integration: { type: OFFICE_365_CALENDAR_TYPE }, + calendars: [{ credentialId: 2, externalId: "cal2@outlook.com", isSelected: true }], + }, + ], + }); + mockCalendarsService.getBusyTimes.mockResolvedValue([]); + + await service.getBusyTimesForGoogleCalendars(userId, from, to, timezone); + + expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith( + [{ credentialId: 1, externalId: "cal1@gmail.com" }], + userId, + from, + to, + timezone + ); + }); + + it("should skip unselected calendars", async () => { + mockCalendarsService.getCalendars.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 1, + integration: { type: GOOGLE_CALENDAR_TYPE }, + calendars: [ + { credentialId: 1, externalId: "selected@gmail.com", isSelected: true }, + { credentialId: 1, externalId: "unselected@gmail.com", isSelected: false }, + ], + }, + ], + }); + mockCalendarsService.getBusyTimes.mockResolvedValue([]); + + await service.getBusyTimesForGoogleCalendars(userId, from, to, timezone); + + expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith( + [{ credentialId: 1, externalId: "selected@gmail.com" }], + userId, + from, + to, + timezone + ); + }); + + it("should return empty array when no Google calendars are selected", async () => { + mockCalendarsService.getCalendars.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 1, + integration: { type: GOOGLE_CALENDAR_TYPE }, + calendars: [{ credentialId: 1, externalId: "cal@gmail.com", isSelected: false }], + }, + ], + }); + + const result = await service.getBusyTimesForGoogleCalendars(userId, from, to, timezone); + + expect(result).toEqual([]); + expect(mockCalendarsService.getBusyTimes).not.toHaveBeenCalled(); + }); + + it("should return empty array when no Google connections exist", async () => { + mockCalendarsService.getCalendars.mockResolvedValue({ + connectedCalendars: [ + { + credentialId: 2, + integration: { type: OFFICE_365_CALENDAR_TYPE }, + calendars: [{ credentialId: 2, externalId: "cal@outlook.com", isSelected: true }], + }, + ], + }); + + const result = await service.getBusyTimesForGoogleCalendars(userId, from, to, timezone); + + expect(result).toEqual([]); + expect(mockCalendarsService.getBusyTimes).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.service.ts b/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.service.ts new file mode 100644 index 0000000000..e3c513b1c8 --- /dev/null +++ b/apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.service.ts @@ -0,0 +1,66 @@ +import { GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants"; +import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries"; +import { Injectable } from "@nestjs/common"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; + +type ConnectedCalendarsList = ConnectedDestinationCalendars["connectedCalendars"]; +type CalendarToLoad = { credentialId: number; externalId: string }; + +/** Integration type at runtime (e.g. google_calendar); not always present on shared type. */ +function getIntegrationType(c: ConnectedCalendarsList[number]): string | undefined { + return (c.integration as { type?: string }).type; +} + +@Injectable() +export class UnifiedCalendarsFreebusyService { + constructor(private readonly calendarsService: CalendarsService) {} + + /** + * Get busy times for a single connection (credential). + * Fetches the connection's selected calendars, falling back to the primary calendar. + */ + async getBusyTimesForConnection( + userId: number, + credentialId: number, + from: string, + to: string, + timezone: string + ) { + const { connectedCalendars } = await this.calendarsService.getCalendarsForConnection( + userId, + credentialId + ); + const conn = connectedCalendars[0]; + let calendarsToLoad: CalendarToLoad[] = (conn.calendars ?? []) + .filter((cal) => cal.isSelected) + .map((cal) => ({ + credentialId: cal.credentialId, + externalId: cal.externalId, + })); + if (calendarsToLoad.length === 0 && conn.primary?.externalId) { + calendarsToLoad = [{ credentialId: conn.credentialId, externalId: conn.primary.externalId ?? "" }]; + } + if (calendarsToLoad.length === 0) { + return []; + } + return this.calendarsService.getBusyTimes(calendarsToLoad, userId, from, to, timezone); + } + + /** + * Get busy times across all Google Calendar connections for a user. + * Aggregates selected calendars from every Google Calendar connection. + */ + async getBusyTimesForGoogleCalendars(userId: number, from: string, to: string, timezone: string) { + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const googleCalendars = connectedCalendars.filter((c) => getIntegrationType(c) === GOOGLE_CALENDAR_TYPE); + const calendarsToLoad: CalendarToLoad[] = googleCalendars.flatMap((conn) => + (conn.calendars ?? []) + .filter((cal) => cal.isSelected) + .map((cal) => ({ credentialId: cal.credentialId, externalId: cal.externalId })) + ); + if (calendarsToLoad.length === 0) { + return []; + } + return this.calendarsService.getBusyTimes(calendarsToLoad, userId, from, to, timezone); + } +} diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts index 88b16a3044..75adfb187b 100644 --- a/apps/api/v2/src/modules/credentials/credentials.repository.ts +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -1,14 +1,16 @@ -import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; -import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; -import { Injectable } from "@nestjs/common"; - import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants"; import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries"; import type { Prisma } from "@calcom/prisma/client"; +import { Injectable } from "@nestjs/common"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; @Injectable() export class CredentialsRepository { - constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService + ) {} async upsertUserAppCredential( type: keyof typeof APPS_TYPE_ID_MAPPING, @@ -60,6 +62,21 @@ export class CredentialsRepository { return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } }); } + /** Find a user's credential by type with delegation info (for unified calendar API). */ + findCredentialWithDelegationByTypeAndUserId(type: string, userId: number) { + return this.dbRead.prisma.credential.findFirst({ + where: { type, userId }, + select: { + id: true, + type: true, + key: true, + invalid: true, + delegationCredentialId: true, + user: { select: { email: true } }, + }, + }); + } + findAllCredentialsByTypeAndUserId(type: string, userId: number) { return this.dbWrite.prisma.credential.findMany({ where: { type, userId } }); } @@ -138,6 +155,21 @@ export class CredentialsRepository { }); } + /** Find a user's credential by id only (for connection-scoped API). */ + async findCredentialByIdAndUserId(credentialId: number, userId: number) { + return this.dbRead.prisma.credential.findFirst({ + where: { id: credentialId, userId }, + select: { + id: true, + type: true, + key: true, + invalid: true, + delegationCredentialId: true, + user: { select: { email: true } }, + }, + }); + } + async deleteUserCredentialById(userId: number, credentialId: number) { return await this.dbWrite.prisma.credential.delete({ where: { id: credentialId, userId }, diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index bcfa3f66d0..0c7fbddd4a 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -10813,11 +10813,408 @@ "tags": ["Bookings / Guests"] } }, - "/v2/calendars/{calendar}/event/{eventUid}": { + "/v2/calendars/connections": { + "get": { + "operationId": "CalUnifiedCalendarsController_listConnections", + "summary": "List calendar connections", + "description": "Returns all calendar connections for the authenticated user (Google, Office 365, Apple). Use connectionId in connection-scoped endpoints. Note: Event CRUD (list/create/get/update/delete events) is currently only supported for Google Calendar connections; other types will return 400.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListConnectionsOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + } + }, + "/v2/calendars/connections/{connectionId}/events": { + "get": { + "operationId": "CalUnifiedCalendarsController_listConnectionEvents", + "summary": "List events for a connection", + "description": "List events in a date range for a specific calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "path", + "description": "Calendar connection ID from GET /connections", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "required": true, + "in": "query", + "description": "Start of the date range (ISO 8601 date or date-time)", + "schema": { + "example": "2026-03-01", + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "description": "End of the date range (ISO 8601 date or date-time)", + "schema": { + "example": "2026-03-31", + "type": "string" + } + }, + { + "name": "timeZone", + "required": false, + "in": "query", + "description": "IANA time zone for the request (e.g. America/New_York)", + "schema": { + "type": "string" + } + }, + { + "name": "calendarId", + "required": false, + "in": "query", + "description": "Calendar ID. Use 'primary' for the user's primary calendar, or the external ID of a connected calendar.", + "schema": { + "default": "primary", + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUnifiedCalendarEventsOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + }, + "post": { + "operationId": "CalUnifiedCalendarsController_createConnectionEvent", + "summary": "Create event on a connection", + "description": "Create a new event on the specified calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "calendarId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUnifiedCalendarEventInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUnifiedCalendarEventOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + } + }, + "/v2/calendars/connections/{connectionId}/events/{eventId}": { + "get": { + "operationId": "CalUnifiedCalendarsController_getConnectionEvent", + "summary": "Get event for a connection", + "description": "Get a single event by ID for the specified calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "eventId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "calendarId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUnifiedCalendarEventOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + }, + "patch": { + "operationId": "CalUnifiedCalendarsController_updateConnectionEvent", + "summary": "Update event for a connection", + "description": "Update an event on the specified calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "eventId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "calendarId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUnifiedCalendarEventInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUnifiedCalendarEventOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + }, + "delete": { + "operationId": "CalUnifiedCalendarsController_deleteConnectionEvent", + "summary": "Delete event for a connection", + "description": "Delete/cancel an event on the specified calendar connection. Only supported for Google Calendar connections; other connection types return 400.", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "eventId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "calendarId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": ["Cal Unified Calendars"] + } + }, + "/v2/calendars/connections/{connectionId}/freebusy": { + "get": { + "operationId": "CalUnifiedCalendarsController_getConnectionFreeBusy", + "summary": "Get free/busy for a connection", + "description": "Get busy time slots for the specified calendar connection.", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "required": true, + "in": "query", + "description": "Start of the date range (ISO 8601 date or date-time)", + "schema": { + "example": "2026-03-10", + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "description": "End of the date range (ISO 8601 date or date-time)", + "schema": { + "example": "2026-03-10", + "type": "string" + } + }, + { + "name": "timeZone", + "required": false, + "in": "query", + "description": "IANA time zone (e.g. America/New_York)", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBusyTimesOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + } + }, + "/v2/calendars/{calendar}/events/{eventUid}": { "get": { "operationId": "CalUnifiedCalendarsController_getCalendarEventDetails", "summary": "Get meeting details from calendar", - "description": "Returns detailed information about a meeting including attendance metrics", + "description": "Returns detailed information about a meeting including attendance metrics. The singular /event/ path is deprecated — use /events/ (plural) instead. For connection-scoped access use GET /connections/{connectionId}/events/{eventId}.", "parameters": [ { "name": "calendar", @@ -10860,13 +11257,11 @@ } }, "tags": ["Cal Unified Calendars"] - } - }, - "/v2/calendars/{calendar}/events/{eventUid}": { + }, "patch": { "operationId": "CalUnifiedCalendarsController_updateCalendarEvent", "summary": "Update meeting details in calendar", - "description": "Updates event information in the specified calendar provider", + "description": "Updates event information in the specified calendar provider. The singular /event/ path is deprecated — use /events/ (plural) instead. For connection-scoped access use PATCH /connections/{connectionId}/events/{eventId}.", "parameters": [ { "name": "calendar", @@ -10919,6 +11314,348 @@ } }, "tags": ["Cal Unified Calendars"] + }, + "delete": { + "operationId": "CalUnifiedCalendarsController_deleteCalendarEvent", + "summary": "Delete a calendar event", + "description": "Delete/cancel an event on the authenticated user's calendar. Currently only Google Calendar is supported.", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "enum": ["google", "office365", "apple"], + "type": "string" + } + }, + { + "name": "eventUid", + "required": true, + "in": "path", + "description": "The calendar provider's event ID (e.g. Google Calendar event ID)", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": ["Cal Unified Calendars"] + } + }, + "/v2/calendars/{calendar}/event/{eventUid}": { + "get": { + "operationId": "CalUnifiedCalendarsController_getCalendarEventDetails", + "summary": "Get meeting details from calendar", + "description": "Returns detailed information about a meeting including attendance metrics. The singular /event/ path is deprecated — use /events/ (plural) instead. For connection-scoped access use GET /connections/{connectionId}/events/{eventId}.", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "enum": ["google"], + "type": "string" + } + }, + { + "name": "eventUid", + "required": true, + "in": "path", + "description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUnifiedCalendarEventOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + }, + "patch": { + "operationId": "CalUnifiedCalendarsController_updateCalendarEvent", + "summary": "Update meeting details in calendar", + "description": "Updates event information in the specified calendar provider. The singular /event/ path is deprecated — use /events/ (plural) instead. For connection-scoped access use PATCH /connections/{connectionId}/events/{eventId}.", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "enum": ["google"], + "type": "string" + } + }, + { + "name": "eventUid", + "required": true, + "in": "path", + "description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUnifiedCalendarEventInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUnifiedCalendarEventOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + } + }, + "/v2/calendars/{calendar}/events": { + "get": { + "operationId": "CalUnifiedCalendarsController_listCalendarEvents", + "summary": "List calendar events", + "description": "List events in a date range for the authenticated user's calendar. Currently only Google Calendar is supported.", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "enum": ["google", "office365", "apple"], + "type": "string" + } + }, + { + "name": "from", + "required": true, + "in": "query", + "description": "Start of the date range (ISO 8601 date or date-time)", + "schema": { + "example": "2026-03-01", + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "description": "End of the date range (ISO 8601 date or date-time)", + "schema": { + "example": "2026-03-31", + "type": "string" + } + }, + { + "name": "timeZone", + "required": false, + "in": "query", + "description": "IANA time zone for the request (e.g. America/New_York)", + "schema": { + "type": "string" + } + }, + { + "name": "calendarId", + "required": false, + "in": "query", + "description": "Calendar ID. Use 'primary' for the user's primary calendar, or the external ID of a connected calendar.", + "schema": { + "default": "primary", + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUnifiedCalendarEventsOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + }, + "post": { + "operationId": "CalUnifiedCalendarsController_createCalendarEvent", + "summary": "Create a calendar event", + "description": "Create a new event on the authenticated user's calendar. Currently only Google Calendar is supported.", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "enum": ["google", "office365", "apple"], + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUnifiedCalendarEventInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUnifiedCalendarEventOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] + } + }, + "/v2/calendars/{calendar}/freebusy": { + "get": { + "operationId": "CalUnifiedCalendarsController_getFreeBusy", + "summary": "Get free/busy times", + "description": "Get busy time slots for the authenticated user's selected calendars in the given date range. Currently only Google Calendar is supported.", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "enum": ["google", "office365", "apple"], + "type": "string" + } + }, + { + "name": "from", + "required": true, + "in": "query", + "description": "Start of the date range (ISO 8601 date or date-time)", + "schema": { + "example": "2026-03-10", + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "description": "End of the date range (ISO 8601 date or date-time)", + "schema": { + "example": "2026-03-10", + "type": "string" + } + }, + { + "name": "timeZone", + "required": false, + "in": "query", + "description": "IANA time zone (e.g. America/New_York)", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBusyTimesOutput" + } + } + } + } + }, + "tags": ["Cal Unified Calendars"] } }, "/v2/calendars/ics-feed/save": { @@ -33440,6 +34177,55 @@ }, "required": ["status", "data"] }, + "CalendarConnectionItem": { + "type": "object", + "properties": { + "connectionId": { + "type": "string", + "description": "Stable ID for this calendar connection (use in connection-scoped endpoints)", + "example": "123" + }, + "type": { + "type": "string", + "enum": ["google", "office365", "apple"], + "description": "Calendar provider type", + "example": "google" + }, + "email": { + "type": "string", + "nullable": true, + "description": "Primary email for this connection (null if unavailable)", + "example": "user@gmail.com" + } + }, + "required": ["connectionId", "type"] + }, + "ListConnectionsData": { + "type": "object", + "properties": { + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CalendarConnectionItem" + } + } + }, + "required": ["connections"] + }, + "ListConnectionsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/ListConnectionsData" + } + }, + "required": ["status", "data"] + }, "CalendarEventVideoLocation": { "type": "object", "properties": { @@ -33756,6 +34542,90 @@ }, "required": ["start", "end", "id", "title", "source"] }, + "ListUnifiedCalendarEventsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnifiedCalendarEventOutput" + } + } + }, + "required": ["status", "data"] + }, + "CreateEventDateTimeWithZone": { + "type": "object", + "properties": { + "time": { + "type": "string", + "format": "date-time", + "description": "Start or end time in ISO 8601 format" + }, + "timeZone": { + "type": "string", + "description": "IANA time zone (e.g. America/New_York)" + } + }, + "required": ["time", "timeZone"] + }, + "CreateEventAttendee": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Email address of the attendee" + }, + "name": { + "type": "string", + "description": "Display name of the attendee" + } + }, + "required": ["email"] + }, + "CreateUnifiedCalendarEventInput": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title of the calendar event" + }, + "start": { + "description": "Start date and time with time zone", + "allOf": [ + { + "$ref": "#/components/schemas/CreateEventDateTimeWithZone" + } + ] + }, + "end": { + "description": "End date and time with time zone", + "allOf": [ + { + "$ref": "#/components/schemas/CreateEventDateTimeWithZone" + } + ] + }, + "description": { + "type": "string", + "nullable": true, + "description": "Description of the event" + }, + "attendees": { + "description": "List of attendees", + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateEventAttendee" + } + } + }, + "required": ["title", "start", "end"] + }, "GetUnifiedCalendarEventOutput": { "type": "object", "properties": {