feat(server): run server-exposed logic functions in the owner workspace (#22002)
## Summary Implements the server-level logic-function tier in the simplest shape: a logic function is "server-exposed" iff its manifest entry carries `serverWebhookTriggerSettings`. Execution delegates to the owner-workspace copy of that function — billing, throttling, env vars, and the existing executor all apply uniformly against that workspace. Supersedes #21971 with the simplified design from that discussion (no `applicationRegistrationLogicFunction` registry, no dedicated manifest type, no separate SDK helper, no special throttling). ## Design - **Manifest**: `LogicFunctionManifest` gains `serverWebhookTriggerSettings?`. The declarative `workspaceIdResolver` shape is dropped. - **Materialization**: those settings become two new jsonb columns on `LogicFunctionEntity`. The manifest → flat converter and the create-from-source DTO/util forward them; the property-config map and editable-properties list are extended. - **Lookup**: a single QB query joins `logicFunction → application → applicationRegistration` and filters on `lf.workspaceId = reg.workspaceId` to get only the owner workspace's copy. - **Webhook**: `POST /webhooks/server/:logicFunctionUniversalIdentifier` → `ServerWebhookTriggerService.handle` → join lookup → `LogicFunctionTriggerService.run`. No registry table, no `:applicationRegistrationUniversalIdentifier` segment, no resolver. - **Gate**: `IS_SERVER_LOGIC_FUNCTION_ENABLED` config var (disabled by default). ## Test plan - [x] `npx jest server-webhook-trigger` — 9 unit tests across the webhook service. - [x] `npx jest logic-function` — 88 existing tests stay green. - [x] `npx nx typecheck twenty-server`. - [x] `npx nx lint:diff-with-main twenty-server`. - [x] Reset DB → init → run `database:migrate:prod` → run `database:migrate:generate --name pending-migration-check` → no drift. - [ ] Manual: hit `/webhooks/server/<uid>` end-to-end against a manifest carrying `serverWebhookTriggerSettings`. https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh --- _Generated by [Claude Code](https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22002?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. -->
This commit is contained in:
+325
@@ -0,0 +1,325 @@
|
||||
import { type Request } from 'express';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { type LogicFunctionExecuteResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
|
||||
import {
|
||||
LogicFunctionExecutionException,
|
||||
LogicFunctionExecutionExceptionCode,
|
||||
type LogicFunctionExecutorService,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import { ServerRouteTriggerExceptionCode } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
|
||||
import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service';
|
||||
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
|
||||
|
||||
const RESOLVER_UID = 'resolver-uid';
|
||||
const TARGET_UID = 'target-uid';
|
||||
|
||||
const buildExecuteResult = (
|
||||
data: object | null,
|
||||
error?: { errorMessage: string },
|
||||
): LogicFunctionExecuteResult => ({
|
||||
data,
|
||||
duration: 1,
|
||||
logs: '',
|
||||
status: error
|
||||
? LogicFunctionExecutionStatus.ERROR
|
||||
: LogicFunctionExecutionStatus.SUCCESS,
|
||||
...(error
|
||||
? {
|
||||
error: {
|
||||
errorType: 'Error',
|
||||
errorMessage: error.errorMessage,
|
||||
stackTrace: '',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const buildRequest = (body: object | null = {}): Request =>
|
||||
({
|
||||
method: 'POST',
|
||||
path: `/webhooks/server/${RESOLVER_UID}`,
|
||||
query: {},
|
||||
headers: {},
|
||||
rawBody: Buffer.from(JSON.stringify(body ?? {}), 'utf-8'),
|
||||
body,
|
||||
}) as unknown as Request;
|
||||
|
||||
describe('ServerRouteTriggerService', () => {
|
||||
let service: ServerRouteTriggerService;
|
||||
let logicFunctionRepository: jest.Mocked<
|
||||
Pick<Repository<LogicFunctionEntity>, 'find' | 'findOne'>
|
||||
>;
|
||||
let logicFunctionExecutorService: jest.Mocked<
|
||||
Pick<LogicFunctionExecutorService, 'execute'>
|
||||
>;
|
||||
let twentyConfigService: jest.Mocked<Pick<TwentyConfigService, 'get'>>;
|
||||
|
||||
const handle = () =>
|
||||
service.handle({
|
||||
request: buildRequest(),
|
||||
resolverLogicFunctionUniversalIdentifier: RESOLVER_UID,
|
||||
});
|
||||
|
||||
const buildResolverRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'resolver-id',
|
||||
universalIdentifier: RESOLVER_UID,
|
||||
workspaceId: 'owner-ws',
|
||||
serverRouteTriggerSettings: { forwardedRequestHeaders: ['x-test'] },
|
||||
application: {
|
||||
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
logicFunctionRepository = {
|
||||
find: jest.fn().mockResolvedValue([buildResolverRow()]),
|
||||
findOne: jest
|
||||
.fn()
|
||||
// resolver lookup inside runFunction
|
||||
.mockResolvedValueOnce({ id: 'resolver-id' })
|
||||
// target lookup inside runFunction
|
||||
.mockResolvedValueOnce({ id: 'target-id' }),
|
||||
};
|
||||
logicFunctionExecutorService = {
|
||||
execute: jest
|
||||
.fn()
|
||||
// resolver returns { workspaceId, targetLogicFunctionUniversalIdentifier, payload }
|
||||
.mockResolvedValueOnce(
|
||||
buildExecuteResult({
|
||||
workspaceId: 'target-ws',
|
||||
targetLogicFunctionUniversalIdentifier: TARGET_UID,
|
||||
payload: { from: 'resolver' },
|
||||
}),
|
||||
)
|
||||
// target returns the final response body
|
||||
.mockResolvedValueOnce(buildExecuteResult({ ok: true })),
|
||||
};
|
||||
twentyConfigService = { get: jest.fn().mockReturnValue(true) };
|
||||
|
||||
service = new ServerRouteTriggerService(
|
||||
logicFunctionRepository as unknown as Repository<LogicFunctionEntity>,
|
||||
logicFunctionExecutorService as unknown as LogicFunctionExecutorService,
|
||||
twentyConfigService as unknown as TwentyConfigService,
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the resolver in the owner workspace then the resolver-named target in the resolved workspace', async () => {
|
||||
const result = await handle();
|
||||
|
||||
expect(logicFunctionExecutorService.execute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
logicFunctionId: 'resolver-id',
|
||||
workspaceId: 'owner-ws',
|
||||
}),
|
||||
);
|
||||
expect(logicFunctionExecutorService.execute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
logicFunctionId: 'target-id',
|
||||
workspaceId: 'target-ws',
|
||||
payload: { from: 'resolver' },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
statusCode: 200,
|
||||
body: { ok: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses when the feature is disabled', async () => {
|
||||
twentyConfigService.get.mockReturnValue(false);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.FEATURE_DISABLED,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws LOGIC_FUNCTION_NOT_FOUND when no row matches the universalIdentifier', async () => {
|
||||
logicFunctionRepository.find.mockResolvedValue([]);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws LOGIC_FUNCTION_NOT_FOUND when only non-owner-workspace copies exist', async () => {
|
||||
logicFunctionRepository.find.mockResolvedValue([
|
||||
buildResolverRow({
|
||||
workspaceId: 'other-ws',
|
||||
application: {
|
||||
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('picks the owner-workspace copy when multiple workspaces installed the app', async () => {
|
||||
logicFunctionRepository.find.mockResolvedValue([
|
||||
buildResolverRow({
|
||||
id: 'tenant-copy',
|
||||
workspaceId: 'tenant-ws',
|
||||
application: {
|
||||
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
|
||||
},
|
||||
}),
|
||||
buildResolverRow({
|
||||
id: 'owner-copy',
|
||||
workspaceId: 'owner-ws',
|
||||
application: {
|
||||
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
await handle();
|
||||
|
||||
// runFunction's internal findOne is called with the
|
||||
// (universalIdentifier, workspaceId) of the owner-workspace copy.
|
||||
expect(logicFunctionRepository.findOne).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
universalIdentifier: RESOLVER_UID,
|
||||
workspaceId: 'owner-ws',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws RESOLVER_INVALID_RESULT when the resolver does not return a workspaceId', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockResolvedValueOnce(
|
||||
buildExecuteResult({
|
||||
targetLogicFunctionUniversalIdentifier: TARGET_UID,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws RESOLVER_INVALID_RESULT when the resolver does not return a targetLogicFunctionUniversalIdentifier', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockResolvedValueOnce(
|
||||
buildExecuteResult({ workspaceId: 'target-ws' }),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws USER_UNCAUGHT_ERROR when the resolver returns an error', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockResolvedValueOnce(
|
||||
buildExecuteResult(null, { errorMessage: 'boom' }),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws LOGIC_FUNCTION_NOT_FOUND when the target named by the resolver is missing in the resolved workspace', async () => {
|
||||
logicFunctionRepository.findOne.mockReset();
|
||||
logicFunctionRepository.findOne
|
||||
// resolver lookup succeeds
|
||||
.mockResolvedValueOnce({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
id: 'resolver-id',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
// target lookup returns null
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a target userError as a server-route exception', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute
|
||||
.mockResolvedValueOnce(
|
||||
buildExecuteResult({
|
||||
workspaceId: 'target-ws',
|
||||
targetLogicFunctionUniversalIdentifier: TARGET_UID,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildExecuteResult(null, { errorMessage: 'boom' }),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a LogicFunctionExecutionException(LOGIC_FUNCTION_NOT_FOUND) to the server-route not-found code', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockRejectedValue(
|
||||
new LogicFunctionExecutionException(
|
||||
'not found',
|
||||
LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to PLATFORM_ERROR for any other thrown executor error', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it('looks up the resolver by universalIdentifier and loads the application registration chain', async () => {
|
||||
await handle();
|
||||
|
||||
const findArgs = logicFunctionRepository.find.mock.calls[0][0];
|
||||
|
||||
expect(findArgs?.where).toEqual(
|
||||
expect.objectContaining({ universalIdentifier: RESOLVER_UID }),
|
||||
);
|
||||
expect(findArgs?.relations).toEqual(
|
||||
expect.objectContaining({
|
||||
application: expect.objectContaining({
|
||||
applicationRegistration: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a LogicFunctionExecutionException(RATE_LIMIT_EXCEEDED) to the server-route rate-limit code', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockRejectedValue(
|
||||
new LogicFunctionExecutionException(
|
||||
'too many requests',
|
||||
LogicFunctionExecutionExceptionCode.RATE_LIMIT_EXCEEDED,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED,
|
||||
});
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import {
|
||||
ServerRouteTriggerException,
|
||||
ServerRouteTriggerExceptionCode,
|
||||
} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
|
||||
import type { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
@Catch(ServerRouteTriggerException)
|
||||
export class ServerRouteTriggerRestApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(
|
||||
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
catch(exception: ServerRouteTriggerException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
switch (exception.code) {
|
||||
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
404,
|
||||
);
|
||||
case ServerRouteTriggerExceptionCode.FEATURE_DISABLED:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
503,
|
||||
);
|
||||
case ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
429,
|
||||
undefined,
|
||||
undefined,
|
||||
{ shouldBeCapturedBySentry: false },
|
||||
);
|
||||
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
500,
|
||||
undefined,
|
||||
undefined,
|
||||
{ shouldBeCapturedBySentry: false },
|
||||
);
|
||||
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
500,
|
||||
);
|
||||
case ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
502,
|
||||
undefined,
|
||||
undefined,
|
||||
{ shouldBeCapturedBySentry: false },
|
||||
);
|
||||
default: {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum ServerRouteTriggerExceptionCode {
|
||||
FEATURE_DISABLED = 'FEATURE_DISABLED',
|
||||
LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
SERVER_ROUTE_USER_UNCAUGHT_ERROR = 'SERVER_ROUTE_USER_UNCAUGHT_ERROR',
|
||||
SERVER_ROUTE_PLATFORM_ERROR = 'SERVER_ROUTE_PLATFORM_ERROR',
|
||||
RESOLVER_INVALID_RESULT = 'RESOLVER_INVALID_RESULT',
|
||||
}
|
||||
|
||||
const getServerRouteTriggerExceptionUserFriendlyMessage = (
|
||||
code: ServerRouteTriggerExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case ServerRouteTriggerExceptionCode.FEATURE_DISABLED:
|
||||
return msg`Server logic functions are disabled on this instance.`;
|
||||
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return msg`Server logic function not found.`;
|
||||
case ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED:
|
||||
return msg`Rate limit exceeded.`;
|
||||
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR:
|
||||
return msg`Logic function execution failed.`;
|
||||
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR:
|
||||
return msg`An unexpected error occurred while handling the server route.`;
|
||||
case ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT:
|
||||
return msg`Resolver logic function returned an invalid result.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class ServerRouteTriggerException extends CustomException<ServerRouteTriggerExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: ServerRouteTriggerExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getServerRouteTriggerExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Controller,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
|
||||
import { ServerRouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger-rest-api-exception-filter';
|
||||
import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
|
||||
@Controller('webhooks/server')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@UseFilters(ServerRouteTriggerRestApiExceptionFilter)
|
||||
export class ServerRouteTriggerController {
|
||||
constructor(
|
||||
private readonly serverRouteTriggerService: ServerRouteTriggerService,
|
||||
) {}
|
||||
|
||||
@Post(':resolverLogicFunctionUniversalIdentifier')
|
||||
async post(
|
||||
@Param('resolverLogicFunctionUniversalIdentifier')
|
||||
resolverLogicFunctionUniversalIdentifier: string,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
sendRouteTriggerResponse(
|
||||
response,
|
||||
await this.serverRouteTriggerService.handle({
|
||||
request,
|
||||
resolverLogicFunctionUniversalIdentifier,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { LogicFunctionExecutorModule } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module';
|
||||
import { ServerRouteTriggerController } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.controller';
|
||||
import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LogicFunctionEntity]),
|
||||
LogicFunctionExecutorModule,
|
||||
],
|
||||
controllers: [ServerRouteTriggerController],
|
||||
providers: [ServerRouteTriggerService],
|
||||
})
|
||||
export class ServerRouteTriggerModule {}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { Request } from 'express';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
LogicFunctionExecutionException,
|
||||
LogicFunctionExecutionExceptionCode,
|
||||
LogicFunctionExecutorService,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
|
||||
import {
|
||||
type RouteTriggerResponse,
|
||||
buildRouteTriggerResponse,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
|
||||
import {
|
||||
ServerRouteTriggerException,
|
||||
ServerRouteTriggerExceptionCode,
|
||||
} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
|
||||
type ResolverResult = {
|
||||
workspaceId: string;
|
||||
targetLogicFunctionUniversalIdentifier: string;
|
||||
payload?: object;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ServerRouteTriggerService {
|
||||
private readonly logger = new Logger(ServerRouteTriggerService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async handle({
|
||||
request,
|
||||
resolverLogicFunctionUniversalIdentifier,
|
||||
}: {
|
||||
request: Request;
|
||||
resolverLogicFunctionUniversalIdentifier: string;
|
||||
}): Promise<RouteTriggerResponse> {
|
||||
if (!this.twentyConfigService.get('IS_SERVER_LOGIC_FUNCTION_ENABLED')) {
|
||||
throw new ServerRouteTriggerException(
|
||||
'Server logic functions are disabled on this instance',
|
||||
ServerRouteTriggerExceptionCode.FEATURE_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
const resolver = await this.findResolver({
|
||||
logicFunctionUniversalIdentifier:
|
||||
resolverLogicFunctionUniversalIdentifier,
|
||||
});
|
||||
|
||||
if (!isDefined(resolver)) {
|
||||
throw new ServerRouteTriggerException(
|
||||
`Server resolver function ${resolverLogicFunctionUniversalIdentifier} not found`,
|
||||
ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const event = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders:
|
||||
resolver.serverRouteTriggerSettings?.forwardedRequestHeaders ?? [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
const resolverResult = await this.runFunction({
|
||||
logicFunctionUniversalIdentifier: resolver.universalIdentifier,
|
||||
workspaceId: resolver.workspaceId,
|
||||
payload: event,
|
||||
});
|
||||
const resolved = this.parseResolverResult(resolverResult);
|
||||
|
||||
const targetResult = await this.runFunction({
|
||||
logicFunctionUniversalIdentifier:
|
||||
resolved.targetLogicFunctionUniversalIdentifier,
|
||||
workspaceId: resolved.workspaceId,
|
||||
payload: resolved.payload ?? event,
|
||||
});
|
||||
|
||||
if (isDefined(targetResult.error)) {
|
||||
throw new ServerRouteTriggerException(
|
||||
targetResult.error.errorMessage,
|
||||
ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return buildRouteTriggerResponse(targetResult.data);
|
||||
}
|
||||
|
||||
private async findResolver({
|
||||
logicFunctionUniversalIdentifier,
|
||||
}: {
|
||||
logicFunctionUniversalIdentifier: string;
|
||||
}): Promise<LogicFunctionEntity | null> {
|
||||
const candidates = await this.logicFunctionRepository.find({
|
||||
where: { universalIdentifier: logicFunctionUniversalIdentifier },
|
||||
relations: { application: { applicationRegistration: true } },
|
||||
});
|
||||
|
||||
return (
|
||||
candidates.find(
|
||||
(candidate) =>
|
||||
isDefined(candidate.application?.applicationRegistration) &&
|
||||
candidate.workspaceId ===
|
||||
candidate.application.applicationRegistration.ownerWorkspaceId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private parseResolverResult(result: {
|
||||
data: object | null;
|
||||
error?: { errorMessage: string };
|
||||
}): ResolverResult {
|
||||
if (isDefined(result.error)) {
|
||||
throw new ServerRouteTriggerException(
|
||||
result.error.errorMessage,
|
||||
ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const data = result.data as {
|
||||
workspaceId?: unknown;
|
||||
targetLogicFunctionUniversalIdentifier?: unknown;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
if (
|
||||
!isString(data?.workspaceId) ||
|
||||
!isString(data?.targetLogicFunctionUniversalIdentifier)
|
||||
) {
|
||||
throw new ServerRouteTriggerException(
|
||||
'Resolver logic function must return { workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }',
|
||||
ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: data.workspaceId,
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
data.targetLogicFunctionUniversalIdentifier,
|
||||
payload:
|
||||
typeof data.payload === 'object' && data.payload !== null
|
||||
? (data.payload as object)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async runFunction({
|
||||
logicFunctionUniversalIdentifier,
|
||||
workspaceId,
|
||||
payload,
|
||||
}: {
|
||||
logicFunctionUniversalIdentifier: string;
|
||||
workspaceId: string;
|
||||
payload: object;
|
||||
}): Promise<{ data: object | null; error?: { errorMessage: string } }> {
|
||||
const logicFunction = await this.logicFunctionRepository.findOne({
|
||||
where: {
|
||||
universalIdentifier: logicFunctionUniversalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(logicFunction)) {
|
||||
throw new ServerRouteTriggerException(
|
||||
`Logic function ${logicFunctionUniversalIdentifier} not found in workspace ${workspaceId}`,
|
||||
ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.logicFunctionExecutorService.execute({
|
||||
logicFunctionId: logicFunction.id,
|
||||
workspaceId,
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Server logic function ${logicFunction.id} failed in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw new ServerRouteTriggerException(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
this.mapExecutorErrorToServerRouteCode(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapExecutorErrorToServerRouteCode(
|
||||
error: unknown,
|
||||
): ServerRouteTriggerExceptionCode {
|
||||
if (!(error instanceof LogicFunctionExecutionException)) {
|
||||
return ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR;
|
||||
}
|
||||
|
||||
switch (error.code) {
|
||||
case LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND;
|
||||
case LogicFunctionExecutionExceptionCode.RATE_LIMIT_EXCEEDED:
|
||||
return ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED;
|
||||
default:
|
||||
return ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user