From 72acf09ff20bdaa50173fcabceeb00825397668c Mon Sep 17 00:00:00 2001 From: Sahitya Chandra Date: Wed, 18 Mar 2026 15:15:53 +0530 Subject: [PATCH] feat(unified-cal): connection-based unified calendar API with CRUD, freebusy, and list connections (#28387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(unified-cal): connection-based unified calendar API with CRUD, freebusy, and list connections - New GET /v2/calendars/connections endpoint returning all calendar connections with connectionId - Connection-scoped CRUD: GET/POST/PATCH/DELETE /v2/calendars/connections/{connectionId}/events/* - Connection-scoped free/busy: GET /v2/calendars/connections/{connectionId}/freebusy - Legacy calendar-type endpoints: GET/POST/DELETE /v2/calendars/{calendar}/events, GET /{calendar}/freebusy - Backward compat: dual @Patch decorators for singular /event/ (deprecated) and plural /events/ - ConnectedCalendarEntry interface to eliminate inline type annotations - DRY service layer with shared private helpers (listEventsWithClient, createEventWithClient, etc.) - Input validation: @IsDefined() on start/end, @IsTimeZone() on timezone fields, cross-field to >= from validation - All-day event support: Google Calendar date-only events converted to midnight UTC - New findCredentialByIdAndUserId method in CredentialsRepository for connection-scoped lookups * style: apply biome formatting to unified calendar API files * fix: use @IsTimeZone() validator for timeZone field in CreateEventDateTimeWithZone * fix: add delegation auth support, extract freebusy service layer - Comment 3: getCalendarClientForUser and getCalendarClientByCredentialId now use getAuthorizedCalendarInstance with delegated-auth fallback instead of requiring credential.key directly. Added findCredentialWithDelegationByTypeAndUserId and expanded findCredentialByIdAndUserId to include delegationCredentialId. - Comment 5: Extracted freebusy and connections logic from controller into UnifiedCalendarsFreebusyService, keeping the controller thin (HTTP-only). Moved ConnectedCalendarEntry type and INTEGRATION_TYPE_TO_API mapping into the service layer. - Biome auto-formatting applied to touched files. * test: add unit and integration tests for unified calendar API - GoogleCalendarService: 30 tests covering delegation auth, client creation, CRUD - UnifiedCalendarsFreebusyService: 21 tests covering connections, busy times, filtering - CalUnifiedCalendarsController: 31 tests covering all endpoints (connection-scoped + legacy) - Pipe specs: 37 existing tests continue to pass Total: 98 tests across 5 suites * fix: address Devin Review feedback - fix JSDoc and validator pattern - Fix incorrect JSDoc on listEventsForUser (all-day events ARE included, not skipped) - Fix IsAfterFrom validator to return false instead of throwing BadRequestException (preserves standard ValidationPipe error format) * fix: revert IsAfterFrom to throw BadRequestException per team convention Cubic AI (confidence 9/10, team feedback): validators should throw BadRequestException to preserve the API's standard bad-request response structure, per team convention. * fix: add calendarId query param to createConnectionEvent for API consistency All other connection-scoped endpoints accept calendarId; this was the only one hardcoding 'primary'. Added @ApiQuery decorator and @Query parameter with ?? 'primary' fallback, plus a test for custom calendarId. * Update apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Revert "Update apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.ts" This reverts commit e18e4621eff46d8ec49e4d03230783ce50b0c0e4. * feat: enhance calendar service with connection-specific methods and improve API documentation * test: complete delegation auth tests, document virtual mocks, fix key leak tests - Item 3: Add 7 comprehensive delegation auth integration tests covering JWT creation params, email cleaning, fallback scenarios, and error handling - Item 7: Document why virtual mocks are necessary in all test files (workspace packages with DB dependencies cannot resolve in Jest) - Cubic #1: Document getCalendarsForConnection caching and upstream limitation - Cubic #2+#3: Make credential key leak tests non-vacuous by including actual key fields in mocks and verifying they don't leak - Remove unused BadRequestException import from freebusy service * fix: add defense-in-depth key stripping in listConnections controller Controller now destructures only { connectionId, type, email } from each connection before returning, so credential.key can never leak even if the service layer has a future regression. Test updated to verify stripping. * feat: add unified calendar API endpoints for connections and events management * fix: add try/catch error handling to CRUD helper methods Wrap Google Calendar API calls in listEventsWithClient, createEventWithClient, getEventWithClient, updateEventWithClient, and deleteEventWithClient with try/catch blocks matching the legacy getEventDetails/updateEventDetails pattern. This ensures proper NestJS exceptions (NotFoundException, BadRequestException) are returned instead of raw 500 errors when the Google API throws. * fix: map Google API errors to correct HTTP status codes Replace blanket NotFoundException/BadRequestException in CRUD catch blocks with mapGoogleApiError() that inspects the GaxiosError status code and returns the appropriate NestJS exception (404→NotFoundException, 401/403→UnauthorizedException, 400→BadRequestException, else→500). * fix: preserve upstream Google API status codes in error mapping Separate 403 (ForbiddenException) from 401 (UnauthorizedException) and add 429 rate-limit handling. This ensures permission-denied and throttling errors are not misreported to API clients. * fix: distinguish Google quota/rate-limit 403 from permission 403 Check GaxiosError reason field for rateLimitExceeded, userRateLimitExceeded, and dailyLimitExceeded before mapping 403 to ForbiddenException. Quota errors are now correctly mapped to 429 (retriable) instead. * fix: keep dailyLimitExceeded as 403 (non-retriable quota exhaustion) dailyLimitExceeded is a daily quota cap, not transient throttling. Only rateLimitExceeded and userRateLimitExceeded are remapped to 429. * fix: add missing @ApiQuery decorators for calendarId on get/update/delete endpoints getConnectionEvent, updateConnectionEvent, and deleteConnectionEvent were missing @ApiQuery({ name: 'calendarId', required: false }) which caused OpenAPI spec to incorrectly mark calendarId as required. * ci: retry flaky vitest worker test * fix: update calendarId query parameter to be optional in OpenAPI specification * fix: swap dual decorator order so plural /events/ path appears in OpenAPI spec NestJS Swagger only picks up the first HTTP method decorator. Swapping the order ensures the preferred plural path (/events/:eventUid) is generated in the OpenAPI spec, while the deprecated singular path (/event/:eventUid) still works at runtime. * fix: split dual decorators into separate methods so both paths appear in OpenAPI spec NestJS Swagger only picks up the first HTTP method decorator per handler. Split getCalendarEventDetails and updateCalendarEvent into separate methods for the singular /event/ (deprecated) and plural /events/ paths, each delegating to a shared private helper. Both routes now appear in the generated OpenAPI spec. * fix: update openapi.json with split dual-decorator paths for GET/PATCH event endpoints * fix: mapGoogleApiError - coerce string code to number and read errors from response.data * fix: mapGoogleApiError - guard against NaN from non-numeric error codes * fix: use read replica for findCredentialWithDelegationByTypeAndUserId query * refactor: address review comments - UnifiedCalendarService, ParseConnectionIdPipe, thin controller - Comment 70 (Ryukemeister): Remove 'what' JSDoc from calendars.service.ts - Comment 71 (Ryukemeister): Use array syntax for dual paths instead of separate methods - Comments 73-78 (ThyMinimalDev): Create ParseConnectionIdPipe for connectionId validation - Comments 79-84 (ThyMinimalDev): Create UnifiedCalendarService with strategy pattern - Comment 85 (ThyMinimalDev): Move getConnections from freebusy to UnifiedCalendarService - Controller now only handles HTTP concerns, delegates all logic to UnifiedCalendarService - Updated all test specs to match refactored architecture * chore: regenerate openapi.json after controller refactor to array syntax paths --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../calendars/services/calendars.service.ts | 26 +- .../cal-unified-calendars.module.ts | 8 +- .../cal-unified-calendars.controller.spec.ts | 559 +++++++++++ .../cal-unified-calendars.controller.ts | 383 ++++++-- .../create-unified-calendar-event.input.ts | 92 ++ .../inputs/freebusy-unified.input.ts | 53 ++ .../list-unified-calendar-events.input.ts | 38 + .../update-unified-calendar-event.input.ts | 5 +- .../get-unified-calendar-event.output.ts | 14 +- .../outputs/list-connections.output.ts | 49 + ...calendar-event-details-output-pipe.spec.ts | 11 +- .../get-calendar-event-details-output-pipe.ts | 30 +- .../pipes/google-calendar-event-input-pipe.ts | 5 +- .../google-calendar-event-input.pipe.spec.ts | 2 +- .../pipes/parse-connection-id.pipe.ts | 12 + .../services/google-calendar.service.spec.ts | 461 +++++++++ .../services/google-calendar.service.ts | 325 ++++++- .../services/unified-calendar.service.ts | 192 ++++ ...ied-calendars-freebusy.integration.spec.ts | 82 ++ ...unified-calendars-freebusy.service.spec.ts | 298 ++++++ .../unified-calendars-freebusy.service.ts | 66 ++ .../credentials/credentials.repository.ts | 42 +- docs/api-reference/v2/openapi.json | 882 +++++++++++++++++- 23 files changed, 3531 insertions(+), 104 deletions(-) create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.spec.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/inputs/create-unified-calendar-event.input.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/inputs/freebusy-unified.input.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/inputs/list-unified-calendar-events.input.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/outputs/list-connections.output.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/pipes/parse-connection-id.pipe.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.spec.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendar.service.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.integration.spec.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.service.spec.ts create mode 100644 apps/api/v2/src/modules/cal-unified-calendars/services/unified-calendars-freebusy.service.ts 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": {