Files
twenty/packages/twenty-server/test/integration/graphql/suites/create-calendar-event.integration-spec.ts
T
Félix Malfait 0e22ae0521 feat: create calendar events on Google and Microsoft accounts (#22231)
## Context

Twenty can import calendar events and send emails, but cannot create
calendar events. This adds calendar event creation on connected
**Google** and **Microsoft** accounts, mirroring the existing email-send
architecture (`message-outbound-manager`).

## What it adds

The capability is exposed three ways, all backed by the same composer →
driver → persist pipeline:

- **GraphQL mutation** `createCalendarEvent` (metadata API)
- **AI agent tool** `create_calendar_event` (flows to MCP
automatically), gated by a new `CREATE_CALENDAR_EVENT_TOOL` permission
flag
- **Workflow builder node** "Create Calendar Event" in the **Core**
section, with a full settings form (variable interpolation supported)

CalDAV/IMAP is intentionally out of scope for now (different long pole).

## Design notes

- **Reuse over reinvention** — the created event is run through the
existing inbound formatters (`formatGoogleCalendarEvents` /
`formatMicrosoftCalendarEvents`) and persisted immediately via the
existing `CalendarSaveEventsService`, so it appears in Twenty right away
and is reconciled by the next provider sync (dedup on external id).
Persistence is best-effort.
- **OAuth scopes** — Google already requests `calendar.events`
(read+write), so no change there. Microsoft moves `Calendars.Read` →
`Calendars.ReadWrite`; existing Microsoft accounts must re-consent
(surfaced as a clear "reconnect" error via a missing-scope check).
- **Deliberate invitation semantics** — `sendInvitations` is off by
default. When off, the event is created with **no attendees** on either
provider, so creating an event never silently emails external people.
When on, attendees are attached and notified (Google `sendUpdates: all`,
Microsoft's default). This sidesteps Microsoft Graph having no
per-request suppression.
- **Timezone correctness** — Microsoft Graph interprets `dateTime` as
wall-clock in the supplied `timeZone` and ignores the offset, so the
absolute instant is converted to its wall-clock form before sending
(Google honors the offset directly). Both providers end up scheduling
the same instant.
- **Conferencing** — optional Google Meet
(`conferenceData.createRequest`, with a follow-up `events.get` to
resolve the async link) / Microsoft Teams (`isOnlineMeeting`).
- Attendees are a comma-separated string everywhere (tool input, GraphQL
DTO, workflow input), consistent with `send_email` recipients; the
composer parses to its internal list.

## Test plan

- **Unit**: 45 tests covering the composer (validation, all-day
boundaries, offset enforcement, timezone, scope checks, default-account
resolution), both provider drivers, the dispatcher, and the workflow
step-log builder.
- **Integration**: `createCalendarEvent` on the `/metadata` API fails
closed with a structured error for a non-existent account (the
auth/ownership/validation path that doesn't require provider mocking).
- **Manual**: verified the workflow node appears in the Core section,
the settings form renders and round-trips (edit → autosave → reload),
and the live mutation returns a structured failure for a bogus account.

## Open question for reviewers

The metadata mutation `createCalendarEvent` shares a name with the core
schema's auto-generated `createCalendarEvent(data:)` CRUD mutation for
the CalendarEvent object — they live on different endpoints (`/metadata`
vs `/graphql`) so there's no runtime conflict, but it's a potential
point of confusion for API consumers. Happy to rename (e.g.
`createCalendarEventOnConnectedAccount`) if preferred.

## Out of scope / follow-ups

- CalDAV/IMAP support
- Event update/delete and recurrence
- Existing Microsoft accounts need re-consent for the widened scope


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22231?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-06-27 14:05:58 +02:00

47 lines
1.6 KiB
TypeScript

import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
const NON_EXISTENT_CONNECTED_ACCOUNT_ID =
'00000000-0000-4000-8000-000000000000';
const CREATE_CALENDAR_EVENT_MUTATION = gql`
mutation CreateCalendarEvent($input: CreateCalendarEventInput!) {
createCalendarEvent(input: $input) {
success
error
iCalUid
conferenceLink
}
}
`;
// Creating an actual event hits Google/Microsoft and cannot be integration-tested
// without real OAuth + provider mocking. The auth + ownership + validation paths,
// however, fail closed before any provider call — so we assert the mutation is
// wired into the metadata schema and returns a structured failure (never a 500)
// for an account that does not exist.
describe('createCalendarEvent (metadata API) (e2e)', () => {
it('fails closed with a structured error when the connected account does not exist', async () => {
const response = await makeMetadataAPIRequest({
query: CREATE_CALENDAR_EVENT_MUTATION,
variables: {
input: {
connectedAccountId: NON_EXISTENT_CONNECTED_ACCOUNT_ID,
title: 'Integration test event',
startsAt: '2026-07-01T15:00:00Z',
endsAt: '2026-07-01T16:00:00Z',
},
},
});
expect(response.body.errors).toBeUndefined();
const result = response.body.data.createCalendarEvent;
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
expect(result.iCalUid).toBeNull();
expect(result.conferenceLink).toBeNull();
});
});