Common API - create common find one query (#14720)

closes https://github.com/twentyhq/core-team-issues/issues/1418

Tested : 
- findOne on Rest and Gql

### Vision
#### Common
- Common is kind of renamed Gql base resolver
- Common handles args (filter, values, ...) validation
- Common accepts depth or raw gql selected fields to compute
selectedFields
- Common is directly called by each CommonQueries (findOne, ...)
service, which extend CommonBaseQuery service
- Common sequence : 
| - Parse & Validate args (args-handlers, to create)
| - Build query (query-parsers : currently in gql-query-parsers, to
move)
| - Execute query
| - Fetch relation + format
#### Rest
- Simple parsing (without metadata validation)
- Calling Common API
- Simple rest response formatting
#### Gql
- Calling Common API
This commit is contained in:
Etienne
2025-10-08 12:18:42 +02:00
committed by GitHub
parent 8914ba9fec
commit c693c4d9cf
49 changed files with 1230 additions and 110 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:latest
image: clickhouse/clickhouse-server:25.8.8
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
+1 -1
View File
@@ -191,7 +191,7 @@ jobs:
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:latest
image: clickhouse/clickhouse-server:25.8.8
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
@@ -1195,6 +1195,7 @@ export enum FeatureFlagKey {
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
IS_COMMON_API_ENABLED = 'IS_COMMON_API_ENABLED',
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
@@ -1159,6 +1159,7 @@ export enum FeatureFlagKey {
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
IS_COMMON_API_ENABLED = 'IS_COMMON_API_ENABLED',
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
@@ -0,0 +1,3 @@
import { CommonSelectedFieldsHandler } from 'src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler';
export const CommonArgsHandlers = [CommonSelectedFieldsHandler];
@@ -0,0 +1,140 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import {
Depth,
MAX_DEPTH,
} from 'src/engine/api/rest/input-factories/depth-input.factory';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@Injectable()
export class CommonSelectedFieldsHandler {
computeFromDepth = ({
objectsPermissions,
objectMetadataMaps,
objectMetadataMapItem,
depth,
}: {
objectsPermissions: ObjectsPermissions;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
depth: Depth | undefined;
}): CommonSelectedFieldsResult => {
const restrictedFields =
objectsPermissions[objectMetadataMapItem.id].restrictedFields;
const { relations, relationsSelectFields } =
this.getRelationsAndRelationsSelectFields({
objectMetadataMaps,
objectMetadataMapItem,
objectsPermissions,
depth,
});
const selectableFields = getAllSelectableFields({
restrictedFields,
objectMetadata: {
objectMetadataMapItem,
},
});
return {
select: {
...selectableFields,
...relationsSelectFields,
},
relations,
aggregate: {},
};
};
private getRelationsAndRelationsSelectFields({
objectMetadataMaps,
objectMetadataMapItem,
objectsPermissions,
depth,
}: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
objectsPermissions: ObjectsPermissions;
depth: Depth | undefined;
}) {
if (!isDefined(depth) || depth === 0) {
return {
relations: {},
relationsSelectFields: {},
};
}
let relations: { [key: string]: boolean | { [key: string]: boolean } } = {};
let relationsSelectFields: {
[key: string]:
| boolean
| { [key: string]: boolean | { [key: string]: boolean } };
} = {};
for (const field of Object.values(objectMetadataMapItem.fieldsById)) {
if (!isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION))
continue;
const relationTargetObjectMetadata =
objectMetadataMaps.byId[field.relationTargetObjectMetadataId];
if (!isDefined(relationTargetObjectMetadata)) {
throw new BadRequestException(
`Object metadata relation target not found for relation creation payload`,
);
}
const relationFieldSelectFields = getAllSelectableFields({
restrictedFields:
objectsPermissions[relationTargetObjectMetadata.id].restrictedFields,
objectMetadata: {
objectMetadataMapItem: relationTargetObjectMetadata,
},
});
if (Object.keys(relationFieldSelectFields).length === 0) continue;
if (
depth === MAX_DEPTH &&
isDefined(field.relationTargetObjectMetadataId)
) {
const {
relations: depth2Relations,
relationsSelectFields: depth2RelationsSelectFields,
} = this.getRelationsAndRelationsSelectFields({
objectMetadataMaps,
objectMetadataMapItem: relationTargetObjectMetadata,
objectsPermissions,
depth: 1,
}) as {
relations: { [key: string]: boolean };
relationsSelectFields: {
[key: string]: boolean;
};
};
relations[field.name] = depth2Relations as {
[key: string]: boolean;
};
relationsSelectFields[field.name] = {
...relationFieldSelectFields,
...depth2RelationsSelectFields,
};
} else {
relations[field.name] = true;
relationsSelectFields[field.name] = relationFieldSelectFields;
}
}
return { relations, relationsSelectFields };
}
}
@@ -0,0 +1,214 @@
import { Inject, Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';
import { CommonSelectedFieldsHandler } from 'src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler';
import { CommonQueryNames } from 'src/engine/api/common/types/common-query-args.type';
import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/query-result-getters.factory';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export abstract class CommonBaseQueryRunnerService<
Response extends
| ObjectRecord
| ObjectRecord[]
| IConnection<ObjectRecord, IEdge<ObjectRecord>>
| IConnection<ObjectRecord, IEdge<ObjectRecord>>[],
> {
@Inject()
protected readonly workspaceQueryHookService: WorkspaceQueryHookService;
@Inject()
protected readonly queryRunnerArgsFactory: QueryRunnerArgsFactory;
@Inject()
protected readonly queryResultGettersFactory: QueryResultGettersFactory;
@Inject()
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager;
@Inject()
protected readonly processNestedRelationsHelper: ProcessNestedRelationsHelper;
@Inject()
protected readonly permissionsService: PermissionsService;
@Inject()
protected readonly userRoleService: UserRoleService;
@Inject()
protected readonly apiKeyRoleService: ApiKeyRoleService;
@Inject()
protected readonly selectedFieldsHandler: CommonSelectedFieldsHandler;
@Inject()
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService;
public async prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
}: {
authContext: WorkspaceAuthContext;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}) {
if (objectMetadataItemWithFieldMaps.isSystem === true) {
await this.validateSettingsPermissionsOnObjectOrThrow(
authContext,
objectMetadataItemWithFieldMaps,
);
}
const workspace = authContext.workspace;
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: workspace.id,
});
const { roleId } = await this.getRoleIdAndObjectsPermissions(
authContext,
workspace.id,
);
const repository = workspaceDataSource.getRepository(
objectMetadataItemWithFieldMaps.nameSingular,
false,
roleId,
authContext,
);
return {
workspaceDataSource,
repository,
isExecutedByApiKey: isDefined(authContext.apiKey),
roleId,
shouldBypassPermissionChecks: false,
};
}
public async enrichResultsWithGettersAndHooks({
results,
operationName,
authContext,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
}: {
results: Response;
operationName: CommonQueryNames;
authContext: WorkspaceAuthContext;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
}) {
const resultWithGetters = await this.queryResultGettersFactory.create(
results,
objectMetadataItemWithFieldMaps,
authContext.workspace.id,
objectMetadataMaps,
);
await this.workspaceQueryHookService.executePostQueryHooks(
authContext,
objectMetadataItemWithFieldMaps.nameSingular,
operationName,
resultWithGetters,
);
return resultWithGetters;
}
private async validateSettingsPermissionsOnObjectOrThrow(
authContext: WorkspaceAuthContext,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
const workspace = authContext.workspace;
if (
Object.keys(OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS).includes(
objectMetadataItemWithFieldMaps.nameSingular,
)
) {
const permissionRequired: PermissionFlagType =
OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS[
objectMetadataItemWithFieldMaps.nameSingular as keyof typeof OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS
];
const userHasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId: authContext.userWorkspaceId,
setting: permissionRequired,
workspaceId: workspace.id,
apiKeyId: authContext.apiKey?.id,
});
if (!userHasPermission) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
}
}
private async getRoleIdAndObjectsPermissions(
authContext: AuthContext,
workspaceId: string,
) {
let roleId: string;
if (
!isDefined(authContext.apiKey) &&
!isDefined(authContext.userWorkspaceId)
) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_AUTHENTICATION_CONTEXT,
PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT,
);
}
if (isDefined(authContext.apiKey)) {
roleId = await this.apiKeyRoleService.getRoleIdForApiKey(
authContext.apiKey.id,
workspaceId,
);
} else {
const userWorkspaceRoleId =
await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
if (!isDefined(userWorkspaceRoleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
);
}
roleId = userWorkspaceRoleId;
}
const objectMetadataPermissions =
await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles(
{
workspaceId: workspaceId,
roleIds: [roleId],
},
);
return { roleId, objectsPermissions: objectMetadataPermissions[roleId] };
}
}
@@ -0,0 +1,185 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import {
ObjectRecord,
ObjectRecordFilter,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import {
CommonQueryNames,
FindOneQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type';
import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerService<ObjectRecord> {
async run({
args,
authContext: toValidateAuthContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
}: {
args: FindOneQueryArgs;
authContext: AuthContext;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<ObjectRecord> {
const authContext = toValidateAuthContext;
if (!isWorkspaceAuthContext(authContext)) {
throw new CommonQueryRunnerException(
'Invalid auth context',
CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT,
);
}
const {
workspaceDataSource,
repository,
roleId,
shouldBypassPermissionChecks,
} = await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const processedArgs = await this.processQueryArgs({
authContext,
objectMetadataItemWithFieldMaps,
args,
});
if (
!processedArgs.filter ||
Object.keys(processedArgs.filter).length === 0
) {
throw new CommonQueryRunnerException(
'Missing filter argument',
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
const queryBuilder = repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
//TODO : Refacto-common - QueryParser should be common branded service
const commonQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
commonQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
processedArgs.filter ?? ({} as ObjectRecordFilter),
);
commonQueryParser.applyDeletedAtToBuilder(
queryBuilder,
processedArgs.filter ?? ({} as ObjectRecordFilter),
);
const columnsToSelect = buildColumnsToSelect({
select: args.selectedFieldsResult.select,
relations: args.selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const objectRecord = await queryBuilder
.setFindOptions({
select: columnsToSelect,
})
.getOne();
if (!objectRecord) {
throw new CommonQueryRunnerException(
'Record not found',
CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
const objectRecords = [objectRecord] as ObjectRecord[];
if (isDefined(args.selectedFieldsResult.relations)) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: objectRecords,
//TODO : Refacto-common - To fix when switching processNestedRelationsHelper to Common
relations: args.selectedFieldsResult.relations as Record<
string,
FindOptionsRelations<ObjectLiteral>
>,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource,
roleId,
shouldBypassPermissionChecks,
selectedFields: args.selectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
const results = typeORMObjectRecordsParser.processRecord({
objectRecord: objectRecords[0],
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}) as ObjectRecord;
return this.enrichResultsWithGettersAndHooks({
results,
authContext,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
operationName: CommonQueryNames.findOne,
});
}
async processQueryArgs({
authContext,
objectMetadataItemWithFieldMaps,
args,
}: {
authContext: WorkspaceAuthContext;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
args: FindOneQueryArgs;
}): Promise<FindOneQueryArgs> {
const hookedArgs =
(await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItemWithFieldMaps.nameSingular,
CommonQueryNames.findOne,
args,
//TODO : Refacto-common - To fix when updating workspaceQueryHookService, removing gql typing dependency
)) as FindOneQueryArgs;
return {
...hookedArgs,
filter: this.queryRunnerArgsFactory.overrideFilterByFieldMetadata(
hookedArgs.filter,
objectMetadataItemWithFieldMaps,
),
};
}
}
@@ -0,0 +1,3 @@
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
export const CommonQueryRunners = [CommonFindOneQueryRunnerService];
@@ -0,0 +1,9 @@
import { CustomException } from 'src/utils/custom-exception';
export class CommonQueryRunnerException extends CustomException<CommonQueryRunnerExceptionCode> {}
export enum CommonQueryRunnerExceptionCode {
RECORD_NOT_FOUND = 'RECORD_NOT_FOUND',
INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT',
INVALID_AUTH_CONTEXT = 'INVALID_AUTH_CONTEXT',
}
@@ -0,0 +1,27 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
CommonQueryRunnerExceptionCode,
type CommonQueryRunnerException,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import {
AuthenticationError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const commonQueryRunnerToGraphqlApiExceptionHandler = (
error: CommonQueryRunnerException,
) => {
switch (error.code) {
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundError(error);
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
throw new UserInputError(error);
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new AuthenticationError(error);
default: {
return assertUnreachable(error.code);
}
}
};
@@ -0,0 +1,28 @@
import {
BadRequestException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import {
CommonQueryRunnerExceptionCode,
type CommonQueryRunnerException,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
export const commonQueryRunnerToRestApiExceptionHandler = (
error: CommonQueryRunnerException,
): never => {
switch (error.code) {
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
throw new BadRequestException(error.message);
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundException('Record not found');
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new UnauthorizedException(error.message);
default: {
return assertUnreachable(error.code);
}
}
};
@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommonArgsHandlers } from 'src/engine/api/common/common-args-handlers/common-query-selected-fields/common-arg-handlers';
import { CommonQueryRunners } from 'src/engine/api/common/common-query-runners/common-query-runners';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
@Module({
imports: [
WorkspaceQueryHookModule,
WorkspaceQueryRunnerModule,
PermissionsModule,
TypeOrmModule.forFeature([RoleTargetsEntity]),
UserRoleModule,
ApiKeyModule,
WorkspacePermissionsCacheModule,
],
providers: [
ProcessNestedRelationsHelper,
ProcessNestedRelationsV2Helper,
...CommonArgsHandlers,
ProcessAggregateHelper,
...CommonQueryRunners,
],
exports: [...CommonQueryRunners],
})
export class CoreCommonApiModule {}
@@ -0,0 +1,21 @@
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
interface AuthContextWithDefinedWorkspaceProperties {
user: AuthContext['user'];
workspace: NonNullable<AuthContext['workspace']>;
workspaceMetadataVersion?: string;
workspaceMemberId: AuthContext['workspaceMemberId'];
userWorkspaceId: AuthContext['userWorkspaceId'];
apiKey: AuthContext['apiKey'];
}
interface ApiKeyAuthContext extends Request {
apiKey: NonNullable<AuthContext['apiKey']>;
}
interface UserWorkspaceAuthContext extends Request {
userWorkspaceId: NonNullable<AuthContext['userWorkspaceId']>;
}
export type WorkspaceAuthContext = AuthContextWithDefinedWorkspaceProperties &
(ApiKeyAuthContext | UserWorkspaceAuthContext);
@@ -0,0 +1,14 @@
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
export enum CommonQueryNames {
findOne = 'findOne',
}
export interface FindOneQueryArgs {
selectedFieldsResult: CommonSelectedFieldsResult;
filter?: ObjectRecordFilter;
}
export type CommonQueryArgs = FindOneQueryArgs;
@@ -0,0 +1,9 @@
interface SelectedFields {
[key: string]: boolean | SelectedFields;
}
export type CommonSelectedFieldsResult = {
select: SelectedFields;
relations: SelectedFields;
aggregate: SelectedFields;
};
@@ -0,0 +1,14 @@
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
export const isWorkspaceAuthContext = (
context: AuthContext,
): context is WorkspaceAuthContext => {
return (
isDefined(context.workspace) &&
(isDefined(context.userWorkspaceId) || isDefined(context.apiKey))
);
};
@@ -1,3 +1,4 @@
//TODO : Refacto-common - To delete
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
@@ -1,3 +1,4 @@
//TODO : Refacto-common - Should be moved to common api layer
export interface ObjectRecord {
id: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -7,7 +7,7 @@ import {
type ObjectRecord,
type ObjectRecordFilter,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import {
type CreateManyResolverArgs,
type CreateOneResolverArgs,
@@ -23,9 +23,9 @@ import {
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { type FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
@Injectable()
export class QueryRunnerArgsFactory {
@@ -214,7 +214,7 @@ export class QueryRunnerArgsFactory {
return allOverriddenRecords;
}
private overrideFilterByFieldMetadata(
public overrideFilterByFieldMetadata(
filter: ObjectRecordFilter | undefined,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
@@ -6,7 +6,7 @@ import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/objec
export interface WorkspaceQueryRunnerOptions {
authContext: AuthContext;
info: GraphQLResolveInfo;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
info: GraphQLResolveInfo;
}
@@ -1,5 +1,7 @@
import { type QueryFailedError } from 'typeorm';
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { commonQueryRunnerToGraphqlApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util';
import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util';
import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util';
@@ -18,6 +20,7 @@ import { twentyORMGraphqlApiExceptionHandler } from 'src/engine/twenty-orm/utils
interface QueryFailedErrorWithCode extends QueryFailedError {
code: string;
}
//TODO : Refacto-common - Should be handle first in common api layer
export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
error: QueryFailedErrorWithCode,
@@ -33,6 +36,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
return graphqlQueryRunnerExceptionHandler(error);
case error instanceof TwentyORMException:
return twentyORMGraphqlApiExceptionHandler(error);
case error instanceof CommonQueryRunnerException:
return commonQueryRunnerToGraphqlApiExceptionHandler(error);
case error instanceof AuthException:
return authGraphqlApiExceptionHandler(error);
case error instanceof ApiKeyException:
@@ -5,6 +5,7 @@ import merge from 'lodash.merge';
import { type QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-field-value';
import { type WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { CommonQueryNames } from 'src/engine/api/common/types/common-query-args.type';
import { type WorkspaceQueryHookKey } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { WorkspaceQueryHookStorage } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/storage/workspace-query-hook.storage';
import { type WorkspacePreQueryHookPayload } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
@@ -18,8 +19,9 @@ export class WorkspaceQueryHookService {
private readonly workspaceQueryHookExplorer: WorkspaceQueryHookExplorer,
) {}
//TODO : Refacto-common - Should be Common
public async executePreQueryHooks<
T extends WorkspaceResolverBuilderMethodNames,
T extends WorkspaceResolverBuilderMethodNames | CommonQueryNames,
>(
authContext: AuthContext,
// TODO: We should allow wildcard for object name
@@ -13,8 +13,8 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { EntityEventsToDbListener } from './listeners/entity-events-to-db.listener';
@@ -1,15 +1,21 @@
import { Injectable } from '@nestjs/common';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import graphqlFields from 'graphql-fields';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type FindOneResolverArgs,
type Resolver,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class FindOneResolverFactory
@@ -18,6 +24,8 @@ export class FindOneResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.FIND_ONE;
constructor(
private readonly commonFindOneQueryRunnerService: CommonFindOneQueryRunnerService,
private readonly featureFlagService: FeatureFlagService,
private readonly graphqlQueryRunnerService: GraphqlQueryFindOneResolverService,
) {}
@@ -27,17 +35,45 @@ export class FindOneResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
internalContext.authContext.workspace?.id as string,
);
if (isCommonApiEnabled) {
try {
const graphqlQueryParser = new GraphqlQueryParser(
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
return await this.commonFindOneQueryRunnerService.run({
args: { ...args, selectedFieldsResult },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
return await this.graphqlQueryRunnerService.execute(
args,
options,
{
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
},
FindOneResolverFactory.methodName,
);
};
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module';
import { GraphqlQueryRunnerModule } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module';
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -9,7 +10,7 @@ import { WorkspaceResolverFactory } from './workspace-resolver.factory';
import { workspaceResolverBuilderFactories } from './factories/factories';
@Module({
imports: [GraphqlQueryRunnerModule, FeatureFlagModule],
imports: [GraphqlQueryRunnerModule, FeatureFlagModule, CoreCommonApiModule],
providers: [
...workspaceResolverBuilderFactories,
WorkspaceResolverFactory,
@@ -12,10 +12,11 @@ import {
UseGuards,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Response } from 'express';
import { RestApiCoreService } from 'src/engine/api/rest/core/services/rest-api-core.service';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -27,7 +28,10 @@ export class RestApiCoreController {
constructor(private readonly restApiCoreService: RestApiCoreService) {}
@Post('batch/*')
async handleApiPostBatch(@Req() request: Request, @Res() res: Response) {
async handleApiPostBatch(
@Req() request: AuthenticatedRequest,
@Res() res: Response,
) {
this.logger.log(
`[REST API] Processing BATCH request to ${request.path} on workspace ${request.workspaceId}`,
);
@@ -37,7 +41,10 @@ export class RestApiCoreController {
}
@Post('*/duplicates')
async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) {
async handleApiFindDuplicates(
@Req() request: AuthenticatedRequest,
@Res() res: Response,
) {
this.logger.log(
`[REST API] Processing DUPLICATES request to ${request.path} on workspace ${request.workspaceId}`,
);
@@ -47,7 +54,10 @@ export class RestApiCoreController {
}
@Post('*')
async handleApiPost(@Req() request: Request, @Res() res: Response) {
async handleApiPost(
@Req() request: AuthenticatedRequest,
@Res() res: Response,
) {
this.logger.log(
`[REST API] Processing POST request to ${request.path} on workspace ${request.workspaceId}`,
);
@@ -57,7 +67,10 @@ export class RestApiCoreController {
}
@Get('*')
async handleApiGet(@Req() request: Request, @Res() res: Response) {
async handleApiGet(
@Req() request: AuthenticatedRequest,
@Res() res: Response,
) {
this.logger.log(
`[REST API] Processing GET request to ${request.path} on workspace ${request.workspaceId}`,
);
@@ -67,7 +80,10 @@ export class RestApiCoreController {
}
@Delete('*')
async handleApiDelete(@Req() request: Request, @Res() res: Response) {
async handleApiDelete(
@Req() request: AuthenticatedRequest,
@Res() res: Response,
) {
this.logger.log(
`[REST API] Processing DELETE request to ${request.path} on workspace ${request.workspaceId}`,
);
@@ -77,7 +93,10 @@ export class RestApiCoreController {
}
@Patch('*')
async handleApiPatch(@Req() request: Request, @Res() res: Response) {
async handleApiPatch(
@Req() request: AuthenticatedRequest,
@Res() res: Response,
) {
this.logger.log(
`[REST API] Processing PATCH request to ${request.path} on workspace ${request.workspaceId}`,
);
@@ -90,7 +109,10 @@ export class RestApiCoreController {
// We keep it to avoid a breaking change since it initially used PUT instead
// of PATCH, and because the PUT verb is often used as a PATCH.
@Put('*')
async handleApiPut(@Req() request: Request, @Res() res: Response) {
async handleApiPut(
@Req() request: AuthenticatedRequest,
@Res() res: Response,
) {
this.logger.log(
`[REST API] Processing PUT request to ${request.path} on workspace ${request.workspaceId}`,
);
@@ -4,17 +4,17 @@ import {
InternalServerErrorException,
} from '@nestjs/common';
import { type Request } from 'express';
import isEmpty from 'lodash.isempty';
import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@Injectable()
export class RestApiCreateManyHandler extends RestApiBaseHandler {
async handle(request: Request) {
async handle(request: AuthenticatedRequest) {
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
@@ -4,17 +4,17 @@ import {
InternalServerErrorException,
} from '@nestjs/common';
import { type Request } from 'express';
import isEmpty from 'lodash.isempty';
import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@Injectable()
export class RestApiCreateOneHandler extends RestApiBaseHandler {
async handle(request: Request) {
async handle(request: AuthenticatedRequest) {
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
@@ -1,15 +1,14 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@Injectable()
export class RestApiDeleteOneHandler extends RestApiBaseHandler {
async handle(request: Request) {
async handle(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
if (!recordId) {
@@ -10,11 +10,12 @@ import {
RestApiBaseHandler,
} from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { buildDuplicateConditions } from 'src/engine/api/utils/build-duplicate-conditions.utils';
@Injectable()
export class RestApiFindDuplicatesHandler extends RestApiBaseHandler {
async handle(request: Request) {
async handle(request: AuthenticatedRequest) {
this.validate(request);
const {
@@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
@Injectable()
export class RestApiFindManyHandler extends RestApiBaseHandler {
async handle(request: Request) {
async handle(request: AuthenticatedRequest) {
const {
repository,
objectMetadata,
@@ -1,15 +1,24 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util';
@Injectable()
export class RestApiFindOneHandler extends RestApiBaseHandler {
async handle(request: Request) {
constructor(
private readonly commonFindOneQueryRunnerService: CommonFindOneQueryRunnerService,
) {
super();
}
async handle(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
if (!isDefined(recordId)) {
@@ -46,4 +55,69 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
data: record,
});
}
async commonHandle(request: AuthenticatedRequest) {
try {
const { filter, depth } = await this.parseRequestArgs(request);
const {
authContext,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
} = await this.buildCommonOptions(request);
const selectedFieldsResult = await this.computeSelectedFields({
depth,
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
objectMetadataMaps,
authContext,
});
const record = await this.commonFindOneQueryRunnerService.run({
args: { filter, selectedFieldsResult },
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
});
return this.formatRestResponse(
record,
objectMetadataItemWithFieldMaps.nameSingular,
);
} catch (error) {
return workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
private formatRestResponse(record: ObjectRecord, objectNameSingular: string) {
return { data: { [objectNameSingular]: record } };
}
private async parseRequestArgs(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
const filter = { id: { eq: recordId } };
const depth = this.depthInputFactory.create(request);
return {
filter,
depth,
};
}
private async buildCommonOptions(request: AuthenticatedRequest) {
const { object: parsedObject } = parseCorePath(request);
const { objectMetadataMaps, objectMetadataMapItem } =
await this.coreQueryBuilderFactory.getObjectMetadata(
request,
parsedObject,
);
const authContext = this.getAuthContextFromRequest(request);
return {
authContext: authContext,
objectMetadataItemWithFieldMaps: objectMetadataMapItem,
objectMetadataMaps: objectMetadataMaps,
};
}
}
@@ -4,18 +4,18 @@ import {
InternalServerErrorException,
} from '@nestjs/common';
import { type Request } from 'express';
import isEmpty from 'lodash.isempty';
import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@Injectable()
export class RestApiUpdateOneHandler extends RestApiBaseHandler {
async handle(request: Request) {
async handle(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
if (!recordId) {
@@ -10,6 +10,7 @@ import {
import { capitalize, isDefined } from 'twenty-shared/utils';
import { In, type ObjectLiteral } from 'typeorm';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import {
type ObjectRecord,
type ObjectRecordFilter,
@@ -20,17 +21,18 @@ import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { RestToCommonSelectedFieldsHandler } from 'src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler';
import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
import {
type Depth,
DepthInputFactory,
MAX_DEPTH,
type Depth,
} from 'src/engine/api/rest/input-factories/depth-input.factory';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import {
@@ -40,13 +42,16 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
export interface PageInfo {
hasNextPage?: boolean;
@@ -94,13 +99,21 @@ export abstract class RestApiBaseHandler {
@Inject()
protected readonly createdByFromAuthContextService: CreatedByFromAuthContextService;
@Inject()
protected readonly workspaceCacheStorageService: WorkspaceCacheStorageService;
@Inject()
protected readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService;
@Inject()
protected readonly apiKeyRoleService: ApiKeyRoleService;
@Inject()
protected readonly restToCommonSelectedFieldsHandler: RestToCommonSelectedFieldsHandler;
@Inject()
protected readonly userRoleService: UserRoleService;
protected abstract handle(
request: Request,
request: AuthenticatedRequest,
): Promise<FormatResult | { data: FormatResult[] }>;
public async getRepositoryAndMetadataOrFail(request: Request) {
public async getRepositoryAndMetadataOrFail(request: AuthenticatedRequest) {
const { workspace, apiKey, userWorkspaceId } = request;
const { object: parsedObject } = parseCorePath(request);
@@ -109,14 +122,14 @@ export abstract class RestApiBaseHandler {
parsedObject,
);
if (!objectMetadata) {
throw new BadRequestException('Object metadata not found');
}
if (!workspace?.id) {
throw new BadRequestException('Workspace not found');
}
if (!objectMetadata) {
throw new BadRequestException('Object metadata not found');
}
const workspaceDataSource = await this.twentyORMManager.getDatasource();
const objectMetadataNameSingular =
@@ -134,18 +147,22 @@ export abstract class RestApiBaseHandler {
);
}
let roleId: string | undefined = undefined;
let shouldBypassPermissionChecks = false;
let roleId: string;
if (isDefined(apiKey)) {
roleId = await this.apiKeyRoleService.getRoleIdForApiKey(
apiKey.id,
workspace.id,
);
}
if (isDefined(userWorkspaceId)) {
roleId =
if (!isDefined(roleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.API_KEY_ROLE_NOT_FOUND,
PermissionsExceptionCode.API_KEY_ROLE_NOT_FOUND,
);
}
} else {
const userWorkspaceRoleId =
await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId(
{
workspaceId: workspace.id,
@@ -153,60 +170,53 @@ export abstract class RestApiBaseHandler {
},
);
if (!roleId) {
if (!isDefined(userWorkspaceRoleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
);
}
}
if (!isDefined(apiKey) && !isDefined(userWorkspaceId)) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_AUTHENTICATION_CONTEXT,
PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT,
);
roleId = userWorkspaceRoleId;
}
const repository = workspaceDataSource.getRepository<ObjectRecord>(
objectMetadataNameSingular,
shouldBypassPermissionChecks,
false,
roleId,
);
let restrictedFields: RestrictedFieldsPermissions = {};
const objectMetadataPermissions =
await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles(
{
workspaceId: workspace.id,
roleIds: roleId ? [roleId] : undefined,
},
);
if (roleId) {
const objectMetadataPermissions =
await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles(
{
workspaceId: workspace.id,
roleIds: roleId ? [roleId] : undefined,
},
);
if (
!isDefined(
objectMetadataPermissions?.[roleId]?.[
objectMetadata.objectMetadataMapItem.id
]?.restrictedFields,
)
) {
throw new InternalServerError('Fields permissions not found for role');
}
restrictedFields =
objectMetadataPermissions[roleId][
if (
!isDefined(
objectMetadataPermissions?.[roleId]?.[
objectMetadata.objectMetadataMapItem.id
].restrictedFields;
]?.restrictedFields,
)
) {
throw new InternalServerError('Fields permissions not found for role');
}
const restrictedFields =
objectMetadataPermissions[roleId][objectMetadata.objectMetadataMapItem.id]
.restrictedFields;
return {
objectMetadata,
repository,
workspaceDataSource,
objectMetadataItemWithFieldsMaps,
restrictedFields,
isExecutedByApiKey: isDefined(apiKey),
authContext: this.getAuthContextFromRequest(request),
objectsPermissions: objectMetadataPermissions[roleId],
};
}
@@ -325,14 +335,10 @@ export abstract class RestApiBaseHandler {
return orderedRecords;
}
public getAuthContextFromRequest(request: Request): AuthContext {
return {
user: request.user,
workspace: request.workspace,
apiKey: request.apiKey,
workspaceMemberId: request.workspaceMemberId,
userWorkspaceId: request.userWorkspaceId,
};
public getAuthContextFromRequest(
request: AuthenticatedRequest,
): WorkspaceAuthContext {
return request;
}
public formatResult<T>({
@@ -546,4 +552,62 @@ export abstract class RestApiBaseHandler {
throw new BadRequestException(`Invalid cursor: ${cursor}`);
}
};
private getObjectsPermissions = async (authContext: WorkspaceAuthContext) => {
let roleId: string;
if (isDefined(authContext.apiKey)) {
roleId = await this.apiKeyRoleService.getRoleIdForApiKey(
authContext.apiKey.id,
authContext.workspace.id,
);
} else {
const userWorkspaceRoleId =
await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId: authContext.workspace.id,
});
if (!isDefined(userWorkspaceRoleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
);
}
roleId = userWorkspaceRoleId;
}
const objectMetadataPermissions =
await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles(
{
workspaceId: authContext.workspace.id,
roleIds: [roleId],
},
);
return { objectsPermissions: objectMetadataPermissions[roleId] };
};
async computeSelectedFields({
authContext,
depth,
objectMetadataMapItem,
objectMetadataMaps,
}: {
authContext: WorkspaceAuthContext;
depth: Depth;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
}) {
const { objectsPermissions } =
await this.getObjectsPermissions(authContext);
return this.restToCommonSelectedFieldsHandler.computeFromDepth({
objectsPermissions,
objectMetadataMaps,
objectMetadataMapItem,
depth,
});
}
}
@@ -2,19 +2,23 @@ import { Module } from '@nestjs/common';
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
AuthModule,
ApiKeyModule,
DomainManagerModule,
FeatureFlagModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataCacheModule,
WorkspacePermissionsCacheModule,
],
providers: [...coreQueryBuilderFactories, CoreQueryBuilderFactory],
exports: [CoreQueryBuilderFactory],
@@ -1,8 +1,8 @@
import { CreateManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-many-query.factory';
import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory';
import { FindDuplicatesQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-query.factory';
import { FindDuplicatesVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-variables.factory';
import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory';
import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory';
import { inputFactories } from 'src/engine/api/rest/input-factories/factories';
export const coreQueryBuilderFactories = [
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
@@ -29,7 +30,7 @@ export class GetVariablesFactory {
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
): QueryVariables {
if (id) {
if (isDefined(id)) {
return { filter: { id: { eq: id } } };
}
@@ -1,6 +1,7 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module';
import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller';
import { RestApiCreateManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-many.handler';
import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler';
@@ -11,13 +12,17 @@ import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-ap
import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler';
import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module';
import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories';
import { restToCommonArgsHandlers } from 'src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers';
import { RestApiCoreService } from 'src/engine/api/rest/core/services/rest-api-core.service';
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -38,12 +43,16 @@ const restApiCoreResolvers = [
WorkspaceCacheStorageModule,
AuthModule,
ApiKeyModule,
UserRoleModule,
HttpModule,
TwentyORMModule,
RecordTransformerModule,
WorkspacePermissionsCacheModule,
WorkspaceMetadataCacheModule,
ActorModule,
FeatureFlagModule,
CoreCommonApiModule,
DomainManagerModule,
],
controllers: [RestApiCoreController],
providers: [
@@ -51,6 +60,7 @@ const restApiCoreResolvers = [
RestApiCoreService,
...coreQueryBuilderFactories,
...restApiCoreResolvers,
...restToCommonArgsHandlers,
],
})
export class RestApiCoreModule {}
@@ -0,0 +1,3 @@
import { RestToCommonSelectedFieldsHandler } from 'src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler';
export const restToCommonArgsHandlers = [RestToCommonSelectedFieldsHandler];
@@ -0,0 +1,140 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import {
Depth,
MAX_DEPTH,
} from 'src/engine/api/rest/input-factories/depth-input.factory';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@Injectable()
export class RestToCommonSelectedFieldsHandler {
computeFromDepth = ({
objectsPermissions,
objectMetadataMaps,
objectMetadataMapItem,
depth,
}: {
objectsPermissions: ObjectsPermissions;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
depth: Depth | undefined;
}): CommonSelectedFieldsResult => {
const restrictedFields =
objectsPermissions[objectMetadataMapItem.id].restrictedFields;
const { relations, relationsSelectFields } =
this.getRelationsAndRelationsSelectFields({
objectMetadataMaps,
objectMetadataMapItem,
objectsPermissions,
depth,
});
const selectableFields = getAllSelectableFields({
restrictedFields,
objectMetadata: {
objectMetadataMapItem,
},
});
return {
select: {
...selectableFields,
...relationsSelectFields,
},
relations,
aggregate: {},
};
};
private getRelationsAndRelationsSelectFields({
objectMetadataMaps,
objectMetadataMapItem,
objectsPermissions,
depth,
}: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
objectsPermissions: ObjectsPermissions;
depth: Depth | undefined;
}) {
if (!isDefined(depth) || depth === 0) {
return {
relations: {},
relationsSelectFields: {},
};
}
let relations: { [key: string]: boolean | { [key: string]: boolean } } = {};
let relationsSelectFields: {
[key: string]:
| boolean
| { [key: string]: boolean | { [key: string]: boolean } };
} = {};
for (const field of Object.values(objectMetadataMapItem.fieldsById)) {
if (!isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION))
continue;
const relationTargetObjectMetadata =
objectMetadataMaps.byId[field.relationTargetObjectMetadataId];
if (!isDefined(relationTargetObjectMetadata)) {
throw new BadRequestException(
`Object metadata relation target not found for relation creation payload`,
);
}
const relationFieldSelectFields = getAllSelectableFields({
restrictedFields:
objectsPermissions[relationTargetObjectMetadata.id].restrictedFields,
objectMetadata: {
objectMetadataMapItem: relationTargetObjectMetadata,
},
});
if (Object.keys(relationFieldSelectFields).length === 0) continue;
if (
depth === MAX_DEPTH &&
isDefined(field.relationTargetObjectMetadataId)
) {
const {
relations: depth2Relations,
relationsSelectFields: depth2RelationsSelectFields,
} = this.getRelationsAndRelationsSelectFields({
objectMetadataMaps,
objectMetadataMapItem: relationTargetObjectMetadata,
objectsPermissions,
depth: 1,
}) as {
relations: { [key: string]: boolean };
relationsSelectFields: {
[key: string]: boolean;
};
};
relations[field.name] = depth2Relations as {
[key: string]: boolean;
};
relationsSelectFields[field.name] = {
...relationFieldSelectFields,
...depth2RelationsSelectFields,
};
} else {
relations[field.name] = true;
relationsSelectFields[field.name] = relationFieldSelectFields;
}
}
return { relations, relationsSelectFields };
}
}
@@ -1,16 +1,18 @@
import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-delete-one.handler';
import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler';
import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler';
import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler';
import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler';
import { RestApiCreateManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-many.handler';
import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler';
import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-delete-one.handler';
import { RestApiFindDuplicatesHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler';
import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler';
import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler';
import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class RestApiCoreService {
@@ -22,32 +24,42 @@ export class RestApiCoreService {
private readonly restApiFindOneHandler: RestApiFindOneHandler,
private readonly restApiFindManyHandler: RestApiFindManyHandler,
private readonly restApiFindDuplicatesHandler: RestApiFindDuplicatesHandler,
private readonly featureFlagService: FeatureFlagService,
) {}
async delete(request: Request) {
async delete(request: AuthenticatedRequest) {
return await this.restApiDeleteOneHandler.handle(request);
}
async createOne(request: Request) {
async createOne(request: AuthenticatedRequest) {
return await this.restApiCreateOneHandler.handle(request);
}
async createMany(request: Request) {
async createMany(request: AuthenticatedRequest) {
return await this.restApiCreateManyHandler.handle(request);
}
async findDuplicates(request: Request) {
async findDuplicates(request: AuthenticatedRequest) {
return await this.restApiFindDuplicatesHandler.handle(request);
}
async update(request: Request) {
async update(request: AuthenticatedRequest) {
return await this.restApiUpdateOneHandler.handle(request);
}
async get(request: Request) {
async get(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
if (isDefined(recordId)) {
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
request.workspace.id,
);
if (isCommonApiEnabled) {
return await this.restApiFindOneHandler.commonHandle(request);
}
return await this.restApiFindOneHandler.handle(request);
} else {
return await this.restApiFindManyHandler.handle(request);
@@ -0,0 +1,5 @@
import { type Request } from 'express';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
export type AuthenticatedRequest = Request & WorkspaceAuthContext;
@@ -0,0 +1,19 @@
import { type QueryFailedError } from 'typeorm';
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { commonQueryRunnerToRestApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util';
interface QueryFailedErrorWithCode extends QueryFailedError {
code: string;
}
export const workspaceQueryRunnerRestApiExceptionHandler = (
error: QueryFailedErrorWithCode,
): never => {
switch (true) {
case error instanceof CommonQueryRunnerException:
return commonQueryRunnerToRestApiExceptionHandler(error);
default:
throw error;
}
};
@@ -19,4 +19,5 @@ export enum FeatureFlagKey {
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
IS_COMMON_API_ENABLED = 'IS_COMMON_API_ENABLED',
}
@@ -4,6 +4,8 @@ import {
Injectable,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@@ -26,6 +28,10 @@ export class JwtAuthGuard implements CanActivate {
)
: undefined;
if (!isDefined(data.apiKey) && !isDefined(data.userWorkspaceId)) {
return false;
}
request.user = data.user;
request.apiKey = data.apiKey;
request.workspace = data.workspace;
@@ -142,6 +142,7 @@ describe('WorkspaceEntityManager', () => {
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAILING_DOMAIN_ENABLED: false,
IS_DYNAMIC_SEARCH_FIELDS_ENABLED: false,
IS_COMMON_API_ENABLED: false,
},
eventEmitterService: {
emitMutationEvent: jest.fn(),
@@ -56,6 +56,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: workspaceId === SEED_APPLE_WORKSPACE_ID,
},
{
key: FeatureFlagKey.IS_COMMON_API_ENABLED,
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED,
workspaceId: workspaceId,
@@ -62,9 +62,11 @@ describe('Core REST API Find One endpoint', () => {
method: 'get',
path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`,
})
//TODO : Refacto-common - This should be a 404
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain('Record not found');
//TODO : Refacto-common - This should be a NotFoundException
expect(res.body.error).toBe('BadRequestException');
});
});