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:
@@ -35,6 +35,8 @@ export { baseWorkflowActionSchema } from './schemas/base-workflow-action-schema'
|
||||
export { baseWorkflowActionSettingsSchema } from './schemas/base-workflow-action-settings-schema';
|
||||
export { workflowCodeActionSchema } from './schemas/code-action-schema';
|
||||
export { workflowCodeActionSettingsSchema } from './schemas/code-action-settings-schema';
|
||||
export { workflowCreateCalendarEventActionSchema } from './schemas/create-calendar-event-action-schema';
|
||||
export { workflowCreateCalendarEventActionSettingsSchema } from './schemas/create-calendar-event-action-settings-schema';
|
||||
export { workflowCreateRecordActionSchema } from './schemas/create-record-action-schema';
|
||||
export { workflowCreateRecordActionSettingsSchema } from './schemas/create-record-action-settings-schema';
|
||||
export { workflowCronTriggerSchema } from './schemas/cron-trigger-schema';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { baseWorkflowActionSchema } from './base-workflow-action-schema';
|
||||
import { workflowCreateCalendarEventActionSettingsSchema } from './create-calendar-event-action-settings-schema';
|
||||
|
||||
export const workflowCreateCalendarEventActionSchema =
|
||||
baseWorkflowActionSchema.extend({
|
||||
type: z.literal('CREATE_CALENDAR_EVENT'),
|
||||
settings: workflowCreateCalendarEventActionSettingsSchema,
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
|
||||
|
||||
export const workflowCreateCalendarEventActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
connectedAccountId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
startsAt: z.string(),
|
||||
endsAt: z.string(),
|
||||
isFullDay: z.boolean(),
|
||||
timeZone: z.string().optional(),
|
||||
attendees: z.string().optional().default(''),
|
||||
sendInvitations: z.boolean(),
|
||||
addConferencing: z.boolean(),
|
||||
}),
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { workflowAiAgentActionSchema } from './ai-agent-action-schema';
|
||||
import { workflowCodeActionSchema } from './code-action-schema';
|
||||
import { workflowCreateCalendarEventActionSchema } from './create-calendar-event-action-schema';
|
||||
import { workflowCreateRecordActionSchema } from './create-record-action-schema';
|
||||
import { workflowDeleteRecordActionSchema } from './delete-record-action-schema';
|
||||
import { workflowDraftEmailActionSchema } from './draft-email-action-schema';
|
||||
@@ -23,6 +24,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [
|
||||
workflowLogicFunctionActionSchema,
|
||||
workflowSendEmailActionSchema,
|
||||
workflowDraftEmailActionSchema,
|
||||
workflowCreateCalendarEventActionSchema,
|
||||
workflowCreateRecordActionSchema,
|
||||
workflowUpdateRecordActionSchema,
|
||||
workflowDeleteRecordActionSchema,
|
||||
|
||||
@@ -96,11 +96,26 @@ const emailStepLogDetailsSchema = z.object({
|
||||
durationMs: z.number(),
|
||||
});
|
||||
|
||||
const createCalendarEventStepLogDetailsSchema = z.object({
|
||||
type: z.literal('CREATE_CALENDAR_EVENT'),
|
||||
status: z.enum(['SUCCESS', 'ERROR']),
|
||||
title: z.string().optional(),
|
||||
startsAt: z.string().optional(),
|
||||
endsAt: z.string().optional(),
|
||||
attendeeCount: z.number().optional(),
|
||||
conferenceLink: z.string().optional(),
|
||||
connectedAccountId: z.string().optional(),
|
||||
iCalUid: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
durationMs: z.number(),
|
||||
});
|
||||
|
||||
const stepLogDetailsSchema = z.discriminatedUnion('type', [
|
||||
aiAgentStepLogDetailsSchema,
|
||||
codeStepLogDetailsSchema,
|
||||
httpRequestStepLogDetailsSchema,
|
||||
emailStepLogDetailsSchema,
|
||||
createCalendarEventStepLogDetailsSchema,
|
||||
]);
|
||||
|
||||
export const workflowRunStepLogSchema = z.object({
|
||||
|
||||
@@ -3,6 +3,7 @@ export enum WorkflowActionType {
|
||||
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
|
||||
SEND_EMAIL = 'SEND_EMAIL',
|
||||
DRAFT_EMAIL = 'DRAFT_EMAIL',
|
||||
CREATE_CALENDAR_EVENT = 'CREATE_CALENDAR_EVENT',
|
||||
CREATE_RECORD = 'CREATE_RECORD',
|
||||
UPDATE_RECORD = 'UPDATE_RECORD',
|
||||
DELETE_RECORD = 'DELETE_RECORD',
|
||||
|
||||
Reference in New Issue
Block a user