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>
This commit is contained in:
Félix Malfait
2026-06-27 14:05:58 +02:00
committed by GitHub
parent 2662fda647
commit 0e22ae0521
86 changed files with 4991 additions and 1824 deletions
@@ -311,6 +311,31 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
};
}
case WorkflowActionType.CREATE_CALENDAR_EVENT: {
return {
builtStep: {
...baseStep,
name: 'Create Calendar Event',
type: WorkflowActionType.CREATE_CALENDAR_EVENT,
settings: {
...BASE_STEP_DEFINITION,
input: {
connectedAccountId: '',
title: '',
description: '',
location: '',
startsAt: '',
endsAt: '',
isFullDay: false,
timeZone: '',
attendees: '',
sendInvitations: false,
addConferencing: false,
},
},
},
};
}
case WorkflowActionType.DRAFT_EMAIL: {
return {
builtStep: {
@@ -14,6 +14,7 @@ import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/wor
import { FormWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form.workflow-action';
import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action';
import { IfElseWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/if-else.workflow-action';
import { CreateCalendarEventWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/create-calendar-event.workflow-action';
import { IteratorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action';
import { LogicFunctionWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function.workflow-action';
import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action';
@@ -44,6 +45,7 @@ export class WorkflowActionFactory {
private readonly httpRequestWorkflowAction: HttpRequestWorkflowAction,
private readonly sendEmailWorkflowAction: SendEmailWorkflowAction,
private readonly draftEmailWorkflowAction: DraftEmailWorkflowAction,
private readonly createCalendarEventWorkflowAction: CreateCalendarEventWorkflowAction,
private readonly aiAgentWorkflowAction: AiAgentWorkflowAction,
private readonly emptyWorkflowAction: EmptyWorkflowAction,
private readonly delayWorkflowAction: DelayWorkflowAction,
@@ -59,6 +61,8 @@ export class WorkflowActionFactory {
return this.sendEmailWorkflowAction;
case WorkflowActionType.DRAFT_EMAIL:
return this.draftEmailWorkflowAction;
case WorkflowActionType.CREATE_CALENDAR_EVENT:
return this.createCalendarEventWorkflowAction;
case WorkflowActionType.CREATE_RECORD:
return this.createRecordWorkflowAction;
case WorkflowActionType.UPSERT_RECORD:
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { CreateCalendarEventWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/create-calendar-event.workflow-action';
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
@Module({
imports: [ToolModule, WorkflowRunModule],
providers: [CreateCalendarEventWorkflowAction],
exports: [CreateCalendarEventWorkflowAction],
})
export class CreateCalendarEventActionModule {}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
import { CreateCalendarEventTool } from 'src/engine/core-modules/tool/tools/calendar-tool/create-calendar-event-tool';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { isWorkflowCreateCalendarEventAction } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/guards/is-workflow-create-calendar-event-action.guard';
import { type WorkflowCreateCalendarEventActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/types/workflow-create-calendar-event-action-input.type';
import { buildCreateCalendarEventStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/utils/build-create-calendar-event-step-log.util';
import { ToolBackedWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-backed/tool-backed.workflow-action';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service';
@Injectable()
export class CreateCalendarEventWorkflowAction extends ToolBackedWorkflowAction<WorkflowCreateCalendarEventActionInput> {
constructor(
private readonly createCalendarEventTool: CreateCalendarEventTool,
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
) {
super(CreateCalendarEventWorkflowAction.name, workflowRunStepLogService);
}
protected getTool(): Tool {
return this.createCalendarEventTool;
}
protected assertStep(step: WorkflowAction): void {
if (!isWorkflowCreateCalendarEventAction(step)) {
throw new WorkflowStepExecutorException(
'Step is not a create-calendar-event action',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
}
protected buildStepLog({
input,
output,
durationMs,
}: {
input: WorkflowCreateCalendarEventActionInput;
output: ToolOutput;
durationMs: number;
}): WorkflowRunStepLog {
return buildCreateCalendarEventStepLog({ input, output, durationMs });
}
}
@@ -0,0 +1,12 @@
import { WorkflowActionType } from 'twenty-shared/workflow';
import {
type WorkflowAction,
type WorkflowCreateCalendarEventAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const isWorkflowCreateCalendarEventAction = (
action: WorkflowAction,
): action is WorkflowCreateCalendarEventAction => {
return action.type === WorkflowActionType.CREATE_CALENDAR_EVENT;
};
@@ -0,0 +1,13 @@
export type WorkflowCreateCalendarEventActionInput = {
connectedAccountId: string;
title: string;
description?: string;
location?: string;
startsAt: string;
endsAt: string;
isFullDay: boolean;
timeZone?: string;
attendees?: string;
sendInvitations: boolean;
addConferencing: boolean;
};
@@ -0,0 +1,8 @@
import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
import { type WorkflowCreateCalendarEventActionInput } from './workflow-create-calendar-event-action-input.type';
export type WorkflowCreateCalendarEventActionSettings =
BaseWorkflowActionSettings & {
input: WorkflowCreateCalendarEventActionInput;
};
@@ -0,0 +1,69 @@
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type WorkflowCreateCalendarEventActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/types/workflow-create-calendar-event-action-input.type';
import { buildCreateCalendarEventStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/utils/build-create-calendar-event-step-log.util';
const input: WorkflowCreateCalendarEventActionInput = {
connectedAccountId: 'account-1',
title: 'Sync',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
isFullDay: false,
sendInvitations: false,
addConferencing: false,
};
describe('buildCreateCalendarEventStepLog', () => {
it('builds a success log from the tool result', () => {
const output: ToolOutput = {
success: true,
message: 'Calendar event "Sync" created',
result: {
iCalUid: 'uid-1',
title: 'Sync',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
conferenceLink: 'https://meet.google.com/abc',
attendeeCount: 2,
connectedAccountId: 'resolved-account',
},
};
const log = buildCreateCalendarEventStepLog({
input,
output,
durationMs: 12,
});
expect(log.details).toMatchObject({
type: 'CREATE_CALENDAR_EVENT',
status: 'SUCCESS',
iCalUid: 'uid-1',
conferenceLink: 'https://meet.google.com/abc',
attendeeCount: 2,
connectedAccountId: 'resolved-account',
durationMs: 12,
});
});
it('builds an error log and falls back to the input fields', () => {
const output: ToolOutput = {
success: false,
message: 'Failed to create calendar event',
error: 'boom',
};
const log = buildCreateCalendarEventStepLog({
input,
output,
durationMs: 5,
});
expect(log.details).toMatchObject({
type: 'CREATE_CALENDAR_EVENT',
status: 'ERROR',
title: 'Sync',
connectedAccountId: 'account-1',
error: 'boom',
});
});
});
@@ -0,0 +1,41 @@
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type WorkflowCreateCalendarEventActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/types/workflow-create-calendar-event-action-input.type';
export const buildCreateCalendarEventStepLog = ({
input,
output,
durationMs,
}: {
input: WorkflowCreateCalendarEventActionInput;
output: ToolOutput;
durationMs: number;
}): WorkflowRunStepLog => {
const result = (output.result ?? {}) as Record<string, unknown>;
const extractString = (key: string): string | undefined =>
typeof result[key] === 'string' ? result[key] : undefined;
const extractNumber = (key: string): number | undefined =>
typeof result[key] === 'number' ? result[key] : undefined;
return {
details: {
type: 'CREATE_CALENDAR_EVENT',
status: output.success ? 'SUCCESS' : 'ERROR',
title: extractString('title') ?? input.title,
startsAt: extractString('startsAt') ?? input.startsAt,
endsAt: extractString('endsAt') ?? input.endsAt,
attendeeCount: extractNumber('attendeeCount'),
conferenceLink: extractString('conferenceLink'),
connectedAccountId:
extractString('connectedAccountId') ?? input.connectedAccountId,
iCalUid: extractString('iCalUid'),
error: output.error,
durationMs,
},
entries: [],
sizeBytes: 0,
};
};
@@ -1,6 +1,7 @@
import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { type WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type';
import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
import { type WorkflowCreateCalendarEventActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/types/workflow-create-calendar-event-action-settings.type';
import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-settings.type';
import { type WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { type WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
@@ -37,6 +38,7 @@ export type WithExpectedOutputSchema = {
export type WorkflowActionSettings =
| WorkflowLogicFunctionActionSettings
| WorkflowSendEmailActionSettings
| WorkflowCreateCalendarEventActionSettings
| WorkflowCodeActionSettings
| WorkflowCreateRecordActionSettings
| WorkflowUpdateRecordActionSettings
@@ -2,6 +2,7 @@ import { WorkflowActionType } from 'twenty-shared/workflow';
import { type WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type';
import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
import { type WorkflowCreateCalendarEventActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/types/workflow-create-calendar-event-action-settings.type';
import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-settings.type';
import { type WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { type WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
@@ -53,6 +54,11 @@ export type WorkflowDraftEmailAction = BaseWorkflowAction & {
settings: WorkflowSendEmailActionSettings;
};
export type WorkflowCreateCalendarEventAction = BaseWorkflowAction & {
type: WorkflowActionType.CREATE_CALENDAR_EVENT;
settings: WorkflowCreateCalendarEventActionSettings;
};
export type WorkflowCreateRecordAction = BaseWorkflowAction & {
type: WorkflowActionType.CREATE_RECORD;
settings: WorkflowCreateRecordActionSettings;
@@ -127,6 +133,7 @@ export type WorkflowAction =
| WorkflowLogicFunctionAction
| WorkflowSendEmailAction
| WorkflowDraftEmailAction
| WorkflowCreateCalendarEventAction
| WorkflowCreateRecordAction
| WorkflowUpdateRecordAction
| WorkflowDeleteRecordAction
@@ -8,6 +8,7 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
import { AiAgentActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module';
import { CodeActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module';
import { CreateCalendarEventActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/create-calendar-event/create-calendar-event-action.module';
import { DelayActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/delay-action.module';
import { EmptyActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/empty-action.module';
import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module';
@@ -40,6 +41,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
FeatureFlagModule,
HttpRequestActionModule,
MailSenderActionModule,
CreateCalendarEventActionModule,
MetricsModule,
],
providers: [WorkflowExecutorWorkspaceService, WorkflowActionFactory],