From 329af2b670d8a65d9cf132efdbabb282623257cf Mon Sep 17 00:00:00 2001 From: Weiko Date: Fri, 3 Oct 2025 12:11:18 +0200 Subject: [PATCH] Add route trigger to twenty apps (#14864) --- .../src/commands/app-add.command.ts | 79 +++++++++-- packages/twenty-cli/src/types/config.types.ts | 10 +- .../twenty-front/src/generated/graphql.ts | 49 +++++++ .../application/application-sync.service.ts | 133 +++++++++++++++++- .../application/application.exception.ts | 1 + .../application/application.module.ts | 2 + .../application/types/application.types.ts | 3 +- .../resolvers/route-trigger.resolver.ts | 2 +- .../services/route-trigger-v2.service.ts | 21 ++- 9 files changed, 276 insertions(+), 24 deletions(-) diff --git a/packages/twenty-cli/src/commands/app-add.command.ts b/packages/twenty-cli/src/commands/app-add.command.ts index 16d70bf218b..a8ca0bf3907 100644 --- a/packages/twenty-cli/src/commands/app-add.command.ts +++ b/packages/twenty-cli/src/commands/app-add.command.ts @@ -1,11 +1,12 @@ import chalk from 'chalk'; +import { randomUUID } from 'crypto'; import * as fs from 'fs-extra'; import inquirer from 'inquirer'; import path from 'path'; -import { randomUUID } from 'crypto'; +import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; +import { HTTPMethod } from '../types/config.types'; import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser'; import { getSchemaUrls } from '../utils/schema-validator'; -import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; export enum SyncableEntity { AGENT = 'agent', @@ -97,7 +98,7 @@ export class AppAddCommand { } private async getEntity() { - const { entity } = await inquirer.prompt([ + const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([ { type: 'select', name: 'entity', @@ -107,11 +108,11 @@ export class AppAddCommand { }, ]); - return entity as SyncableEntity; + return entity; } private async getEntityName(entity: SyncableEntity) { - const { name } = await inquirer.prompt([ + const { name } = await inquirer.prompt<{ name: string }>([ { type: 'input', name: 'name', @@ -131,7 +132,7 @@ export class AppAddCommand { }, ]); - return name as string; + return name; } private async getEntityToCreateData( @@ -169,7 +170,7 @@ export class AppAddCommand { continue; } - const answer = await inquirer.prompt([ + const answer = await inquirer.prompt<{ [key: string]: string }>([ { type: 'input', name: requiredField, @@ -206,7 +207,9 @@ export class AppAddCommand { process.exit(1); } - const { serverlessFunctionName } = await inquirer.prompt([ + const { serverlessFunctionName } = await inquirer.prompt<{ + serverlessFunctionName: string; + }>([ { type: 'list', name: 'serverlessFunctionName', @@ -215,12 +218,12 @@ export class AppAddCommand { }, ]); - const { triggerType } = await inquirer.prompt([ + const { triggerType } = await inquirer.prompt<{ triggerType: string }>([ { type: 'list', name: 'triggerType', message: 'Select the type of trigger:', - choices: ['databaseEvent', 'cron'], + choices: ['databaseEvent', 'cron', 'route'], }, ]); @@ -230,6 +233,8 @@ export class AppAddCommand { triggerData = await this.createDatabaseEventTrigger(); } else if (triggerType === 'cron') { triggerData = await this.createCronTrigger(); + } else if (triggerType === 'route') { + triggerData = await this.createRouteTrigger(); } const manifestPath = path.join( @@ -256,7 +261,7 @@ export class AppAddCommand { private async createDatabaseEventTrigger() { const uuid = randomUUID(); - const { eventName } = await inquirer.prompt([ + const { eventName } = await inquirer.prompt<{ eventName: string }>([ { type: 'input', name: 'eventName', @@ -284,7 +289,7 @@ export class AppAddCommand { private async createCronTrigger() { const uuid = randomUUID(); - const { schedule } = await inquirer.prompt([ + const { schedule } = await inquirer.prompt<{ schedule: string }>([ { type: 'input', name: 'schedule', @@ -310,4 +315,54 @@ export class AppAddCommand { schedule, }; } + + private async createRouteTrigger() { + const uuid = randomUUID(); + + const { path } = await inquirer.prompt<{ path: string }>([ + { + type: 'input', + name: 'path', + message: 'Enter the route path (e.g., /webhook/company):', + validate: (input) => { + if (input.length === 0) { + return 'Path is required'; + } + if (!input.startsWith('/')) { + return 'Path must start with /'; + } + return true; + }, + }, + ]); + + const { httpMethod } = await inquirer.prompt<{ httpMethod: HTTPMethod }>([ + { + type: 'list', + name: 'httpMethod', + message: 'Select the HTTP method:', + choices: Object.values(HTTPMethod), + default: HTTPMethod.GET, + }, + ]); + + const { isAuthRequired } = await inquirer.prompt<{ + isAuthRequired: boolean; + }>([ + { + type: 'confirm', + name: 'isAuthRequired', + message: 'Is authentication required?', + default: true, + }, + ]); + + return { + universalIdentifier: uuid, + type: 'route', + path, + httpMethod, + isAuthRequired, + }; + } } diff --git a/packages/twenty-cli/src/types/config.types.ts b/packages/twenty-cli/src/types/config.types.ts index 5e3be219e5b..96606848bed 100644 --- a/packages/twenty-cli/src/types/config.types.ts +++ b/packages/twenty-cli/src/types/config.types.ts @@ -43,6 +43,14 @@ export type ServerlessFunctionManifest = { code: ServerlessFunctionCodeManifest; }; +export enum HTTPMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', + DELETE = 'DELETE', +} + export type ServerlessFunctionTriggerManifest = | { type: 'cron'; @@ -55,7 +63,7 @@ export type ServerlessFunctionTriggerManifest = | { type: 'route'; path: string; - method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + httpMethod: HTTPMethod; isAuthRequired: boolean; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 2b2e70cecf0..89f86748c0f 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -808,6 +808,13 @@ export type CreateRoleInput = { label: Scalars['String']; }; +export type CreateRouteTriggerInput = { + httpMethod?: HttpMethod; + isAuthRequired?: Scalars['Boolean']; + path: Scalars['String']; + serverlessFunctionId: Scalars['String']; +}; + export type CreateServerlessFunctionInput = { code?: InputMaybe; description?: InputMaybe; @@ -1633,6 +1640,7 @@ export type Mutation = { createOneField: Field; createOneObject: Object; createOneRole: Role; + createOneRouteTrigger: RouteTrigger; createOneServerlessFunction: ServerlessFunction; createOneServerlessFunctionLayer: ServerlessFunctionLayer; createPageLayout: PageLayout; @@ -1661,6 +1669,7 @@ export type Mutation = { deleteOneField: Field; deleteOneObject: Object; deleteOneRole: Scalars['String']; + deleteOneRouteTrigger: RouteTrigger; deleteOneServerlessFunction: ServerlessFunction; deletePageLayout: PageLayout; deletePageLayoutTab: Scalars['Boolean']; @@ -1740,6 +1749,7 @@ export type Mutation = { updateOneField: Field; updateOneObject: Object; updateOneRole: Role; + updateOneRouteTrigger: RouteTrigger; updateOneServerlessFunction: ServerlessFunction; updatePageLayout: PageLayout; updatePageLayoutTab: PageLayoutTab; @@ -1924,6 +1934,11 @@ export type MutationCreateOneRoleArgs = { }; +export type MutationCreateOneRouteTriggerArgs = { + input: CreateRouteTriggerInput; +}; + + export type MutationCreateOneServerlessFunctionArgs = { input: CreateServerlessFunctionInput; }; @@ -2060,6 +2075,11 @@ export type MutationDeleteOneRoleArgs = { }; +export type MutationDeleteOneRouteTriggerArgs = { + input: RouteTriggerIdInput; +}; + + export type MutationDeleteOneServerlessFunctionArgs = { input: ServerlessFunctionIdInput; }; @@ -2448,6 +2468,11 @@ export type MutationUpdateOneRoleArgs = { }; +export type MutationUpdateOneRouteTriggerArgs = { + input: UpdateRouteTriggerInput; +}; + + export type MutationUpdateOneServerlessFunctionArgs = { input: UpdateServerlessFunctionInput; }; @@ -2913,10 +2938,12 @@ export type Query = { findManyCronTriggers: Array; findManyDatabaseEventTriggers: Array; findManyPublicDomains: Array; + findManyRouteTriggers: Array; findManyServerlessFunctions: Array; findOneAgent: Agent; findOneCronTrigger: CronTrigger; findOneDatabaseEventTrigger: DatabaseEventTrigger; + findOneRouteTrigger: RouteTrigger; findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; @@ -3035,6 +3062,11 @@ export type QueryFindOneDatabaseEventTriggerArgs = { }; +export type QueryFindOneRouteTriggerArgs = { + input: RouteTriggerIdInput; +}; + + export type QueryFindOneServerlessFunctionArgs = { input: ServerlessFunctionIdInput; }; @@ -3369,6 +3401,10 @@ export type RouteTrigger = { workspaceId: Scalars['String']; }; +export type RouteTriggerIdInput = { + id: Scalars['String']; +}; + export type RunWorkflowVersionInput = { /** Execution result in JSON format */ payload?: InputMaybe; @@ -3854,6 +3890,19 @@ export type UpdateRolePayload = { label?: InputMaybe; }; +export type UpdateRouteTriggerInput = { + /** The id of the route to update */ + id: Scalars['String']; + /** The route updates */ + update: UpdateRouteTriggerInputUpdates; +}; + +export type UpdateRouteTriggerInputUpdates = { + httpMethod: HttpMethod; + isAuthRequired: Scalars['Boolean']; + path: Scalars['String']; +}; + export type UpdateServerlessFunctionInput = { /** Id of the serverless function to update */ id: Scalars['UUID']; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index c7d1293739e..d33c7f69d31 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -23,6 +23,8 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data- import { DatabaseEventTriggerV2Service } from 'src/engine/metadata-modules/database-event-trigger/services/database-event-trigger-v2.service'; import { FlatDatabaseEventTrigger } from 'src/engine/metadata-modules/database-event-trigger/types/flat-database-event-trigger.type'; import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service'; +import { RouteTriggerV2Service } from 'src/engine/metadata-modules/route-trigger/services/route-trigger-v2.service'; +import { FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type'; import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service'; import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service'; import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type'; @@ -41,6 +43,7 @@ export class ApplicationSyncService { private readonly agentService: AgentService, private readonly databaseEventTriggerV2Service: DatabaseEventTriggerV2Service, private readonly cronTriggerV2Service: CronTriggerV2Service, + private readonly routeTriggerV2Service: RouteTriggerV2Service, ) {} public async synchronizeFromManifest({ @@ -392,6 +395,12 @@ export class ApplicationSyncService { triggersToSync: serverlessFunctionToSync.triggers || [], workspaceId, }); + + await this.syncRouteTriggersForServerlessFunction({ + serverlessFunctionId: serverlessFunctionToUpdate.id, + triggersToSync: serverlessFunctionToSync.triggers || [], + workspaceId, + }); } for (const serverlessFunctionToCreate of serverlessFunctionsToCreate) { @@ -421,6 +430,12 @@ export class ApplicationSyncService { triggersToSync: serverlessFunctionToCreate.triggers || [], workspaceId, }); + + await this.syncRouteTriggersForServerlessFunction({ + serverlessFunctionId: createdServerlessFunction.id, + triggersToSync: serverlessFunctionToCreate.triggers || [], + workspaceId, + }); } } @@ -501,7 +516,7 @@ export class ApplicationSyncService { if (!triggerToSync || triggerToSync.type !== 'databaseEvent') { throw new ApplicationException( `Failed to find database event trigger to sync with universalIdentifier ${triggerToUpdate.universalIdentifier}`, - ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND, + ApplicationExceptionCode.ENTITY_NOT_FOUND, ); } @@ -614,7 +629,7 @@ export class ApplicationSyncService { if (!triggerToSync || triggerToSync.type !== 'cron') { throw new ApplicationException( `Failed to find cron trigger to sync with universalIdentifier ${triggerToUpdate.universalIdentifier}`, - ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND, + ApplicationExceptionCode.ENTITY_NOT_FOUND, ); } @@ -652,4 +667,118 @@ export class ApplicationSyncService { ); } } + + private async syncRouteTriggersForServerlessFunction({ + serverlessFunctionId, + triggersToSync, + workspaceId, + }: { + serverlessFunctionId: string; + triggersToSync: ServerlessFunctionTriggerManifest[]; + workspaceId: string; + }) { + const routeTriggersToSync = triggersToSync.filter( + (trigger) => trigger.type === 'route', + ); + + const { flatRouteTriggerMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatEntities: ['flatRouteTriggerMaps'], + }, + ); + + const existingRouteTriggers = Object.values( + flatRouteTriggerMaps.byId, + ).filter( + (trigger) => + isDefined(trigger) && + trigger.serverlessFunctionId === serverlessFunctionId, + ) as FlatRouteTrigger[]; + + const triggersToSyncUniversalIdentifiers = routeTriggersToSync.map( + (trigger) => trigger.universalIdentifier, + ); + + const existingTriggersUniversalIdentifiers = existingRouteTriggers.map( + (trigger) => trigger.universalIdentifier, + ); + + const triggersToDelete = existingRouteTriggers.filter( + (trigger) => + isDefined(trigger.universalIdentifier) && + !triggersToSyncUniversalIdentifiers.includes( + trigger.universalIdentifier, + ), + ); + + const triggersToUpdate = existingRouteTriggers.filter( + (trigger) => + isDefined(trigger.universalIdentifier) && + triggersToSyncUniversalIdentifiers.includes( + trigger.universalIdentifier, + ), + ); + + const triggersToCreate = routeTriggersToSync.filter( + (triggerToSync) => + !existingTriggersUniversalIdentifiers.includes( + triggerToSync.universalIdentifier, + ), + ); + + for (const triggerToDelete of triggersToDelete) { + await this.routeTriggerV2Service.destroyOne({ + destroyRouteTriggerInput: { id: triggerToDelete.id }, + workspaceId, + }); + } + + for (const triggerToUpdate of triggersToUpdate) { + const triggerToSync = routeTriggersToSync.find( + (trigger) => + trigger.universalIdentifier === triggerToUpdate.universalIdentifier, + ); + + if (!triggerToSync || triggerToSync.type !== 'route') { + throw new ApplicationException( + `Failed to find route trigger to sync with universalIdentifier ${triggerToUpdate.universalIdentifier}`, + ApplicationExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const updateRouteTriggerInput = { + id: triggerToUpdate.id, + update: { + path: triggerToSync.path, + httpMethod: triggerToSync.httpMethod, + isAuthRequired: triggerToSync.isAuthRequired, + }, + }; + + await this.routeTriggerV2Service.updateOne( + updateRouteTriggerInput, + workspaceId, + ); + } + + for (const triggerToCreate of triggersToCreate) { + if (triggerToCreate.type !== 'route') { + continue; + } + + const createRouteTriggerInput = { + path: triggerToCreate.path, + httpMethod: triggerToCreate.httpMethod, + isAuthRequired: triggerToCreate.isAuthRequired, + serverlessFunctionId, + }; + + await this.routeTriggerV2Service.createOne( + createRouteTriggerInput, + workspaceId, + ); + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts index 7cee0617814..c2d3262a8cc 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts @@ -5,4 +5,5 @@ export class ApplicationException extends CustomException { const { @@ -178,7 +184,8 @@ export class RouteTriggerV2Service { }, ); - const existingFlatRoute = existingFlatRouteMaps.byId[destroyRouteInput.id]; + const existingFlatRoute = + existingFlatRouteMaps.byId[destroyRouteTriggerInput.id]; if (!isDefined(existingFlatRoute)) { throw new RouteTriggerException(