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/<workspace_id>` 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
This commit is contained in:
martmull
2025-09-02 11:24:59 +02:00
committed by GitHub
parent bfad5b0477
commit a722d97724
12 changed files with 367 additions and 8 deletions
+1
View File
@@ -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",
@@ -0,0 +1,23 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class RemoveUselessDeletedAt1756476065711 implements MigrationInterface {
name = 'RemoveUselessDeletedAt1756476065711';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`,
);
}
}
@@ -0,0 +1,31 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddRouteEntity1756803679889 implements MigrationInterface {
name = 'AddRouteEntity1756803679889';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
@@ -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: [
@@ -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,
});
}
}
@@ -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<ServerlessFunctionEntity>;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -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 {}
@@ -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<Route>,
) {}
private async getOneRouteWithPathParamsOrFail({
workspaceId,
request,
httpMethod,
}: {
workspaceId: string;
request: Request;
httpMethod: HTTPMethod;
}): Promise<{
route: Route;
pathParams: Partial<Record<string, string | string[]>>;
}> {
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',
);
}
}
@@ -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;
@@ -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;
}
@@ -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;
}
+8
View File
@@ -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"