Skip to content

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

Merged
Ryukemeister merged 32 commits intomainfrom
devin/1773319698-unified-calendar-api
Mar 18, 2026
Merged

feat(unified-cal): connection-based unified calendar API with CRUD, freebusy, and list connections#28387
Ryukemeister merged 32 commits intomainfrom
devin/1773319698-unified-calendar-api

Conversation

@sahitya-chandra
Copy link
Member

@sahitya-chandra sahitya-chandra commented Mar 12, 2026

What does this PR do?

Adds a unified calendar API to the cal-unified-calendars module in API v2. Extracted from #28297 as a standalone PR.

Two sets of endpoints are introduced:

  • Connection-scoped (/v2/calendars/connections/...) — CRUD + freebusy scoped to a specific calendar connection by connectionId
  • Legacy user-scoped (/v2/calendars/:calendar/...) — operates on the user's default calendar credential

Currently only Google Calendar is supported for CRUD operations. Non-Google connections are returned by listConnections but will receive a 400 if used for event operations.

Recommended review order

Start with the service layer (core logic), then the controller (thin HTTP layer), then supporting files:

  1. services/unified-calendar.service.ts — Orchestration service with strategy pattern. Delegates to GoogleCalendarService for Google-specific operations, validates calendar type, transforms responses via GoogleCalendarEventOutputPipe. All business logic lives here — the controller calls this service exclusively.

  2. services/google-calendar.service.ts — Core Google Calendar service. Contains auth logic (getAuthorizedCalendarInstance tries delegated auth first, falls back to OAuth), DRY CRUD helpers (listEventsWithClient, createEventWithClient, etc.), and mapGoogleApiError() for proper HTTP status mapping of GaxiosError responses.

  3. services/unified-calendars-freebusy.service.ts — Freebusy service. Contains getBusyTimesForConnection() and getBusyTimesForGoogleCalendars(). (Connection listing was moved to UnifiedCalendarService.)

  4. controllers/cal-unified-calendars.controller.ts — Thin controller. Handles HTTP concerns only: request parsing, calling UnifiedCalendarService, wrapping responses. Uses ParseConnectionIdPipe for connectionId validation. Deprecated singular /event/ and preferred plural /events/ paths use array syntax on the same method.

  5. pipes/parse-connection-id.pipe.ts — Validates and parses connectionId path param to a number, throwing BadRequestException for invalid values. Used on all connection-scoped endpoints.

  6. inputs/ — Input DTOs with validation: create-unified-calendar-event.input.ts (event creation with @IsTimeZone(), @IsDefined(), @IsISO8601()), freebusy-unified.input.ts (cross-field to >= from via @Validate(IsAfterFrom)), list-unified-calendar-events.input.ts.

  7. outputs/ — Response DTOs: list-connections.output.ts, get-unified-calendar-event.output.ts.

  8. credentials.repository.ts — Two new repository methods: findCredentialByIdAndUserId and findCredentialWithDelegationByTypeAndUserId. Both use the read replica and include delegationCredentialId + user.email for delegation-aware lookups.

  9. pipes/ — Updated GoogleCalendarEventOutputPipe to handle all-day events (start.date format → midnight UTC).

  10. docs/api-reference/v2/openapi.json — Auto-generated from controller decorators but must be committed because CI's oasdiff compares the committed file. Regenerate locally with NODE_ENV=development yarn generate-swagger if decorators change.

Things to keep in mind while reviewing

  • Thin controller pattern: The controller delegates all business logic to UnifiedCalendarService. Calendar type validation, event transformation, and CRUD calls all happen in the service layer — the controller only handles HTTP concerns (request parsing, response wrapping).

  • Auth flow: getAuthorizedCalendarInstance() is the single entry point for all auth. It checks for delegation credentials first (service account impersonation via JWT), then falls back to direct OAuth. Both getCalendarClientForUser and getCalendarClientByCredentialId delegate to it.

  • Error handling: All CRUD helpers wrap Google Calendar API calls in try/catch with mapGoogleApiError(). GaxiosError stores HTTP status as a string in error.code (e.g. "404") and error reasons at error.response.data.error.errors — the mapper handles both correctly with typeof checks and Number.isNaN() guards. dailyLimitExceeded stays 403 (non-retriable), only rateLimitExceeded/userRateLimitExceeded are remapped to 429.

  • Credential key stripping: credential.key (OAuth tokens) is never exposed in API responses. The service layer returns only { connectionId, type, email }, and the controller also destructures as defense-in-depth. Tests verify this at both layers.

  • Deprecated paths: /:calendar/event/:eventUid (singular) is kept for backward compat alongside /:calendar/events/:eventUid (plural). They use NestJS array syntax on the same method: @Get(["/:calendar/events/:eventUid", "/:calendar/event/:eventUid"]).

  • ParseConnectionIdPipe: All connection-scoped endpoints use @Param("connectionId", ParseConnectionIdPipe) credentialId: number instead of inline parseInt + isNaN checks.

  • Virtual mocks in tests: @calcom/platform-libraries is mocked with { virtual: true } because its transitive dependencies (prisma, DB) can't resolve in Jest. An integration spec imports the real ConnectedDestinationCalendars type to catch shape changes at compile time.

Important items for review

  • getCalendarsForConnection fetches all calendars then filters: The upstream platform-libraries API only supports fetching all credentials at once. The method in calendars.service.ts calls getCalendars() (which benefits from CalendarsCacheService) and filters to the target credential. A targeted single-credential query would need upstream changes.
  • Strategy pattern only validates at runtime: UnifiedCalendarService.ensureGoogleCalendar() throws BadRequestException if a non-Google calendar type is used. When Office 365 / Apple support is added, new strategy branches should be added here.
  • Array syntax and OpenAPI: NestJS Swagger picks up only the first path in an array decorator. The deprecated /event/ path still works at runtime but only the /events/ path appears in the generated OpenAPI spec.

New endpoints

Method Path Description
GET /v2/calendars/connections List all calendar connections
GET /v2/calendars/connections/:id/events List events for a connection
POST /v2/calendars/connections/:id/events Create event on a connection
GET /v2/calendars/connections/:id/events/:eventId Get single event
PATCH /v2/calendars/connections/:id/events/:eventId Update event
DELETE /v2/calendars/connections/:id/events/:eventId Delete event
GET /v2/calendars/connections/:id/freebusy Free/busy for a connection
GET /v2/calendars/:calendar/events List events (user-scoped)
POST /v2/calendars/:calendar/events Create event (user-scoped)
GET /v2/calendars/:calendar/events/:eventUid Get event details
PATCH /v2/calendars/:calendar/events/:eventUid Update event
DELETE /v2/calendars/:calendar/events/:eventUid Delete event
GET /v2/calendars/:calendar/freebusy Free/busy (user-scoped)

Tests

95 tests across 6 suites, all passing:

cd apps/api/v2
npx jest --config jest.config.ts --testPathPattern="cal-unified-calendars" --no-cache
  • GoogleCalendarService — delegation auth, client creation, credential validation, error mapping
  • UnifiedCalendarsFreebusyService — busy times, connection filtering
  • CalUnifiedCalendarsController — all endpoints, validation, credential leak guards
  • Integration spec — validates service against real ConnectedDestinationCalendars type shape
  • Pipe specs — pre-existing tests continue to pass

How should this be tested?

# List connections
curl -H "Authorization: Bearer $TOKEN" https://api.cal.com/v2/calendars/connections

# List events for a connection
curl -H "Authorization: Bearer $TOKEN" \
  "https://api.cal.com/v2/calendars/connections/{connId}/events?from=2026-03-01&to=2026-03-31"

# Create event on a connection
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Test","start":{"time":"2026-03-10T14:00:00","timeZone":"UTC"},"end":{"time":"2026-03-10T15:00:00","timeZone":"UTC"}}' \
  https://api.cal.com/v2/calendars/connections/{connId}/events

# User-scoped list events
curl -H "Authorization: Bearer $TOKEN" \
  "https://api.cal.com/v2/calendars/google/events?from=2026-03-01&to=2026-03-31"
  • Requires a user with at least one connected Google Calendar credential
  • $TOKEN is an API key (prefixed cal_) or managed user access token
  • {connId} is the connectionId from the list connections endpoint

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A — OpenAPI docs are auto-generated from NestJS decorators.
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

Link to Devin Session: https://app.devin.ai/sessions/42d4c8d802d44f09bb7349c07614024e
Requested by: @sahitya-chandra

…reebusy, 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
@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

cubic-dev-ai[bot]

This comment was marked as resolved.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 12, 2026

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session


✅ Pushed commit 0898c10

- 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.
- 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
devin-ai-integration[bot]

This comment was marked as resolved.

- 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)
cubic-dev-ai[bot]

This comment was marked as resolved.

@github-actions
Copy link
Contributor

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session

Cubic AI (confidence 9/10, team feedback): validators should throw
BadRequestException to preserve the API's standard bad-request response
structure, per team convention.
devin-ai-integration[bot]

This comment was marked as resolved.

…istency

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.
cubic-dev-ai[bot]

This comment was marked as resolved.

@github-actions
Copy link
Contributor

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session

sahitya-chandra and others added 2 commits March 13, 2026 00:56
…unified-calendars.controller.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
…ers/cal-unified-calendars.controller.ts"

This reverts commit e18e462.
cubic-dev-ai[bot]

This comment was marked as resolved.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 12, 2026

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.


✅ No changes pushed

cubic-dev-ai[bot]

This comment was marked as resolved.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session


✅ Pushed commit 4718c7e

… 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
cubic-dev-ai[bot]

This comment was marked as resolved.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session


✅ Pushed commit 4f39e2f

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.
cubic-dev-ai[bot]

This comment was marked as resolved.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session


✅ Pushed commit 84cffd4f88

devin-ai-integration[bot]

This comment was marked as resolved.

@sahitya-chandra sahitya-chandra marked this pull request as ready for review March 13, 2026 15:37
@sahitya-chandra sahitya-chandra requested a review from a team as a code owner March 13, 2026 15:37
@graphite-app graphite-app bot added the community Created by Linear-GitHub Sync label Mar 13, 2026
Copy link
Contributor

@Ryukemeister Ryukemeister left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: nice work on this @sahitya-chandra, had a few suggestions. since this PR is so big can you do a self review of this. also it would be good if the PR descriptioin would explain what files to look at first, what things to keep in mind while reviewing this PR, is ⚠️ Important items for human review the one we should look at first while reviewing?

…ectionIdPipe, 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
Copy link
Contributor

@ThyMinimalDev ThyMinimalDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unified calendar service will need some refactors when we start having multiple calendars, ok for now

@sahitya-chandra sahitya-chandra requested review from Ryukemeister and removed request for Ryukemeister March 18, 2026 07:11
@Ryukemeister Ryukemeister merged commit 72acf09 into main Mar 18, 2026
140 of 148 checks passed
@Ryukemeister Ryukemeister deleted the devin/1773319698-unified-calendar-api branch March 18, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community Created by Linear-GitHub Sync ready-for-e2e size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants