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:
+1
-1
@@ -6,7 +6,7 @@ export const getMicrosoftApisOauthScopes = () => {
|
||||
'offline_access',
|
||||
'Mail.ReadWrite',
|
||||
'Mail.Send',
|
||||
'Calendars.Read',
|
||||
'Calendars.ReadWrite',
|
||||
'User.Read',
|
||||
];
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ import { CodeInterpreterSessionCleanupModule } from 'src/engine/core-modules/cod
|
||||
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
|
||||
import { CreateCalendarEventModule } from 'src/modules/calendar/calendar-event-creation-manager/create-calendar-event.module';
|
||||
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
|
||||
import { SendEmailModule } from 'src/modules/messaging/message-outbound-manager/send-email.module';
|
||||
import { ClientConfigModule } from './client-config/client-config.module';
|
||||
@@ -130,6 +131,7 @@ import { FileModule } from './file/file.module';
|
||||
ImapSmtpCaldavModule,
|
||||
ChannelSyncModule,
|
||||
SendEmailModule,
|
||||
CreateCalendarEventModule,
|
||||
FileStorageModule.forRoot(),
|
||||
LoggerModule.forRootAsync({
|
||||
useFactory: loggerModuleFactory,
|
||||
|
||||
+4
@@ -7,6 +7,7 @@ export const ACTION_TOOL_IDS = [
|
||||
'http_request',
|
||||
'send_email',
|
||||
'draft_email',
|
||||
'create_calendar_event',
|
||||
'search_help_center',
|
||||
'code_interpreter',
|
||||
'navigate_app',
|
||||
@@ -24,6 +25,9 @@ export const ACTION_TOOL_LABELS: Record<ActionToolId, ActionToolLabel> = {
|
||||
draft_email: {
|
||||
label: i18nLabel(msg`Draft Email`),
|
||||
},
|
||||
create_calendar_event: {
|
||||
label: i18nLabel(msg`Create Calendar Event`),
|
||||
},
|
||||
search_help_center: {
|
||||
label: i18nLabel(msg`Search Help Center`),
|
||||
},
|
||||
|
||||
+21
@@ -21,6 +21,7 @@ import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-t
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
|
||||
import { CreateCalendarEventTool } from 'src/engine/core-modules/tool/tools/calendar-tool/create-calendar-event-tool';
|
||||
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
@@ -43,6 +44,7 @@ export class ActionToolProvider implements ToolProvider {
|
||||
private readonly httpTool: HttpTool,
|
||||
private readonly sendEmailTool: SendEmailTool,
|
||||
private readonly draftEmailTool: DraftEmailTool,
|
||||
private readonly createCalendarEventTool: CreateCalendarEventTool,
|
||||
private readonly searchHelpCenterTool: SearchHelpCenterTool,
|
||||
private readonly codeInterpreterTool: CodeInterpreterTool,
|
||||
private readonly navigateAppTool: NavigateAppTool,
|
||||
@@ -56,6 +58,7 @@ export class ActionToolProvider implements ToolProvider {
|
||||
['http_request', this.httpTool],
|
||||
['send_email', this.sendEmailTool],
|
||||
['draft_email', this.draftEmailTool],
|
||||
['create_calendar_event', this.createCalendarEventTool],
|
||||
['search_help_center', this.searchHelpCenterTool],
|
||||
['code_interpreter', this.codeInterpreterTool],
|
||||
['navigate_app', this.navigateAppTool],
|
||||
@@ -117,6 +120,24 @@ export class ActionToolProvider implements ToolProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const hasCreateCalendarEventPermission =
|
||||
await this.permissionsService.hasToolPermission(
|
||||
context.rolePermissionConfig,
|
||||
context.workspaceId,
|
||||
PermissionFlagType.CREATE_CALENDAR_EVENT_TOOL,
|
||||
);
|
||||
|
||||
if (hasCreateCalendarEventPermission) {
|
||||
descriptors.push(
|
||||
this.buildDescriptor(
|
||||
'create_calendar_event',
|
||||
this.createCalendarEventTool,
|
||||
includeSchemas,
|
||||
context.locale,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
descriptors.push(
|
||||
this.buildDescriptor(
|
||||
'search_help_center',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
|
||||
import { CreateCalendarEventTool } from 'src/engine/core-modules/tool/tools/calendar-tool/create-calendar-event-tool';
|
||||
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
|
||||
@@ -22,6 +23,7 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac
|
||||
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
|
||||
import { CalendarEventCreationManagerModule } from 'src/modules/calendar/calendar-event-creation-manager/calendar-event-creation-manager.module';
|
||||
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
|
||||
import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module';
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
@@ -29,6 +31,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
imports: [
|
||||
MessagingImportManagerModule,
|
||||
MessagingSendManagerModule,
|
||||
CalendarEventCreationManagerModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ConnectedAccountEntity]),
|
||||
ApplicationModule,
|
||||
FeatureFlagModule,
|
||||
@@ -44,6 +47,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
HttpTool,
|
||||
SendEmailTool,
|
||||
DraftEmailTool,
|
||||
CreateCalendarEventTool,
|
||||
EmailComposerService,
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
@@ -57,6 +61,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
HttpTool,
|
||||
SendEmailTool,
|
||||
DraftEmailTool,
|
||||
CreateCalendarEventTool,
|
||||
EmailComposerService,
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateCalendarEventToolInputZodSchema = z.object({
|
||||
title: z.string().describe('The title of the calendar event'),
|
||||
description: z
|
||||
.string()
|
||||
.describe('The event description or agenda')
|
||||
.optional(),
|
||||
location: z
|
||||
.string()
|
||||
.describe('The physical or virtual location of the event')
|
||||
.optional(),
|
||||
startsAt: z
|
||||
.string()
|
||||
.describe(
|
||||
'Event start time as an ISO 8601 date-time with an offset (e.g. 2026-07-01T15:00:00Z). For all-day events pass a date (e.g. 2026-07-01).',
|
||||
),
|
||||
endsAt: z
|
||||
.string()
|
||||
.describe(
|
||||
'Event end time as an ISO 8601 date-time with an offset, after startsAt. For all-day events pass the exclusive end date (the day after the last day).',
|
||||
),
|
||||
isFullDay: z
|
||||
.boolean()
|
||||
.describe('Whether the event lasts the whole day')
|
||||
.default(false),
|
||||
timeZone: z
|
||||
.string()
|
||||
.describe(
|
||||
'IANA time zone for the event (e.g. America/New_York). Defaults to UTC.',
|
||||
)
|
||||
.optional(),
|
||||
attendees: z
|
||||
.string()
|
||||
.describe(
|
||||
'Comma-separated attendee email addresses. Only applied when sendInvitations is true; otherwise ignored.',
|
||||
)
|
||||
.optional()
|
||||
.default(''),
|
||||
sendInvitations: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'When true, attendees are added to the event and emailed an invitation. When false, the event is created with no attendees and nobody is notified.',
|
||||
)
|
||||
.default(false),
|
||||
addConferencing: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'When true, a video conferencing link is generated (Google Meet for Google accounts, Microsoft Teams for Microsoft accounts).',
|
||||
)
|
||||
.default(false),
|
||||
connectedAccountId: z
|
||||
.string()
|
||||
.refine((val) => isValidUuid(val))
|
||||
.describe(
|
||||
'The UUID of the connected account to create the event on. Provide only if known; otherwise leave blank to use the default calendar account.',
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { CreateCalendarEventToolInputZodSchema } from 'src/engine/core-modules/tool/tools/calendar-tool/calendar-tool.schema';
|
||||
import { type CreateCalendarEventToolInput } from 'src/engine/core-modules/tool/tools/calendar-tool/types/create-calendar-event-tool-input.type';
|
||||
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
|
||||
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 { CalendarEventCreationException } from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
|
||||
import { CalendarEventComposerService } from 'src/modules/calendar/calendar-event-creation-manager/services/calendar-event-composer.service';
|
||||
import { CreateCalendarEventService } from 'src/modules/calendar/calendar-event-creation-manager/services/create-calendar-event.service';
|
||||
|
||||
@Injectable()
|
||||
export class CreateCalendarEventTool implements Tool {
|
||||
private readonly logger = new Logger(CreateCalendarEventTool.name);
|
||||
|
||||
description =
|
||||
'Create a calendar event on a connected Google or Microsoft account. Requires CREATE_CALENDAR_EVENT_TOOL permission. Set sendInvitations to true to attach attendees and email them an invitation; when false the event is created with no attendees and nobody is notified.';
|
||||
inputSchema = CreateCalendarEventToolInputZodSchema;
|
||||
flag = PermissionFlagType.CREATE_CALENDAR_EVENT_TOOL;
|
||||
|
||||
constructor(
|
||||
private readonly calendarEventComposerService: CalendarEventComposerService,
|
||||
private readonly createCalendarEventService: CreateCalendarEventService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
parameters: CreateCalendarEventToolInput,
|
||||
context: ToolExecutionContext,
|
||||
): Promise<ToolOutput> {
|
||||
try {
|
||||
const result =
|
||||
await this.calendarEventComposerService.composeCalendarEvent(
|
||||
parameters,
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to create calendar event',
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = result;
|
||||
|
||||
const createdEvent =
|
||||
await this.createCalendarEventService.createComposedCalendarEvent(data);
|
||||
|
||||
await this.createCalendarEventService.persistCalendarEvent(
|
||||
createdEvent,
|
||||
data,
|
||||
context.workspaceId,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Calendar event "${createdEvent.title}" created on connected account ${data.connectedAccount.id}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Calendar event "${createdEvent.title}" created`,
|
||||
result: {
|
||||
iCalUid: createdEvent.iCalUid,
|
||||
externalEventId: createdEvent.id,
|
||||
title: createdEvent.title,
|
||||
startsAt: createdEvent.startsAt,
|
||||
endsAt: createdEvent.endsAt,
|
||||
conferenceLink: createdEvent.conferenceLinkUrl || undefined,
|
||||
attendeeCount: createdEvent.participants.length,
|
||||
connectedAccountId: data.connectedAccount.id,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CalendarEventCreationException) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to create calendar event',
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to create calendar event: ${error}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to create calendar event',
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to create calendar event',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type z } from 'zod';
|
||||
|
||||
import { type CreateCalendarEventToolInputZodSchema } from 'src/engine/core-modules/tool/tools/calendar-tool/calendar-tool.schema';
|
||||
|
||||
export type CreateCalendarEventToolInput = z.infer<
|
||||
typeof CreateCalendarEventToolInputZodSchema
|
||||
>;
|
||||
Reference in New Issue
Block a user