feat(unified-cal): connection-based unified calendar API with CRUD, freebusy, and list connections (#28387)

* 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>
This commit is contained in:
Sahitya Chandra
2026-03-18 15:15:53 +05:30
committed by GitHub
co-authored by cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent fa20f19e54
commit 72acf09ff2
23 changed files with 3531 additions and 104 deletions
@@ -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<ConnectedDestinationCalendars> {
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<ConnectedDestinationCalendars> {
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"],
@@ -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,
@@ -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<string, jest.Mock>;
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>(CalUnifiedCalendarsController);
});
describe("listConnections", () => {
it("should return connections from unified calendar service", async () => {
const connections = [
{ connectionId: "1", type: "google" as const, email: "[email protected]" },
{ connectionId: "2", type: "office365" as const, email: "[email protected]" },
];
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: "[email protected]",
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", "[email protected]");
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: "[email protected]",
});
expect(mockUnifiedCalendarService.listConnectionEvents).toHaveBeenCalledWith(
userId,
10,
"[email protected]",
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, "[email protected]");
expect(mockUnifiedCalendarService.createConnectionEvent).toHaveBeenCalledWith(
userId,
10,
"[email protected]",
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);
});
});
});
@@ -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<GetUnifiedCalendarEventOutput> {
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<ListConnectionsOutput> {
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<ListUnifiedCalendarEventsOutput> {
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<GetUnifiedCalendarEventOutput> {
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<GetUnifiedCalendarEventOutput> {
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<GetUnifiedCalendarEventOutput> {
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<void> {
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<GetBusyTimesOutput> {
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<GetUnifiedCalendarEventOutput> {
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<GetUnifiedCalendarEventOutput> {
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<ListUnifiedCalendarEventsOutput> {
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<GetUnifiedCalendarEventOutput> {
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<void> {
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<GetBusyTimesOutput> {
const timezone = query.timeZone ?? "UTC";
const data = await this.unifiedCalendarService.getFreeBusy(calendar, userId, query.from, query.to, timezone);
return { status: SUCCESS_STATUS, data };
}
}
@@ -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[];
}
@@ -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;
}
@@ -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;
}
@@ -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 {
@@ -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[];
}
@@ -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: "[email protected]",
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;
}
@@ -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;
@@ -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;
}
@@ -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 {
@@ -1,6 +1,6 @@
import {
UpdateUnifiedCalendarEventInput,
UpdateDateTimeWithZone,
UpdateUnifiedCalendarEventInput,
} from "../inputs/update-unified-calendar-event.input";
import {
CalendarEventResponseStatus,
@@ -0,0 +1,12 @@
import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common";
@Injectable()
export class ParseConnectionIdPipe implements PipeTransform<string, number> {
transform(value: string): number {
const id = parseInt(value, 10);
if (Number.isNaN(id)) {
throw new BadRequestException("Invalid connectionId");
}
return id;
}
}
@@ -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>(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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]" },
});
// 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: "[email protected]",
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: "[email protected]" },
});
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: "[email protected]",
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: "[email protected]" },
});
await service.getCalendarClientForUser(userId);
expect(MockJWT).toHaveBeenCalledWith({
email: "[email protected]",
key: "-----BEGIN PRIVATE KEY-----\nTEST_KEY\n-----END PRIVATE KEY-----\n",
scopes: ["https://www.googleapis.com/auth/calendar"],
subject: "[email protected]",
});
expect(mockJwtAuthorize).toHaveBeenCalled();
});
it("should strip OAuth client ID alias from email before delegation", async () => {
MockJWT.mockClear();
mockDelegationFindById.mockResolvedValueOnce({
serviceAccountKey: {
client_email: "[email protected]",
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: "[email protected]" },
});
await service.getCalendarClientForUser(userId);
expect(MockJWT).toHaveBeenCalledWith(
expect.objectContaining({
subject: "[email protected]",
})
);
});
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: "[email protected]" },
});
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: "[email protected]",
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: "[email protected]" },
});
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: "[email protected]",
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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]" },
});
// 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: "[email protected]",
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: "[email protected]" },
});
const calendar = await service.getCalendarClientByCredentialId(userId, credentialId);
expect(calendar).toBeDefined();
expect(MockJWT).toHaveBeenCalledWith({
email: "[email protected]",
key: "-----BEGIN PRIVATE KEY-----\nKEY_FOR_CRED\n-----END PRIVATE KEY-----\n",
scopes: ["https://www.googleapis.com/auth/calendar"],
subject: "[email protected]",
});
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
);
});
});
});
@@ -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<GoogleCalendarEventResponse> {
@@ -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<calendar_v3.Calendar> {
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<calendar_v3.Calendar> {
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<GoogleCalendarEventResponse[]> {
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<GoogleCalendarEventResponse> {
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<GoogleCalendarEventResponse> {
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<GoogleCalendarEventResponse> {
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<void> {
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<GoogleCalendarEventResponse[]> {
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<GoogleCalendarEventResponse> {
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<void> {
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<GoogleCalendarEventResponse[]> {
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<GoogleCalendarEventResponse> {
const calendar = await this.getCalendarClientByCredentialId(userId, credentialId);
return this.createEventWithClient(calendar, calendarId, body);
}
async getEventByConnectionId(
userId: number,
credentialId: number,
calendarId: string,
eventId: string
): Promise<GoogleCalendarEventResponse> {
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<GoogleCalendarEventResponse> {
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<void> {
const calendar = await this.getCalendarClientByCredentialId(userId, credentialId);
return this.deleteEventWithClient(calendar, calendarId, eventId);
}
}
@@ -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<Array<{ connectionId: string; type: "google" | "office365" | "apple"; email: string | null }>> {
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);
}
}
@@ -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>(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: "[email protected]" },
calendars: [
{ credentialId: 100, externalId: "[email protected]", isSelected: true },
{ credentialId: 100, externalId: "[email protected]", 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: "[email protected]" }],
userId,
"2025-03-10T00:00:00Z",
"2025-03-11T00:00:00Z",
"UTC"
);
});
});
@@ -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>(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: "[email protected]" },
calendars: [
{ credentialId: 10, externalId: "[email protected]", isSelected: true },
{ credentialId: 10, externalId: "[email protected]", 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: "[email protected]" }],
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: "[email protected]" },
calendars: [{ credentialId: 10, externalId: "[email protected]", isSelected: false }],
},
],
});
mockCalendarsService.getBusyTimes.mockResolvedValue([]);
await service.getBusyTimesForConnection(userId, 10, from, to, timezone);
expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith(
[{ credentialId: 10, externalId: "[email protected]" }],
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: "[email protected]" },
},
],
});
mockCalendarsService.getBusyTimes.mockResolvedValue([]);
await service.getBusyTimesForConnection(userId, 10, from, to, timezone);
expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith(
[{ credentialId: 10, externalId: "[email protected]" }],
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: "[email protected]", isSelected: true },
{ credentialId: 1, externalId: "[email protected]", isSelected: true },
],
},
{
credentialId: 2,
integration: { type: GOOGLE_CALENDAR_TYPE },
calendars: [{ credentialId: 2, externalId: "[email protected]", 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: "[email protected]" },
{ credentialId: 1, externalId: "[email protected]" },
{ credentialId: 2, externalId: "[email protected]" },
],
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: "[email protected]", isSelected: true }],
},
{
credentialId: 2,
integration: { type: OFFICE_365_CALENDAR_TYPE },
calendars: [{ credentialId: 2, externalId: "[email protected]", isSelected: true }],
},
],
});
mockCalendarsService.getBusyTimes.mockResolvedValue([]);
await service.getBusyTimesForGoogleCalendars(userId, from, to, timezone);
expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith(
[{ credentialId: 1, externalId: "[email protected]" }],
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: "[email protected]", isSelected: true },
{ credentialId: 1, externalId: "[email protected]", isSelected: false },
],
},
],
});
mockCalendarsService.getBusyTimes.mockResolvedValue([]);
await service.getBusyTimesForGoogleCalendars(userId, from, to, timezone);
expect(mockCalendarsService.getBusyTimes).toHaveBeenCalledWith(
[{ credentialId: 1, externalId: "[email protected]" }],
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: "[email protected]", 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: "[email protected]", isSelected: true }],
},
],
});
const result = await service.getBusyTimesForGoogleCalendars(userId, from, to, timezone);
expect(result).toEqual([]);
expect(mockCalendarsService.getBusyTimes).not.toHaveBeenCalled();
});
});
});
@@ -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);
}
}
@@ -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 },
+876 -6
View File
@@ -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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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 <token>` where `<token>` 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": "[email protected]"
}
},
"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": {