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