Migrate cron, databaseEventTrigger, httpRoute triggers to serverless functions (#17488)

## Summary

Migrates trigger entities (`CronTriggerEntity`,
`DatabaseEventTriggerEntity`, `RouteTriggerEntity`) into
`ServerlessFunctionEntity` by storing trigger settings as JSONB columns
directly on the serverless function. This simplifies the architecture
since these relationships were effectively one-to-one.

## Changes

### Schema Changes
- Added three new nullable JSONB columns to `ServerlessFunctionEntity`:
  - `cronTriggerSettings` - stores cron pattern
- `databaseEventTriggerSettings` - stores event name and updated fields
filter
- `httpRouteTriggerSettings` - stores path, HTTP method, auth
requirements, and forwarded headers

### Core Logic Updates
- `CronTriggerCronJob` - now queries `ServerlessFunctionEntity` directly
instead of `CronTriggerEntity`
- `CallDatabaseEventTriggerJobsJob` - now queries
`ServerlessFunctionEntity` directly
- `RouteTriggerService` - now queries `ServerlessFunctionEntity`
directly
- `ApplicationSyncService` - extracts trigger settings from manifest and
writes to serverless function
This commit is contained in:
Charles Bochet
2026-01-27 20:54:09 +01:00
committed by GitHub
parent e1f92bd951
commit a4499d21bb
110 changed files with 439 additions and 5193 deletions
@@ -1,8 +0,0 @@
import { type FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
export const FLAT_ROUTE_TRIGGER_EDITABLE_PROPERTIES = [
'path',
'isAuthRequired',
'httpMethod',
'forwardedRequestHeaders',
] as const satisfies (keyof FlatRouteTrigger)[];
@@ -1,51 +0,0 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { HTTPMethod } from 'twenty-shared/types';
@InputType()
export class CreateRouteTriggerInput {
@IsString()
@IsNotEmpty()
@Field()
path: string;
@IsBoolean()
@IsNotEmpty()
@Field({ defaultValue: true })
isAuthRequired: boolean;
@IsEnum(HTTPMethod)
@IsNotEmpty()
@Field(() => HTTPMethod, { defaultValue: HTTPMethod.GET })
httpMethod: HTTPMethod;
@IsUUID()
@IsNotEmpty()
@Field()
serverlessFunctionId: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
@Field(() => [String], {
defaultValue: [],
description:
'List of HTTP header names to forward to the serverless function event',
})
forwardedRequestHeaders: string[];
@HideField()
universalIdentifier?: string;
@HideField()
applicationId?: string;
}
@@ -1,11 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
@InputType()
export class RouteTriggerIdInput {
@IsUUID()
@IsNotEmpty()
@Field()
id: string;
}
@@ -1,32 +0,0 @@
import { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql';
import { HTTPMethod } from 'twenty-shared/types';
registerEnumType(HTTPMethod, { name: 'HTTPMethod' });
@ObjectType('RouteTrigger')
export class RouteTriggerDTO {
@Field(() => ID)
id: string;
@Field()
path: string;
@Field()
isAuthRequired: boolean;
@Field(() => HTTPMethod)
httpMethod: HTTPMethod;
@Field(() => [String], {
description:
'List of HTTP header names to forward to the serverless function event',
})
forwardedRequestHeaders: string[];
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
}
@@ -1,59 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { HTTPMethod } from 'twenty-shared/types';
@InputType()
class UpdateRouteTriggerInputUpdates {
@IsString()
@IsNotEmpty()
@Field()
path: string;
@IsBoolean()
@IsNotEmpty()
@Field()
isAuthRequired: boolean;
@IsEnum(HTTPMethod)
@IsNotEmpty()
@Field(() => HTTPMethod)
httpMethod: HTTPMethod;
@IsArray()
@IsString({ each: true })
@IsOptional()
@Field(() => [String], {
defaultValue: [],
description:
'List of HTTP header names to forward to the serverless function event',
})
forwardedRequestHeaders: string[];
}
@InputType()
export class UpdateRouteTriggerInput {
@IsUUID()
@IsNotEmpty()
@Field({
description: 'The id of the route to update',
})
id: string;
@Type(() => UpdateRouteTriggerInputUpdates)
@ValidateNested()
@Field(() => UpdateRouteTriggerInputUpdates, {
description: 'The route updates',
})
update: UpdateRouteTriggerInputUpdates;
}
@@ -1,149 +0,0 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
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 { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { CreateRouteTriggerInput } from 'src/engine/metadata-modules/route-trigger/dtos/create-route-trigger.input';
import { RouteTriggerIdInput } from 'src/engine/metadata-modules/route-trigger/dtos/route-trigger-id.input';
import { RouteTriggerDTO } from 'src/engine/metadata-modules/route-trigger/dtos/route-trigger.dto';
import { UpdateRouteTriggerInput } from 'src/engine/metadata-modules/route-trigger/dtos/update-route-trigger.input';
import {
RouteTriggerException,
RouteTriggerExceptionCode,
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
import { RouteTriggerV2Service } from 'src/engine/metadata-modules/route-trigger/services/route-trigger-v2.service';
import { fromFlatRouteTriggerToRouteTriggerDto } from 'src/engine/metadata-modules/route-trigger/utils/from-flat-route-trigger-to-route-trigger-dto.util';
import { routeTriggerGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/route-trigger/utils/route-trigger-graphql-api-exception-handler.utils';
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.WORKFLOWS),
)
@Resolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(
PreventNestToAutoLogGraphqlErrorsFilter,
PermissionsGraphqlApiExceptionFilter,
)
export class RouteTriggerResolver {
constructor(
private readonly routeV2Service: RouteTriggerV2Service,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
@Query(() => RouteTriggerDTO)
async findOneRouteTrigger(
@Args('input') { id }: RouteTriggerIdInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<RouteTriggerDTO> {
try {
const { flatRouteTriggerMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatRouteTriggerMaps'],
},
);
const flatRouteTrigger = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatRouteTriggerMaps,
});
if (!isDefined(flatRouteTrigger)) {
throw new RouteTriggerException(
`Route trigger with id ${id} not found`,
RouteTriggerExceptionCode.ROUTE_NOT_FOUND,
);
}
return fromFlatRouteTriggerToRouteTriggerDto(flatRouteTrigger);
} catch (error) {
return routeTriggerGraphQLApiExceptionHandler(error);
}
}
@Query(() => [RouteTriggerDTO])
async findManyRouteTriggers(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<RouteTriggerDTO[]> {
try {
const { flatRouteTriggerMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatRouteTriggerMaps'],
},
);
return Object.values(flatRouteTriggerMaps.byId)
.filter(isDefined)
.map(fromFlatRouteTriggerToRouteTriggerDto);
} catch (error) {
return routeTriggerGraphQLApiExceptionHandler(error);
}
}
@Mutation(() => RouteTriggerDTO)
async deleteOneRouteTrigger(
@Args('input') input: RouteTriggerIdInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<RouteTriggerDTO> {
try {
const flatRouteTrigger = await this.routeV2Service.destroyOne({
destroyRouteTriggerInput: input,
workspaceId,
});
return fromFlatRouteTriggerToRouteTriggerDto(flatRouteTrigger);
} catch (error) {
return routeTriggerGraphQLApiExceptionHandler(error);
}
}
@Mutation(() => RouteTriggerDTO)
async updateOneRouteTrigger(
@Args('input')
input: UpdateRouteTriggerInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<RouteTriggerDTO> {
try {
const flatRouteTrigger = await this.routeV2Service.updateOne(
input,
workspaceId,
);
return fromFlatRouteTriggerToRouteTriggerDto(flatRouteTrigger);
} catch (error) {
return routeTriggerGraphQLApiExceptionHandler(error);
}
}
@Mutation(() => RouteTriggerDTO)
async createOneRouteTrigger(
@Args('input')
input: CreateRouteTriggerInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<RouteTriggerDTO> {
try {
const flatRouteTrigger = await this.routeV2Service.createOne(
input,
workspaceId,
);
return fromFlatRouteTriggerToRouteTriggerDto(flatRouteTrigger);
} catch (error) {
return routeTriggerGraphQLApiExceptionHandler(error);
}
}
}
@@ -1,66 +0,0 @@
import { HTTPMethod } from 'twenty-shared/types';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'routeTrigger', schema: 'core' })
@Unique('IDX_ROUTE_TRIGGER_PATH_HTTP_METHOD_WORKSPACE_ID_UNIQUE', [
'path',
'httpMethod',
'workspaceId',
])
@Index('IDX_ROUTE_TRIGGER_SERVERLESS_FUNCTION_ID', ['serverlessFunctionId'])
export class RouteTriggerEntity
extends SyncableEntity
implements Required<RouteTriggerEntity>
{
@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;
@Column({ nullable: false, type: 'jsonb', default: [] })
forwardedRequestHeaders: JsonbProperty<string[]>;
@ManyToOne(
() => ServerlessFunctionEntity,
(serverlessFunction) => serverlessFunction.routeTriggers,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'serverlessFunctionId' })
serverlessFunction: Relation<ServerlessFunctionEntity>;
@Column({ nullable: false, type: 'uuid' })
serverlessFunctionId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -1,38 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RouteTriggerResolver } from 'src/engine/metadata-modules/route-trigger/resolvers/route-trigger.resolver';
import { RouteTriggerController } from 'src/engine/metadata-modules/route-trigger/route-trigger.controller';
import { RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
import { RouteTriggerService } from 'src/engine/metadata-modules/route-trigger/route-trigger.service';
import { RouteTriggerV2Service } from 'src/engine/metadata-modules/route-trigger/services/route-trigger-v2.service';
import { WorkspaceFlatRouteTriggerMapCacheService } from 'src/engine/metadata-modules/route-trigger/services/workspace-flat-route-trigger-map-cache.service';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
TypeOrmModule.forFeature([RouteTriggerEntity]),
ApplicationModule,
TypeOrmModule.forFeature([ServerlessFunctionEntity]),
TokenModule,
WorkspaceDomainsModule,
ServerlessFunctionModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
PermissionsModule,
WorkspaceMigrationModule,
],
controllers: [RouteTriggerController],
providers: [
RouteTriggerService,
RouteTriggerV2Service,
WorkspaceFlatRouteTriggerMapCacheService,
RouteTriggerResolver,
],
exports: [RouteTriggerV2Service, WorkspaceFlatRouteTriggerMapCacheService],
providers: [RouteTriggerService],
exports: [],
})
export class RouteTriggerModule {}
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Request } from 'express';
import { match } from 'path-to-regexp';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { IsNull, Not, Repository } from 'typeorm';
import { HTTPMethod } from 'twenty-shared/types';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
@@ -13,8 +13,8 @@ import {
RouteTriggerException,
RouteTriggerExceptionCode,
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
import { RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
import { buildServerlessFunctionEvent } from 'src/engine/metadata-modules/route-trigger/utils/build-serverless-function-event.util';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@Injectable()
@@ -23,18 +23,18 @@ export class RouteTriggerService {
private readonly accessTokenService: AccessTokenService,
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
@InjectRepository(RouteTriggerEntity)
private readonly routeTriggerRepository: Repository<RouteTriggerEntity>,
@InjectRepository(ServerlessFunctionEntity)
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
) {}
private async getOneRouteTriggerWithPathParamsOrFail({
private async getServerlessFunctionWithPathParamsOrFail({
request,
httpMethod,
}: {
request: Request;
httpMethod: HTTPMethod;
}): Promise<{
routeTrigger: RouteTriggerEntity;
serverlessFunction: ServerlessFunctionEntity;
pathParams: Partial<Record<string, string | string[]>>;
}> {
const host = `${request.protocol}://${request.get('host')}`;
@@ -52,26 +52,35 @@ export class RouteTriggerService {
),
);
const routeTriggers = await this.routeTriggerRepository.find({
where: {
httpMethod,
workspaceId: workspace.id,
},
relations: ['serverlessFunction'],
});
const serverlessFunctionsWithHttpRouteTrigger =
await this.serverlessFunctionRepository.find({
where: {
workspaceId: workspace.id,
httpRouteTriggerSettings: Not(IsNull()),
},
});
const requestPath = request.path.replace(/^\/s\//, '/');
for (const routeTrigger of routeTriggers) {
const routeTriggerMatcher = match(routeTrigger.path, {
for (const serverlessFunction of serverlessFunctionsWithHttpRouteTrigger) {
const httpRouteSettings = serverlessFunction.httpRouteTriggerSettings;
if (
!isDefined(httpRouteSettings) ||
httpRouteSettings.httpMethod !== httpMethod
) {
continue;
}
const routeMatcher = match(httpRouteSettings.path, {
decode: decodeURIComponent,
});
const routeTriggerMatched = routeTriggerMatcher(requestPath);
const routeMatched = routeMatcher(requestPath);
if (routeTriggerMatched) {
if (routeMatched) {
return {
routeTrigger,
pathParams: routeTriggerMatched.params,
serverlessFunction,
pathParams: routeMatched.params,
};
}
}
@@ -116,30 +125,31 @@ export class RouteTriggerService {
request: Request;
httpMethod: HTTPMethod;
}) {
const routeTriggerWithPathParams =
await this.getOneRouteTriggerWithPathParamsOrFail({
const { serverlessFunction, pathParams } =
await this.getServerlessFunctionWithPathParamsOrFail({
request,
httpMethod,
});
if (routeTriggerWithPathParams.routeTrigger.isAuthRequired) {
const httpRouteSettings = serverlessFunction.httpRouteTriggerSettings;
if (httpRouteSettings?.isAuthRequired) {
await this.validateWorkspaceFromRequest({
request,
workspaceId: routeTriggerWithPathParams.routeTrigger.workspaceId,
workspaceId: serverlessFunction.workspaceId,
});
}
const event = buildServerlessFunctionEvent({
request,
pathParameters: routeTriggerWithPathParams.pathParams,
forwardedRequestHeaders:
routeTriggerWithPathParams.routeTrigger.forwardedRequestHeaders ?? [],
pathParameters: pathParams,
forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [],
});
const result =
await this.serverlessFunctionService.executeOneServerlessFunction({
id: routeTriggerWithPathParams.routeTrigger.serverlessFunction.id,
workspaceId: routeTriggerWithPathParams.routeTrigger.workspaceId,
id: serverlessFunction.id,
workspaceId: serverlessFunction.workspaceId,
payload: event,
version: 'draft',
});
@@ -1,192 +0,0 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { CreateRouteTriggerInput } from 'src/engine/metadata-modules/route-trigger/dtos/create-route-trigger.input';
import { RouteTriggerIdInput } from 'src/engine/metadata-modules/route-trigger/dtos/route-trigger-id.input';
import { UpdateRouteTriggerInput } from 'src/engine/metadata-modules/route-trigger/dtos/update-route-trigger.input';
import {
RouteTriggerException,
RouteTriggerExceptionCode,
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
import { type FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
import { fromCreateRouteTriggerInputToFlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/utils/from-create-route-trigger-input-to-flat-route-trigger.util';
import { fromUpdateRouteTriggerInputToFlatRouteTriggerToUpdateOrThrow } from 'src/engine/metadata-modules/route-trigger/utils/from-update-route-trigger-input-to-flat-route-trigger-to-update-or-throw.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class RouteTriggerV2Service {
constructor(
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly applicationService: ApplicationService,
) {}
async createOne(
routeTriggerInput: CreateRouteTriggerInput,
workspaceId: string,
/**
* @deprecated do not use call validateBuildAndRunWorkspaceMigration contextually
* when interacting with another application than workspace custom one
* */
applicationId?: string,
): Promise<FlatRouteTrigger> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const flatRouteTriggerToCreate =
fromCreateRouteTriggerInputToFlatRouteTrigger({
createRouteTriggerInput: routeTriggerInput,
workspaceId,
workspaceCustomApplicationId:
applicationId ?? workspaceCustomFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
routeTrigger: {
flatEntityToCreate: [flatRouteTriggerToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while creating route',
);
}
const { flatRouteTriggerMaps: recomputedExistingFlatRouteMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatRouteTriggerMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatRouteTriggerToCreate.id,
flatEntityMaps: recomputedExistingFlatRouteMaps,
});
}
async updateOne(
routeTriggerInput: UpdateRouteTriggerInput,
workspaceId: string,
): Promise<FlatRouteTrigger> {
const { flatRouteTriggerMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatRouteTriggerMaps'],
},
);
const optimisticallyUpdatedFlatRouteTrigger =
fromUpdateRouteTriggerInputToFlatRouteTriggerToUpdateOrThrow({
flatRouteTriggerMaps,
updateRouteTriggerInput: routeTriggerInput,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
routeTrigger: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [optimisticallyUpdatedFlatRouteTrigger],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating route',
);
}
const { flatRouteTriggerMaps: recomputedExistingFlatRouteMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatRouteTriggerMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: optimisticallyUpdatedFlatRouteTrigger.id,
flatEntityMaps: recomputedExistingFlatRouteMaps,
});
}
async destroyOne({
destroyRouteTriggerInput,
workspaceId,
}: {
destroyRouteTriggerInput: RouteTriggerIdInput;
workspaceId: string;
}): Promise<FlatRouteTrigger> {
const { flatRouteTriggerMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatRouteTriggerMaps'],
},
);
const existingFlatRoute =
flatRouteTriggerMaps.byId[destroyRouteTriggerInput.id];
if (!isDefined(existingFlatRoute)) {
throw new RouteTriggerException(
'Route to destroy not found',
RouteTriggerExceptionCode.ROUTE_NOT_FOUND,
);
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
routeTrigger: {
flatEntityToCreate: [],
flatEntityToDelete: [existingFlatRoute],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while destroying route',
);
}
return existingFlatRoute;
}
}
@@ -1,60 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { getMetadataEntityRelationProperties } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-entity-relation-properties.util';
import { RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
import { FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@Injectable()
@WorkspaceCache('flatRouteTriggerMaps')
export class WorkspaceFlatRouteTriggerMapCacheService extends WorkspaceCacheProvider<
FlatEntityMaps<FlatRouteTrigger>
> {
constructor(
@InjectRepository(RouteTriggerEntity)
private readonly routeTriggerRepository: Repository<RouteTriggerEntity>,
) {
super();
}
async computeForCache(
workspaceId: string,
): Promise<FlatEntityMaps<FlatRouteTrigger>> {
const routeTriggers = await this.routeTriggerRepository.find({
where: {
workspaceId,
},
});
const flatRouteTriggerMaps = createEmptyFlatEntityMaps();
for (const routeTriggerEntity of routeTriggers) {
const routeTriggerEntityWithoutRelations = removePropertiesFromRecord(
routeTriggerEntity,
getMetadataEntityRelationProperties('routeTrigger'),
);
const flatRouteTrigger = {
...routeTriggerEntityWithoutRelations,
createdAt: routeTriggerEntity.createdAt.toISOString(),
updatedAt: routeTriggerEntity.updatedAt.toISOString(),
universalIdentifier: routeTriggerEntity.universalIdentifier,
} satisfies FlatRouteTrigger;
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatRouteTrigger,
flatEntityMapsToMutate: flatRouteTriggerMaps,
});
}
return flatRouteTriggerMaps;
}
}
@@ -1,4 +0,0 @@
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
import { type RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
export type FlatRouteTrigger = FlatEntityFrom<RouteTriggerEntity>;
@@ -1,33 +0,0 @@
import { v4 as uuidV4 } from 'uuid';
import { type CreateRouteTriggerInput } from 'src/engine/metadata-modules/route-trigger/dtos/create-route-trigger.input';
import { type FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
export const fromCreateRouteTriggerInputToFlatRouteTrigger = ({
createRouteTriggerInput,
workspaceId,
workspaceCustomApplicationId,
}: {
createRouteTriggerInput: CreateRouteTriggerInput;
workspaceId: string;
workspaceCustomApplicationId: string;
}): FlatRouteTrigger => {
const now = new Date();
const id = uuidV4();
return {
id,
universalIdentifier:
createRouteTriggerInput.universalIdentifier ?? uuidV4(),
path: createRouteTriggerInput.path,
isAuthRequired: createRouteTriggerInput.isAuthRequired,
httpMethod: createRouteTriggerInput.httpMethod,
forwardedRequestHeaders:
createRouteTriggerInput.forwardedRequestHeaders ?? [],
serverlessFunctionId: createRouteTriggerInput.serverlessFunctionId,
workspaceId,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
applicationId: workspaceCustomApplicationId,
};
};
@@ -1,14 +0,0 @@
import { type RouteTriggerDTO } from 'src/engine/metadata-modules/route-trigger/dtos/route-trigger.dto';
import { type FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
export const fromFlatRouteTriggerToRouteTriggerDto = (
flatRouteTrigger: FlatRouteTrigger,
): RouteTriggerDTO => ({
id: flatRouteTrigger.id,
path: flatRouteTrigger.path,
isAuthRequired: flatRouteTrigger.isAuthRequired,
httpMethod: flatRouteTrigger.httpMethod,
forwardedRequestHeaders: flatRouteTrigger.forwardedRequestHeaders,
createdAt: new Date(flatRouteTrigger.createdAt),
updatedAt: new Date(flatRouteTrigger.updatedAt),
});
@@ -1,36 +0,0 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type UpdateRouteTriggerInput } from 'src/engine/metadata-modules/route-trigger/dtos/update-route-trigger.input';
import {
RouteTriggerException,
RouteTriggerExceptionCode,
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
import { type FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
export const fromUpdateRouteTriggerInputToFlatRouteTriggerToUpdateOrThrow = ({
flatRouteTriggerMaps,
updateRouteTriggerInput,
}: {
flatRouteTriggerMaps: FlatEntityMaps<FlatRouteTrigger>;
updateRouteTriggerInput: UpdateRouteTriggerInput;
}): FlatRouteTrigger => {
const existingFlatRouteTrigger =
flatRouteTriggerMaps.byId[updateRouteTriggerInput.id];
if (!existingFlatRouteTrigger) {
throw new RouteTriggerException(
'Route not found',
RouteTriggerExceptionCode.ROUTE_NOT_FOUND,
);
}
return {
...existingFlatRouteTrigger,
updatedAt: new Date().toISOString(),
path: updateRouteTriggerInput.update.path,
isAuthRequired: updateRouteTriggerInput.update.isAuthRequired,
httpMethod: updateRouteTriggerInput.update.httpMethod,
forwardedRequestHeaders:
updateRouteTriggerInput.update.forwardedRequestHeaders ??
existingFlatRouteTrigger.forwardedRequestHeaders,
};
};
@@ -1,23 +0,0 @@
import {
ConflictError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
RouteTriggerException,
RouteTriggerExceptionCode,
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
export const routeTriggerGraphQLApiExceptionHandler = (error: Error): never => {
if (error instanceof RouteTriggerException) {
switch (error.code) {
case RouteTriggerExceptionCode.ROUTE_NOT_FOUND:
throw new NotFoundError('Route not found');
case RouteTriggerExceptionCode.ROUTE_ALREADY_EXIST:
throw new ConflictError('Route already exists');
default:
throw error;
}
}
throw error;
};