From a722d97724e0de28278f4507728fed8b978e5986 Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 2 Sep 2025 11:24:59 +0200 Subject: [PATCH] 14066 extensibility add coretriggerroute table (#14241) This Pr - adds a `route` table to the core schema - adds a controller to trigger route For now, we need to add a `/s/` prefix to all routes We plan to create a custom domain table in order to let the users create subdomains for their workspace, and so to link their routes to a subdomain. Thank to that, we will be able to identify workspace_id from domain name, and the prefix could be `/s`. If we create a native dedicated subdomain for routes for each workspaces, the prefix could be completely removed! Here the follow up ticket to do that -> https://github.com/twentyhq/twenty/issues/14240 --- packages/twenty-server/package.json | 1 + .../1756476065711-removeUselessDeletedAt.ts | 23 ++++ .../common/1756803679889-addRouteEntity.ts | 31 +++++ .../metadata-engine.module.ts | 2 + .../route/route.controller.ts | 86 ++++++++++++ .../metadata-modules/route/route.entity.ts | 65 +++++++++ .../metadata-modules/route/route.module.ts | 21 +++ .../metadata-modules/route/route.service.ts | 124 ++++++++++++++++++ .../serverless-function.entity.ts | 6 + .../trigger/entities/cron-trigger.entity.ts | 4 - .../entities/database-event-trigger.entity.ts | 4 - yarn.lock | 8 ++ 12 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1756476065711-removeUselessDeletedAt.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1756803679889-addRouteEntity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/route/route.controller.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/route/route.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/route/route.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/route/route.service.ts diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 7fcf7819c2..6a70c7f1b0 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -159,6 +159,7 @@ "passport-google-oauth20": "2.0.0", "passport-jwt": "4.0.1", "passport-microsoft": "2.1.0", + "path-to-regexp": "^8.2.0", "pg": "8.12.0", "pg-boss": "9.0.3", "planer": "1.2.0", diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1756476065711-removeUselessDeletedAt.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756476065711-removeUselessDeletedAt.ts new file mode 100644 index 0000000000..395fdd9867 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756476065711-removeUselessDeletedAt.ts @@ -0,0 +1,23 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class RemoveUselessDeletedAt1756476065711 implements MigrationInterface { + name = 'RemoveUselessDeletedAt1756476065711'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."cronTrigger" DROP COLUMN "deletedAt"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."databaseEventTrigger" DROP COLUMN "deletedAt"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."databaseEventTrigger" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "core"."cronTrigger" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1756803679889-addRouteEntity.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756803679889-addRouteEntity.ts new file mode 100644 index 0000000000..832213c84f --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756803679889-addRouteEntity.ts @@ -0,0 +1,31 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddRouteEntity1756803679889 implements MigrationInterface { + name = 'AddRouteEntity1756803679889'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "core"."route_httpmethod_enum" AS ENUM('GET', 'POST', 'PUT', 'PATCH', 'DELETE')`, + ); + await queryRunner.query( + `CREATE TABLE "core"."route" ("universalIdentifier" uuid, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "path" character varying NOT NULL, "isAuthRequired" boolean NOT NULL DEFAULT true, "httpMethod" "core"."route_httpmethod_enum" NOT NULL DEFAULT 'GET', "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "serverlessFunctionId" uuid, CONSTRAINT "IDX_ROUTE_PATH_HTTP_METHOD_WORKSPACE_ID_UNIQUE" UNIQUE ("path", "httpMethod", "workspaceId"), CONSTRAINT "PK_08affcd076e46415e5821acf52d" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_1c39502392dd9cbb186deba158" ON "core"."route" ("workspaceId", "universalIdentifier") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."route" ADD CONSTRAINT "FK_c63b1110bbf09051be2f495d0be" FOREIGN KEY ("serverlessFunctionId") REFERENCES "core"."serverlessFunction"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."route" DROP CONSTRAINT "FK_c63b1110bbf09051be2f495d0be"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_1c39502392dd9cbb186deba158"`, + ); + await queryRunner.query(`DROP TABLE "core"."route"`); + await queryRunner.query(`DROP TYPE "core"."route_httpmethod_enum"`); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index 2a1894d254..517c6f9e3f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -10,6 +10,7 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module'; import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; +import { RouteModule } from 'src/engine/metadata-modules/route/route.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace- RemoteServerModule, RoleModule, PermissionsModule, + RouteModule, ], providers: [], exports: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/route/route.controller.ts b/packages/twenty-server/src/engine/metadata-modules/route/route.controller.ts new file mode 100644 index 0000000000..d746b28e0a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/route/route.controller.ts @@ -0,0 +1,86 @@ +import { + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + Req, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { Request } from 'express'; + +import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { RouteService } from 'src/engine/metadata-modules/route/route.service'; +import { HTTPMethod } from 'src/engine/metadata-modules/route/route.entity'; +import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter'; + +@Controller('s/:workspaceId') +@UseGuards(PublicEndpointGuard) +@UseFilters(RestApiExceptionFilter) +export class RouteController { + constructor(private readonly routeService: RouteService) {} + + @Get('*') + async get( + @Param('workspaceId') workspaceId: string, + @Req() request: Request, + ) { + return await this.routeService.handle({ + workspaceId, + request, + httpMethod: HTTPMethod.GET, + }); + } + + @Post('*') + async post( + @Param('workspaceId') workspaceId: string, + @Req() request: Request, + ) { + return await this.routeService.handle({ + workspaceId, + request, + httpMethod: HTTPMethod.POST, + }); + } + + @Put('*') + async put( + @Param('workspaceId') workspaceId: string, + @Req() request: Request, + ) { + return await this.routeService.handle({ + workspaceId, + request, + httpMethod: HTTPMethod.PUT, + }); + } + + @Patch('*') + async patch( + @Param('workspaceId') workspaceId: string, + @Req() request: Request, + ) { + return await this.routeService.handle({ + workspaceId, + request, + httpMethod: HTTPMethod.PATCH, + }); + } + + @Delete('*') + async delete( + @Param('workspaceId') workspaceId: string, + @Req() request: Request, + ) { + return await this.routeService.handle({ + workspaceId, + request, + httpMethod: HTTPMethod.DELETE, + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/route/route.entity.ts b/packages/twenty-server/src/engine/metadata-modules/route/route.entity.ts new file mode 100644 index 0000000000..44e717d5ff --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/route/route.entity.ts @@ -0,0 +1,65 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + UpdateDateColumn, + Unique, +} from 'typeorm'; + +import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface'; + +import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; + +export enum HTTPMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', + DELETE = 'DELETE', +} + +@Entity({ name: 'route', schema: 'core' }) +@Unique('IDX_ROUTE_PATH_HTTP_METHOD_WORKSPACE_ID_UNIQUE', [ + 'path', + 'httpMethod', + 'workspaceId', +]) +export class Route extends SyncableEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: false }) + path: string; + + @Column({ nullable: false, default: true }) + isAuthRequired: boolean; + + @Column({ + type: 'enum', + enum: HTTPMethod, + default: HTTPMethod.GET, + nullable: false, + }) + httpMethod: HTTPMethod; + + @ManyToOne( + () => ServerlessFunctionEntity, + (serverlessFunction) => serverlessFunction.routes, + { onDelete: 'CASCADE' }, + ) + @JoinColumn({ name: 'serverlessFunctionId' }) + serverlessFunction: Relation; + + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/route/route.module.ts b/packages/twenty-server/src/engine/metadata-modules/route/route.module.ts new file mode 100644 index 0000000000..68c893116c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/route/route.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RouteService } from 'src/engine/metadata-modules/route/route.service'; +import { Route } from 'src/engine/metadata-modules/route/route.entity'; +import { RouteController } from 'src/engine/metadata-modules/route/route.controller'; +import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Route]), + AuthModule, + DomainManagerModule, + ServerlessFunctionModule, + ], + controllers: [RouteController], + providers: [RouteService], +}) +export class RouteModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/route/route.service.ts b/packages/twenty-server/src/engine/metadata-modules/route/route.service.ts new file mode 100644 index 0000000000..dc3c47ec2d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/route/route.service.ts @@ -0,0 +1,124 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; +import { Request } from 'express'; +import { match } from 'path-to-regexp'; +import { isDefined } from 'twenty-shared/utils'; + +import { + HTTPMethod, + Route, +} from 'src/engine/metadata-modules/route/route.entity'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; + +@Injectable() +export class RouteService { + constructor( + private readonly accessTokenService: AccessTokenService, + private readonly serverlessFunctionService: ServerlessFunctionService, + @InjectRepository(Route) + private readonly routeRepository: Repository, + ) {} + + private async getOneRouteWithPathParamsOrFail({ + workspaceId, + request, + httpMethod, + }: { + workspaceId: string; + request: Request; + httpMethod: HTTPMethod; + }): Promise<{ + route: Route; + pathParams: Partial>; + }> { + const routes = await this.routeRepository.find({ + where: { + httpMethod, + workspaceId, + }, + relations: ['serverlessFunction'], + }); + + const requestPath = request.path.replace(`/s/${workspaceId}/`, ''); + + for (const route of routes) { + const routeMatcher = match(route.path, { decode: decodeURIComponent }); + const routeMatched = routeMatcher(requestPath); + + if (routeMatched) { + return { + route, + pathParams: routeMatched.params, + }; + } + } + + throw new NotFoundException('No Route found'); + } + + private async validateWorkspaceFromRequest({ + request, + workspaceId, + }: { + request: Request; + workspaceId: string; + }) { + const { workspace } = + await this.accessTokenService.validateTokenByRequest(request); + + if (!isDefined(workspace)) { + throw new NotFoundException('Workspace not found'); + } + + if (workspace.id !== workspaceId) { + throw new ForbiddenException('Invalid Workspace'); + } + } + + async handle({ + workspaceId, + request, + httpMethod, + }: { + workspaceId: string; + request: Request; + httpMethod: HTTPMethod; + }) { + const routeWithPathParams = await this.getOneRouteWithPathParamsOrFail({ + workspaceId, + request, + httpMethod, + }); + + if (routeWithPathParams.route.isAuthRequired) { + await this.validateWorkspaceFromRequest({ + request, + workspaceId: routeWithPathParams.route.workspaceId, + }); + } + + const queryParams = request.query; + + const bodyParams = request.body; + + const executionParams = { + ...queryParams, + ...bodyParams, + ...routeWithPathParams.pathParams, + }; + + return await this.serverlessFunctionService.executeOneServerlessFunction( + routeWithPathParams.route.serverlessFunction.id, + routeWithPathParams.route.workspaceId, + executionParams, + 'draft', + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts index b9b1fa1948..76f1b6fc3c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts @@ -13,6 +13,7 @@ import { import { InputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type'; import { CronTrigger } from 'src/engine/metadata-modules/trigger/entities/cron-trigger.entity'; import { DatabaseEventTrigger } from 'src/engine/metadata-modules/trigger/entities/database-event-trigger.entity'; +import { Route } from 'src/engine/metadata-modules/route/route.entity'; const DEFAULT_SERVERLESS_TIMEOUT_SECONDS = 300; // 5 minutes @@ -73,6 +74,11 @@ export class ServerlessFunctionEntity { ) databaseEventTriggers: DatabaseEventTrigger[]; + @OneToMany(() => Route, (route) => route.serverlessFunction, { + cascade: true, + }) + routes: Route[]; + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/trigger/entities/cron-trigger.entity.ts b/packages/twenty-server/src/engine/metadata-modules/trigger/entities/cron-trigger.entity.ts index 77c7dc24f3..3f3bbbf92e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/trigger/entities/cron-trigger.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/trigger/entities/cron-trigger.entity.ts @@ -2,7 +2,6 @@ import { Column, CreateDateColumn, Entity, - DeleteDateColumn, JoinColumn, ManyToOne, PrimaryGeneratedColumn, @@ -44,7 +43,4 @@ export class CronTrigger extends SyncableEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; - - @DeleteDateColumn({ type: 'timestamptz' }) - deletedAt?: Date; } diff --git a/packages/twenty-server/src/engine/metadata-modules/trigger/entities/database-event-trigger.entity.ts b/packages/twenty-server/src/engine/metadata-modules/trigger/entities/database-event-trigger.entity.ts index 17ca6fe297..7727dd5e52 100644 --- a/packages/twenty-server/src/engine/metadata-modules/trigger/entities/database-event-trigger.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/trigger/entities/database-event-trigger.entity.ts @@ -2,7 +2,6 @@ import { Column, CreateDateColumn, Entity, - DeleteDateColumn, JoinColumn, ManyToOne, PrimaryGeneratedColumn, @@ -44,7 +43,4 @@ export class DatabaseEventTrigger extends SyncableEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; - - @DeleteDateColumn({ type: 'timestamptz' }) - deletedAt?: Date; } diff --git a/yarn.lock b/yarn.lock index 8ae6807bae..1ab41c22ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -43930,6 +43930,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^8.2.0": + version: 8.2.0 + resolution: "path-to-regexp@npm:8.2.0" + checksum: 10c0/ef7d0a887b603c0a142fad16ccebdcdc42910f0b14830517c724466ad676107476bba2fe9fffd28fd4c141391ccd42ea426f32bb44c2c82ecaefe10c37b90f5a + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -51212,6 +51219,7 @@ __metadata: passport-google-oauth20: "npm:2.0.0" passport-jwt: "npm:4.0.1" passport-microsoft: "npm:2.1.0" + path-to-regexp: "npm:^8.2.0" pg: "npm:8.12.0" pg-boss: "npm:9.0.3" planer: "npm:1.2.0"