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:
martmull
2026-06-24 15:34:12 +02:00
committed by GitHub
parent 20ac0a52bf
commit b5a1aed24b
36 changed files with 1231 additions and 1061 deletions
@@ -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,
});
});
});
@@ -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,
);
}
}
}
}
@@ -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),
});
}
}
@@ -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,
}),
);
}
}
@@ -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 {}
@@ -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;
}
}
}