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
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { CalDavCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/caldav/services/caldav-create-event.service';
import { GoogleCalendarCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/google-calendar/services/google-calendar-create-event.service';
import { MicrosoftCalendarCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/microsoft-calendar/services/microsoft-calendar-create-event.service';
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';
import { CalDavDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module';
import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module';
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
@Module({
imports: [
TypeOrmModule.forFeature([ConnectedAccountEntity, CalendarChannelEntity]),
OAuth2ClientManagerModule,
CalendarEventImportManagerModule,
CalDavDriverModule,
],
providers: [
CalendarEventComposerService,
CreateCalendarEventService,
GoogleCalendarCreateEventService,
MicrosoftCalendarCreateEventService,
CalDavCreateEventService,
],
exports: [CalendarEventComposerService, CreateCalendarEventService],
})
export class CalendarEventCreationManagerModule {}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { CalendarEventCreationManagerModule } from 'src/modules/calendar/calendar-event-creation-manager/calendar-event-creation-manager.module';
import { CreateCalendarEventResolver } from 'src/modules/calendar/calendar-event-creation-manager/resolvers/create-calendar-event.resolver';
@Module({
imports: [
CalendarEventCreationManagerModule,
ConnectedAccountMetadataModule,
PermissionsModule,
],
providers: [CreateCalendarEventResolver],
})
export class CreateCalendarEventModule {}
@@ -0,0 +1,108 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CalDavCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/caldav/services/caldav-create-event.service';
import { CalendarEventCreationException } from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav-client.provider';
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
const connectedAccount = {
id: 'account-1',
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
};
const baseInput: CalendarEventToCreate = {
title: 'Sync',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
isFullDay: false,
timeZone: 'UTC',
attendees: [],
sendInvitations: false,
addConferencing: false,
};
const CALENDAR_URL = 'https://dav.example.com/calendars/jane/default/';
describe('CalDavCreateEventService', () => {
let service: CalDavCreateEventService;
const createCalendarObject = jest.fn();
const client = { createCalendarObject };
const getClient = jest.fn();
const listEventCalendars = jest.fn();
const fetchEventsByHrefs = jest.fn();
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CalDavCreateEventService,
{ provide: CalDavClientProvider, useValue: { getClient } },
{
provide: CalDavFetchEventsService,
useValue: { listEventCalendars, fetchEventsByHrefs },
},
],
}).compile();
service = module.get(CalDavCreateEventService);
getClient.mockResolvedValue(client);
listEventCalendars.mockResolvedValue([{ url: CALENDAR_URL }]);
createCalendarObject.mockResolvedValue({});
});
afterEach(() => jest.clearAllMocks());
it('keys the event on the server-returned href, not the locally reconstructed url', async () => {
const serverHref = '/calendars/jane/default/server-assigned.ics';
fetchEventsByHrefs.mockResolvedValue([
{ id: serverHref, iCalUid: 'whatever' },
]);
const result = await service.createCalendarEvent(
baseInput,
connectedAccount,
);
expect(createCalendarObject).toHaveBeenCalledTimes(1);
expect(fetchEventsByHrefs).toHaveBeenCalledWith(client, [
expect.stringMatching(/^https:\/\/dav\.example\.com\/.*\.ics$/),
]);
expect(result.id).toBe(serverHref);
});
it('falls back to the reconstructed href when the server copy is not retrievable', async () => {
fetchEventsByHrefs.mockResolvedValue([]);
const result = await service.createCalendarEvent(
baseInput,
connectedAccount,
);
expect(result.id).toMatch(
/^https:\/\/dav\.example\.com\/calendars\/jane\/default\/.*\.ics$/,
);
});
it('falls back to the reconstructed href when re-fetch fails, without failing the create', async () => {
fetchEventsByHrefs.mockRejectedValue(new Error('network'));
const result = await service.createCalendarEvent(
baseInput,
connectedAccount,
);
expect(result.id).toMatch(/\.ics$/);
});
it('throws when no writable calendar is available', async () => {
listEventCalendars.mockResolvedValue([]);
await expect(
service.createCalendarEvent(baseInput, connectedAccount),
).rejects.toThrow(CalendarEventCreationException);
});
});
@@ -0,0 +1,124 @@
import { Injectable } from '@nestjs/common';
import ical from 'ical-generator';
import { v4 as uuid } from 'uuid';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
CalendarEventCreationException,
CalendarEventCreationExceptionCode,
} from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { type CalendarEventCreationDriver } from 'src/modules/calendar/calendar-event-creation-manager/interfaces/calendar-event-creation-driver.interface';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav-client.provider';
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
@Injectable()
export class CalDavCreateEventService implements CalendarEventCreationDriver {
constructor(
private readonly calDavClientProvider: CalDavClientProvider,
private readonly calDavFetchEventsService: CalDavFetchEventsService,
) {}
async createCalendarEvent(
input: CalendarEventToCreate,
connectedAccount: Pick<ConnectedAccountEntity, 'id' | 'provider'>,
): Promise<FetchedCalendarEvent> {
try {
const client = await this.calDavClientProvider.getClient(
connectedAccount.id,
);
const [targetCalendar] =
await this.calDavFetchEventsService.listEventCalendars(client);
if (!targetCalendar) {
throw new CalendarEventCreationException(
'No writable CalDAV calendar found',
CalendarEventCreationExceptionCode.PROVIDER_REQUEST_FAILED,
);
}
const uid = uuid();
const calendar = ical({ prodId: '//Twenty//Calendar//EN' });
calendar.createEvent({
id: uid,
start: new Date(input.startsAt),
end: new Date(input.endsAt),
allDay: input.isFullDay,
summary: input.title,
description: input.description,
location: input.location,
attendees: input.attendees.map((attendee) => ({
email: attendee.email,
name: attendee.displayName,
})),
});
const filename = `${uid}.ics`;
await client.createCalendarObject({
calendar: targetCalendar,
filename,
iCalString: calendar.toString(),
});
// Source the resource href from the server through the same path the
// import keys on, so a later sync reconciles this event instead of
// duplicating it. Reconstructing the href locally risks a format mismatch
// (full URL vs the server-relative path the provider returns).
const reconstructedHref = new URL(filename, targetCalendar.url).href;
let id = reconstructedHref;
try {
const [syncedEvent] =
await this.calDavFetchEventsService.fetchEventsByHrefs(client, [
reconstructedHref,
]);
id = syncedEvent?.id ?? reconstructedHref;
} catch {
// The event was created; resolving its server href is best-effort, so
// fall back to the reconstructed href rather than failing the create.
}
const now = new Date().toISOString();
return {
id,
iCalUid: uid,
title: input.title,
description: input.description ?? '',
location: input.location ?? '',
startsAt: input.startsAt,
endsAt: input.endsAt,
isFullDay: input.isFullDay,
isCanceled: false,
status: 'CONFIRMED',
conferenceLinkLabel: '',
conferenceLinkUrl: '',
conferenceSolution: '',
externalCreatedAt: now,
externalUpdatedAt: now,
participants: input.attendees.map((attendee) => ({
displayName: attendee.displayName ?? '',
handle: attendee.email,
responseStatus: 'needsAction',
isOrganizer: false,
})),
};
} catch (error) {
if (error instanceof CalendarEventCreationException) {
throw error;
}
throw new CalendarEventCreationException(
`Failed to create CalDAV calendar event: ${error instanceof Error ? error.message : 'unknown error'}`,
CalendarEventCreationExceptionCode.PROVIDER_REQUEST_FAILED,
);
}
}
}
@@ -0,0 +1,132 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { google } from 'googleapis';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { GoogleCalendarCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/google-calendar/services/google-calendar-create-event.service';
import { CalendarEventCreationException } from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
import { GoogleOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client.provider';
const connectedAccount = {
id: 'account-1',
provider: ConnectedAccountProvider.GOOGLE,
};
const baseInput: CalendarEventToCreate = {
title: 'Sync',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
isFullDay: false,
timeZone: 'UTC',
attendees: [],
sendInvitations: false,
addConferencing: false,
};
describe('GoogleCalendarCreateEventService', () => {
let service: GoogleCalendarCreateEventService;
const insert = jest.fn();
const get = jest.fn();
beforeEach(async () => {
jest
.spyOn(google, 'calendar')
.mockReturnValue({ events: { insert, get } } as never);
const module: TestingModule = await Test.createTestingModule({
providers: [
GoogleCalendarCreateEventService,
{
provide: GoogleOAuth2ClientProvider,
useValue: { getClient: jest.fn().mockResolvedValue({}) },
},
],
}).compile();
service = module.get(GoogleCalendarCreateEventService);
insert.mockResolvedValue({
data: {
id: 'google-event-1',
iCalUID: 'google-event-1@google.com',
summary: 'Sync',
status: 'confirmed',
start: { dateTime: '2026-07-01T14:00:00Z' },
end: { dateTime: '2026-07-01T15:00:00Z' },
},
});
});
afterEach(() => {
insert.mockReset();
get.mockReset();
jest.restoreAllMocks();
});
it('inserts the event without notifications or conferencing by default', async () => {
const result = await service.createCalendarEvent(
baseInput,
connectedAccount,
);
expect(insert).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: 'primary',
conferenceDataVersion: 0,
sendUpdates: 'none',
requestBody: expect.objectContaining({ summary: 'Sync' }),
}),
);
expect(get).not.toHaveBeenCalled();
expect(result.id).toBe('google-event-1');
expect(result.iCalUid).toBe('google-event-1@google.com');
});
it('notifies attendees and requests conferencing when enabled', async () => {
await service.createCalendarEvent(
{ ...baseInput, sendInvitations: true, addConferencing: true },
connectedAccount,
);
expect(insert).toHaveBeenCalledWith(
expect.objectContaining({
conferenceDataVersion: 1,
sendUpdates: 'all',
}),
);
});
it('returns the Meet link from the insert response without re-fetching', async () => {
insert.mockResolvedValue({
data: {
id: 'google-event-1',
iCalUID: 'google-event-1@google.com',
status: 'confirmed',
start: { dateTime: '2026-07-01T14:00:00Z' },
end: { dateTime: '2026-07-01T15:00:00Z' },
conferenceData: {
entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }],
},
},
});
const result = await service.createCalendarEvent(
{ ...baseInput, addConferencing: true },
connectedAccount,
);
expect(get).not.toHaveBeenCalled();
expect(result.conferenceLinkUrl).toBe(
'https://meet.google.com/abc-defg-hij',
);
});
it('wraps provider errors in a CalendarEventCreationException', async () => {
insert.mockRejectedValue(new Error('boom'));
await expect(
service.createCalendarEvent(baseInput, connectedAccount),
).rejects.toThrow(CalendarEventCreationException);
});
});
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { google } from 'googleapis';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { formatGoogleCalendarEvents } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/utils/format-google-calendar-event.util';
import { toGoogleEventInput } from 'src/modules/calendar/calendar-event-creation-manager/drivers/utils/to-google-event-input.util';
import {
CalendarEventCreationException,
CalendarEventCreationExceptionCode,
} from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { type CalendarEventCreationDriver } from 'src/modules/calendar/calendar-event-creation-manager/interfaces/calendar-event-creation-driver.interface';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
import { GoogleOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client.provider';
const GOOGLE_CALENDAR_ID = 'primary';
@Injectable()
export class GoogleCalendarCreateEventService implements CalendarEventCreationDriver {
constructor(
private readonly googleOAuth2ClientProvider: GoogleOAuth2ClientProvider,
) {}
async createCalendarEvent(
input: CalendarEventToCreate,
connectedAccount: Pick<ConnectedAccountEntity, 'id' | 'provider'>,
): Promise<FetchedCalendarEvent> {
const oAuth2Client = await this.googleOAuth2ClientProvider.getClient(
connectedAccount.id,
);
const googleCalendarClient = google.calendar({
version: 'v3',
auth: oAuth2Client,
});
try {
const { data } = await googleCalendarClient.events.insert({
calendarId: GOOGLE_CALENDAR_ID,
conferenceDataVersion: input.addConferencing ? 1 : 0,
sendUpdates: input.sendInvitations ? 'all' : 'none',
requestBody: toGoogleEventInput(input),
});
return formatGoogleCalendarEvents([data])[0];
} catch (error) {
throw new CalendarEventCreationException(
`Failed to create Google calendar event: ${error instanceof Error ? error.message : 'unknown error'}`,
CalendarEventCreationExceptionCode.PROVIDER_REQUEST_FAILED,
);
}
}
}
@@ -0,0 +1,80 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { MicrosoftCalendarCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/microsoft-calendar/services/microsoft-calendar-create-event.service';
import { CalendarEventCreationException } from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
import { MicrosoftOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client.provider';
const connectedAccount = {
id: 'account-1',
provider: ConnectedAccountProvider.MICROSOFT,
};
const baseInput: CalendarEventToCreate = {
title: 'Sync',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
isFullDay: false,
timeZone: 'UTC',
attendees: [],
sendInvitations: false,
addConferencing: false,
};
describe('MicrosoftCalendarCreateEventService', () => {
let service: MicrosoftCalendarCreateEventService;
const post = jest.fn();
const header = jest.fn();
const request = { header, post };
header.mockReturnValue(request);
const api = jest.fn().mockReturnValue(request);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MicrosoftCalendarCreateEventService,
{
provide: MicrosoftOAuth2ClientProvider,
useValue: { getClient: jest.fn().mockResolvedValue({ api }) },
},
],
}).compile();
service = module.get(MicrosoftCalendarCreateEventService);
post.mockResolvedValue({
id: 'microsoft-event-1',
iCalUId: 'microsoft-event-1@outlook.com',
subject: 'Sync',
start: { dateTime: '2026-07-01T14:00:00.0000000', timeZone: 'UTC' },
end: { dateTime: '2026-07-01T15:00:00.0000000', timeZone: 'UTC' },
});
});
afterEach(() => jest.clearAllMocks());
it('posts the mapped event to the calendar events endpoint', async () => {
const result = await service.createCalendarEvent(
baseInput,
connectedAccount,
);
expect(api).toHaveBeenCalledWith('/me/calendar/events');
expect(header).toHaveBeenCalledWith('Prefer', 'outlook.timezone="UTC"');
expect(post).toHaveBeenCalledWith(
expect.objectContaining({ subject: 'Sync', isAllDay: false }),
);
expect(result.id).toBe('microsoft-event-1');
expect(result.iCalUid).toBe('microsoft-event-1@outlook.com');
});
it('wraps provider errors in a CalendarEventCreationException', async () => {
post.mockRejectedValue(new Error('boom'));
await expect(
service.createCalendarEvent(baseInput, connectedAccount),
).rejects.toThrow(CalendarEventCreationException);
});
});
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { type Event } from '@microsoft/microsoft-graph-types';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { formatMicrosoftCalendarEvents } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/format-microsoft-calendar-event.util';
import { toMicrosoftEventInput } from 'src/modules/calendar/calendar-event-creation-manager/drivers/utils/to-microsoft-event-input.util';
import {
CalendarEventCreationException,
CalendarEventCreationExceptionCode,
} from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { type CalendarEventCreationDriver } from 'src/modules/calendar/calendar-event-creation-manager/interfaces/calendar-event-creation-driver.interface';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
import { MicrosoftOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client.provider';
@Injectable()
export class MicrosoftCalendarCreateEventService implements CalendarEventCreationDriver {
constructor(
private readonly microsoftOAuth2ClientProvider: MicrosoftOAuth2ClientProvider,
) {}
async createCalendarEvent(
input: CalendarEventToCreate,
connectedAccount: Pick<ConnectedAccountEntity, 'id' | 'provider'>,
): Promise<FetchedCalendarEvent> {
const microsoftClient = await this.microsoftOAuth2ClientProvider.getClient(
connectedAccount.id,
);
try {
// Request the created event back in UTC so its start/end are absolute
// instants. Graph otherwise echoes the request time zone, which the shared
// formatter would persist as an ambiguous wall-clock time. This matches how
// the import path consumes Graph datetimes.
const createdEvent: Event = await microsoftClient
.api('/me/calendar/events')
.header('Prefer', 'outlook.timezone="UTC"')
.post(toMicrosoftEventInput(input));
return formatMicrosoftCalendarEvents([createdEvent])[0];
} catch (error) {
throw new CalendarEventCreationException(
`Failed to create Microsoft calendar event: ${error instanceof Error ? error.message : 'unknown error'}`,
CalendarEventCreationExceptionCode.PROVIDER_REQUEST_FAILED,
);
}
}
}
@@ -0,0 +1,65 @@
import { toGoogleEventInput } from 'src/modules/calendar/calendar-event-creation-manager/drivers/utils/to-google-event-input.util';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
const baseInput: CalendarEventToCreate = {
title: 'Team Sync',
description: 'Weekly sync',
location: 'Room A',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
isFullDay: false,
timeZone: 'America/New_York',
attendees: [],
sendInvitations: false,
addConferencing: false,
};
describe('toGoogleEventInput', () => {
it('maps a timed event with a dateTime and time zone', () => {
const event = toGoogleEventInput(baseInput);
expect(event).toMatchObject({
summary: 'Team Sync',
description: 'Weekly sync',
location: 'Room A',
start: { dateTime: '2026-07-01T14:00:00Z', timeZone: 'America/New_York' },
end: { dateTime: '2026-07-01T15:00:00Z', timeZone: 'America/New_York' },
});
expect(event.attendees).toBeUndefined();
expect(event.conferenceData).toBeUndefined();
});
it('maps an all-day event with date-only boundaries and no time zone', () => {
const event = toGoogleEventInput({
...baseInput,
isFullDay: true,
startsAt: '2026-07-01T00:00:00Z',
endsAt: '2026-07-02T00:00:00Z',
});
expect(event.start).toEqual({ date: '2026-07-01' });
expect(event.end).toEqual({ date: '2026-07-02' });
});
it('maps attendees when present', () => {
const event = toGoogleEventInput({
...baseInput,
attendees: [{ email: 'guest@example.com', displayName: 'Guest' }],
});
expect(event.attendees).toEqual([
{ email: 'guest@example.com', displayName: 'Guest' },
]);
});
it('adds a Google Meet conference request when conferencing is enabled', () => {
const event = toGoogleEventInput({ ...baseInput, addConferencing: true });
expect(event.conferenceData?.createRequest?.conferenceSolutionKey).toEqual({
type: 'hangoutsMeet',
});
expect(event.conferenceData?.createRequest?.requestId).toEqual(
expect.any(String),
);
});
});
@@ -0,0 +1,85 @@
import { toMicrosoftEventInput } from 'src/modules/calendar/calendar-event-creation-manager/drivers/utils/to-microsoft-event-input.util';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
const baseInput: CalendarEventToCreate = {
title: 'Team Sync',
description: 'Weekly sync',
location: 'Room A',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
isFullDay: false,
timeZone: 'America/New_York',
attendees: [],
sendInvitations: false,
addConferencing: false,
};
describe('toMicrosoftEventInput', () => {
it('converts the absolute instant to wall-clock in the event time zone (Graph ignores the offset)', () => {
// 14:00Z / 15:00Z in July is 10:00 / 11:00 in America/New_York (EDT, UTC-4).
const event = toMicrosoftEventInput(baseInput);
expect(event).toMatchObject({
subject: 'Team Sync',
body: { contentType: 'text', content: 'Weekly sync' },
start: { dateTime: '2026-07-01T10:00:00', timeZone: 'America/New_York' },
end: { dateTime: '2026-07-01T11:00:00', timeZone: 'America/New_York' },
isAllDay: false,
location: { displayName: 'Room A' },
});
expect(event.attendees).toBeUndefined();
expect(event.isOnlineMeeting).toBeUndefined();
});
it('keeps the wall-clock unchanged when the event time zone is UTC', () => {
const event = toMicrosoftEventInput({ ...baseInput, timeZone: 'UTC' });
expect(event.start).toEqual({
dateTime: '2026-07-01T14:00:00',
timeZone: 'UTC',
});
});
it('pins all-day events to midnight in the event time zone', () => {
const event = toMicrosoftEventInput({
...baseInput,
isFullDay: true,
startsAt: '2026-07-01T09:30:00Z',
endsAt: '2026-07-02T09:30:00Z',
});
expect(event.isAllDay).toBe(true);
expect(event.start).toEqual({
dateTime: '2026-07-01T00:00:00',
timeZone: 'America/New_York',
});
expect(event.end).toEqual({
dateTime: '2026-07-02T00:00:00',
timeZone: 'America/New_York',
});
});
it('maps attendees as required participants', () => {
const event = toMicrosoftEventInput({
...baseInput,
attendees: [{ email: 'guest@example.com', displayName: 'Guest' }],
});
expect(event.attendees).toEqual([
{
emailAddress: { address: 'guest@example.com', name: 'Guest' },
type: 'required',
},
]);
});
it('requests a Teams meeting when conferencing is enabled', () => {
const event = toMicrosoftEventInput({
...baseInput,
addConferencing: true,
});
expect(event.isOnlineMeeting).toBe(true);
expect(event.onlineMeetingProvider).toBe('teamsForBusiness');
});
});
@@ -0,0 +1,49 @@
import { type calendar_v3 as calendarV3 } from 'googleapis';
import { v4 as uuid } from 'uuid';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
// Google represents all-day events with a date-only boundary, and timed events
// with an RFC 3339 dateTime paired with an IANA time zone.
const toGoogleEventDateTime = (
isoDateTime: string,
isFullDay: boolean,
timeZone: string,
): calendarV3.Schema$EventDateTime =>
isFullDay
? { date: isoDateTime.slice(0, 10) }
: { dateTime: isoDateTime, timeZone };
export const toGoogleEventInput = (
input: CalendarEventToCreate,
): calendarV3.Schema$Event => {
const event: calendarV3.Schema$Event = {
summary: input.title,
description: input.description,
location: input.location,
start: toGoogleEventDateTime(
input.startsAt,
input.isFullDay,
input.timeZone,
),
end: toGoogleEventDateTime(input.endsAt, input.isFullDay, input.timeZone),
};
if (input.attendees.length > 0) {
event.attendees = input.attendees.map((attendee) => ({
email: attendee.email,
displayName: attendee.displayName,
}));
}
if (input.addConferencing) {
event.conferenceData = {
createRequest: {
requestId: uuid(),
conferenceSolutionKey: { type: 'hangoutsMeet' },
},
};
}
return event;
};
@@ -0,0 +1,76 @@
import { type Event } from '@microsoft/microsoft-graph-types';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
// Microsoft Graph interprets `dateTime` as a wall-clock time in the supplied
// `timeZone` and ignores any embedded offset, so an absolute instant must be
// converted to its wall-clock representation in the event time zone.
const toWallClockInTimeZone = (
isoInstant: string,
timeZone: string,
): string => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(new Date(isoInstant));
const part = (type: string) =>
parts.find((candidate) => candidate.type === type)?.value ?? '00';
return `${part('year')}-${part('month')}-${part('day')}T${part('hour')}:${part('minute')}:${part('second')}`;
};
// All-day events are timezone-agnostic dates pinned to midnight; timed events are
// absolute instants expressed as wall-clock in the event time zone.
const toMicrosoftEventDateTime = (
isoDateTime: string,
isFullDay: boolean,
timeZone: string,
) => ({
dateTime: isFullDay
? `${isoDateTime.slice(0, 10)}T00:00:00`
: toWallClockInTimeZone(isoDateTime, timeZone),
timeZone,
});
export const toMicrosoftEventInput = (input: CalendarEventToCreate): Event => {
const event: Event = {
subject: input.title,
body: { contentType: 'text', content: input.description ?? '' },
start: toMicrosoftEventDateTime(
input.startsAt,
input.isFullDay,
input.timeZone,
),
end: toMicrosoftEventDateTime(
input.endsAt,
input.isFullDay,
input.timeZone,
),
isAllDay: input.isFullDay,
};
if (input.location) {
event.location = { displayName: input.location };
}
if (input.attendees.length > 0) {
event.attendees = input.attendees.map((attendee) => ({
emailAddress: { address: attendee.email, name: attendee.displayName },
type: 'required',
}));
}
if (input.addConferencing) {
event.isOnlineMeeting = true;
event.onlineMeetingProvider = 'teamsForBusiness';
}
return event;
};
@@ -0,0 +1,17 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('CreateCalendarEventOutput')
export class CreateCalendarEventOutputDTO {
@Field(() => Boolean)
success: boolean;
// Stable cross-provider identifier; query the created event in Twenty by iCalUid.
@Field(() => String, { nullable: true })
iCalUid?: string;
@Field(() => String, { nullable: true })
conferenceLink?: string;
@Field(() => String, { nullable: true })
error?: string;
}
@@ -0,0 +1,38 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class CreateCalendarEventInput {
@Field(() => String)
connectedAccountId: string;
@Field(() => String)
title: string;
@Field(() => String, { nullable: true })
description?: string;
@Field(() => String, { nullable: true })
location?: string;
@Field(() => String)
startsAt: string;
@Field(() => String)
endsAt: string;
@Field(() => Boolean, { nullable: true })
isFullDay?: boolean;
@Field(() => String, { nullable: true })
timeZone?: string;
// Comma-separated attendee email addresses.
@Field(() => String, { nullable: true })
attendees?: string;
@Field(() => Boolean, { nullable: true })
sendInvitations?: boolean;
@Field(() => Boolean, { nullable: true })
addConferencing?: boolean;
}
@@ -0,0 +1,37 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum CalendarEventCreationExceptionCode {
PROVIDER_NOT_SUPPORTED = 'PROVIDER_NOT_SUPPORTED',
PROVIDER_REQUEST_FAILED = 'PROVIDER_REQUEST_FAILED',
}
const getCalendarEventCreationExceptionUserFriendlyMessage = (
code: CalendarEventCreationExceptionCode,
) => {
switch (code) {
case CalendarEventCreationExceptionCode.PROVIDER_NOT_SUPPORTED:
return msg`Calendar event creation is not supported for this account.`;
case CalendarEventCreationExceptionCode.PROVIDER_REQUEST_FAILED:
return msg`The calendar provider rejected the event creation request.`;
default:
assertUnreachable(code);
}
};
export class CalendarEventCreationException extends CustomException<CalendarEventCreationExceptionCode> {
constructor(
message: string,
code: CalendarEventCreationExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getCalendarEventCreationExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,10 @@
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
export type CalendarEventCreationDriver = {
createCalendarEvent(
input: CalendarEventToCreate,
connectedAccount: Pick<ConnectedAccountEntity, 'id' | 'provider'>,
): Promise<FetchedCalendarEvent>;
};
@@ -0,0 +1,112 @@
import {
ForbiddenException,
Logger,
UseFilters,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { CreateCalendarEventOutputDTO } from 'src/modules/calendar/calendar-event-creation-manager/dtos/create-calendar-event-output.dto';
import { CreateCalendarEventInput } from 'src/modules/calendar/calendar-event-creation-manager/dtos/create-calendar-event.input';
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';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(AuthGraphqlApiExceptionFilter)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.CREATE_CALENDAR_EVENT_TOOL),
)
export class CreateCalendarEventResolver {
private readonly logger = new Logger(CreateCalendarEventResolver.name);
constructor(
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly calendarEventComposerService: CalendarEventComposerService,
private readonly createCalendarEventService: CreateCalendarEventService,
) {}
@Mutation(() => CreateCalendarEventOutputDTO)
async createCalendarEvent(
@Args('input') input: CreateCalendarEventInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<CreateCalendarEventOutputDTO> {
try {
await this.connectedAccountMetadataService.verifyOwnership({
id: input.connectedAccountId,
userWorkspaceId,
workspaceId: workspace.id,
});
const result =
await this.calendarEventComposerService.composeCalendarEvent(
{
connectedAccountId: input.connectedAccountId,
title: input.title,
description: input.description,
location: input.location,
startsAt: input.startsAt,
endsAt: input.endsAt,
isFullDay: input.isFullDay,
timeZone: input.timeZone,
attendees: input.attendees,
sendInvitations: input.sendInvitations,
addConferencing: input.addConferencing,
},
workspace.id,
);
if (!result.success) {
return {
success: false,
error: result.error,
};
}
const createdEvent =
await this.createCalendarEventService.createComposedCalendarEvent(
result.data,
);
await this.createCalendarEventService.persistCalendarEvent(
createdEvent,
result.data,
workspace.id,
);
return {
success: true,
iCalUid: createdEvent.iCalUid || undefined,
conferenceLink: createdEvent.conferenceLinkUrl || undefined,
};
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Failed to create calendar event: ${error}`);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Failed to create calendar event',
};
}
}
}
@@ -0,0 +1,255 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { CalendarEventComposerService } from 'src/modules/calendar/calendar-event-creation-manager/services/calendar-event-composer.service';
import { type ComposeCalendarEventParams } from 'src/modules/calendar/calendar-event-creation-manager/types/compose-calendar-event-params.type';
const GOOGLE_SCOPE = 'https://www.googleapis.com/auth/calendar.events';
const WORKSPACE_ID = 'workspace-1';
const ACCOUNT_ID = '11111111-1111-4111-8111-111111111111';
const googleAccount = {
id: ACCOUNT_ID,
provider: ConnectedAccountProvider.GOOGLE,
scopes: [GOOGLE_SCOPE],
archivedAt: null,
} as ConnectedAccountEntity;
const calendarChannel = { id: 'channel-1' } as CalendarChannelEntity;
const validParams: ComposeCalendarEventParams = {
title: 'Sync',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
connectedAccountId: ACCOUNT_ID,
};
describe('CalendarEventComposerService', () => {
let service: CalendarEventComposerService;
const connectedAccountFindOne = jest.fn();
const calendarChannelFindOne = jest.fn();
const calendarChannelFind = jest.fn();
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CalendarEventComposerService,
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: { findOne: connectedAccountFindOne },
},
{
provide: getRepositoryToken(CalendarChannelEntity),
useValue: {
findOne: calendarChannelFindOne,
find: calendarChannelFind,
},
},
],
}).compile();
service = module.get(CalendarEventComposerService);
connectedAccountFindOne.mockResolvedValue(googleAccount);
calendarChannelFindOne.mockResolvedValue(calendarChannel);
});
afterEach(() => jest.clearAllMocks());
it('rejects an event without a title', async () => {
const result = await service.composeCalendarEvent(
{ ...validParams, title: '' },
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('rejects when endsAt is not after startsAt', async () => {
const result = await service.composeCalendarEvent(
{ ...validParams, endsAt: validParams.startsAt },
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('rejects a whitespace-only title', async () => {
const result = await service.composeCalendarEvent(
{ ...validParams, title: ' ' },
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('rejects a timed event without a UTC offset', async () => {
const result = await service.composeCalendarEvent(
{
...validParams,
startsAt: '2026-07-01T14:00:00',
endsAt: '2026-07-01T15:00:00',
},
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('rejects a date-only value for a timed event', async () => {
const result = await service.composeCalendarEvent(
{ ...validParams, startsAt: '2026-07-01', endsAt: '2026-07-02' },
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('rejects an all-day event whose start and end fall on the same day', async () => {
const result = await service.composeCalendarEvent(
{
...validParams,
isFullDay: true,
startsAt: '2026-07-01T00:00:00Z',
endsAt: '2026-07-01T12:00:00Z',
},
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('accepts a valid multi-day all-day event', async () => {
const result = await service.composeCalendarEvent(
{
...validParams,
isFullDay: true,
startsAt: '2026-07-01',
endsAt: '2026-07-03',
},
WORKSPACE_ID,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.input.isFullDay).toBe(true);
}
});
it('rejects an invalid time zone', async () => {
const result = await service.composeCalendarEvent(
{ ...validParams, timeZone: 'Not/AZone' },
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('rejects invalid attendee emails when sending invitations', async () => {
const result = await service.composeCalendarEvent(
{
...validParams,
sendInvitations: true,
attendees: 'not-an-email',
},
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('strips attendees entirely when invitations are not requested', async () => {
const result = await service.composeCalendarEvent(
{
...validParams,
sendInvitations: false,
attendees: 'guest@example.com',
},
WORKSPACE_ID,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.input.attendees).toEqual([]);
}
});
it('parses comma-separated attendees when sending invitations', async () => {
const result = await service.composeCalendarEvent(
{
...validParams,
sendInvitations: true,
attendees: 'a@example.com, b@example.com',
},
WORKSPACE_ID,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.input.attendees).toEqual([
{ email: 'a@example.com' },
{ email: 'b@example.com' },
]);
}
});
it('fails when the connected account does not exist', async () => {
connectedAccountFindOne.mockResolvedValue(null);
const result = await service.composeCalendarEvent(
validParams,
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('fails when the account is missing the calendar write scope', async () => {
connectedAccountFindOne.mockResolvedValue({
...googleAccount,
scopes: ['email'],
});
const result = await service.composeCalendarEvent(
validParams,
WORKSPACE_ID,
);
expect(result.success).toBe(false);
});
it('resolves the account and channel for a valid request', async () => {
const result = await service.composeCalendarEvent(
validParams,
WORKSPACE_ID,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.connectedAccount.id).toBe(ACCOUNT_ID);
expect(result.data.calendarChannel.id).toBe('channel-1');
expect(result.data.input.timeZone).toBe('UTC');
}
});
it('falls back to the first calendar-capable account when none is specified', async () => {
calendarChannelFind.mockResolvedValue([
{ ...calendarChannel, connectedAccount: googleAccount },
]);
const result = await service.composeCalendarEvent(
{ ...validParams, connectedAccountId: undefined },
WORKSPACE_ID,
);
expect(calendarChannelFind).toHaveBeenCalled();
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.connectedAccount.id).toBe(ACCOUNT_ID);
}
});
});
@@ -0,0 +1,140 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CalDavCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/caldav/services/caldav-create-event.service';
import { GoogleCalendarCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/google-calendar/services/google-calendar-create-event.service';
import { MicrosoftCalendarCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/microsoft-calendar/services/microsoft-calendar-create-event.service';
import { CalendarEventCreationException } from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { CreateCalendarEventService } from 'src/modules/calendar/calendar-event-creation-manager/services/create-calendar-event.service';
import { type ComposedCalendarEvent } from 'src/modules/calendar/calendar-event-creation-manager/types/composed-calendar-event.type';
import { CalendarSaveEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
const composedEvent = (
provider: ConnectedAccountProvider,
): ComposedCalendarEvent =>
({
input: {
title: 'Sync',
startsAt: '2026-07-01T14:00:00Z',
endsAt: '2026-07-01T15:00:00Z',
isFullDay: false,
timeZone: 'UTC',
attendees: [],
sendInvitations: false,
addConferencing: false,
},
connectedAccount: { id: 'account-1', provider },
calendarChannel: { id: 'channel-1' },
}) as unknown as ComposedCalendarEvent;
const createdEvent = { id: 'event-1' } as FetchedCalendarEvent;
describe('CreateCalendarEventService', () => {
let service: CreateCalendarEventService;
const googleCreate = jest.fn();
const microsoftCreate = jest.fn();
const calDavCreate = jest.fn();
const saveCalendarEvents = jest.fn();
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CreateCalendarEventService,
{
provide: GoogleCalendarCreateEventService,
useValue: { createCalendarEvent: googleCreate },
},
{
provide: MicrosoftCalendarCreateEventService,
useValue: { createCalendarEvent: microsoftCreate },
},
{
provide: CalDavCreateEventService,
useValue: { createCalendarEvent: calDavCreate },
},
{
provide: CalendarSaveEventsService,
useValue: {
saveCalendarEventsAndEnqueueContactCreationJob: saveCalendarEvents,
},
},
],
}).compile();
service = module.get(CreateCalendarEventService);
});
afterEach(() => jest.clearAllMocks());
it('routes Google accounts to the Google driver', async () => {
const data = composedEvent(ConnectedAccountProvider.GOOGLE);
await service.createComposedCalendarEvent(data);
expect(googleCreate).toHaveBeenCalledWith(
data.input,
data.connectedAccount,
);
expect(microsoftCreate).not.toHaveBeenCalled();
});
it('routes Microsoft accounts to the Microsoft driver', async () => {
const data = composedEvent(ConnectedAccountProvider.MICROSOFT);
await service.createComposedCalendarEvent(data);
expect(microsoftCreate).toHaveBeenCalledWith(
data.input,
data.connectedAccount,
);
expect(googleCreate).not.toHaveBeenCalled();
});
it('routes CalDAV accounts to the CalDav driver', async () => {
const data = composedEvent(ConnectedAccountProvider.IMAP_SMTP_CALDAV);
await service.createComposedCalendarEvent(data);
expect(calDavCreate).toHaveBeenCalledWith(
data.input,
data.connectedAccount,
);
expect(googleCreate).not.toHaveBeenCalled();
expect(microsoftCreate).not.toHaveBeenCalled();
});
it('throws for unsupported providers', async () => {
await expect(
service.createComposedCalendarEvent(
composedEvent(ConnectedAccountProvider.EMAIL_GROUP),
),
).rejects.toThrow(CalendarEventCreationException);
});
it('persists the created event through the save service', async () => {
const data = composedEvent(ConnectedAccountProvider.GOOGLE);
await service.persistCalendarEvent(createdEvent, data, 'workspace-1');
expect(saveCalendarEvents).toHaveBeenCalledWith(
[createdEvent],
data.calendarChannel,
data.connectedAccount,
'workspace-1',
);
});
it('swallows persistence failures so the create still succeeds', async () => {
saveCalendarEvents.mockRejectedValue(new Error('db down'));
await expect(
service.persistCalendarEvent(
createdEvent,
composedEvent(ConnectedAccountProvider.GOOGLE),
'workspace-1',
),
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,272 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { z } from 'zod';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { getMissingCreateEventScopes } from 'src/modules/calendar/calendar-event-creation-manager/utils/get-missing-create-event-scopes.util';
import { isCalendarCreationSupportedProvider } from 'src/modules/calendar/calendar-event-creation-manager/utils/is-calendar-creation-supported-provider.util';
import { isValidTimeZone } from 'src/modules/calendar/calendar-event-creation-manager/utils/is-valid-time-zone.util';
import { type CalendarEventComposerResult } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-composer-result.type';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
import { type ComposeCalendarEventParams } from 'src/modules/calendar/calendar-event-creation-manager/types/compose-calendar-event-params.type';
// Timed events need an absolute instant, so the date-time must carry an explicit
// UTC offset (Z or ±hh:mm); without one the instant is ambiguous and providers
// would schedule it at the wrong time. All-day boundaries are calendar dates.
const offsetDateTimeSchema = z.string().datetime({ offset: true });
const dateSchema = z.string().date();
type ResolvedCalendarAccount =
| {
connectedAccount: ConnectedAccountEntity;
calendarChannel: CalendarChannelEntity;
}
| { error: string };
@Injectable()
export class CalendarEventComposerService {
private readonly emailSchema = z.string().trim().pipe(z.email());
constructor(
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(CalendarChannelEntity)
private readonly calendarChannelRepository: Repository<CalendarChannelEntity>,
) {}
async composeCalendarEvent(
params: ComposeCalendarEventParams,
workspaceId: string,
): Promise<CalendarEventComposerResult> {
const normalizedInput = this.normalizeAndValidateInput(params);
if ('error' in normalizedInput) {
return { success: false, error: normalizedInput.error };
}
const resolution = await this.resolveCalendarAccount(
params.connectedAccountId,
workspaceId,
);
if ('error' in resolution) {
return { success: false, error: resolution.error };
}
const { connectedAccount, calendarChannel } = resolution;
const missingScopes = getMissingCreateEventScopes(connectedAccount);
if (missingScopes.length > 0) {
return {
success: false,
error: `The connected ${connectedAccount.provider} account is missing calendar permissions (${missingScopes.join(', ')}). Please reconnect the account to grant calendar access.`,
};
}
return {
success: true,
data: { input: normalizedInput, connectedAccount, calendarChannel },
};
}
private normalizeAndValidateInput(
params: ComposeCalendarEventParams,
): CalendarEventToCreate | { error: string } {
const title = params.title?.trim();
if (!isNonEmptyString(title)) {
return { error: 'A title is required to create a calendar event' };
}
const isFullDay = params.isFullDay ?? false;
const datesError = this.validateDates(
params.startsAt,
params.endsAt,
isFullDay,
);
if (isDefined(datesError)) {
return { error: datesError };
}
const timeZone = params.timeZone ?? 'UTC';
if (!isValidTimeZone(timeZone)) {
return { error: `timeZone '${timeZone}' is not a valid IANA time zone` };
}
const sendInvitations = params.sendInvitations ?? false;
// Attendees are only ever attached when the caller explicitly opts in to
// notifying them, so creating an event never silently emails external people.
const attendeeEmails = sendInvitations
? this.parseAttendeeEmails(params.attendees)
: [];
if (attendeeEmails.length > MAX_EMAIL_RECIPIENTS) {
return {
error: `Too many attendees: ${attendeeEmails.length}. Maximum allowed is ${MAX_EMAIL_RECIPIENTS}.`,
};
}
const invalidAttendees = attendeeEmails.filter(
(email) => !this.emailSchema.safeParse(email).success,
);
if (invalidAttendees.length > 0) {
return {
error: `Invalid attendee email addresses: ${invalidAttendees.join(', ')}`,
};
}
return {
title,
description: params.description,
location: params.location,
startsAt: params.startsAt,
endsAt: params.endsAt,
isFullDay,
timeZone,
attendees: attendeeEmails.map((email) => ({ email })),
sendInvitations,
addConferencing: params.addConferencing ?? false,
};
}
// All-day boundaries collapse to a date, so they must be validated at day
// granularity; timed boundaries are absolute instants and must carry an offset.
private validateDates(
startsAt: string,
endsAt: string,
isFullDay: boolean,
): string | undefined {
if (isFullDay) {
const startDate = startsAt.slice(0, 10);
const endDate = endsAt.slice(0, 10);
if (
!dateSchema.safeParse(startDate).success ||
!dateSchema.safeParse(endDate).success
) {
return 'startsAt and endsAt must be valid ISO 8601 dates';
}
if (endDate <= startDate) {
return 'endsAt must be a later day than startsAt for all-day events';
}
return undefined;
}
if (
!offsetDateTimeSchema.safeParse(startsAt).success ||
!offsetDateTimeSchema.safeParse(endsAt).success
) {
return 'startsAt and endsAt must be ISO 8601 date-times with an offset (e.g. 2026-07-01T15:00:00Z)';
}
if (Date.parse(endsAt) <= Date.parse(startsAt)) {
return 'endsAt must be after startsAt';
}
return undefined;
}
private parseAttendeeEmails(attendees: string | undefined): string[] {
return (attendees ?? '')
.split(',')
.map((email) => email.trim())
.filter((email) => email.length > 0);
}
private async resolveCalendarAccount(
connectedAccountId: string | undefined,
workspaceId: string,
): Promise<ResolvedCalendarAccount> {
// A blank id (the workflow node's default) falls back to the default account.
if (isNonEmptyString(connectedAccountId)) {
if (!isValidUuid(connectedAccountId)) {
return { error: 'The provided connectedAccountId is not a valid UUID' };
}
const connectedAccount = await this.connectedAccountRepository.findOne({
where: { id: connectedAccountId, workspaceId },
});
if (!isDefined(connectedAccount)) {
return {
error: `No connected account found for id '${connectedAccountId}'`,
};
}
if (!isCalendarCreationSupportedProvider(connectedAccount.provider)) {
return {
error: `Calendar event creation is only supported for Google, Microsoft and CalDAV accounts (got ${connectedAccount.provider})`,
};
}
const calendarChannel = await this.findSyncEnabledCalendarChannel(
connectedAccount.id,
workspaceId,
);
if (!isDefined(calendarChannel)) {
return {
error: `Connected account '${connectedAccountId}' has no calendar channel with sync enabled. Enable calendar sync for this account first.`,
};
}
return { connectedAccount, calendarChannel };
}
return this.resolveDefaultCalendarAccount(workspaceId);
}
// Only sync-enabled channels are eligible: a created event is reconciled by the
// provider sync, which skips channels whose sync is disabled.
private async resolveDefaultCalendarAccount(
workspaceId: string,
): Promise<ResolvedCalendarAccount> {
const calendarChannels = await this.calendarChannelRepository.find({
where: { workspaceId, isSyncEnabled: true },
relations: { connectedAccount: true },
order: { createdAt: 'ASC' },
});
const calendarChannel = calendarChannels.find(
(channel) =>
isDefined(channel.connectedAccount) &&
!isDefined(channel.connectedAccount.archivedAt) &&
isCalendarCreationSupportedProvider(channel.connectedAccount.provider),
);
if (!isDefined(calendarChannel)) {
return {
error:
'No Google, Microsoft or CalDAV account with calendar sync is connected in this workspace',
};
}
return {
connectedAccount: calendarChannel.connectedAccount,
calendarChannel,
};
}
private async findSyncEnabledCalendarChannel(
connectedAccountId: string,
workspaceId: string,
): Promise<CalendarChannelEntity | null> {
return this.calendarChannelRepository.findOne({
where: { connectedAccountId, workspaceId, isSyncEnabled: true },
order: { createdAt: 'ASC' },
});
}
}
@@ -0,0 +1,75 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CalDavCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/caldav/services/caldav-create-event.service';
import { GoogleCalendarCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/google-calendar/services/google-calendar-create-event.service';
import { MicrosoftCalendarCreateEventService } from 'src/modules/calendar/calendar-event-creation-manager/drivers/microsoft-calendar/services/microsoft-calendar-create-event.service';
import {
CalendarEventCreationException,
CalendarEventCreationExceptionCode,
} from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { CalendarSaveEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service';
import { type ComposedCalendarEvent } from 'src/modules/calendar/calendar-event-creation-manager/types/composed-calendar-event.type';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
@Injectable()
export class CreateCalendarEventService {
private readonly logger = new Logger(CreateCalendarEventService.name);
constructor(
private readonly googleCalendarCreateEventService: GoogleCalendarCreateEventService,
private readonly microsoftCalendarCreateEventService: MicrosoftCalendarCreateEventService,
private readonly calDavCreateEventService: CalDavCreateEventService,
private readonly calendarSaveEventsService: CalendarSaveEventsService,
) {}
async createComposedCalendarEvent(
data: ComposedCalendarEvent,
): Promise<FetchedCalendarEvent> {
switch (data.connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.googleCalendarCreateEventService.createCalendarEvent(
data.input,
data.connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftCalendarCreateEventService.createCalendarEvent(
data.input,
data.connectedAccount,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.calDavCreateEventService.createCalendarEvent(
data.input,
data.connectedAccount,
);
default:
throw new CalendarEventCreationException(
`Calendar event creation is not supported for provider ${data.connectedAccount.provider}`,
CalendarEventCreationExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
}
// Persist the created event right away so it is immediately visible in Twenty.
// The next provider sync reconciles it via its external id, so a persistence
// failure here is non-fatal.
async persistCalendarEvent(
createdEvent: FetchedCalendarEvent,
data: ComposedCalendarEvent,
workspaceId: string,
): Promise<void> {
try {
await this.calendarSaveEventsService.saveCalendarEventsAndEnqueueContactCreationJob(
[createdEvent],
data.calendarChannel,
data.connectedAccount,
workspaceId,
);
} catch (persistenceError) {
this.logger.warn(
`Failed to persist created calendar event (sync will recover): ${persistenceError}`,
);
}
}
}
@@ -0,0 +1,4 @@
export type CalendarEventAttendeeToCreate = {
email: string;
displayName?: string;
};
@@ -0,0 +1,5 @@
import { type ComposedCalendarEvent } from 'src/modules/calendar/calendar-event-creation-manager/types/composed-calendar-event.type';
export type CalendarEventComposerResult =
| { success: true; data: ComposedCalendarEvent }
| { success: false; error: string };
@@ -0,0 +1,14 @@
import { type CalendarEventAttendeeToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-attendee-to-create.type';
export type CalendarEventToCreate = {
title: string;
description?: string;
location?: string;
startsAt: string;
endsAt: string;
isFullDay: boolean;
timeZone: string;
attendees: CalendarEventAttendeeToCreate[];
sendInvitations: boolean;
addConferencing: boolean;
};
@@ -0,0 +1,13 @@
export type ComposeCalendarEventParams = {
title: string;
description?: string;
location?: string;
startsAt: string;
endsAt: string;
isFullDay?: boolean;
timeZone?: string;
attendees?: string;
sendInvitations?: boolean;
addConferencing?: boolean;
connectedAccountId?: string;
};
@@ -0,0 +1,9 @@
import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type CalendarEventToCreate } from 'src/modules/calendar/calendar-event-creation-manager/types/calendar-event-to-create.type';
export type ComposedCalendarEvent = {
input: CalendarEventToCreate;
connectedAccount: ConnectedAccountEntity;
calendarChannel: CalendarChannelEntity;
};
@@ -0,0 +1,62 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { getMissingCreateEventScopes } from 'src/modules/calendar/calendar-event-creation-manager/utils/get-missing-create-event-scopes.util';
const GOOGLE_SCOPE = 'https://www.googleapis.com/auth/calendar.events';
const MICROSOFT_SCOPE = 'Calendars.ReadWrite';
describe('getMissingCreateEventScopes', () => {
it('returns no missing scope when Google has calendar.events', () => {
expect(
getMissingCreateEventScopes({
provider: ConnectedAccountProvider.GOOGLE,
scopes: ['email', GOOGLE_SCOPE],
}),
).toEqual([]);
});
it('reports the Google calendar.events scope when missing', () => {
expect(
getMissingCreateEventScopes({
provider: ConnectedAccountProvider.GOOGLE,
scopes: ['email'],
}),
).toEqual([GOOGLE_SCOPE]);
});
it('returns no missing scope when Microsoft has Calendars.ReadWrite', () => {
expect(
getMissingCreateEventScopes({
provider: ConnectedAccountProvider.MICROSOFT,
scopes: [MICROSOFT_SCOPE],
}),
).toEqual([]);
});
it('reports the Microsoft Calendars.ReadWrite scope when missing', () => {
expect(
getMissingCreateEventScopes({
provider: ConnectedAccountProvider.MICROSOFT,
scopes: ['Calendars.Read'],
}),
).toEqual([MICROSOFT_SCOPE]);
});
it('treats null scopes as missing', () => {
expect(
getMissingCreateEventScopes({
provider: ConnectedAccountProvider.GOOGLE,
scopes: null,
}),
).toEqual([GOOGLE_SCOPE]);
});
it('does not require OAuth scopes for non-OAuth providers', () => {
expect(
getMissingCreateEventScopes({
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
scopes: null,
}),
).toEqual([]);
});
});
@@ -0,0 +1,41 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
const GOOGLE_CALENDAR_EVENTS_SCOPE =
'https://www.googleapis.com/auth/calendar.events';
const MICROSOFT_CALENDARS_READ_WRITE_SCOPE = 'Calendars.ReadWrite';
export const getMissingCreateEventScopes = (connectedAccount: {
provider: ConnectedAccountProvider;
scopes: string[] | null;
}): string[] => {
const scopes = connectedAccount.scopes;
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE: {
const hasScope =
isDefined(scopes) && scopes.includes(GOOGLE_CALENDAR_EVENTS_SCOPE);
return hasScope ? [] : [GOOGLE_CALENDAR_EVENTS_SCOPE];
}
case ConnectedAccountProvider.MICROSOFT: {
const hasScope =
isDefined(scopes) &&
scopes.includes(MICROSOFT_CALENDARS_READ_WRITE_SCOPE);
return hasScope ? [] : [MICROSOFT_CALENDARS_READ_WRITE_SCOPE];
}
// Non-OAuth providers do not rely on OAuth scopes to create events.
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.APP:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
return [];
default:
return assertUnreachable(
connectedAccount.provider,
`Unhandled connected account provider for create event scopes: ${connectedAccount.provider}`,
);
}
};
@@ -0,0 +1,8 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
export const isCalendarCreationSupportedProvider = (
provider: ConnectedAccountProvider,
): boolean =>
provider === ConnectedAccountProvider.GOOGLE ||
provider === ConnectedAccountProvider.MICROSOFT ||
provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV;
@@ -0,0 +1,9 @@
export const isValidTimeZone = (timeZone: string): boolean => {
try {
new Intl.DateTimeFormat('en-US', { timeZone });
return true;
} catch {
return false;
}
};
@@ -93,6 +93,7 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
exports: [
CalendarEventsImportService,
CalendarFetchEventsService,
CalendarSaveEventsService,
CalendarEventListFetchCronCommand,
CalendarEventsImportCronCommand,
CalendarOngoingStaleCronCommand,
@@ -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],