Compare commits

...

5 Commits

Author SHA1 Message Date
Thomas Trompette 74add70348 Remove relations from manual trigger output schema (#15578)
Fixes https://github.com/twentyhq/twenty/issues/15319
Fixes https://github.com/twentyhq/twenty/issues/15255

On index page, we load record with only a few relation fields. So record
selected on manual trigger do not contain all the fields. To match that
behavior, let's remove relations from output schema. The user will do a
search on relation id if needed.
2025-11-03 17:55:18 +01:00
Marie d83368bbde [demo] Allow workspace to work with a non-system workspaceMember object (#15547)
For demo purposes, we want to work with workspaceMember has a non-system
object, allowing it to be displayed on the product, to be added custom
fields etc.
It is something we may want to do later anyway. 
This PR adds a bypassPermissionChecks on workspaceMember repository
calls. This does not provide more permissions than before for other
workspaces: workspaceMember being a system object, permissions are
already bypassed for this one. (Note that actions such inviting a new
workspaceMember, updating a workspaceMember are protected by a system
permission checked in the relevant places).

This work does **not** allow any workspace to switch their
workspaceMember object to non-system, the switch is still manual.
2025-11-03 17:54:50 +01:00
Etienne ac5793ff5f Common - fixes (#15463)
Fixes https://github.com/twentyhq/twenty/issues/15435
Fixes https://github.com/twentyhq/private-issues/issues/338
Fixes https://github.com/twentyhq/twenty/issues/15457
2025-11-03 10:29:44 +01:00
Charles Bochet 087a4e384d Fix view favorite making the app crash (#15517)
On new workspaces, as they come without the "view" object which not part
of engine metadata, favorites pointing to views make the app crashes.

This fixes it
2025-11-01 20:06:54 +01:00
prastoin 4c93c90ea1 Revert "Common - Remove feature flag (#15371)"
This reverts commit 0849483d2e.
2025-10-29 12:27:21 +01:00
122 changed files with 6808 additions and 777 deletions
@@ -1244,6 +1244,7 @@ export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_APPLICATION_ENABLED = 'IS_APPLICATION_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',
@@ -1208,6 +1208,7 @@ export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_APPLICATION_ENABLED = 'IS_APPLICATION_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',
@@ -1,6 +1,5 @@
import { FavoriteFolderNavigationDrawerItemDropdown } from '@/favorites/components/FavoriteFolderNavigationDrawerItemDropdown';
import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
import { FavoriteNavigationDrawerSubItem } from '@/favorites/components/FavoriteNavigationDrawerSubItem';
import { FavoritesDroppable } from '@/favorites/components/FavoritesDroppable';
import { FAVORITE_FOLDER_DELETE_MODAL_ID } from '@/favorites/constants/FavoriteFolderDeleteModalId';
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
@@ -8,8 +7,10 @@ import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useDeleteFavoriteFolder } from '@/favorites/hooks/useDeleteFavoriteFolder';
import { useRenameFavoriteFolder } from '@/favorites/hooks/useRenameFavoriteFolder';
import { openFavoriteFolderIdsState } from '@/favorites/states/openFavoriteFolderIdsState';
import { getFavoriteSecondaryLabel } from '@/favorites/utils/getFavoriteSecondaryLabel';
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
@@ -19,13 +20,14 @@ import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpe
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { Droppable } from '@hello-pangea/dnd';
import { useContext, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconFolder, IconFolderOpen, IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
@@ -43,6 +45,7 @@ export const CurrentWorkspaceMemberFavorites = ({
folder,
isGroup,
}: CurrentWorkspaceMemberFavoritesProps) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const currentPath = useLocation().pathname;
const currentViewPath = useLocation().pathname + useLocation().search;
const { isDragging } = useContext(FavoritesDragContext);
@@ -196,8 +199,12 @@ export const CurrentWorkspaceMemberFavorites = ({
index={index}
isInsideScrollableContainer
itemComponent={
<FavoriteNavigationDrawerSubItem
favorite={favorite}
<NavigationDrawerSubItem
secondaryLabel={getFavoriteSecondaryLabel({
objectMetadataItems,
favoriteObjectNameSingular:
favorite.objectNameSingular,
})}
label={favorite.labelIdentifier}
Icon={() => <FavoriteIcon favorite={favorite} />}
to={isDragging ? undefined : favorite.link}
@@ -1,14 +1,17 @@
import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
import { FavoriteNavigationDrawerItem } from '@/favorites/components/FavoriteNavigationDrawerItem';
import { FavoritesDroppable } from '@/favorites/components/FavoritesDroppable';
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { getFavoriteSecondaryLabel } from '@/favorites/utils/getFavoriteSecondaryLabel';
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
@@ -21,6 +24,7 @@ const StyledOrphanFavoritesContainer = styled.div`
`;
export const CurrentWorkspaceMemberOrphanFavorites = () => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const currentPath = useLocation().pathname;
@@ -42,8 +46,11 @@ export const CurrentWorkspaceMemberOrphanFavorites = () => {
isInsideScrollableContainer={true}
itemComponent={
<StyledOrphanFavoritesContainer>
<FavoriteNavigationDrawerItem
favorite={favorite}
<NavigationDrawerItem
secondaryLabel={getFavoriteSecondaryLabel({
objectMetadataItems,
favoriteObjectNameSingular: favorite.objectNameSingular,
})}
label={favorite.labelIdentifier}
Icon={() => <FavoriteIcon favorite={favorite} />}
active={isLocationMatchingFavorite(
@@ -1,50 +0,0 @@
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
type FavoriteNavigationDrawerItemProps = {
favorite: ProcessedFavorite;
label: string;
Icon?: React.ComponentProps<typeof NavigationDrawerItem>['Icon'];
to?: string;
active?: boolean;
rightOptions?: React.ComponentProps<
typeof NavigationDrawerItem
>['rightOptions'];
isDragging?: boolean;
triggerEvent?: React.ComponentProps<
typeof NavigationDrawerItem
>['triggerEvent'];
};
export const FavoriteNavigationDrawerItem = ({
favorite,
label,
Icon,
to,
active,
rightOptions,
isDragging,
triggerEvent,
}: FavoriteNavigationDrawerItemProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: favorite.objectNameSingular,
});
if (!favorite.objectNameSingular) {
return null;
}
return (
<NavigationDrawerItem
label={label}
secondaryLabel={objectMetadataItem.labelSingular}
Icon={Icon}
to={to}
active={active}
rightOptions={rightOptions}
isDragging={isDragging}
triggerEvent={triggerEvent}
/>
);
};
@@ -1,55 +0,0 @@
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
type FavoriteNavigationDrawerSubItemProps = {
favorite: ProcessedFavorite;
label: string;
Icon?: React.ComponentProps<typeof NavigationDrawerSubItem>['Icon'];
to?: string;
active?: boolean;
subItemState?: React.ComponentProps<
typeof NavigationDrawerSubItem
>['subItemState'];
rightOptions?: React.ComponentProps<
typeof NavigationDrawerSubItem
>['rightOptions'];
isDragging?: boolean;
triggerEvent?: React.ComponentProps<
typeof NavigationDrawerSubItem
>['triggerEvent'];
};
export const FavoriteNavigationDrawerSubItem = ({
favorite,
label,
Icon,
to,
active,
subItemState,
rightOptions,
isDragging,
triggerEvent,
}: FavoriteNavigationDrawerSubItemProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: favorite.objectNameSingular || '',
});
if (!favorite.objectNameSingular) {
return null;
}
return (
<NavigationDrawerSubItem
label={label}
secondaryLabel={objectMetadataItem.labelSingular}
Icon={Icon}
to={to}
active={active}
subItemState={subItemState}
rightOptions={rightOptions}
isDragging={isDragging}
triggerEvent={triggerEvent}
/>
);
};
@@ -0,0 +1,31 @@
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getFavoriteSecondaryLabel } from '../getFavoriteSecondaryLabel';
describe('getFavoriteSecondaryLabel', () => {
it('should return "View" for view object', () => {
const result = getFavoriteSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
favoriteObjectNameSingular: 'view',
});
expect(result).toBe('View');
});
it('should return labelSingular for matching object metadata item', () => {
const result = getFavoriteSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
favoriteObjectNameSingular: 'person',
});
expect(result).toBe('Person');
});
it('should return undefined when object metadata item is not found', () => {
const result = getFavoriteSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
favoriteObjectNameSingular: 'nonexistent',
});
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,23 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
type GetFavoriteSecondaryLabelProps = {
objectMetadataItems: Pick<
ObjectMetadataItem,
'nameSingular' | 'labelSingular'
>[];
favoriteObjectNameSingular: string;
};
export const getFavoriteSecondaryLabel = ({
objectMetadataItems,
favoriteObjectNameSingular,
}: GetFavoriteSecondaryLabelProps) => {
if (favoriteObjectNameSingular === 'view') {
return 'View';
}
return objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === favoriteObjectNameSingular,
)?.labelSingular;
};
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -47,6 +48,16 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
args: CommonExtendedInput<CreateManyQueryArgs>,
queryRunnerContext: CommonExtendedQueryRunnerContext,
): Promise<ObjectRecord[]> {
if (args.data.length > QUERY_MAX_RECORDS) {
throw new CommonQueryRunnerException(
`Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`,
CommonQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED,
{
userFriendlyMessage: msg`Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`,
},
);
}
const {
repository,
authContext,
@@ -126,6 +126,10 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
objectMetadataMaps,
});
if (isDefined(args.offset)) {
queryBuilder.skip(args.offset);
}
const objectRecords = (await queryBuilder
.setFindOptions({
select: columnsToSelect,
@@ -12,4 +12,5 @@ export enum CommonQueryRunnerExceptionCode {
UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT = 'UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT',
MISSING_SYSTEM_FIELD = 'MISSING_SYSTEM_FIELD',
INVALID_CURSOR = 'INVALID_CURSOR',
UPSERT_MAX_RECORDS_EXCEEDED = 'UPSERT_MAX_RECORDS_EXCEEDED',
}
@@ -22,6 +22,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
case CommonQueryRunnerExceptionCode.INVALID_CURSOR:
case CommonQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED:
throw new UserInputError(error);
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new AuthenticationError(error);
@@ -21,6 +21,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
case CommonQueryRunnerExceptionCode.INVALID_CURSOR:
case CommonQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED:
throw new BadRequestException(error.message);
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundException('Record not found');
@@ -1,4 +1,4 @@
import { type PageInfo } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { type PageInfo } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
export type CommonPageInfo = {
hasNextPage: NonNullable<PageInfo['hasNextPage']>;
@@ -50,6 +50,7 @@ export interface FindManyQueryArgs {
last?: number;
before?: string;
after?: string;
offset?: number;
}
export interface CreateManyQueryArgs {
@@ -4,6 +4,20 @@ import { TypeOrmModule } from '@nestjs/typeorm';
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 { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service';
import { GraphqlQueryDeleteManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service';
import { GraphqlQueryDeleteOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service';
import { GraphqlQueryDestroyManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service';
import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service';
import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service';
import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service';
import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service';
import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service';
import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service';
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';
@@ -14,6 +28,23 @@ import { ViewFilterGroupModule } from 'src/engine/metadata-modules/view-filter-g
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
const graphqlQueryResolvers = [
GraphqlQueryCreateManyResolverService,
GraphqlQueryCreateOneResolverService,
GraphqlQueryDeleteManyResolverService,
GraphqlQueryDeleteOneResolverService,
GraphqlQueryDestroyManyResolverService,
GraphqlQueryDestroyOneResolverService,
GraphqlQueryFindDuplicatesResolverService,
GraphqlQueryFindManyResolverService,
GraphqlQueryFindOneResolverService,
GraphqlQueryMergeManyResolverService,
GraphqlQueryRestoreManyResolverService,
GraphqlQueryRestoreOneResolverService,
GraphqlQueryUpdateManyResolverService,
GraphqlQueryUpdateOneResolverService,
];
@Module({
imports: [
WorkspaceQueryHookModule,
@@ -30,6 +61,8 @@ import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
ProcessNestedRelationsHelper,
ProcessNestedRelationsV2Helper,
ProcessAggregateHelper,
...graphqlQueryResolvers,
],
exports: [...graphqlQueryResolvers],
})
export class GraphqlQueryRunnerModule {}
@@ -0,0 +1,248 @@
import { Inject, Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type ObjectRecord } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { type ObjectLiteral } from 'typeorm';
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 { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import {
type ResolverArgs,
type WorkspaceResolverBuilderMethodNames,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements';
import { type GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
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 { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
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 { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
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 { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { type WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type GraphqlQueryResolverExecutionArgs<Input extends ResolverArgs> = {
args: Input;
options: WorkspaceQueryRunnerOptions;
workspaceDataSource: WorkspaceDataSource;
repository: WorkspaceRepository<ObjectLiteral>;
graphqlQueryParser: GraphqlQueryParser;
graphqlQuerySelectedFieldsResult: GraphqlQuerySelectedFieldsResult;
isExecutedByApiKey: boolean;
rolePermissionConfig?: RolePermissionConfig;
};
@Injectable()
export abstract class GraphqlQueryBaseResolverService<
Input extends ResolverArgs,
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;
public async execute(
args: Input,
options: WorkspaceQueryRunnerOptions,
operationName: WorkspaceResolverBuilderMethodNames,
): Promise<Response | undefined> {
try {
const { authContext, objectMetadataItemWithFieldMaps } = options;
const workspace = authContext.workspace;
assertIsDefinedOrThrow(workspace);
await this.validate(args, options);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: workspace.id,
});
const featureFlagsMap = workspaceDataSource.featureFlagMap;
if (objectMetadataItemWithFieldMaps.isSystem === true) {
await this.validateSettingsPermissionsOnObjectOrThrow(options);
}
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItemWithFieldMaps.nameSingular,
operationName,
args,
);
const computedArgs = (await this.queryRunnerArgsFactory.create(
hookedArgs,
options,
operationName,
)) as Input;
let roleId: string | undefined;
if (isDefined(authContext.apiKey)) {
roleId = await this.apiKeyRoleService.getRoleIdForApiKey(
authContext.apiKey.id,
workspace.id,
);
}
if (isDefined(authContext.userWorkspaceId)) {
roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId: workspace.id,
});
if (!roleId) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
);
}
}
if (
!isDefined(authContext.apiKey) &&
!isDefined(authContext.userWorkspaceId)
) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_AUTHENTICATION_CONTEXT,
PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT,
);
}
const rolePermissionConfig: RolePermissionConfig | undefined = roleId
? { unionOf: [roleId] }
: undefined;
const repository = workspaceDataSource.getRepository(
objectMetadataItemWithFieldMaps.nameSingular,
rolePermissionConfig,
authContext,
);
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
options.objectMetadataMaps,
);
const selectedFields = graphqlFields(options.info);
const graphqlQuerySelectedFieldsResult =
graphqlQueryParser.parseSelectedFields(selectedFields);
const graphqlQueryResolverExecutionArgs = {
args: computedArgs,
options,
workspaceDataSource,
repository,
graphqlQueryParser,
graphqlQuerySelectedFieldsResult,
isExecutedByApiKey: isDefined(authContext.apiKey),
rolePermissionConfig,
};
const results = await this.resolve(
graphqlQueryResolverExecutionArgs,
featureFlagsMap,
);
const resultWithGetters = await this.queryResultGettersFactory.create(
results,
objectMetadataItemWithFieldMaps,
workspace.id,
options.objectMetadataMaps,
);
await this.workspaceQueryHookService.executePostQueryHooks(
authContext,
objectMetadataItemWithFieldMaps.nameSingular,
operationName,
resultWithGetters,
);
return resultWithGetters;
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
private async validateSettingsPermissionsOnObjectOrThrow(
options: WorkspaceQueryRunnerOptions,
) {
const { authContext, objectMetadataItemWithFieldMaps } = options;
const workspace = authContext.workspace;
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
if (
Object.keys(OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS).includes(
objectMetadataItemWithFieldMaps.nameSingular,
)
) {
const permissionRequired: PermissionFlagType =
// @ts-expect-error legacy noImplicitAny
OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS[
objectMetadataItemWithFieldMaps.nameSingular
];
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,
);
}
}
}
protected abstract resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<Input>,
featureFlagsMap: Record<FeatureFlagKey, boolean>,
): Promise<Response>;
protected abstract validate(
args: Input,
options: WorkspaceQueryRunnerOptions,
): Promise<void>;
}
@@ -0,0 +1,542 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
In,
type FindOperator,
type InsertResult,
type ObjectLiteral,
} from 'typeorm';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
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 { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
type PartialObjectRecordWithId = Partial<ObjectRecord> & { id: string };
@Injectable()
export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResolverService<
CreateManyResolverArgs,
ObjectRecord[]
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
): Promise<ObjectRecord[]> {
if (executionArgs.args.data.length > QUERY_MAX_RECORDS) {
throw new GraphqlQueryRunnerException(
`Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`,
GraphqlQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED,
{
userFriendlyMessage: msg`Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`,
},
);
}
const { objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const objectRecords = await this.insertOrUpsertRecords(executionArgs);
const upsertedRecords = await this.fetchUpsertedRecords(
executionArgs,
objectRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
await this.processNestedRelationsIfNeeded({
executionArgs,
records: upsertedRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
return this.formatRecordsForResponse(
upsertedRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
}
private async insertOrUpsertRecords(
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
): Promise<InsertResult> {
if (!executionArgs.args.upsert) {
const { objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const selectedColumns = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
return await executionArgs.repository.insert(
executionArgs.args.data,
undefined,
selectedColumns,
);
}
return this.performUpsertOperation(executionArgs);
}
private async performUpsertOperation(
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
): Promise<InsertResult> {
const { objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const conflictingFields = this.getConflictingFields(
objectMetadataItemWithFieldMaps,
);
const existingRecords = await this.findExistingRecords(
executionArgs,
conflictingFields,
);
const { recordsToUpdate, recordsToInsert } = this.categorizeRecords(
executionArgs.args.data,
conflictingFields,
existingRecords,
);
const result: InsertResult = {
identifiers: [],
generatedMaps: [],
raw: [],
};
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
if (recordsToUpdate.length > 0) {
await this.processRecordsToUpdate({
partialRecordsToUpdate: recordsToUpdate,
repository: executionArgs.repository,
objectMetadataItemWithFieldMaps,
result,
columnsToReturn,
});
}
await this.processRecordsToInsert({
recordsToInsert,
repository: executionArgs.repository,
result,
columnsToReturn,
});
return result;
}
//TODO : Improve conflicting fields logic - unicity can be define on combination of fields - should be based on unique index not on field metadata
//TODO : https://github.com/twentyhq/core-team-issues/issues/1115
private getConflictingFields(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): {
baseField: string;
fullPath: string;
column: string;
}[] {
return Object.values(objectMetadataItemWithFieldMaps.fieldsById)
.filter((field) => field.isUnique || field.name === 'id')
.flatMap((field) => {
const compositeType = compositeTypeDefinitions.get(field.type);
if (!compositeType) {
return [
{
baseField: field.name,
fullPath: field.name,
column: field.name,
},
];
}
const property = compositeType.properties.find(
(prop) => prop.isIncludedInUniqueConstraint,
);
return property
? [
{
baseField: field.name,
fullPath: `${field.name}.${property.name}`,
column: `${field.name}${capitalize(property.name)}`,
},
]
: [];
});
}
private async findExistingRecords(
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
): Promise<PartialObjectRecordWithId[]> {
const { objectMetadataItemWithFieldMaps } = executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const whereConditions = this.buildWhereConditions(
executionArgs.args.data,
conflictingFields,
);
whereConditions.forEach((condition) => {
queryBuilder.orWhere(condition);
});
const restrictedFields =
executionArgs.repository.objectRecordsPermissions?.[
objectMetadataItemWithFieldMaps.id
]?.restrictedFields;
const selectOptions = getAllSelectableColumnNames({
restrictedFields: restrictedFields ?? {},
objectMetadata: {
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
},
});
return (await queryBuilder
.withDeleted()
.setFindOptions({
select: selectOptions,
})
.getMany()) as PartialObjectRecordWithId[];
}
private getValueFromPath(
record: Partial<ObjectRecord>,
path: string,
): unknown {
const pathParts = path.split('.');
if (pathParts.length === 1) {
return record[path];
}
const [parentField, childField] = pathParts;
return record[parentField]?.[childField];
}
private buildWhereConditions(
records: Partial<ObjectRecord>[],
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
): Record<string, FindOperator<string>>[] {
const whereConditions = [];
for (const field of conflictingFields) {
const fieldValues = records
.map((record) => this.getValueFromPath(record, field.fullPath))
.filter(Boolean);
//TODO : Adapt to composite constraint - https://github.com/twentyhq/core-team-issues/issues/1115
if (fieldValues.length > 0) {
whereConditions.push({ [field.column]: In(fieldValues) });
}
}
return whereConditions;
}
private categorizeRecords(
records: Partial<ObjectRecord>[],
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
existingRecords: PartialObjectRecordWithId[],
): {
recordsToUpdate: PartialObjectRecordWithId[];
recordsToInsert: Partial<ObjectRecord>[];
} {
const recordsToUpdate: PartialObjectRecordWithId[] = [];
const recordsToInsert: Partial<ObjectRecord>[] = [];
for (const record of records) {
const matchingRecordId = this.getMatchingRecordId(
record,
conflictingFields,
existingRecords,
);
if (isDefined(matchingRecordId)) {
recordsToUpdate.push({ ...record, id: matchingRecordId });
} else {
recordsToInsert.push(record);
}
}
return { recordsToUpdate, recordsToInsert };
}
private getMatchingRecordId(
record: Partial<ObjectRecord>,
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
existingRecords: PartialObjectRecordWithId[],
): string | undefined {
const matchingRecordIds = conflictingFields.reduce<string[]>(
(acc, field) => {
const requestFieldValue = this.getValueFromPath(record, field.fullPath);
const matchingRecord = existingRecords.find((existingRecord) => {
const existingFieldValue = this.getValueFromPath(
existingRecord,
field.fullPath,
);
return (
isDefined(existingFieldValue) &&
existingFieldValue === requestFieldValue
);
});
if (isDefined(matchingRecord)) {
acc.push(matchingRecord.id);
}
return acc;
},
[],
);
if ([...new Set(matchingRecordIds)].length > 1) {
const conflictingFieldsValues = conflictingFields
.map((field) => {
const value = this.getValueFromPath(record, field.fullPath);
return isDefined(value) ? `${field.fullPath}: ${value}` : undefined;
})
.filter(isDefined)
.join(', ');
throw new GraphqlQueryRunnerException(
`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
GraphqlQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT,
{
userFriendlyMessage: msg`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
},
);
}
return matchingRecordIds[0];
}
private async processRecordsToUpdate({
partialRecordsToUpdate,
repository,
objectMetadataItemWithFieldMaps,
result,
columnsToReturn,
}: {
partialRecordsToUpdate: PartialObjectRecordWithId[];
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
result: InsertResult;
columnsToReturn: string[];
}): Promise<void> {
const partialRecordsToUpdateWithoutCreatedByUpdate =
partialRecordsToUpdate.map((record) =>
this.getRecordWithoutCreatedBy(record, objectMetadataItemWithFieldMaps),
);
const savedRecords = await repository.updateMany(
partialRecordsToUpdateWithoutCreatedByUpdate.map((record) => ({
criteria: record.id,
partialEntity: { ...record, deletedAt: null },
})),
undefined,
columnsToReturn,
);
result.identifiers.push(
...savedRecords.generatedMaps.map((record) => ({ id: record.id })),
);
result.generatedMaps.push(
...savedRecords.generatedMaps.map((record) => ({ id: record.id })),
);
}
private async processRecordsToInsert({
recordsToInsert,
repository,
result,
columnsToReturn,
}: {
recordsToInsert: Partial<ObjectRecord>[];
repository: WorkspaceRepository<ObjectLiteral>;
result: InsertResult;
columnsToReturn: string[];
}): Promise<void> {
if (recordsToInsert.length > 0) {
const insertResult = await repository.insert(
recordsToInsert,
undefined,
columnsToReturn,
);
result.identifiers.push(...insertResult.identifiers);
result.generatedMaps.push(...insertResult.generatedMaps);
result.raw.push(...insertResult.raw);
}
}
private async fetchUpsertedRecords(
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
objectRecords: InsertResult,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
): Promise<ObjectRecord[]> {
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const columnsToSelect = buildColumnsToSelect({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const upsertedRecords = await queryBuilder
.setFindOptions({
select: columnsToSelect,
})
.where({
id: In(objectRecords.generatedMaps.map((record) => record.id)),
})
.withDeleted()
.take(QUERY_MAX_RECORDS)
.getMany();
return upsertedRecords as ObjectRecord[];
}
private async processNestedRelationsIfNeeded({
executionArgs,
records,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
}: {
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>;
records: ObjectRecord[];
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
}): Promise<void> {
if (!executionArgs.graphqlQuerySelectedFieldsResult.relations) {
return;
}
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: records,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext: executionArgs.options.authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
private formatRecordsForResponse(
upsertedRecords: ObjectRecord[],
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
): ObjectRecord[] {
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return upsertedRecords.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
}
private getRecordWithoutCreatedBy(
record: PartialObjectRecordWithId,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): Omit<PartialObjectRecordWithId, 'createdBy'> {
let recordWithoutCreatedByUpdate = record;
const createdByFieldMetadataId =
objectMetadataItemWithFieldMaps.fieldIdByName['createdBy'];
const createdByFieldMetadata =
objectMetadataItemWithFieldMaps.fieldsById[createdByFieldMetadataId];
if (!isDefined(createdByFieldMetadata)) {
throw new GraphqlQueryRunnerException(
`Missing createdBy field metadata for object ${objectMetadataItemWithFieldMaps.nameSingular}`,
GraphqlQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD,
);
}
if ('createdBy' in record && createdByFieldMetadata.isCustom === false) {
const { createdBy: _createdBy, ...recordWithoutCreatedBy } = record;
recordWithoutCreatedByUpdate = recordWithoutCreatedBy;
}
return recordWithoutCreatedByUpdate;
}
async validate<T extends ObjectRecord>(
args: CreateManyResolverArgs<Partial<T>>,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
args.data.forEach((record) => {
if (record?.id) {
assertIsValidUuid(record.id);
}
});
}
}
@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
@Injectable()
export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolverService<
CreateOneResolverArgs,
ObjectRecord
> {
constructor(
private readonly createManyResolverService: GraphqlQueryCreateManyResolverService,
) {
super();
}
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<CreateOneResolverArgs>,
): Promise<ObjectRecord> {
const result = await this.createManyResolverService.resolve({
...executionArgs,
args: {
...executionArgs.args,
data: [executionArgs.args.data],
},
});
return result[0];
}
async validate(
args: CreateOneResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
if (args.data?.id) {
assertIsValidUuid(args.data.id);
}
}
}
@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
@Injectable()
export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResolverService<
DeleteManyResolverArgs,
ObjectRecord[]
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<DeleteManyResolverArgs>,
): Promise<ObjectRecord[]> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
executionArgs.graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
executionArgs.args.filter,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const deletedObjectRecords = await queryBuilder
.softDelete()
.returning(columnsToReturn)
.execute();
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords:
deletedObjectRecords.generatedMaps as ObjectRecord[],
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return deletedObjectRecords.generatedMaps.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
}
async validate(
args: DeleteManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
if (!args.filter) {
throw new Error('Filter is required');
}
args.filter.id?.in?.forEach((id: string) => assertIsValidUuid(id));
}
}
@@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type DeleteOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
@Injectable()
export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolverService<
DeleteOneResolverArgs,
ObjectRecord
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<DeleteOneResolverArgs>,
): Promise<ObjectRecord> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const deletedObjectRecords = await queryBuilder
.softDelete()
.where({ id: executionArgs.args.id })
.returning(columnsToReturn)
.execute();
if (deletedObjectRecords.generatedMaps.length === 0) {
throw new GraphqlQueryRunnerException(
'Record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
const deletedRecord = deletedObjectRecords.generatedMaps[0] as ObjectRecord;
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: [deletedRecord],
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
const result = typeORMObjectRecordsParser.processRecord({
objectRecord: deletedRecord,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
return result;
}
async validate(
args: DeleteOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
assertIsValidUuid(args.id);
}
}
@@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type DestroyManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
@Injectable()
export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseResolverService<
DestroyManyResolverArgs,
ObjectRecord[]
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<DestroyManyResolverArgs>,
): Promise<ObjectRecord[]> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
executionArgs.graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
executionArgs.args.filter,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const deletedObjectRecords = await queryBuilder
.delete()
.returning(columnsToReturn)
.execute();
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords:
deletedObjectRecords.generatedMaps as ObjectRecord[],
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return deletedObjectRecords.generatedMaps.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
}
async validate(
args: DestroyManyResolverArgs,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (!args.filter) {
throw new Error('Filter is required');
}
}
}
@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type DestroyOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
@Injectable()
export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResolverService<
DestroyOneResolverArgs,
ObjectRecord
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<DestroyOneResolverArgs>,
): Promise<ObjectRecord> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const deletedObjectRecords = await queryBuilder
.delete()
.where({ id: executionArgs.args.id })
.returning(columnsToReturn)
.execute();
if (!deletedObjectRecords.affected) {
throw new GraphqlQueryRunnerException(
'Record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords:
deletedObjectRecords.generatedMaps as ObjectRecord[],
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.processRecord({
objectRecord: deletedObjectRecords.generatedMaps[0] as ObjectRecord,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
}
async validate(
args: DestroyOneResolverArgs,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (!args.id) {
throw new GraphqlQueryRunnerException(
'Missing id',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}
@@ -0,0 +1,161 @@
import { Injectable } from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { OrderByDirection, type ObjectRecord } from 'twenty-shared/types';
import { In } from 'typeorm';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type FindDuplicatesResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
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 { buildDuplicateConditions } from 'src/engine/api/utils/build-duplicate-conditions.utils';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
@Injectable()
export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseResolverService<
FindDuplicatesResolverArgs,
IConnection<ObjectRecord>[]
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<FindDuplicatesResolverArgs>,
): Promise<IConnection<ObjectRecord>[]> {
const { objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const existingRecordsQueryBuilder =
executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const objectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(
objectMetadataMaps,
objectMetadataItemWithFieldMaps.nameSingular,
);
if (!objectMetadataItemWithFieldsMaps) {
throw new GraphqlQueryRunnerException(
`Object ${objectMetadataItemWithFieldMaps.nameSingular} not found`,
GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
let objectRecords: Partial<ObjectRecord>[] = [];
const columnsToSelect = buildColumnsToSelect({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
if (executionArgs.args.ids) {
objectRecords = (await existingRecordsQueryBuilder
.where({ id: In(executionArgs.args.ids) })
.setFindOptions({
select: columnsToSelect,
})
.getMany()) as ObjectRecord[];
} else if (executionArgs.args.data && !isEmpty(executionArgs.args.data)) {
objectRecords = executionArgs.args.data;
}
const duplicateConnections: IConnection<ObjectRecord>[] = await Promise.all(
objectRecords.map(async (record) => {
const duplicateConditions = buildDuplicateConditions(
objectMetadataItemWithFieldMaps,
[record],
record.id,
);
if (isEmpty(duplicateConditions)) {
return typeORMObjectRecordsParser.createConnection({
objectRecords: [],
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 0,
totalCount: 0,
order: [{ id: OrderByDirection.AscNullsFirst }],
hasNextPage: false,
hasPreviousPage: false,
});
}
const duplicateRecordsQueryBuilder =
executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
graphqlQueryParser.applyFilterToBuilder(
duplicateRecordsQueryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
duplicateConditions,
);
const duplicates = (await duplicateRecordsQueryBuilder
.setFindOptions({
select: columnsToSelect,
})
.take(QUERY_MAX_RECORDS)
.getMany()) as ObjectRecord[];
return typeORMObjectRecordsParser.createConnection({
objectRecords: duplicates,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: duplicates.length,
totalCount: duplicates.length,
order: [{ id: OrderByDirection.AscNullsFirst }],
hasNextPage: false,
hasPreviousPage: false,
});
}),
);
return duplicateConnections;
}
async validate(
args: FindDuplicatesResolverArgs,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (!args.data && !args.ids) {
throw new GraphqlQueryRunnerException(
'You have to provide either "data" or "ids" argument',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (args.data && args.ids) {
throw new GraphqlQueryRunnerException(
'You cannot provide both "data" and "ids" arguments',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (!args.ids && isEmpty(args.data)) {
throw new GraphqlQueryRunnerException(
'The "data" condition can not be empty when "ids" input not provided',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}
@@ -0,0 +1,229 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { ObjectRecord, OrderByDirection } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import {
type ObjectRecordFilter,
type ObjectRecordOrderBy,
} 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 WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import {
getCursor,
getPaginationInfo,
} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
@Injectable()
export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolverService<
FindManyResolverArgs,
IConnection<ObjectRecord>
> {
constructor() {
super();
}
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<FindManyResolverArgs>,
): Promise<IConnection<ObjectRecord>> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const objectMetadataNameSingular =
objectMetadataItemWithFieldMaps.nameSingular;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataNameSingular,
);
const aggregateQueryBuilder = queryBuilder.clone();
let appliedFilters =
executionArgs.args.filter ?? ({} as ObjectRecordFilter);
executionArgs.graphqlQueryParser.applyFilterToBuilder(
aggregateQueryBuilder,
objectMetadataNameSingular,
appliedFilters,
);
executionArgs.graphqlQueryParser.applyDeletedAtToBuilder(
aggregateQueryBuilder,
appliedFilters,
);
const orderByWithIdCondition = [
...(executionArgs.args.orderBy ?? []),
{ id: OrderByDirection.AscNullsFirst },
] as ObjectRecordOrderBy;
const isForwardPagination = !isDefined(executionArgs.args.before);
const cursor = getCursor(executionArgs.args);
if (cursor) {
const cursorArgFilter = computeCursorArgFilter(
cursor,
orderByWithIdCondition,
objectMetadataItemWithFieldMaps,
isForwardPagination,
);
appliedFilters = (executionArgs.args.filter
? {
and: [executionArgs.args.filter, { or: cursorArgFilter }],
}
: { or: cursorArgFilter }) as unknown as ObjectRecordFilter;
}
executionArgs.graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataNameSingular,
appliedFilters,
);
executionArgs.graphqlQueryParser.applyOrderToBuilder(
queryBuilder,
orderByWithIdCondition,
objectMetadataNameSingular,
isForwardPagination,
);
executionArgs.graphqlQueryParser.applyDeletedAtToBuilder(
queryBuilder,
appliedFilters,
);
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields:
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
queryBuilder: aggregateQueryBuilder,
objectMetadataNameSingular,
});
const limit =
executionArgs.args.first ?? executionArgs.args.last ?? QUERY_MAX_RECORDS;
const columnsToSelect = buildColumnsToSelect({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
if (isDefined(executionArgs.args.offset)) {
queryBuilder.skip(executionArgs.args.offset);
}
const objectRecords = (await queryBuilder
.setFindOptions({
select: columnsToSelect,
})
.take(limit + 1)
.getMany()) as ObjectRecord[];
const { hasNextPage, hasPreviousPage } = getPaginationInfo(
objectRecords,
limit,
isForwardPagination,
);
if (objectRecords.length > limit) {
objectRecords.pop();
}
const parentObjectRecordsAggregatedValues =
await aggregateQueryBuilder.getRawOne();
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: objectRecords,
parentObjectRecordsAggregatedValues,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
aggregate: executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.createConnection({
objectRecords: isForwardPagination
? objectRecords
: objectRecords.reverse(),
objectRecordsAggregatedValues: parentObjectRecordsAggregatedValues,
selectedAggregatedFields:
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
objectName: objectMetadataNameSingular,
take: limit,
totalCount: parentObjectRecordsAggregatedValues?.totalCount,
order: orderByWithIdCondition,
hasNextPage,
hasPreviousPage,
});
}
async validate<Filter extends ObjectRecordFilter>(
args: FindManyResolverArgs<Filter>,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (args.first && args.last) {
throw new GraphqlQueryRunnerException(
'Cannot provide both first and last',
GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT,
);
}
if (args.before && args.after) {
throw new GraphqlQueryRunnerException(
'Cannot provide both before and after',
GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT,
);
}
if (args.before && args.first) {
throw new GraphqlQueryRunnerException(
'Cannot provide both before and first',
GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT,
);
}
if (args.after && args.last) {
throw new GraphqlQueryRunnerException(
'Cannot provide both after and last',
GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT,
);
}
if (args.first !== undefined && args.first < 0) {
throw new GraphqlQueryRunnerException(
'First argument must be non-negative',
GraphqlQueryRunnerExceptionCode.INVALID_ARGS_FIRST,
);
}
if (args.last !== undefined && args.last < 0) {
throw new GraphqlQueryRunnerException(
'Last argument must be non-negative',
GraphqlQueryRunnerExceptionCode.INVALID_ARGS_LAST,
);
}
}
}
@@ -0,0 +1,110 @@
//TODO : Refacto-common - To delete
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { 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 { type FindOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
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 {
WorkspaceQueryRunnerException,
WorkspaceQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
@Injectable()
export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolverService<
FindOneResolverArgs,
ObjectRecord
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<FindOneResolverArgs>,
): Promise<ObjectRecord> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
executionArgs.graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
executionArgs.args.filter ?? ({} as ObjectRecordFilter),
);
executionArgs.graphqlQueryParser.applyDeletedAtToBuilder(
queryBuilder,
executionArgs.args.filter ?? ({} as ObjectRecordFilter),
);
const columnsToSelect = buildColumnsToSelect({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const objectRecord = await queryBuilder
.setFindOptions({
select: columnsToSelect,
})
.getOne();
if (!objectRecord) {
throw new GraphqlQueryRunnerException(
'Record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
const objectRecords = [objectRecord] as ObjectRecord[];
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: objectRecords,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.processRecord({
objectRecord: objectRecords[0],
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}) as ObjectRecord;
}
async validate(
args: FindOneResolverArgs<ObjectRecordFilter>,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (!args.filter || Object.keys(args.filter).length === 0) {
throw new WorkspaceQueryRunnerException(
'Missing filter argument',
WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}
@@ -0,0 +1,438 @@
import { Injectable, Logger } from '@nestjs/common';
import {
MUTATION_MAX_MERGE_RECORDS,
QUERY_MAX_RECORDS,
} from 'twenty-shared/constants';
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type MergeManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { type FieldMetadataRelationSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
import { mergeFieldValues } from 'src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
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 { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@Injectable()
export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolverService<
MergeManyResolverArgs,
ObjectRecord
> {
private readonly logger = new Logger(
GraphqlQueryMergeManyResolverService.name,
);
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
): Promise<ObjectRecord> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { ids, conflictPriorityIndex, dryRun } = executionArgs.args;
const recordsToMerge = await this.fetchRecordsToMerge(executionArgs, ids);
const priorityRecord = this.validateAndGetPriorityRecord(
recordsToMerge,
ids,
conflictPriorityIndex,
);
const mergedData = this.performDeepMerge(
recordsToMerge,
priorityRecord.id,
objectMetadataItemWithFieldMaps,
);
if (dryRun) {
return this.createDryRunResponse(
priorityRecord,
mergedData,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
}
const idsToDelete = ids.filter((id) => id !== priorityRecord.id);
await this.migrateRelatedRecords(
executionArgs,
idsToDelete,
priorityRecord.id,
);
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
await queryBuilder
.softDelete()
.whereInIds(idsToDelete)
.returning('*')
.execute();
const updatedRecord = await this.updatePriorityRecord(
executionArgs,
priorityRecord.id,
mergedData,
);
if (executionArgs.rolePermissionConfig) {
await this.processNestedRelations({
executionArgs,
updatedRecords: [updatedRecord],
authContext,
});
}
return this.formatResponse(
updatedRecord,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
}
private async fetchRecordsToMerge(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
ids: string[],
): Promise<ObjectRecord[]> {
const recordsToMerge = await executionArgs.repository.find({
where: { id: In(ids) },
});
if (recordsToMerge.length !== ids.length) {
throw new GraphqlQueryRunnerException(
'One or more records not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
return recordsToMerge as ObjectRecord[];
}
private validateAndGetPriorityRecord(
recordsToMerge: ObjectRecord[],
ids: string[],
conflictPriorityIndex: number,
): ObjectRecord {
const priorityRecordId = ids[conflictPriorityIndex];
const priorityRecord = recordsToMerge.find(
(record) => record.id === priorityRecordId,
);
if (!priorityRecord) {
throw new GraphqlQueryRunnerException(
'Priority record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
return priorityRecord;
}
private performDeepMerge(
recordsToMerge: ObjectRecord[],
priorityRecordId: string,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): Partial<ObjectRecord> {
const mergedResult: Partial<ObjectRecord> = {};
const allFieldNames = new Set<string>();
recordsToMerge.forEach((record) => {
Object.keys(record).forEach((fieldName) => {
if (
!this.shouldExcludeFieldFromMerge(
fieldName,
objectMetadataItemWithFieldMaps,
)
) {
allFieldNames.add(fieldName);
}
});
});
allFieldNames.forEach((fieldName) => {
const recordsWithValues: { value: unknown; recordId: string }[] = [];
recordsToMerge.forEach((record) => {
const fieldValue = record[fieldName];
if (hasRecordFieldValue(fieldValue)) {
recordsWithValues.push({ value: fieldValue, recordId: record.id });
}
});
if (recordsWithValues.length === 0) {
return;
} else if (recordsWithValues.length === 1) {
mergedResult[fieldName] = recordsWithValues[0].value;
} else {
const fieldMetadata = Object.values(
objectMetadataItemWithFieldMaps.fieldsById,
).find((field) => field?.name === fieldName);
if (!fieldMetadata) {
return;
}
mergedResult[fieldName] = mergeFieldValues(
fieldMetadata.type,
recordsWithValues,
priorityRecordId,
);
}
});
return mergedResult;
}
private shouldExcludeFieldFromMerge(
fieldName: string,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): boolean {
const fieldMetadata = Object.values(
objectMetadataItemWithFieldMaps.fieldsById,
).find((field) => field?.name === fieldName);
return fieldMetadata?.isSystem ?? false;
}
private createDryRunResponse(
priorityRecord: ObjectRecord,
mergedData: Partial<ObjectRecord>,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
): ObjectRecord {
const dryRunRecord = {
...priorityRecord,
...mergedData,
id: uuidv4(),
deletedAt: new Date().toISOString(),
} as ObjectRecord;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.processRecord({
objectRecord: dryRunRecord,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
}
private async updatePriorityRecord(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
priorityRecordId: string,
mergedData: Partial<ObjectRecord>,
): Promise<ObjectRecord> {
const { objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const updatedObjectRecords = await queryBuilder
.update()
.set(mergedData)
.where({ id: priorityRecordId })
.returning(columnsToReturn)
.execute();
if (!updatedObjectRecords.generatedMaps.length) {
throw new GraphqlQueryRunnerException(
'Failed to update record',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
const updatedRecord = updatedObjectRecords.generatedMaps[0] as ObjectRecord;
return updatedRecord;
}
private async migrateRelatedRecords(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
fromIds: string[],
toId: string,
): Promise<void> {
const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
executionArgs.options;
const relationFieldsPointingToCurrentObject = Object.values(
objectMetadataMaps.byId,
)
.filter(isDefined)
.flatMap((metadata) => {
const relationFields = Object.values(metadata.fieldsById)
.filter(isDefined)
.filter((field) =>
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION),
)
.filter(
(field) =>
field.relationTargetObjectMetadataId ===
objectMetadataItemWithFieldMaps.id && field.isActive,
);
return relationFields
.filter((field) => {
const relationSettings =
field.settings as FieldMetadataRelationSettings;
return (
relationSettings?.relationType === RelationType.MANY_TO_ONE &&
relationSettings?.joinColumnName
);
})
.map((field) => {
const relationSettings =
field.settings as FieldMetadataRelationSettings;
return {
objectMetadata: metadata,
fieldName: field.name,
fieldId: field.id,
joinColumnName: relationSettings.joinColumnName,
};
});
});
for (const relationField of relationFieldsPointingToCurrentObject) {
if (!relationField.joinColumnName) {
continue;
}
try {
const repository = executionArgs.workspaceDataSource.getRepository(
relationField.objectMetadata.nameSingular,
executionArgs.rolePermissionConfig,
);
const whereCondition = { [relationField.joinColumnName]: In(fromIds) };
const existingRecords = await repository.find({
where: whereCondition,
});
if (existingRecords.length > 0) {
await repository.update(whereCondition, {
[relationField.joinColumnName]: toId,
});
}
} catch (error) {
this.logger.warn(
`Failed to migrate relation field "${relationField.fieldName}" (${relationField.joinColumnName}) in object "${relationField.objectMetadata.nameSingular}":`,
error.message,
);
}
}
}
private async processNestedRelations({
executionArgs,
updatedRecords,
authContext,
}: {
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>;
updatedRecords: ObjectRecord[];
authContext: AuthContext;
}): Promise<void> {
const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
executionArgs.options;
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: updatedRecords,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
}
private formatResponse(
updatedRecord: ObjectRecord,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
): ObjectRecord {
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.processRecord({
objectRecord: updatedRecord,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
}
async validate(
args: MergeManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
if (!isDefined(options.objectMetadataItemWithFieldMaps.duplicateCriteria)) {
throw new GraphqlQueryRunnerException(
`Merge is only available for objects with duplicate criteria. Object '${options.objectMetadataItemWithFieldMaps.nameSingular}' does not have duplicate criteria defined.`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
const { ids, conflictPriorityIndex } = args;
if (!ids || ids.length < 2) {
throw new GraphqlQueryRunnerException(
'At least 2 record IDs are required for merge',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (ids.length > MUTATION_MAX_MERGE_RECORDS) {
throw new GraphqlQueryRunnerException(
`Maximum ${MUTATION_MAX_MERGE_RECORDS} records can be merged at once`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (conflictPriorityIndex < 0 || conflictPriorityIndex >= ids.length) {
throw new GraphqlQueryRunnerException(
`Invalid conflict priority '${conflictPriorityIndex}'. Valid options for ${ids.length} records: 0-${ids.length - 1}`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}
@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type RestoreManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
@Injectable()
export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseResolverService<
RestoreManyResolverArgs,
ObjectRecord[]
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<RestoreManyResolverArgs>,
): Promise<ObjectRecord[]> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
executionArgs.graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
executionArgs.args.filter,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const restoredObjectRecords = await queryBuilder
.restore()
.returning(columnsToReturn)
.execute();
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords:
restoredObjectRecords.generatedMaps as ObjectRecord[],
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return restoredObjectRecords.generatedMaps.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
}
async validate(
args: RestoreManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
if (!args.filter) {
throw new Error('Filter is required');
}
args.filter.id?.in?.forEach((id: string) => assertIsValidUuid(id));
}
}
@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type RestoreOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
@Injectable()
export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResolverService<
RestoreOneResolverArgs,
ObjectRecord
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<RestoreOneResolverArgs>,
): Promise<ObjectRecord> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const restoredObjectRecords = await queryBuilder
.restore()
.where({ id: executionArgs.args.id })
.returning(columnsToReturn)
.execute();
if (restoredObjectRecords.generatedMaps.length === 0) {
throw new GraphqlQueryRunnerException(
'Record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
const restoredRecord = restoredObjectRecords
.generatedMaps[0] as ObjectRecord;
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: [restoredRecord],
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.processRecord({
objectRecord: restoredRecord,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
}
async validate(
args: RestoreOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
assertIsValidUuid(args.id);
}
}
@@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
@Injectable()
export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResolverService<
UpdateManyResolverArgs,
ObjectRecord[]
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<UpdateManyResolverArgs>,
): Promise<ObjectRecord[]> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
executionArgs.graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
executionArgs.args.filter,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const updatedObjectRecords = await queryBuilder
.update()
.set(executionArgs.args.data)
.returning(columnsToReturn)
.execute();
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords:
updatedObjectRecords.generatedMaps as ObjectRecord[],
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return updatedObjectRecords.generatedMaps.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
}
async validate(
args: UpdateManyResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
if (!args.filter) {
throw new Error('Filter is required');
}
args.filter.id?.in?.forEach((id: string) => assertIsValidUuid(id));
}
}
@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectRecord } from 'twenty-shared/types';
import {
GraphqlQueryBaseResolverService,
type GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
@Injectable()
export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolverService<
UpdateOneResolverArgs,
ObjectRecord
> {
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<UpdateOneResolverArgs>,
): Promise<ObjectRecord> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const updatedObjectRecords = await queryBuilder
.update()
.set(executionArgs.args.data)
.where({ id: executionArgs.args.id })
.returning(columnsToReturn)
.execute();
const updatedRecord = updatedObjectRecords.generatedMaps[0] as ObjectRecord;
if (!updatedRecord) {
throw new GraphqlQueryRunnerException(
'Record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: [updatedRecord],
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.processRecord({
objectRecord: updatedRecord,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
}
async validate(
args: UpdateOneResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
assertIsValidUuid(args.id);
}
}
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { ObjectRecord } from 'twenty-shared/types';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type CreateManyResolverArgs,
@@ -12,8 +13,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class CreateManyResolverFactory
@@ -22,7 +26,9 @@ export class CreateManyResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.CREATE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryCreateManyResolverService,
private readonly commonCreateManyQueryRunnerService: CommonCreateManyQueryRunnerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
create(
@@ -31,31 +37,54 @@ export class CreateManyResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const records = await this.commonCreateManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const records = await this.commonCreateManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
CreateManyResolverFactory.methodName,
);
};
}
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type CreateOneResolverArgs,
@@ -11,8 +12,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-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 CreateOneResolverFactory
@@ -21,6 +25,8 @@ export class CreateOneResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.CREATE_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryCreateOneResolverService,
private readonly featureFlagService: FeatureFlagService,
private readonly commonCreateOneQueryRunnerService: CommonCreateOneQueryRunnerService,
) {}
@@ -30,29 +36,50 @@ export class CreateOneResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
internalContext.authContext.workspace?.id as string,
);
try {
const record = await this.commonCreateOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
if (isCommonApiEnabled) {
const selectedFields = graphqlFields(info);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
try {
const record = await this.commonCreateOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
CreateOneResolverFactory.methodName,
);
};
}
}
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { ObjectRecord } from 'twenty-shared/types';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type DeleteManyResolverArgs,
@@ -12,8 +13,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonDeleteManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-many-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryDeleteManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class DeleteManyResolverFactory
@@ -22,7 +26,9 @@ export class DeleteManyResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.DELETE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryDeleteManyResolverService,
private readonly commonDeleteManyQueryRunnerService: CommonDeleteManyQueryRunnerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
create(
@@ -31,31 +37,54 @@ export class DeleteManyResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const records = await this.commonDeleteManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const records = await this.commonDeleteManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
DeleteManyResolverFactory.methodName,
);
};
}
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type DeleteOneResolverArgs,
@@ -11,8 +12,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonDeleteOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-one-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryDeleteOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class DeleteOneResolverFactory
@@ -21,7 +25,9 @@ export class DeleteOneResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.DELETE_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryDeleteOneResolverService,
private readonly commonDeleteOneQueryRunnerService: CommonDeleteOneQueryRunnerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
create(
@@ -30,29 +36,52 @@ export class DeleteOneResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const record = await this.commonDeleteOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const record = await this.commonDeleteOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
DeleteOneResolverFactory.methodName,
);
};
}
}
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { ObjectRecord } from 'twenty-shared/types';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type DestroyManyResolverArgs,
@@ -12,8 +13,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonDestroyManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryDestroyManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class DestroyManyResolverFactory
@@ -22,7 +26,9 @@ export class DestroyManyResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.DESTROY_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryDestroyManyResolverService,
private readonly commonDestroyManyQueryRunnerService: CommonDestroyManyQueryRunnerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
create(
@@ -31,31 +37,55 @@ export class DestroyManyResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const records = await this.commonDestroyManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const records =
await this.commonDestroyManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
DestroyManyResolverFactory.methodName,
);
};
}
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type DestroyOneResolverArgs,
@@ -11,8 +12,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonDestroyOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-one-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class DestroyOneResolverFactory
@@ -21,7 +25,9 @@ export class DestroyOneResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.DESTROY_ONE;
constructor(
private readonly graphQLQueryRunnerService: GraphqlQueryDestroyOneResolverService,
private readonly commonDestroyOneQueryRunnerService: CommonDestroyOneQueryRunnerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
create(
@@ -30,29 +36,52 @@ export class DestroyOneResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const record = await this.commonDestroyOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const record = await this.commonDestroyOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphQLQueryRunnerService.execute(
args,
options,
DestroyOneResolverFactory.methodName,
);
};
}
}
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { OrderByDirection } from 'twenty-shared/types';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type FindDuplicatesResolverArgs,
@@ -12,8 +13,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonFindDuplicatesQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-duplicates-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class FindDuplicatesResolverFactory
@@ -22,7 +26,9 @@ export class FindDuplicatesResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.FIND_DUPLICATES;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryFindDuplicatesResolverService,
private readonly commonFindDuplicatesQueryRunnerService: CommonFindDuplicatesQueryRunnerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
create(
@@ -31,35 +37,58 @@ export class FindDuplicatesResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const paginatedDuplicates =
await this.commonFindDuplicatesQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
const featureFlagsMap = workspaceDataSource.featureFlagMap;
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const paginatedDuplicates =
await this.commonFindDuplicatesQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return paginatedDuplicates.map((duplicate) =>
typeORMObjectRecordsParser.createConnection({
objectRecords: duplicate.records,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: duplicate.records.length,
totalCount: duplicate.totalCount,
order: [{ id: OrderByDirection.AscNullsFirst }],
hasNextPage: duplicate.hasNextPage,
hasPreviousPage: duplicate.hasPreviousPage,
}),
);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return paginatedDuplicates.map((duplicate) =>
typeORMObjectRecordsParser.createConnection({
objectRecords: duplicate.records,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: duplicate.records.length,
totalCount: duplicate.totalCount,
order: [{ id: OrderByDirection.AscNullsFirst }],
hasNextPage: duplicate.hasNextPage,
hasPreviousPage: duplicate.hasPreviousPage,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
FindDuplicatesResolverFactory.methodName,
);
};
}
}
@@ -12,8 +12,11 @@ import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-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 FindManyResolverFactory
@@ -23,6 +26,8 @@ export class FindManyResolverFactory
constructor(
private readonly commonFindManyQueryRunnerService: CommonFindManyQueryRunnerService,
private readonly graphqlQueryRunnerService: GraphqlQueryFindManyResolverService,
private readonly featureFlagService: FeatureFlagService,
) {}
create(
@@ -31,40 +36,59 @@ export class FindManyResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
internalContext.authContext.workspace?.id as string,
);
try {
const {
records,
aggregatedValues,
totalCount,
pageInfo,
selectedFieldsResult,
} = await this.commonFindManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
if (isCommonApiEnabled) {
const selectedFields = graphqlFields(info);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
try {
const {
records,
aggregatedValues,
totalCount,
pageInfo,
selectedFieldsResult,
} = await this.commonFindManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return typeORMObjectRecordsParser.createConnection({
objectRecords: records,
objectRecordsAggregatedValues: aggregatedValues,
selectedAggregatedFields: selectedFieldsResult.aggregate,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: args.first ?? args.last ?? QUERY_MAX_RECORDS,
totalCount,
order: args.orderBy,
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.createConnection({
objectRecords: records,
objectRecordsAggregatedValues: aggregatedValues,
selectedAggregatedFields: selectedFieldsResult.aggregate,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: args.first ?? args.last ?? QUERY_MAX_RECORDS,
totalCount,
order: args.orderBy,
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
return await this.graphqlQueryRunnerService.execute(
args,
{
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
},
FindManyResolverFactory.methodName,
);
};
}
}
@@ -11,8 +11,11 @@ import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
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
@@ -22,6 +25,8 @@ export class FindOneResolverFactory
constructor(
private readonly commonFindOneQueryRunnerService: CommonFindOneQueryRunnerService,
private readonly featureFlagService: FeatureFlagService,
private readonly graphqlQueryRunnerService: GraphqlQueryFindOneResolverService,
) {}
create(
@@ -30,29 +35,48 @@ export class FindOneResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
try {
const selectedFields = graphqlFields(info);
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
internalContext.authContext.workspace?.id as string,
);
const record = await this.commonFindOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
if (isCommonApiEnabled) {
try {
const selectedFields = graphqlFields(info);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
const record = await this.commonFindOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
return await this.graphqlQueryRunnerService.execute(
args,
{
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
},
FindOneResolverFactory.methodName,
);
};
}
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type MergeManyResolverArgs,
@@ -11,8 +12,11 @@ import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-
import { CommonMergeManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-merge-many-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-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 MergeManyResolverFactory
@@ -21,7 +25,9 @@ export class MergeManyResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.MERGE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryMergeManyResolverService,
private readonly commonMergeManyQueryRunnerService: CommonMergeManyQueryRunnerService,
private readonly featureFlagService: FeatureFlagService,
) {}
create(
@@ -30,29 +36,50 @@ export class MergeManyResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
internalContext.authContext.workspace?.id as string,
);
try {
const record = await this.commonMergeManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
if (isCommonApiEnabled) {
const selectedFields = graphqlFields(info);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
try {
const record = await this.commonMergeManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
MergeManyResolverFactory.methodName,
);
};
}
}
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { ObjectRecord } from 'twenty-shared/types';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type Resolver,
@@ -12,8 +13,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonRestoreManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-restore-many-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class RestoreManyResolverFactory
@@ -22,6 +26,8 @@ export class RestoreManyResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.RESTORE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryRestoreManyResolverService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly commonRestoreManyQueryRunnerService: CommonRestoreManyQueryRunnerService,
) {}
@@ -31,31 +37,54 @@ export class RestoreManyResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const records = await this.commonRestoreManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const records =
await this.commonRestoreManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
RestoreManyResolverFactory.methodName,
);
};
}
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type Resolver,
@@ -11,8 +12,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonRestoreOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-restore-one-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class RestoreOneResolverFactory
@@ -21,6 +25,8 @@ export class RestoreOneResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.RESTORE_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryRestoreOneResolverService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly commonRestoreOneQueryRunnerService: CommonRestoreOneQueryRunnerService,
) {}
@@ -30,29 +36,52 @@ export class RestoreOneResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const record = await this.commonRestoreOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const record = await this.commonRestoreOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
RestoreOneResolverFactory.methodName,
);
};
}
}
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { ObjectRecord } from 'twenty-shared/types';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type Resolver,
@@ -12,8 +13,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class UpdateManyResolverFactory
@@ -22,7 +26,9 @@ export class UpdateManyResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.UPDATE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryUpdateManyResolverService,
private readonly commonUpdateManyQueryRunnerService: CommonUpdateManyQueryRunnerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
create(
@@ -31,31 +37,54 @@ export class UpdateManyResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const records = await this.commonUpdateManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const records = await this.commonUpdateManyQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return records.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}),
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
UpdateManyResolverFactory.methodName,
);
};
}
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type Resolver,
@@ -11,8 +12,11 @@ import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/works
import { CommonUpdateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-one-query-runner.service';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class UpdateOneResolverFactory
@@ -21,7 +25,9 @@ export class UpdateOneResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.UPDATE_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryUpdateOneResolverService,
private readonly commonUpdateOneQueryRunnerService: CommonUpdateOneQueryRunnerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
create(
@@ -30,29 +36,52 @@ export class UpdateOneResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const selectedFields = graphqlFields(info);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: internalContext.authContext.workspace?.id as string,
});
try {
const record = await this.commonUpdateOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
const featureFlagsMap = workspaceDataSource.featureFlagMap;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const selectedFields = graphqlFields(info);
try {
const record = await this.commonUpdateOneQueryRunnerService.execute(
{ ...args, selectedFields },
internalContext,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
UpdateOneResolverFactory.methodName,
);
};
}
}
@@ -1,250 +0,0 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { SettingsPath } from 'twenty-shared/types';
import {
assertIsDefinedOrThrow,
getSettingsPath,
isDefined,
} from 'twenty-shared/utils';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { RestToCommonSelectedFieldsHandler } from 'src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler';
import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/parse-core-path.utils';
import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
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 { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.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 {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} 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 { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-plural.util';
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 { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
export interface PageInfo {
hasNextPage?: boolean;
hasPreviousPage?: boolean;
startCursor: string | null;
endCursor: string | null;
}
export interface FormatResult {
data?: {
[operation: string]: object;
};
pageInfo?: PageInfo;
totalCount?: number;
}
export abstract class RestApiBaseHandler {
@Inject()
protected readonly recordInputTransformerService: RecordInputTransformerService;
@Inject()
protected readonly twentyORMManager: TwentyORMManager;
@Inject()
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService;
@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;
@Inject()
protected readonly accessTokenService: AccessTokenService;
@Inject()
protected readonly workspaceDomainsService: WorkspaceDomainsService;
@Inject()
protected readonly featureFlagService: FeatureFlagService;
protected abstract handle(
request: AuthenticatedRequest,
): Promise<
FormatResult | { data: FormatResult[] } | CommonGroupByOutputItem[]
>;
public getAuthContextFromRequest(
request: AuthenticatedRequest,
): WorkspaceAuthContext {
return request;
}
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 | undefined;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
}): Promise<CommonSelectedFields> {
const { objectsPermissions } =
await this.getObjectsPermissions(authContext);
return this.restToCommonSelectedFieldsHandler.computeFromDepth({
objectsPermissions,
objectMetadataMaps,
objectMetadataMapItem,
depth,
});
}
async buildCommonOptions(request: AuthenticatedRequest) {
const { object: parsedObject } = parseCorePath(request);
const { objectMetadataMaps, objectMetadataMapItem } =
await this.getObjectMetadata(request, parsedObject);
const authContext = this.getAuthContextFromRequest(request);
return {
authContext,
objectMetadataItemWithFieldMaps: objectMetadataMapItem,
objectMetadataMaps: objectMetadataMaps,
};
}
private async getObjectMetadata(
request: AuthenticatedRequest,
parsedObject: string,
): Promise<{
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
}> {
const { workspace } =
await this.accessTokenService.validateTokenByRequest(request);
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (currentCacheVersion === undefined) {
await this.workspaceMetadataCacheService.recomputeMetadataCache({
workspaceId: workspace.id,
});
throw new BadRequestException('Metadata cache version not found');
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new BadRequestException(
`No object was found for the workspace associated with this API key. You may generate a new one here ${this.workspaceDomainsService
.buildWorkspaceURL({
workspace,
pathname: getSettingsPath(SettingsPath.ApiWebhooks),
})
.toString()}`,
);
}
const objectMetadataItem = getObjectMetadataMapItemByNamePlural(
objectMetadataMaps,
parsedObject,
);
if (!objectMetadataItem) {
const wrongObjectMetadataItem = getObjectMetadataMapItemByNameSingular(
objectMetadataMaps,
parsedObject,
);
let hint = 'eg: companies';
if (wrongObjectMetadataItem) {
hint = `Did you mean '${wrongObjectMetadataItem.namePlural}'?`;
}
throw new BadRequestException(
`object '${parsedObject}' not found. ${hint}`,
);
}
const workspaceFeatureFlagsMap =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspace.id);
// Check if this entity is workspace-gated and should be blocked from workspace API
if (
shouldExcludeFromWorkspaceApi(
objectMetadataItem,
standardObjectMetadataDefinitions,
workspaceFeatureFlagsMap,
)
) {
throw new BadRequestException(
`object '${parsedObject}' not found. ${parsedObject} is not available via REST API.`,
);
}
return {
objectMetadataMaps,
objectMetadataMapItem: objectMetadataItem,
};
}
}
@@ -1,14 +1,21 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { type ObjectRecord } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util';
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';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
@Injectable()
export class RestApiCreateManyHandler extends RestApiBaseHandler {
constructor(
@@ -17,7 +24,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
super();
}
async handle(request: AuthenticatedRequest) {
async commonHandle(request: AuthenticatedRequest) {
try {
const { data, depth, upsert } = this.parseRequestArgs(request);
@@ -48,7 +55,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
objectMetadataItemWithFieldMaps.namePlural,
);
} catch (error) {
return workspaceQueryRunnerRestApiExceptionHandler(error);
workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
@@ -66,4 +73,92 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
upsert: parseUpsertRestRequest(request),
};
}
async handle(request: AuthenticatedRequest) {
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
const body = request.body;
if (!Array.isArray(body)) {
throw new BadRequestException('Body must be an array');
}
if (body.length === 0) {
throw new BadRequestException('Input must not be empty');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const overriddenRecordsToCreate: Record<string, any>[] = [];
for (const recordToCreate of body) {
const overriddenBody = await this.recordInputTransformerService.process({
recordInput: recordToCreate,
objectMetadataMapItem: objectMetadata.objectMetadataMapItem,
});
const recordExists =
isDefined(overriddenBody.id) &&
(await repository.exists({
where: {
id: overriddenBody.id,
},
}));
if (recordExists) {
throw new BadRequestException('Record already exists');
}
overriddenRecordsToCreate.push(overriddenBody);
}
const recordsToCreate =
await this.createdByFromAuthContextService.injectCreatedBy(
overriddenRecordsToCreate,
objectMetadata.objectMetadataMapItem.nameSingular,
this.getAuthContextFromRequest(request),
);
let selectedColumns = undefined;
if (!isEmpty(restrictedFields)) {
const selectableFields = getAllSelectableColumnNames({
restrictedFields,
objectMetadata,
});
selectedColumns = Object.keys(selectableFields).filter(
(key) => selectableFields[key],
);
}
const createdRecords = await repository.insert(
recordsToCreate,
undefined,
selectedColumns,
);
const createdRecordsIds = createdRecords.identifiers.map(
(record) => record.id,
);
const records = await this.getRecord({
recordIds: createdRecordsIds,
repository,
objectMetadata,
depth: parseDepthRestRequest(request),
restrictedFields,
});
if (records.length !== body.length) {
throw new InternalServerErrorException(
`Error when creating records. ${body.length - records.length} records are missing after creation.`,
);
}
return this.formatResult({
operation: 'create',
objectNamePlural: objectMetadata.objectMetadataMapItem.namePlural,
data: records,
});
}
}
@@ -1,14 +1,21 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util';
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';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
@Injectable()
export class RestApiCreateOneHandler extends RestApiBaseHandler {
@@ -18,7 +25,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
super();
}
async handle(request: AuthenticatedRequest) {
async commonHandle(request: AuthenticatedRequest) {
try {
const { data, depth, upsert } = this.parseRequestArgs(request);
@@ -49,7 +56,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
objectMetadataItemWithFieldMaps.nameSingular,
);
} catch (error) {
return workspaceQueryRunnerRestApiExceptionHandler(error);
workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
@@ -64,4 +71,73 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
upsert: parseUpsertRestRequest(request),
};
}
async handle(request: AuthenticatedRequest) {
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
const overriddenBody = await this.recordInputTransformerService.process({
recordInput: request.body,
objectMetadataMapItem: objectMetadata.objectMetadataMapItem,
});
const recordExists =
isDefined(overriddenBody.id) &&
(await repository.exists({
where: {
id: overriddenBody.id,
},
}));
if (recordExists) {
throw new BadRequestException('Record already exists');
}
const [recordToCreate] =
await this.createdByFromAuthContextService.injectCreatedBy(
[overriddenBody],
objectMetadata.objectMetadataMapItem.nameSingular,
this.getAuthContextFromRequest(request),
);
let selectedColumns = undefined;
if (!isEmpty(restrictedFields)) {
const selectableFields = getAllSelectableColumnNames({
restrictedFields,
objectMetadata,
});
selectedColumns = Object.keys(selectableFields).filter(
(key) => selectableFields[key],
);
}
const createdRecordResult = await repository.insert(
recordToCreate,
undefined,
selectedColumns,
);
const createdRecord = createdRecordResult.identifiers[0];
const records = await this.getRecord({
recordIds: [createdRecord.id],
repository,
objectMetadata,
depth: parseDepthRestRequest(request),
restrictedFields,
});
const record = records[0];
if (!isDefined(record)) {
throw new InternalServerErrorException('Created record not found');
}
return this.formatResult({
operation: 'create',
objectNameSingular: objectMetadata.objectMetadataMapItem.nameSingular,
data: record,
});
}
}
@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonDeleteManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-many-query-runner.service';
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
@@ -3,9 +3,10 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonDeleteOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-one-query-runner.service';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/parse-core-path.utils';
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';
@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonDestroyManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service';
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
@@ -3,11 +3,13 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonDestroyOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-one-query-runner.service';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/parse-core-path.utils';
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';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
@Injectable()
export class RestApiDestroyOneHandler extends RestApiBaseHandler {
@@ -17,7 +19,7 @@ export class RestApiDestroyOneHandler extends RestApiBaseHandler {
super();
}
async handle(request: AuthenticatedRequest) {
async commonHandle(request: AuthenticatedRequest) {
try {
const { id } = this.parseRequestArgs(request);
@@ -41,7 +43,7 @@ export class RestApiDestroyOneHandler extends RestApiBaseHandler {
objectMetadataItemWithFieldMaps.nameSingular,
);
} catch (error) {
return workspaceQueryRunnerRestApiExceptionHandler(error);
workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
@@ -60,4 +62,37 @@ export class RestApiDestroyOneHandler extends RestApiBaseHandler {
id,
};
}
async handle(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
if (!recordId) {
throw new BadRequestException('Record ID not found');
}
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
const selectOptions = getAllSelectableColumnNames({
restrictedFields,
objectMetadata,
});
const recordToDelete = await repository.findOneOrFail({
where: { id: recordId },
select: selectOptions,
});
const columnsToReturnForDelete: string[] = [];
await repository.delete(recordId, undefined, columnsToReturnForDelete);
return this.formatResult({
operation: 'delete',
objectNameSingular: objectMetadata.objectMetadataMapItem.nameSingular,
data: {
id: recordToDelete.id,
},
});
}
}
@@ -1,12 +1,20 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { type Request } from 'express';
import isEmpty from 'lodash.isempty';
import { type ObjectRecord } from 'twenty-shared/types';
import { In } from 'typeorm';
import {
type FormatResult,
RestApiBaseHandler,
} from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonFindDuplicatesQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-duplicates-query-runner.service';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
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';
import { buildDuplicateConditions } from 'src/engine/api/utils/build-duplicate-conditions.utils';
@Injectable()
export class RestApiFindDuplicatesHandler extends RestApiBaseHandler {
@@ -16,8 +24,10 @@ export class RestApiFindDuplicatesHandler extends RestApiBaseHandler {
super();
}
async handle(request: AuthenticatedRequest) {
async commonHandle(request: AuthenticatedRequest) {
try {
this.validate(request);
const { data, ids, depth } = this.parseRequestArgs(request);
const {
@@ -48,7 +58,7 @@ export class RestApiFindDuplicatesHandler extends RestApiBaseHandler {
objectMetadataItemWithFieldMaps.nameSingular,
);
} catch (error) {
return workspaceQueryRunnerRestApiExceptionHandler(error);
workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
@@ -84,4 +94,130 @@ export class RestApiFindDuplicatesHandler extends RestApiBaseHandler {
depth: parseDepthRestRequest(request),
};
}
async handle(request: AuthenticatedRequest) {
this.validate(request);
const {
repository,
objectMetadata,
objectMetadataItemWithFieldsMaps,
restrictedFields,
} = await this.getRepositoryAndMetadataOrFail(request);
const existingRecordsQueryBuilder = repository.createQueryBuilder(
objectMetadataItemWithFieldsMaps.nameSingular,
);
let objectRecords: Partial<ObjectRecord>[] = [];
if (request.body.ids) {
objectRecords = (await existingRecordsQueryBuilder
.where({ id: In(request.body.ids) })
.getMany()) as ObjectRecord[];
} else if (request.body.data && !isEmpty(request.body.data)) {
objectRecords = request.body.data;
}
const duplicateConditions = objectRecords.map((record) =>
buildDuplicateConditions(
objectMetadataItemWithFieldsMaps,
[record],
record.id,
),
);
const result: { data: FormatResult[] } = {
data: [],
};
for (const duplicateCondition of duplicateConditions) {
const {
records,
isForwardPagination,
hasMoreRecords,
totalCount,
startCursor,
endCursor,
} = await this.findRecords({
request,
repository,
objectMetadata,
objectMetadataItemWithFieldsMaps,
extraFilters: duplicateCondition,
restrictedFields,
});
const paginatedResult = this.formatPaginatedDuplicatesResult({
finalRecords: records,
objectMetadataNameSingular:
objectMetadata.objectMetadataMapItem.nameSingular,
isForwardPagination,
hasMoreRecords,
totalCount,
startCursor,
endCursor,
});
result.data.push(paginatedResult);
}
return result;
}
private validate(request: Request) {
const { data, ids } = request.body;
if (!data && !ids) {
throw new BadRequestException(
'You have to provide either "data" or "ids" argument',
);
}
if (data && ids) {
throw new BadRequestException(
'You cannot provide both "data" and "ids" arguments',
);
}
if (!ids && isEmpty(data)) {
throw new BadRequestException(
'The "data" condition can not be empty when "ids" input not provided',
);
}
}
formatPaginatedDuplicatesResult({
finalRecords,
objectMetadataNameSingular,
isForwardPagination,
hasMoreRecords,
totalCount,
startCursor,
endCursor,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
finalRecords: any[];
objectMetadataNameSingular: string;
isForwardPagination: boolean;
hasMoreRecords: boolean;
totalCount: number;
startCursor: string | null;
endCursor: string | null;
}) {
const hasPreviousPage = !isForwardPagination && hasMoreRecords;
return this.formatResult({
operation: 'findDuplicates',
objectNameSingular: objectMetadataNameSingular,
data: isForwardPagination ? finalRecords : finalRecords.reverse(),
pageInfo: {
hasNextPage: isForwardPagination && hasMoreRecords,
...(hasPreviousPage ? { hasPreviousPage } : {}),
startCursor,
endCursor,
},
totalCount,
});
}
}
@@ -5,7 +5,8 @@ import { ObjectRecord } from 'twenty-shared/types';
import {
PageInfo,
RestApiBaseHandler,
} from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
} from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
@@ -25,6 +26,74 @@ export class RestApiFindManyHandler extends RestApiBaseHandler {
}
async handle(request: AuthenticatedRequest) {
const {
repository,
objectMetadata,
objectMetadataItemWithFieldsMaps,
restrictedFields,
} = await this.getRepositoryAndMetadataOrFail(request);
const {
records,
isForwardPagination,
hasMoreRecords,
totalCount,
startCursor,
endCursor,
} = await this.findRecords({
request,
repository,
objectMetadata,
objectMetadataItemWithFieldsMaps,
restrictedFields,
});
return this.formatPaginatedResult({
finalRecords: records,
objectMetadataNamePlural: objectMetadata.objectMetadataMapItem.namePlural,
isForwardPagination,
hasMoreRecords,
totalCount,
startCursor,
endCursor,
});
}
formatPaginatedResult({
finalRecords,
objectMetadataNamePlural,
isForwardPagination,
hasMoreRecords,
totalCount,
startCursor,
endCursor,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
finalRecords: any[];
objectMetadataNamePlural: string;
isForwardPagination: boolean;
hasMoreRecords: boolean;
totalCount: number;
startCursor: string | null;
endCursor: string | null;
}) {
const hasPreviousPage = !isForwardPagination && hasMoreRecords;
return this.formatResult({
operation: 'findMany',
objectNamePlural: objectMetadataNamePlural,
data: isForwardPagination ? finalRecords : finalRecords.reverse(),
pageInfo: {
hasNextPage: isForwardPagination && hasMoreRecords,
...(hasPreviousPage ? { hasPreviousPage } : {}),
startCursor,
endCursor,
},
totalCount,
});
}
async commonHandle(request: AuthenticatedRequest) {
try {
const parsedArgs = this.parseRequestArgs(request);
const {
@@ -60,7 +129,7 @@ export class RestApiFindManyHandler extends RestApiBaseHandler {
pageInfo,
);
} catch (error) {
return workspaceQueryRunnerRestApiExceptionHandler(error);
workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
@@ -1,11 +1,13 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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 { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/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';
@@ -18,6 +20,44 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
}
async handle(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
if (!isDefined(recordId)) {
throw new BadRequestException(
'No recordId provided in rest api get one query',
);
}
const {
repository,
objectMetadata,
objectMetadataItemWithFieldsMaps,
restrictedFields,
} = await this.getRepositoryAndMetadataOrFail(request);
const { records } = await this.findRecords({
request,
recordId,
repository,
objectMetadata,
objectMetadataItemWithFieldsMaps,
restrictedFields,
});
const record = records?.[0];
if (!isDefined(record)) {
throw new BadRequestException('Record not found');
}
return this.formatResult({
operation: 'findOne',
objectNameSingular: objectMetadata.objectMetadataMapItem.nameSingular,
data: record,
});
}
async commonHandle(request: AuthenticatedRequest) {
try {
const { filter, depth } = await this.parseRequestArgs(request);
const {
@@ -47,7 +87,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
objectMetadataItemWithFieldMaps.nameSingular,
);
} catch (error) {
return workspaceQueryRunnerRestApiExceptionHandler(error);
workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util';
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonMergeManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-merge-many-query-runner.service';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonRestoreManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-restore-many-query-runner.service';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
@@ -3,10 +3,11 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonRestoreOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-restore-one-query-runner.service';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/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';
@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
@@ -1,14 +1,21 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { ObjectRecord } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonUpdateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-one-query-runner.service';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/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';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
@Injectable()
export class RestApiUpdateOneHandler extends RestApiBaseHandler {
@@ -18,7 +25,7 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
super();
}
async handle(request: AuthenticatedRequest) {
async commonHandle(request: AuthenticatedRequest) {
try {
const { id, data, depth } = this.parseRequestArgs(request);
@@ -49,7 +56,7 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
objectMetadataItemWithFieldMaps.nameSingular,
);
} catch (error) {
return workspaceQueryRunnerRestApiExceptionHandler(error);
workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
@@ -70,4 +77,68 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
depth: parseDepthRestRequest(request),
};
}
async handle(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
if (!recordId) {
throw new BadRequestException('Record ID not found');
}
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
// assert the record exists
await repository.findOneOrFail({
select: { id: true },
where: { id: recordId },
});
const overriddenBody = await this.recordInputTransformerService.process({
recordInput: request.body,
objectMetadataMapItem: objectMetadata.objectMetadataMapItem,
});
let selectedColumns = undefined;
if (!isEmpty(restrictedFields)) {
const selectableFields = getAllSelectableColumnNames({
restrictedFields,
objectMetadata,
});
selectedColumns = Object.keys(selectableFields).filter(
(key) => selectableFields[key],
);
}
const updatedRecord = await repository.update(
recordId,
overriddenBody,
undefined,
selectedColumns,
);
const updatedRecordId = updatedRecord.generatedMaps[0].id;
const records = await this.getRecord({
recordIds: [updatedRecordId],
repository,
objectMetadata,
depth: parseDepthRestRequest(request),
restrictedFields,
});
const record = records[0];
if (!isDefined(record)) {
throw new InternalServerErrorException('Updated record not found');
}
return this.formatResult({
operation: 'update',
objectNameSingular: objectMetadata.objectMetadataMapItem.nameSingular,
data: record,
});
}
}
@@ -0,0 +1,629 @@
import { BadRequestException, Inject } from '@nestjs/common';
import chunk from 'lodash.chunk';
import isEmpty from 'lodash.isempty';
import {
FieldMetadataType,
ObjectRecord,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
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 ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
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 { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.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 { 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 {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} 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;
hasPreviousPage?: boolean;
startCursor: string | null;
endCursor: string | null;
}
interface FormatResultParams<T> {
operation:
| 'delete'
| 'create'
| 'update'
| 'findOne'
| 'findMany'
| 'findDuplicates';
objectNameSingular?: string;
objectNamePlural?: string;
data: T;
pageInfo?: PageInfo;
totalCount?: number;
}
export interface FormatResult {
data?: {
[operation: string]: object;
};
pageInfo?: PageInfo;
totalCount?: number;
}
export abstract class RestApiBaseHandler {
@Inject()
protected readonly recordInputTransformerService: RecordInputTransformerService;
@Inject()
protected readonly coreQueryBuilderFactory: CoreQueryBuilderFactory;
@Inject()
protected readonly twentyORMManager: TwentyORMManager;
@Inject()
protected readonly getVariablesFactory: GetVariablesFactory;
@Inject()
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService;
@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: AuthenticatedRequest,
): Promise<
FormatResult | { data: FormatResult[] } | CommonGroupByOutputItem[]
>;
public async getRepositoryAndMetadataOrFail(request: AuthenticatedRequest) {
const { workspace, apiKey, userWorkspaceId } = request;
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.coreQueryBuilderFactory.getObjectMetadata(
request,
parsedObject,
);
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 =
objectMetadata.objectMetadataMapItem.nameSingular;
const objectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(
objectMetadata.objectMetadataMaps,
objectMetadataNameSingular,
);
if (!isDefined(objectMetadataItemWithFieldsMaps)) {
throw new BadRequestException(
`Object metadata item with name singular ${objectMetadataNameSingular} not found`,
);
}
let roleId: string;
if (isDefined(apiKey)) {
roleId = await this.apiKeyRoleService.getRoleIdForApiKey(
apiKey.id,
workspace.id,
);
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,
userWorkspaceId,
},
);
if (!isDefined(userWorkspaceRoleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
);
}
roleId = userWorkspaceRoleId;
}
const repository = workspaceDataSource.getRepository<ObjectRecord>(
objectMetadataNameSingular,
{
unionOf: [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');
}
const restrictedFields =
objectMetadataPermissions[roleId][objectMetadata.objectMetadataMapItem.id]
.restrictedFields;
return {
objectMetadata,
repository,
workspaceDataSource,
objectMetadataItemWithFieldsMaps,
restrictedFields,
isExecutedByApiKey: isDefined(apiKey),
authContext: this.getAuthContextFromRequest(request),
objectsPermissions: objectMetadataPermissions[roleId],
};
}
getRelations({
objectMetadata,
depth,
}: {
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
};
depth: Depth | undefined;
}) {
if (!isDefined(depth) || depth === 0) {
return [];
}
const relations: string[] = [];
Object.values(objectMetadata.objectMetadataMapItem.fieldsById).forEach(
(field) => {
if (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) {
if (
depth === MAX_DEPTH &&
isDefined(field.relationTargetObjectMetadataId)
) {
const relationTargetObjectMetadata =
objectMetadata.objectMetadataMaps.byId[
field.relationTargetObjectMetadataId
];
if (!isDefined(relationTargetObjectMetadata)) {
throw new BadRequestException(
`Object metadata relation target not found for relation creation payload`,
);
}
const depth2Relations = this.getRelations({
objectMetadata: {
objectMetadataMaps: objectMetadata.objectMetadataMaps,
objectMetadataMapItem: relationTargetObjectMetadata,
},
depth: 1,
});
depth2Relations.forEach((depth2Relation) => {
relations.push(`${field.name}.${depth2Relation}`);
});
} else {
relations.push(`${field.name}`);
}
}
},
);
return relations;
}
async getRecord({
recordIds,
repository,
objectMetadata,
depth,
restrictedFields,
}: {
recordIds: string[];
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
};
depth: Depth | undefined;
restrictedFields: RestrictedFieldsPermissions;
}) {
const relations = this.getRelations({
objectMetadata,
depth: depth,
});
const relationsChunk = chunk(relations, 50);
let selectOptions = undefined;
if (!isEmpty(restrictedFields)) {
selectOptions = getAllSelectableColumnNames({
restrictedFields,
objectMetadata,
});
}
const recordsWithoutRelations = await repository.find({
...(selectOptions && { select: selectOptions }),
where: { id: In(recordIds) },
});
const recordsMap = new Map(
recordsWithoutRelations.map((record) => [record.id, record]),
);
for (const relationChunk of relationsChunk) {
const records = await repository.find({
...(selectOptions && { select: selectOptions }),
where: { id: In(recordIds) },
relations: relationChunk,
});
records.map((record) => {
recordsMap.set(record.id, {
...recordsMap.get(record.id),
...record,
});
});
}
const orderedRecords = recordIds.map((id) => recordsMap.get(id));
return orderedRecords;
}
public getAuthContextFromRequest(
request: AuthenticatedRequest,
): WorkspaceAuthContext {
return request;
}
public formatResult<T>({
operation,
objectNameSingular,
objectNamePlural,
data,
pageInfo,
totalCount,
}: FormatResultParams<T>) {
let prefix: string;
if (isDefined(objectNameSingular) && isDefined(objectNamePlural)) {
throw new Error(
'Cannot define both objectNameSingular and objectNamePlural',
);
}
if (operation === 'findOne') {
prefix = objectNameSingular || '';
} else if (operation === 'findMany') {
prefix = objectNamePlural || '';
} else if (operation === 'findDuplicates') {
prefix = `${objectNameSingular}Duplicates`;
} else {
prefix =
operation + capitalize(objectNameSingular || objectNamePlural || '');
}
return {
...(operation === 'findDuplicates'
? {
[prefix]: data,
}
: {
data: {
[prefix]: data,
},
}),
...(isDefined(pageInfo) ? { pageInfo } : {}),
...(isDefined(totalCount) ? { totalCount } : {}),
};
}
async findRecords({
request,
recordId,
repository,
objectMetadata,
objectMetadataItemWithFieldsMaps,
extraFilters,
restrictedFields,
}: {
request: AuthenticatedRequest;
recordId?: string;
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
};
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps;
extraFilters?: Partial<ObjectRecordFilter>;
restrictedFields: RestrictedFieldsPermissions;
}) {
const objectMetadataNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
const qb = repository
.createQueryBuilder(objectMetadataNameSingular)
.select('id');
const inputs = this.getVariablesFactory.create(
recordId,
request,
objectMetadata,
);
const isForwardPagination = !inputs.endingBefore;
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldsMaps,
objectMetadata.objectMetadataMaps,
);
const filters = this.computeFilters({
inputs,
objectMetadata,
isForwardPagination,
extraFilters,
});
let selectQueryBuilder = isDefined(filters)
? graphqlQueryParser.applyFilterToBuilder(
qb,
objectMetadataNameSingular,
filters,
)
: qb;
const totalCount = await this.getTotalCount(selectQueryBuilder);
selectQueryBuilder = graphqlQueryParser.applyOrderToBuilder(
selectQueryBuilder,
inputs.orderBy || [],
objectMetadataNameSingular,
isForwardPagination,
);
if (inputs.first) {
selectQueryBuilder = selectQueryBuilder.limit(inputs.first);
}
if (inputs.last) {
selectQueryBuilder = selectQueryBuilder.limit(inputs.last);
}
const recordIds = await selectQueryBuilder
.select(`${objectMetadataNameSingular}.id`)
.getMany();
const records = await this.getRecord({
recordIds: recordIds.map((record) => record.id),
repository,
objectMetadata,
depth: parseDepthRestRequest(request),
restrictedFields,
});
const hasMoreRecords = records.length < totalCount;
const finalRecords = formatGetManyData<ObjectRecord[]>(
records,
objectMetadataItemWithFieldsMaps,
objectMetadata.objectMetadataMaps,
);
const startCursor =
finalRecords.length > 0
? encodeCursor(finalRecords[0], inputs.orderBy)
: null;
const endCursor =
finalRecords.length > 0
? encodeCursor(finalRecords[finalRecords.length - 1], inputs.orderBy)
: null;
return {
records: finalRecords,
totalCount,
hasMoreRecords,
isForwardPagination,
startCursor,
endCursor,
};
}
async getTotalCount(
query: WorkspaceSelectQueryBuilder<ObjectLiteral>,
): Promise<number> {
const countQuery = query.clone();
return await countQuery.getCount();
}
computeFilters({
inputs,
objectMetadata,
isForwardPagination,
extraFilters,
}: {
inputs: QueryVariables;
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
};
isForwardPagination: boolean;
extraFilters?: Partial<ObjectRecordFilter>;
}) {
let appliedFilters = inputs.filter;
if (extraFilters) {
appliedFilters = (appliedFilters
? { and: [appliedFilters, extraFilters] }
: extraFilters) as unknown as ObjectRecordFilter;
}
const cursor = inputs.startingAfter || inputs.endingBefore;
if (cursor) {
const cursorArgFilter = computeCursorArgFilter(
this.parseCursor(cursor),
inputs.orderBy || [],
objectMetadata.objectMetadataMapItem,
isForwardPagination,
);
appliedFilters = (appliedFilters
? {
and: [appliedFilters, { or: cursorArgFilter }],
}
: { or: cursorArgFilter }) as unknown as ObjectRecordFilter;
}
return appliedFilters;
}
private parseCursor = (cursor: string) => {
try {
return JSON.parse(Buffer.from(cursor ?? '', 'base64').toString());
} catch {
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 | undefined;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
}): Promise<CommonSelectedFields> {
const { objectsPermissions } =
await this.getObjectsPermissions(authContext);
return this.restToCommonSelectedFieldsHandler.computeFromDepth({
objectsPermissions,
objectMetadataMaps,
objectMetadataMapItem,
depth,
});
}
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,
objectMetadataItemWithFieldMaps: objectMetadataMapItem,
objectMetadataMaps: objectMetadataMaps,
};
}
}
@@ -0,0 +1,146 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { SettingsPath } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, getSettingsPath } from 'twenty-shared/utils';
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 { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compute-depth.utils';
import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { type Query } from 'src/engine/api/rest/core/types/query.type';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.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 { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-plural.util';
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 { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
@Injectable()
export class CoreQueryBuilderFactory {
constructor(
private readonly createManyQueryFactory: CreateManyQueryFactory,
private readonly createVariablesFactory: CreateVariablesFactory,
private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory,
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
private readonly accessTokenService: AccessTokenService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly featureFlagService: FeatureFlagService,
) {}
async getObjectMetadata(
request: Request,
parsedObject: string,
): Promise<{
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
}> {
const { workspace } =
await this.accessTokenService.validateTokenByRequest(request);
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (currentCacheVersion === undefined) {
await this.workspaceMetadataCacheService.recomputeMetadataCache({
workspaceId: workspace.id,
});
throw new BadRequestException('Metadata cache version not found');
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new BadRequestException(
`No object was found for the workspace associated with this API key. You may generate a new one here ${this.workspaceDomainsService
.buildWorkspaceURL({
workspace,
pathname: getSettingsPath(SettingsPath.ApiWebhooks),
})
.toString()}`,
);
}
const objectMetadataItem = getObjectMetadataMapItemByNamePlural(
objectMetadataMaps,
parsedObject,
);
if (!objectMetadataItem) {
const wrongObjectMetadataItem = getObjectMetadataMapItemByNameSingular(
objectMetadataMaps,
parsedObject,
);
let hint = 'eg: companies';
if (wrongObjectMetadataItem) {
hint = `Did you mean '${wrongObjectMetadataItem.namePlural}'?`;
}
throw new BadRequestException(
`object '${parsedObject}' not found. ${hint}`,
);
}
const workspaceFeatureFlagsMap =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspace.id);
// Check if this entity is workspace-gated and should be blocked from workspace API
if (
shouldExcludeFromWorkspaceApi(
objectMetadataItem,
standardObjectMetadataDefinitions,
workspaceFeatureFlagsMap,
)
) {
throw new BadRequestException(
`object '${parsedObject}' not found. ${parsedObject} is not available via REST API.`,
);
}
return {
objectMetadataMaps,
objectMetadataMapItem: objectMetadataItem,
};
}
async createMany(request: Request): Promise<Query> {
const { object: parsedObject } = parseCoreBatchPath(request);
const objectMetadata = await this.getObjectMetadata(request, parsedObject);
const depth = computeDepth(request);
return {
query: this.createManyQueryFactory.create(objectMetadata, depth),
variables: this.createVariablesFactory.create(request),
};
}
async findDuplicates(request: Request): Promise<Query> {
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.getObjectMetadata(request, parsedObject);
const depth = computeDepth(request);
return {
query: this.findDuplicatesQueryFactory.create(objectMetadata, depth),
variables: this.findDuplicatesVariablesFactory.create(request),
};
}
}
@@ -0,0 +1,26 @@
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 { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.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,
WorkspaceDomainsModule,
FeatureFlagModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataCacheModule,
WorkspacePermissionsCacheModule,
],
providers: [...coreQueryBuilderFactories, CoreQueryBuilderFactory],
exports: [CoreQueryBuilderFactory],
})
export class CoreQueryBuilderModule {}
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
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';
@Injectable()
export class CreateManyQueryFactory {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNamePlural = capitalize(
objectMetadata.objectMetadataMapItem.namePlural,
);
const objectNameSingular = capitalize(
objectMetadata.objectMetadataMapItem.nameSingular,
);
return `
mutation Create${objectNamePlural}($data: [${objectNameSingular}CreateInput!]) {
create${objectNamePlural}(data: $data) {
id
${Object.values(objectMetadata.objectMetadataMapItem.fieldsById)
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataMaps,
field,
depth,
),
)
.join('\n')}
}
}
`;
}
}
@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
@Injectable()
export class CreateVariablesFactory {
create(request: Request): QueryVariables {
return {
data: request.body,
};
}
}
@@ -0,0 +1,15 @@
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 { inputFactories } from 'src/engine/api/rest/input-factories/factories';
export const coreQueryBuilderFactories = [
CreateManyQueryFactory,
FindDuplicatesQueryFactory,
CreateVariablesFactory,
GetVariablesFactory,
FindDuplicatesVariablesFactory,
...inputFactories,
];
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
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';
@Injectable()
export class FindDuplicatesQueryFactory {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
return `
query FindDuplicate${capitalize(
objectNameSingular,
)}($ids: [UUID], $data: [${capitalize(objectNameSingular)}CreateInput]) {
${objectNameSingular}Duplicates(ids: $ids, data: $data) {
totalCount
pageInfo {
hasNextPage
startCursor
endCursor
}
edges{
node {
${Object.values(objectMetadata.objectMetadataMapItem.fieldsById)
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataMaps,
field,
depth,
),
)
.join('\n')}
}
}
}
}
`;
}
}
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
@Injectable()
export class FindDuplicatesVariablesFactory {
create(request: Request): QueryVariables {
return request.body;
}
}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util';
import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
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';
@Injectable()
export class GetVariablesFactory {
constructor(
private readonly orderByInputFactory: OrderByInputFactory,
private readonly filterInputFactory: FilterInputFactory,
) {}
create(
id: string | undefined,
request: AuthenticatedRequest,
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
): QueryVariables {
if (isDefined(id)) {
return { filter: { id: { eq: id } } };
}
const filter = this.filterInputFactory.create(request, objectMetadata);
const limit = parseLimitRestRequest(request);
const orderBy = this.orderByInputFactory.create(request, objectMetadata);
const endingBefore = parseEndingBeforeRestRequest(request);
const startingAfter = parseStartingAfterRestRequest(request);
return {
filter,
orderBy,
first: !endingBefore ? limit : undefined,
last: endingBefore ? limit : undefined,
startingAfter,
endingBefore,
};
}
}
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`checkFields should accept valid relation field name 1`] = `"field 'pointOfContact' does not exist in 'opportunity' object"`;
exports[`checkFields should reject invalid field name 1`] = `"field 'wrongField' does not exist in 'opportunity' object"`;
exports[`checkFields should reject mix of valid and invalid field names 1`] = `"field 'wrongField' does not exist in 'opportunity' object"`;
@@ -0,0 +1,63 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { OPPORTUNITY_WITH_FIELDS_MAPS } from 'src/engine/api/rest/core/query-builder/utils/__tests__/mocks/opportunity-field-maps.mock';
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
describe('checkFields', () => {
const testCases: EachTestingContext<{
fields: string[];
shouldThrow?: boolean;
}>[] = [
{
title: 'should accept valid join column id',
context: {
fields: ['pointOfContactId'],
},
},
{
title: 'should accept valid relation field name',
context: {
shouldThrow: true,
fields: ['pointOfContact'],
},
},
{
title: 'should accept valid field name',
context: {
fields: ['position'],
},
},
{
title: 'should reject invalid field name',
context: {
fields: ['wrongField'],
shouldThrow: true,
},
},
{
title: 'should reject mix of valid and invalid field names',
context: {
fields: ['position', 'wrongField'],
shouldThrow: true,
},
},
{
title: 'should accept composite field',
context: {
fields: ['source'],
},
},
];
it.each(testCases)('$title', ({ context }) => {
if (context.shouldThrow) {
expect(() =>
checkFields(OPPORTUNITY_WITH_FIELDS_MAPS, context.fields),
).toThrowErrorMatchingSnapshot();
} else {
expect(() =>
checkFields(OPPORTUNITY_WITH_FIELDS_MAPS, context.fields),
).not.toThrow();
}
});
});
@@ -0,0 +1,28 @@
import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compute-depth.utils';
describe('computeDepth', () => {
[0, 1].forEach((depth) => {
it('should compute depth from query', () => {
const request: any = {
query: { depth: `${depth}` },
};
expect(computeDepth(request)).toEqual(depth);
});
});
it('should return default depth if missing', () => {
const request: any = { query: {} };
expect(computeDepth(request)).toEqual(undefined);
});
it('should raise if wrong depth', () => {
const request: any = { query: { depth: '100' } };
expect(() => computeDepth(request)).toThrow();
request.query.depth = '-1';
expect(() => computeDepth(request)).toThrow();
});
});
@@ -0,0 +1,45 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
fieldNumberMock,
objectMetadataItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { getFieldType } from 'src/engine/api/rest/core/query-builder/utils/get-field-type.utils';
import { type FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
describe('getFieldType', () => {
const completeFieldNumberMock = getMockFieldMetadataEntity({
workspaceId: '20202020-0000-0000-0000-000000000000',
objectMetadataId: '20202020-0000-0000-0000-000000000001',
id: 'field-number-id',
type: fieldNumberMock.type,
name: fieldNumberMock.name,
label: 'Field Number',
isNullable: fieldNumberMock.isNullable,
defaultValue: fieldNumberMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const fieldsById: FieldMetadataMap = {
'field-number-id': completeFieldNumberMock,
};
const mockObjectMetadataWithFieldMaps = {
...objectMetadataItemMock,
fieldsById,
fieldIdByName: {
[completeFieldNumberMock.name]: completeFieldNumberMock.id,
},
fieldIdByJoinColumnName: {},
indexMetadatas: [],
};
it('should get field type', () => {
expect(
getFieldType(mockObjectMetadataWithFieldMaps, 'fieldNumber'),
).toEqual(FieldMetadataType.NUMBER);
});
});
@@ -0,0 +1,144 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataRelationSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import {
fieldCurrencyMock,
fieldNumberMock,
fieldTextMock,
objectMetadataItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
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 { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
describe('mapFieldMetadataToGraphqlQuery', () => {
const typedFieldNumberMock = getMockFieldMetadataEntity({
workspaceId: '20202020-0000-0000-0000-000000000000',
objectMetadataId: '20202020-0000-0000-0000-000000000001',
id: '20202020-0000-0000-0000-000000000002',
name: fieldNumberMock.name,
type: fieldNumberMock.type,
label: 'Field Number',
isNullable: fieldNumberMock.isNullable,
defaultValue: fieldNumberMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const typedFieldTextMock = getMockFieldMetadataEntity({
workspaceId: '20202020-0000-0000-0000-000000000000',
objectMetadataId: '20202020-0000-0000-0000-000000000001',
id: '20202020-0000-0000-0000-000000000003',
name: fieldTextMock.name,
type: fieldTextMock.type,
label: 'Field Text',
isNullable: fieldTextMock.isNullable,
defaultValue: fieldTextMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const typedFieldCurrencyMock = getMockFieldMetadataEntity({
workspaceId: '20202020-0000-0000-0000-000000000000',
objectMetadataId: '20202020-0000-0000-0000-000000000001',
id: '20202020-0000-0000-0000-000000000004',
name: fieldCurrencyMock.name,
type: fieldCurrencyMock.type,
label: 'Field Currency',
isNullable: fieldCurrencyMock.isNullable,
defaultValue: fieldCurrencyMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const fieldsById: FieldMetadataMap = {
'field-number-id': typedFieldNumberMock,
'field-text-id': typedFieldTextMock,
'field-currency-id': typedFieldCurrencyMock,
};
const typedObjectMetadataItem: ObjectMetadataItemWithFieldMaps = {
...objectMetadataItemMock,
fieldsById,
fieldIdByName: {
[typedFieldNumberMock.name]: typedFieldNumberMock.id,
[typedFieldTextMock.name]: typedFieldTextMock.id,
[typedFieldCurrencyMock.name]: typedFieldCurrencyMock.id,
},
fieldIdByJoinColumnName: {},
indexMetadatas: [],
};
const objectMetadataMapsMock: ObjectMetadataMaps = {
byId: {
[objectMetadataItemMock.id]: typedObjectMetadataItem,
},
idByNameSingular: {
[objectMetadataItemMock.nameSingular]: objectMetadataItemMock.id,
},
};
it('should map properly', () => {
expect(
mapFieldMetadataToGraphqlQuery(
objectMetadataMapsMock,
typedFieldNumberMock,
),
).toEqual('fieldNumber');
expect(
mapFieldMetadataToGraphqlQuery(
objectMetadataMapsMock,
typedFieldTextMock,
),
).toEqual('fieldText');
expect(
mapFieldMetadataToGraphqlQuery(
objectMetadataMapsMock,
typedFieldCurrencyMock,
),
).toEqual(`
fieldCurrency
{
amountMicros
currencyCode
}
`);
});
describe('should handle all field metadata types', () => {
Object.values(FieldMetadataType).forEach((fieldMetadataType) => {
it(`with field type ${fieldMetadataType}`, () => {
const field = getMockFieldMetadataEntity({
workspaceId: '20202020-0000-0000-0000-000000000000',
objectMetadataId: '20202020-0000-0000-0000-000000000001',
id: '20202020-0000-0000-0000-000000000005',
type: fieldMetadataType,
name: 'toObjectMetadataName',
label: 'Test Field',
isNullable: true,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
settings:
fieldMetadataType === FieldMetadataType.RELATION ||
fieldMetadataType === FieldMetadataType.MORPH_RELATION
? ({
relationType: RelationType.MANY_TO_ONE,
} as FieldMetadataRelationSettings)
: null,
});
expect(
mapFieldMetadataToGraphqlQuery(objectMetadataMapsMock, field),
).toBeDefined();
});
});
});
});
@@ -0,0 +1,464 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { DateDisplayFormat } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
import { getMockObjectMetadataItemWithFieldsMaps } from 'src/utils/__test__/get-object-metadata-item-with-fields-maps.mock';
const workspaceId = '20202020-1c25-4d02-bf25-6aeccf7ea419';
const objectMetadataId = '20202020-6e2c-42f6-a83c-cc58d776af88';
export const OPPORTUNITY_WITH_FIELDS_MAPS =
getMockObjectMetadataItemWithFieldsMaps({
id: objectMetadataId,
nameSingular: 'opportunity',
namePlural: 'opportunities',
labelSingular: 'Opportunity',
labelPlural: 'Opportunities',
description: 'An opportunity',
icon: 'IconTargetArrow',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
labelIdentifierFieldMetadataId: '20202020-c2f1-4435-adca-22931f8b41b6',
imageIdentifierFieldMetadataId: null,
workspaceId,
indexMetadatas: [],
fieldsById: {
'20202020-c2f1-4435-adca-22931f8b41b6': getMockFieldMetadataEntity({
id: '20202020-c2f1-4435-adca-22931f8b41b6',
workspaceId,
objectMetadataId,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
defaultValue: "''",
description: 'The opportunity name',
icon: 'IconTargetArrow',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-5eef-417a-b517-ebeedaa8e10b': getMockFieldMetadataEntity({
id: '20202020-5eef-417a-b517-ebeedaa8e10b',
workspaceId,
objectMetadataId,
type: FieldMetadataType.CURRENCY,
name: 'amount',
label: 'Amount',
defaultValue: { amountMicros: null, currencyCode: "''" },
description: 'Opportunity amount',
icon: 'IconCurrencyDollar',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-597c-44d3-98ec-ea71aea5256b': getMockFieldMetadataEntity({
id: '20202020-597c-44d3-98ec-ea71aea5256b',
workspaceId,
objectMetadataId,
type: FieldMetadataType.DATE_TIME,
name: 'closeDate',
label: 'Close date',
defaultValue: null,
description: 'Opportunity close date',
icon: 'IconCalendarEvent',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-9b94-454a-94ca-8afb09c68faf': getMockFieldMetadataEntity({
id: '20202020-9b94-454a-94ca-8afb09c68faf',
workspaceId,
objectMetadataId,
type: FieldMetadataType.SELECT,
name: 'stage',
label: 'Stage',
defaultValue: "'NEW'",
description: 'Opportunity stage',
icon: 'IconProgressCheck',
options: [
{
id: '20202020-dba8-4975-81bd-b29a41c0a387',
color: 'red',
label: 'New',
value: 'NEW',
position: 0,
},
{
id: '20202020-1c9d-490c-940c-bf47addcd6a1',
color: 'purple',
label: 'Screening',
value: 'SCREENING',
position: 1,
},
{
id: '20202020-1368-438b-8702-6d5e5727a888',
color: 'sky',
label: 'Meeting',
value: 'MEETING',
position: 2,
},
{
id: '20202020-41e4-4b4e-a038-f02645f55767',
color: 'turquoise',
label: 'Proposal',
value: 'PROPOSAL',
position: 3,
},
{
id: '20202020-8acf-4934-8519-42f7c9133cd5',
color: 'yellow',
label: 'Customer',
value: 'CUSTOMER',
position: 4,
},
],
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-30a5-4d8e-9b93-12d31ece0aaa': getMockFieldMetadataEntity({
id: '20202020-30a5-4d8e-9b93-12d31ece0aaa',
workspaceId,
objectMetadataId,
type: FieldMetadataType.POSITION,
name: 'position',
label: 'Position',
defaultValue: 0,
description: 'Opportunity record position',
icon: 'IconHierarchy2',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-f95f-424f-ab32-65961e8e9635': getMockFieldMetadataEntity({
id: '20202020-f95f-424f-ab32-65961e8e9635',
workspaceId,
objectMetadataId,
type: FieldMetadataType.ACTOR,
name: 'createdBy',
label: 'Created by',
defaultValue: { name: "'System'", source: "'MANUAL'" },
description: 'The creator of the record',
icon: 'IconCreativeCommonsSa',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-5e10-4780-babb-38a465ac546c': getMockFieldMetadataEntity({
id: '20202020-5e10-4780-babb-38a465ac546c',
workspaceId,
objectMetadataId,
type: FieldMetadataType.TEXT,
name: 'searchVector',
label: 'Search vector',
defaultValue: null,
description: 'Field used for full-text search',
icon: 'IconUser',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-8f4a-4f8d-822e-90fe72f75b79': getMockFieldMetadataEntity({
id: '20202020-8f4a-4f8d-822e-90fe72f75b79',
workspaceId,
objectMetadataId,
type: FieldMetadataType.UUID,
name: 'id',
label: 'Id',
defaultValue: 'uuid',
description: 'Id',
icon: 'Icon123',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-f120-4b59-b239-f7f1d8eb243e': getMockFieldMetadataEntity({
id: '20202020-f120-4b59-b239-f7f1d8eb243e',
workspaceId,
objectMetadataId,
type: FieldMetadataType.DATE_TIME,
name: 'createdAt',
label: 'Creation date',
defaultValue: 'now',
description: 'Creation date',
icon: 'IconCalendar',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
isLabelSyncedWithName: false,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-dcc8-4318-9756-b87377692561': getMockFieldMetadataEntity({
id: '20202020-dcc8-4318-9756-b87377692561',
workspaceId,
objectMetadataId,
type: FieldMetadataType.DATE_TIME,
name: 'updatedAt',
label: 'Last update',
defaultValue: 'now',
description: 'Last time the record was changed',
icon: 'IconCalendarClock',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
isLabelSyncedWithName: false,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-1694-4f8b-8760-61a5ff330022': getMockFieldMetadataEntity({
id: '20202020-1694-4f8b-8760-61a5ff330022',
workspaceId,
objectMetadataId,
type: FieldMetadataType.DATE_TIME,
name: 'deletedAt',
label: 'Deletion date',
defaultValue: null,
description: 'Record deletion date',
icon: 'IconCalendarOff',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: false,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-4f52-4dea-a116-723f9bf7f082': getMockFieldMetadataEntity({
id: '20202020-4f52-4dea-a116-723f9bf7f082',
workspaceId,
objectMetadataId,
type: FieldMetadataType.RELATION,
name: 'pointOfContact',
label: 'Point of Contact',
description: 'The point of contact for this opportunity',
icon: 'IconUser',
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'pointOfContactId',
onDelete: RelationOnDeleteAction.CASCADE,
},
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-fc02-4be2-be1a-e121daf5400d': getMockFieldMetadataEntity({
id: '20202020-fc02-4be2-be1a-e121daf5400d',
workspaceId,
objectMetadataId,
type: FieldMetadataType.RELATION,
name: 'company',
label: 'Company',
description: 'The company this opportunity is associated with',
icon: 'IconBuildingSkyscraper',
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'companyId',
onDelete: RelationOnDeleteAction.CASCADE,
},
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-fd9f-48f0-bd5f-5b0fec6a5de4': getMockFieldMetadataEntity({
id: '20202020-fd9f-48f0-bd5f-5b0fec6a5de4',
workspaceId,
objectMetadataId,
type: FieldMetadataType.RELATION,
name: 'favorites',
label: 'Favorites',
description: 'Users who favorited this opportunity',
icon: 'IconStar',
settings: {
relationType: RelationType.ONE_TO_MANY,
onDelete: RelationOnDeleteAction.CASCADE,
},
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-88ab-4138-98ce-80533bb423e3': getMockFieldMetadataEntity({
id: '20202020-88ab-4138-98ce-80533bb423e3',
workspaceId,
objectMetadataId,
type: FieldMetadataType.RELATION,
name: 'taskTargets',
label: 'Task Targets',
description: 'Tasks targeting this opportunity',
icon: 'IconCheckbox',
settings: {
relationType: RelationType.ONE_TO_MANY,
onDelete: RelationOnDeleteAction.CASCADE,
},
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-4258-422b-b35b-db3f090af8da': getMockFieldMetadataEntity({
id: '20202020-4258-422b-b35b-db3f090af8da',
workspaceId,
objectMetadataId,
type: FieldMetadataType.RELATION,
name: 'noteTargets',
label: 'Note Targets',
description: 'Notes targeting this opportunity',
icon: 'IconNotes',
settings: {
relationType: RelationType.ONE_TO_MANY,
onDelete: RelationOnDeleteAction.CASCADE,
},
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-16ca-40a7-a1ba-712975c916cd': getMockFieldMetadataEntity({
id: '20202020-16ca-40a7-a1ba-712975c916cd',
workspaceId,
objectMetadataId,
type: FieldMetadataType.RELATION,
name: 'attachments',
label: 'Attachments',
description: 'Attachments for this opportunity',
icon: 'IconPaperclip',
settings: {
relationType: RelationType.ONE_TO_MANY,
onDelete: RelationOnDeleteAction.CASCADE,
},
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
'20202020-92a5-47bf-a38d-c1c72b2c3e4d': getMockFieldMetadataEntity({
id: '20202020-92a5-47bf-a38d-c1c72b2c3e4d',
workspaceId,
objectMetadataId,
type: FieldMetadataType.RELATION,
name: 'timelineActivities',
label: 'Timeline Activities',
description: 'Timeline activities for this opportunity',
icon: 'IconTimeline',
settings: {
relationType: RelationType.ONE_TO_MANY,
onDelete: RelationOnDeleteAction.CASCADE,
},
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
}),
},
fieldIdByName: {
name: '20202020-c2f1-4435-adca-22931f8b41b6',
amount: '20202020-5eef-417a-b517-ebeedaa8e10b',
closeDate: '20202020-597c-44d3-98ec-ea71aea5256b',
stage: '20202020-9b94-454a-94ca-8afb09c68faf',
position: '20202020-30a5-4d8e-9b93-12d31ece0aaa',
createdBy: '20202020-f95f-424f-ab32-65961e8e9635',
searchVector: '20202020-5e10-4780-babb-38a465ac546c',
id: '20202020-8f4a-4f8d-822e-90fe72f75b79',
createdAt: '20202020-f120-4b59-b239-f7f1d8eb243e',
updatedAt: '20202020-dcc8-4318-9756-b87377692561',
deletedAt: '20202020-1694-4f8b-8760-61a5ff330022',
pointOfContact: '20202020-4f52-4dea-a116-723f9bf7f082',
company: '20202020-fc02-4be2-be1a-e121daf5400d',
favorites: '20202020-fd9f-48f0-bd5f-5b0fec6a5de4',
taskTargets: '20202020-88ab-4138-98ce-80533bb423e3',
noteTargets: '20202020-4258-422b-b35b-db3f090af8da',
attachments: '20202020-16ca-40a7-a1ba-712975c916cd',
timelineActivities: '20202020-92a5-47bf-a38d-c1c72b2c3e4d',
},
fieldIdByJoinColumnName: {
pointOfContactId: '20202020-4f52-4dea-a116-723f9bf7f082',
companyId: '20202020-fc02-4be2-be1a-e121daf5400d',
},
});
@@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const checkFields = (
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps,
fieldNames: string[],
): void => {
const fieldMetadataNames: string[] = Object.values(
objectMetadataItemWithFieldsMaps.fieldsById,
)
.flatMap((field) => {
if (isCompositeFieldMetadataType(field.type)) {
const compositeType = compositeTypeDefinitions.get(field.type);
if (!compositeType) {
throw new BadRequestException(
`Composite type '${field.type}' not found`,
);
}
// TODO: Don't really know why we need to put fieldName and compositeType name here
return [
field.name,
compositeType.properties.map(
(compositeProperty) => compositeProperty.name,
),
].flat();
}
if (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) {
return field.settings?.joinColumnName;
}
return field.name;
})
.filter(isDefined);
for (const fieldName of fieldNames) {
if (!fieldMetadataNames.includes(fieldName)) {
throw new BadRequestException(
`field '${fieldName}' does not exist in '${computeObjectTargetTable(
objectMetadataItemWithFieldsMaps,
)}' object`,
);
}
}
};
@@ -0,0 +1,25 @@
import { BadRequestException } from '@nestjs/common';
import { type Request } from 'express';
const ALLOWED_DEPTH_VALUES = [0, 1];
export const computeDepth = (request: Request): number | undefined => {
if (!request.query.depth) {
return undefined;
}
const depth = +request.query.depth;
if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) {
throw new BadRequestException(
`'depth=${
request.query.depth
}' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join(
', ',
)}`,
);
}
return depth;
};
@@ -0,0 +1,145 @@
import {
fieldNumberMock,
fieldTextMock,
objectMetadataItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { parseFilter } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-filter.utils';
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 { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
describe('parseFilter', () => {
const completeFieldNumberMock = getMockFieldMetadataEntity({
workspaceId: '20202020-0000-0000-0000-000000000000',
objectMetadataId: '20202020-0000-0000-0000-000000000001',
id: '20202020-0000-0000-0000-000000000002',
type: fieldNumberMock.type,
name: fieldNumberMock.name,
label: 'Field Number',
isNullable: fieldNumberMock.isNullable,
defaultValue: fieldNumberMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const completeFieldTextMock = getMockFieldMetadataEntity({
workspaceId: '20202020-0000-0000-0000-000000000000',
objectMetadataId: '20202020-0000-0000-0000-000000000001',
id: '20202020-0000-0000-0000-000000000003',
type: fieldTextMock.type,
name: fieldTextMock.name,
label: 'Field Text',
isNullable: fieldTextMock.isNullable,
defaultValue: fieldTextMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const fieldsById: FieldMetadataMap = {
'field-number-id': completeFieldNumberMock,
'field-text-id': completeFieldTextMock,
};
const mockObjectMetadataWithFieldMaps: ObjectMetadataItemWithFieldMaps = {
...objectMetadataItemMock,
fieldsById,
fieldIdByName: {
[completeFieldNumberMock.name]: completeFieldNumberMock.id,
[completeFieldTextMock.name]: completeFieldTextMock.id,
},
fieldIdByJoinColumnName: {},
indexMetadatas: [],
};
it('should parse string filter test 1', () => {
expect(
parseFilter(
'and(fieldNumber[eq]:1,fieldNumber[eq]:2)',
mockObjectMetadataWithFieldMaps,
),
).toEqual({
and: [{ fieldNumber: { eq: '1' } }, { fieldNumber: { eq: '2' } }],
});
});
it('should parse string filter test 2', () => {
expect(
parseFilter(
'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3))',
mockObjectMetadataWithFieldMaps,
),
).toEqual({
and: [
{ fieldNumber: { eq: '1' } },
{ or: [{ fieldNumber: { eq: '2' } }, { fieldNumber: { eq: '3' } }] },
],
});
});
it('should parse string filter test 3', () => {
expect(
parseFilter(
'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3,and(fieldNumber[eq]:6,fieldNumber[eq]:7)),or(fieldNumber[eq]:4,fieldNumber[eq]:5))',
mockObjectMetadataWithFieldMaps,
),
).toEqual({
and: [
{ fieldNumber: { eq: '1' } },
{
or: [
{ fieldNumber: { eq: '2' } },
{ fieldNumber: { eq: '3' } },
{
and: [{ fieldNumber: { eq: '6' } }, { fieldNumber: { eq: '7' } }],
},
],
},
{ or: [{ fieldNumber: { eq: '4' } }, { fieldNumber: { eq: '5' } }] },
],
});
});
it('should parse string filter test 4', () => {
expect(
parseFilter(
'and(fieldText[gt]:"val,ue",or(fieldNumber[is]:NOT_NULL,not(fieldText[startsWith]:"val"),and(fieldNumber[eq]:6,fieldText[ilike]:"%val%")),or(fieldNumber[eq]:4,fieldText[is]:NULL))',
mockObjectMetadataWithFieldMaps,
),
).toEqual({
and: [
{ fieldText: { gt: 'val,ue' } },
{
or: [
{ fieldNumber: { is: 'NOT_NULL' } },
{ not: { fieldText: { startsWith: 'val' } } },
{
and: [
{ fieldNumber: { eq: '6' } },
{ fieldText: { ilike: '%val%' } },
],
},
],
},
{ or: [{ fieldNumber: { eq: '4' } }, { fieldText: { is: 'NULL' } }] },
],
});
});
it('should handle not', () => {
expect(
parseFilter(
'and(fieldNumber[eq]:1,not(fieldNumber[eq]:2))',
mockObjectMetadataWithFieldMaps,
),
).toEqual({
and: [
{ fieldNumber: { eq: '1' } },
{
not: { fieldNumber: { eq: '2' } },
},
],
});
});
});
@@ -0,0 +1,69 @@
import { BadRequestException } from '@nestjs/common';
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
import { getFieldType } from 'src/engine/api/rest/core/query-builder/utils/get-field-type.utils';
import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
import { checkFilterEnumValues } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-enum-values.util';
import { formatFieldValue } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/format-field-values.util';
import { parseBaseFilter } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-base-filter.util';
import { parseFilterContent } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-content.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export enum Conjunctions {
or = 'or',
and = 'and',
not = 'not',
}
export const parseFilter = (
filterQuery: string,
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
): Record<string, FieldValue> => {
const result: Record<string, FieldValue> = {};
const match = filterQuery.match(
`^(${Object.values(Conjunctions).join('|')})\\((.+)\\)$`,
);
if (match) {
const conjunction = match?.[1];
if (!conjunction) {
throw new BadRequestException(
'Error while matching filter query. Conjunction not found',
);
}
const subResult = parseFilterContent(filterQuery).map((elem) =>
parseFilter(elem, objectMetadataItem),
);
if (conjunction === Conjunctions.not) {
if (subResult.length > 1) {
throw new BadRequestException(
`'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`,
);
}
result[conjunction] = subResult[0];
} else {
result[conjunction] = subResult;
}
return result;
}
const { fields, comparator, value } = parseBaseFilter(filterQuery);
const fieldName = fields[0];
checkFields(objectMetadataItem, fields);
const fieldType = getFieldType(objectMetadataItem, fieldName);
checkFilterEnumValues(fieldType, fieldName, value, objectMetadataItem);
const formattedValue = formatFieldValue(value, fieldType, comparator);
return fields.reverse().reduce(
(acc, currentValue) => {
return { [currentValue]: acc };
},
{ [comparator]: formattedValue },
);
};
@@ -0,0 +1,13 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const getFieldType = (
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
fieldName: string,
): FieldMetadataType | undefined => {
const fieldMetadataId = objectMetadataItem.fieldIdByName[fieldName];
const field = objectMetadataItem.fieldsById[fieldMetadataId];
return field?.type;
};
@@ -0,0 +1,188 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
const DEFAULT_DEPTH_VALUE = 1;
// TODO: Should be properly type and based on composite type definitions
export const mapFieldMetadataToGraphqlQuery = (
objectMetadataMaps: ObjectMetadataMaps,
field: FieldMetadataEntity,
maxDepthForRelations = DEFAULT_DEPTH_VALUE,
): string | undefined => {
if (maxDepthForRelations < 0) {
return '';
}
const fieldType = field.type;
const fieldIsSimpleValue = [
FieldMetadataType.UUID,
FieldMetadataType.TEXT,
FieldMetadataType.DATE_TIME,
FieldMetadataType.DATE,
FieldMetadataType.BOOLEAN,
FieldMetadataType.NUMBER,
FieldMetadataType.NUMERIC,
FieldMetadataType.RATING,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.POSITION,
FieldMetadataType.RAW_JSON,
FieldMetadataType.RICH_TEXT,
FieldMetadataType.ARRAY,
FieldMetadataType.TS_VECTOR,
].includes(fieldType);
const isRelation =
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) ||
isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION);
if (fieldIsSimpleValue) {
return field.name;
} else if (
maxDepthForRelations > 0 &&
isRelation &&
field.settings?.relationType === RelationType.MANY_TO_ONE
) {
const targetObjectMetadataId = field.relationTargetObjectMetadataId;
if (!targetObjectMetadataId) {
return '';
}
const relationMetadataItem =
objectMetadataMaps.byId[targetObjectMetadataId];
if (!isDefined(relationMetadataItem)) {
return '';
}
return `${field.name}
{
id
${Object.values(relationMetadataItem.fieldsById)
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadataMaps,
field,
maxDepthForRelations - 1,
),
)
.join('\n')}
}`;
} else if (
maxDepthForRelations > 0 &&
isRelation &&
field.settings?.relationType === RelationType.ONE_TO_MANY
) {
const targetObjectMetadataId = field.relationTargetObjectMetadataId;
if (!targetObjectMetadataId) {
return '';
}
const relationMetadataItem =
objectMetadataMaps.byId[targetObjectMetadataId];
if (!relationMetadataItem) {
return '';
}
return `${field.name}
{
edges {
node {
id
${Object.values(relationMetadataItem.fieldsById)
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadataMaps,
field,
maxDepthForRelations - 1,
),
)
.join('\n')}
}
}
}`;
} else if (fieldType === FieldMetadataType.LINKS) {
return `
${field.name}
{
primaryLinkLabel
primaryLinkUrl
secondaryLinks
}
`;
} else if (fieldType === FieldMetadataType.CURRENCY) {
return `
${field.name}
{
amountMicros
currencyCode
}
`;
} else if (fieldType === FieldMetadataType.FULL_NAME) {
return `
${field.name}
{
firstName
lastName
}
`;
} else if (fieldType === FieldMetadataType.ADDRESS) {
return `
${field.name}
{
addressStreet1
addressStreet2
addressCity
addressPostcode
addressState
addressCountry
addressLat
addressLng
}
`;
} else if (fieldType === FieldMetadataType.ACTOR) {
return `
${field.name}
{
source
workspaceMemberId
name
}
`;
} else if (fieldType === FieldMetadataType.EMAILS) {
return `
${field.name}
{
primaryEmail
additionalEmails
}
`;
} else if (fieldType === FieldMetadataType.PHONES) {
return `
${field.name}
{
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
`;
} else if (fieldType === FieldMetadataType.RICH_TEXT_V2) {
return `
${field.name}
{
blocknote
markdown
}
`;
}
};
@@ -0,0 +1,11 @@
import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils';
describe('parseCoreBatchPath', () => {
it('should parse object from request path', () => {
const request: any = { path: '/rest/batch/companies' };
expect(parseCoreBatchPath(request)).toEqual({
object: 'companies',
});
});
});
@@ -1,4 +1,4 @@
import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/parse-core-path.utils';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
const testUUID = '20202020-ef5a-4822-9e08-cf6e4a4dcd6b';
@@ -0,0 +1,5 @@
import { type Request } from 'express';
export const parseCoreBatchPath = (request: Request): { object: string } => {
return { object: request.path.replace('/rest/batch/', '') };
};
@@ -18,13 +18,14 @@ import { RestApiRestoreManyHandler } from 'src/engine/api/rest/core/handlers/res
import { RestApiRestoreOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-restore-one.handler';
import { RestApiUpdateManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-many.handler';
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 { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.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';
@@ -54,6 +55,7 @@ const restApiCoreResolvers = [
@Module({
imports: [
CoreQueryBuilderModule,
WorkspaceCacheStorageModule,
AuthModule,
ApiKeyModule,
@@ -66,12 +68,12 @@ const restApiCoreResolvers = [
ActorModule,
FeatureFlagModule,
CoreCommonApiModule,
WorkspaceDomainsModule,
],
controllers: [RestApiCoreController],
providers: [
RestApiService,
RestApiCoreService,
...coreQueryBuilderFactories,
...restApiCoreResolvers,
...restToCommonArgsHandlers,
],
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
@@ -17,9 +17,11 @@ import { RestApiRestoreManyHandler } from 'src/engine/api/rest/core/handlers/res
import { RestApiRestoreOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-restore-one.handler';
import { RestApiUpdateManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-many.handler';
import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler';
import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/parse-core-path.utils';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { parseSoftDeleteRestRequest } from 'src/engine/api/rest/input-request-parsers/soft-delete-parser-utils/parse-soft-delete-rest-request.util';
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 {
@@ -39,37 +41,83 @@ export class RestApiCoreService {
private readonly restApiRestoreOneHandler: RestApiRestoreOneHandler,
private readonly restApiRestoreManyHandler: RestApiRestoreManyHandler,
private readonly restApiMergeManyHandler: RestApiMergeManyHandler,
private readonly featureFlagService: FeatureFlagService,
) {}
private async isCommonApiEnabled(request: AuthenticatedRequest) {
return await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
request.workspace.id,
);
}
async createOne(request: AuthenticatedRequest) {
return await this.restApiCreateOneHandler.handle(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isCommonApiEnabled) {
return await this.restApiCreateOneHandler.commonHandle(request);
} else {
return await this.restApiCreateOneHandler.handle(request);
}
}
async createMany(request: AuthenticatedRequest) {
return await this.restApiCreateManyHandler.handle(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isCommonApiEnabled) {
return await this.restApiCreateManyHandler.commonHandle(request);
} else {
return await this.restApiCreateManyHandler.handle(request);
}
}
async findDuplicates(request: AuthenticatedRequest) {
return await this.restApiFindDuplicatesHandler.handle(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isCommonApiEnabled) {
return await this.restApiFindDuplicatesHandler.commonHandle(request);
} else {
return await this.restApiFindDuplicatesHandler.handle(request);
}
}
async update(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isDefined(recordId)) {
return await this.restApiUpdateOneHandler.handle(request);
if (isCommonApiEnabled) {
if (isDefined(recordId)) {
return await this.restApiUpdateOneHandler.commonHandle(request);
} else {
return await this.restApiUpdateManyHandler.handle(request);
}
} else {
return await this.restApiUpdateManyHandler.handle(request);
if (isDefined(recordId)) {
return await this.restApiUpdateOneHandler.handle(request);
} else {
throw new BadRequestException(
'Activate feature flag to use UpdateMany in the REST API',
);
}
}
}
async get(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isDefined(recordId)) {
return await this.restApiFindOneHandler.handle(request);
if (isCommonApiEnabled) {
if (isDefined(recordId)) {
return await this.restApiFindOneHandler.commonHandle(request);
} else {
return await this.restApiFindManyHandler.commonHandle(request);
}
} else {
return await this.restApiFindManyHandler.handle(request);
if (isDefined(recordId)) {
return await this.restApiFindOneHandler.handle(request);
} else {
return await this.restApiFindManyHandler.handle(request);
}
}
}
@@ -80,30 +128,54 @@ export class RestApiCoreService {
async delete(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
const isSoftDelete = parseSoftDeleteRestRequest(request);
if (!isSoftDelete && isDefined(recordId))
return await this.restApiDestroyOneHandler.handle(request);
if (!isSoftDelete && !isDefined(recordId))
if (isCommonApiEnabled && !isSoftDelete && isDefined(recordId))
return await this.restApiDestroyOneHandler.commonHandle(request);
if (isCommonApiEnabled && !isSoftDelete && !isDefined(recordId))
return await this.restApiDestroyManyHandler.handle(request);
if (isSoftDelete && isDefined(recordId))
if (isCommonApiEnabled && isSoftDelete && isDefined(recordId))
return await this.restApiDeleteOneHandler.handle(request);
if (isSoftDelete && !isDefined(recordId))
if (isCommonApiEnabled && isSoftDelete && !isDefined(recordId))
return await this.restApiDeleteManyHandler.handle(request);
if (!isCommonApiEnabled && !isSoftDelete && isDefined(recordId))
return await this.restApiDestroyOneHandler.handle(request);
throw new BadRequestException(
'Activate feature flag IS_COMMON_API_ENABLED to use Delete in the REST API',
);
}
async restore(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
if (isDefined(recordId)) {
return await this.restApiRestoreOneHandler.handle(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isCommonApiEnabled) {
if (isDefined(recordId)) {
return await this.restApiRestoreOneHandler.handle(request);
} else {
return await this.restApiRestoreManyHandler.handle(request);
}
} else {
return await this.restApiRestoreManyHandler.handle(request);
throw new BadRequestException(
'Activate feature flag to use Restore in the REST API',
);
}
}
async mergeMany(request: AuthenticatedRequest) {
return await this.restApiMergeManyHandler.handle(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isCommonApiEnabled) {
return await this.restApiMergeManyHandler.handle(request);
} else {
throw new BadRequestException(
'Activate feature flag to use Merge in the REST API',
);
}
}
}
@@ -0,0 +1,202 @@
import { Test, type TestingModule } from '@nestjs/testing';
import {
fieldCurrencyMock,
fieldNumberMock,
fieldTextMock,
objectMetadataMapItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
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 { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
describe('FilterInputFactory', () => {
const workspaceId = '20202020-cc80-4306-ad69-da9e11997292';
const completeFieldNumberMock = getMockFieldMetadataEntity({
workspaceId,
id: 'field-number-id',
type: fieldNumberMock.type,
name: fieldNumberMock.name,
label: 'Field Number',
objectMetadataId: 'object-metadata-id',
isNullable: fieldNumberMock.isNullable,
defaultValue: fieldNumberMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const completeFieldTextMock = getMockFieldMetadataEntity({
workspaceId,
id: 'field-text-id',
type: fieldTextMock.type,
name: fieldTextMock.name,
label: 'Field Text',
objectMetadataId: 'object-metadata-id',
isNullable: fieldTextMock.isNullable,
defaultValue: fieldTextMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const completeFieldCurrencyMock = getMockFieldMetadataEntity({
workspaceId,
id: 'field-currency-id',
type: fieldCurrencyMock.type,
name: fieldCurrencyMock.name,
label: 'Field Currency',
objectMetadataId: 'object-metadata-id',
isNullable: fieldCurrencyMock.isNullable,
defaultValue: fieldCurrencyMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const fieldsById: FieldMetadataMap = {
'field-number-id': completeFieldNumberMock,
'field-text-id': completeFieldTextMock,
'field-currency-id': completeFieldCurrencyMock,
};
const objectMetadataMapItem: ObjectMetadataItemWithFieldMaps = {
...objectMetadataMapItemMock,
fieldsById,
fieldIdByName: {
[completeFieldNumberMock.name]: completeFieldNumberMock.id,
[completeFieldTextMock.name]: completeFieldTextMock.id,
[completeFieldCurrencyMock.name]: completeFieldCurrencyMock.id,
},
fieldIdByJoinColumnName: {},
};
const objectMetadataMaps = {
byId: {
[objectMetadataMapItemMock.id || 'mock-id']: objectMetadataMapItem,
},
idByNameSingular: {
[objectMetadataMapItemMock.nameSingular]:
objectMetadataMapItemMock.id || 'mock-id',
},
};
const objectMetadata = {
objectMetadataMaps,
objectMetadataMapItem,
};
let service: FilterInputFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FilterInputFactory],
}).compile();
service = module.get<FilterInputFactory>(FilterInputFactory);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should return default if filter missing', () => {
const request: any = { query: {} };
expect(service.create(request, objectMetadata)).toEqual({});
});
it('should throw when wrong field provided', () => {
const request: any = {
query: {
filter: 'wrongField[eq]:1',
},
};
expect(() => service.create(request, objectMetadata)).toThrow(
"field 'wrongField' does not exist in 'objectName' object",
);
});
it('should throw when wrong comparator provided', () => {
const request: any = {
query: {
filter: 'fieldNumber[wrongComparator]:1',
},
};
expect(() => service.create(request, objectMetadata)).toThrow(
"'filter' invalid for 'fieldNumber[wrongComparator]:1', comparator wrongComparator not in eq, neq, in, containsAny, is, gt, gte, lt, lte, startsWith, like, ilike",
);
});
it('should throw when wrong filter provided', () => {
const request: any = {
query: {
filter: 'fieldNumber[wrongComparator:1',
},
};
expect(() => service.create(request, objectMetadata)).toThrow(
"'filter' invalid for 'fieldNumber[wrongComparator:1'. eg: price[gte]:10",
);
});
it('should throw when parenthesis are not closed', () => {
const request: any = {
query: {
filter: 'and(fieldNumber[eq]:1,not(fieldNumber[neq]:1)',
},
};
expect(() => service.create(request, objectMetadata)).toThrow(
"'filter' invalid. 1 close bracket is missing in the query",
);
});
it('should create filter parser properly', () => {
const request: any = {
query: {
filter: 'fieldNumber[eq]:1,fieldText[eq]:"Test"',
},
};
expect(service.create(request, objectMetadata)).toEqual({
and: [{ fieldNumber: { eq: 1 } }, { fieldText: { eq: 'Test' } }],
});
});
it('should create complex filter parser properly', () => {
const request: any = {
query: {
filter:
'and(fieldNumber[eq]:1,fieldText[gte]:"Test",not(fieldText[ilike]:"%val%"),or(not(and(fieldText[startsWith]:"test",fieldNumber[in]:[2,4,5])),fieldCurrency.amountMicros[gt]:1))',
},
};
expect(service.create(request, objectMetadata)).toEqual({
and: [
{ fieldNumber: { eq: 1 } },
{ fieldText: { gte: 'Test' } },
{ not: { fieldText: { ilike: '%val%' } } },
{
or: [
{
not: {
and: [
{ fieldText: { startsWith: 'test' } },
{ fieldNumber: { in: [2, 4, 5] } },
],
},
},
{ fieldCurrency: { amountMicros: { gt: '1' } } },
],
},
],
});
});
});
});
@@ -0,0 +1,138 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { OrderByDirection } from 'twenty-shared/types';
import {
objectMetadataMapItemMock,
objectMetadataMapsMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
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';
describe('OrderByInputFactory', () => {
const objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
} = {
objectMetadataMaps: objectMetadataMapsMock,
objectMetadataMapItem: objectMetadataMapItemMock,
};
let service: OrderByInputFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OrderByInputFactory],
}).compile();
service = module.get<OrderByInputFactory>(OrderByInputFactory);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should return default if order by missing', () => {
const request: any = { query: {} };
expect(service.create(request, objectMetadata)).toEqual([
{},
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should create order by parser properly', () => {
const request: any = {
query: {
order_by: 'fieldNumber[AscNullsFirst],fieldText[DescNullsLast]',
},
};
expect(service.create(request, objectMetadata)).toEqual([
{ fieldNumber: OrderByDirection.AscNullsFirst },
{ fieldText: OrderByDirection.DescNullsLast },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should choose default direction if missing', () => {
const request: any = {
query: {
order_by: 'fieldNumber',
},
};
expect(service.create(request, objectMetadata)).toEqual([
{ fieldNumber: OrderByDirection.AscNullsFirst },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should handle complex fields', () => {
const request: any = {
query: {
order_by: 'fieldCurrency.amountMicros',
},
};
expect(service.create(request, objectMetadata)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.AscNullsFirst } },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should handle complex fields with direction', () => {
const request: any = {
query: {
order_by: 'fieldCurrency.amountMicros[DescNullsLast]',
},
};
expect(service.create(request, objectMetadata)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast } },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should handle multiple complex fields with direction', () => {
const request: any = {
query: {
order_by:
'fieldCurrency.amountMicros[DescNullsLast],fieldText.label[AscNullsLast]',
},
};
expect(service.create(request, objectMetadata)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast } },
{ fieldText: { label: OrderByDirection.AscNullsLast } },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should throw if direction invalid', () => {
const request: any = {
query: {
order_by: 'fieldText[invalid]',
},
};
expect(() => service.create(request, objectMetadata)).toThrow(
"'order_by' direction 'invalid' invalid. Allowed values are 'AscNullsFirst', 'AscNullsLast', 'DescNullsFirst', 'DescNullsLast'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3",
);
});
it('should throw if field invalid', () => {
const request: any = {
query: {
order_by: 'wrongField[DescNullsLast]',
},
};
expect(() => service.create(request, objectMetadata)).toThrow(
"field 'wrongField' does not exist in 'objectName' object",
);
});
});
});
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class EndingBeforeInputFactory {
create(request: RequestContext): string | undefined {
const cursorQuery = request.query?.ending_before;
if (typeof cursorQuery !== 'string') {
return undefined;
}
return cursorQuery;
}
}
@@ -0,0 +1,4 @@
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
export const inputFactories = [FilterInputFactory, OrderByInputFactory];
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { parseFilter } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-filter.utils';
import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
import { addDefaultConjunctionIfMissing } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/add-default-conjunction.util';
import { checkFilterQuery } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util';
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';
@Injectable()
export class FilterInputFactory {
create(
request: Request,
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
): Record<string, FieldValue> {
let filterQuery = request.query.filter;
if (typeof filterQuery !== 'string') {
return {};
}
checkFilterQuery(filterQuery);
filterQuery = addDefaultConjunctionIfMissing(filterQuery);
return parseFilter(filterQuery, objectMetadata.objectMetadataMapItem);
}
}
@@ -0,0 +1,98 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { OrderByDirection } from 'twenty-shared/types';
import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
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';
export const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst;
@Injectable()
export class OrderByInputFactory {
create(
request: Request,
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
): ObjectRecordOrderBy {
const orderByQuery = request.query.order_by;
if (typeof orderByQuery !== 'string') {
return this.addDefaultOrderById([{}]);
}
//orderByQuery = field_1[AscNullsFirst],field_2[DescNullsLast],field_3
const orderByItems = orderByQuery.split(',');
let result: Array<Record<string, OrderByDirection>> = [];
let itemDirection = '';
let itemFields = '';
for (const orderByItem of orderByItems) {
// orderByItem -> field_1[AscNullsFirst]
if (orderByItem.includes('[') && orderByItem.includes(']')) {
const [fieldsString, direction] = orderByItem
.replace(']', '')
.split('[');
// fields -> [field_1] ; direction -> AscNullsFirst
if (!(direction in OrderByDirection)) {
throw new BadRequestException(
`'order_by' direction '${direction}' invalid. Allowed values are '${Object.values(
OrderByDirection,
).join(
"', '",
)}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`,
);
}
itemDirection = direction;
itemFields = fieldsString;
} else {
// orderByItem -> field_3
itemDirection = DEFAULT_ORDER_DIRECTION;
itemFields = orderByItem;
}
let fieldResult = {};
itemFields
.split('.')
.reverse()
.forEach((field) => {
if (Object.keys(fieldResult).length) {
fieldResult = { [field]: fieldResult };
} else {
// @ts-expect-error legacy noImplicitAny
fieldResult[field] = itemDirection;
}
}, itemDirection);
const resultFields = Object.keys(fieldResult).map((key) => ({
// @ts-expect-error legacy noImplicitAny
[key]: fieldResult[key],
}));
result = [...result, ...resultFields];
}
checkFields(
objectMetadata.objectMetadataMapItem,
result.flatMap((fields) => Object.keys(fields)),
);
return this.addDefaultOrderById(result);
}
addDefaultOrderById(orderBy: ObjectRecordOrderBy) {
const hasIdOrder = orderBy.some((o) => Object.keys(o).includes('id'));
return hasIdOrder
? orderBy
: [...orderBy, { id: OrderByDirection.AscNullsFirst }];
}
}
@@ -1,15 +1,19 @@
import { parseFilter } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter.util';
import { parseFilterWithoutMetadataValidation } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-without-metadata-validation.util';
describe('parseFilter', () => {
describe('parseFilterWithoutMetadataValidation', () => {
it('should parse string filter test 1', () => {
expect(parseFilter('and(fieldNumber[eq]:1,fieldNumber[eq]:2)')).toEqual({
expect(
parseFilterWithoutMetadataValidation(
'and(fieldNumber[eq]:1,fieldNumber[eq]:2)',
),
).toEqual({
and: [{ fieldNumber: { eq: '1' } }, { fieldNumber: { eq: '2' } }],
});
});
it('should parse string filter test 2', () => {
expect(
parseFilter(
parseFilterWithoutMetadataValidation(
'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3))',
),
).toEqual({
@@ -22,7 +26,7 @@ describe('parseFilter', () => {
it('should parse string filter test 3', () => {
expect(
parseFilter(
parseFilterWithoutMetadataValidation(
'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3,and(fieldNumber[eq]:6,fieldNumber[eq]:7)),or(fieldNumber[eq]:4,fieldNumber[eq]:5))',
),
).toEqual({
@@ -44,7 +48,7 @@ describe('parseFilter', () => {
it('should parse string filter test 4', () => {
expect(
parseFilter(
parseFilterWithoutMetadataValidation(
'and(fieldText[gt]:"val,ue",or(fieldNumber[is]:NOT_NULL,not(fieldText[startsWith]:"val"),and(fieldNumber[eq]:6,fieldText[ilike]:"%val%")),or(fieldNumber[eq]:4,fieldText[is]:NULL))',
),
).toEqual({
@@ -69,7 +73,9 @@ describe('parseFilter', () => {
it('should handle not', () => {
expect(
parseFilter('and(fieldNumber[eq]:1,not(fieldNumber[eq]:2))'),
parseFilterWithoutMetadataValidation(
'and(fieldNumber[eq]:1,not(fieldNumber[eq]:2))',
),
).toEqual({
and: [
{ fieldNumber: { eq: '1' } },
@@ -1,4 +1,4 @@
import { Conjunctions } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter.util';
import { Conjunctions } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-filter.utils';
export const DEFAULT_CONJUNCTION = Conjunctions.and;
@@ -2,7 +2,7 @@
import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
import { addDefaultConjunctionIfMissing } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/add-default-conjunction.util';
import { checkFilterQuery } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util';
import { parseFilter } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter.util';
import { parseFilterWithoutMetadataValidation } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-without-metadata-validation.util';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseFilterRestRequest = (
@@ -18,5 +18,5 @@ export const parseFilterRestRequest = (
filterQuery = addDefaultConjunctionIfMissing(filterQuery);
return parseFilter(filterQuery);
return parseFilterWithoutMetadataValidation(filterQuery);
};
@@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { Conjunctions } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-filter.utils';
import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
import { formatFieldValue } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/format-field-values.util';
import { parseBaseFilter } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-base-filter.util';
@@ -9,13 +10,8 @@ import {
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
export enum Conjunctions {
or = 'or',
and = 'and',
not = 'not',
}
export const parseFilter = (
//TODO : Refacto-common - Rename after deleting parseFilter
export const parseFilterWithoutMetadataValidation = (
filterQuery: string,
): Record<string, FieldValue> => {
const result = {};
@@ -32,7 +28,7 @@ export const parseFilter = (
);
}
const subResult = parseFilterContent(filterQuery).map((elem) =>
parseFilter(elem),
parseFilterWithoutMetadataValidation(elem),
);
if (conjunction === Conjunctions.not) {
@@ -4,6 +4,7 @@ import { OrderByDirection } from 'twenty-shared/types';
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { DEFAULT_ORDER_DIRECTION } from 'src/engine/api/rest/input-factories/order-by-input.factory';
import { addDefaultOrderById } from 'src/engine/api/rest/input-request-parsers/order-by-parser-utils/add-default-order-by-id.util';
import {
RestInputRequestParserException,
@@ -11,8 +12,6 @@ import {
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst;
export const parseOrderByRestRequest = (
request: AuthenticatedRequest,
): ObjectRecordOrderBy => {
@@ -1,9 +1,10 @@
import { CreateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory';
import { DeleteMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory';
import { FindManyMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory';
import { FindOneMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory';
import { FindManyMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory';
import { GetMetadataVariablesFactory } from 'src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory';
import { inputFactories } from 'src/engine/api/rest/input-factories/factories';
import { CreateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory';
import { UpdateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory';
import { DeleteMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory';
export const metadataQueryBuilderFactories = [
FindOneMetadataQueryFactory,
@@ -12,4 +13,5 @@ export const metadataQueryBuilderFactories = [
DeleteMetadataQueryFactory,
UpdateMetadataQueryFactory,
GetMetadataVariablesFactory,
...inputFactories,
];

Some files were not shown because too many files have changed in this diff Show More