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:
+25
@@ -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: {
|
||||
|
||||
+4
@@ -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:
|
||||
|
||||
+12
@@ -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 {}
|
||||
+52
@@ -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 });
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
};
|
||||
+13
@@ -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;
|
||||
};
|
||||
+8
@@ -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;
|
||||
};
|
||||
+69
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+41
@@ -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,
|
||||
};
|
||||
};
|
||||
+2
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+2
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user