feat: Add agent role assignment and database CRUD tools for AI agent nodes (#12888)
This PR introduces a significant enhancement to the role-based permission system by extending it to support AI agents, enabling them to perform database operations based on assigned permissions. ## Key Changes ### 1. Database Schema Migration - **Table Rename**: `userWorkspaceRole` → `roleTargets` to better reflect its expanded purpose - **New Column**: Added `agentId` (UUID, nullable) to support AI agent role assignments - **Constraint Updates**: - Made `userWorkspaceId` nullable to accommodate agent-only role assignments - Added check constraint `CHK_role_targets_either_agent_or_user` ensuring either `agentId` OR `userWorkspaceId` is set (not both) ### 2. Entity & Service Layer Updates - **RoleTargetsEntity**: Updated with new `agentId` field and constraint validation - **AgentRoleService**: New service for managing agent role assignments with validation - **AgentService**: Enhanced to include role information when retrieving agents - **RoleResolver**: Added GraphQL mutations for `assignRoleToAgent` and `removeRoleFromAgent` ### 3. AI Agent CRUD Operations - **Permission-Based Tool Generation**: AI agents now receive database tools based on their assigned role permissions - **Dynamic Tool Creation**: The `AgentToolService` generates CRUD tools (`create_*`, `find_*`, `update_*`, `soft_delete_*`, `destroy_*`) for each object based on role permissions - **Granular Permissions**: Supports both global role permissions (`canReadAllObjectRecords`) and object-specific permissions (`canReadObjectRecords`) ### 4. Frontend Integration - **Role Assignment UI**: Added hooks and components for assigning/removing roles from agents ## Demo https://github.com/user-attachments/assets/41732267-742e-416c-b423-b687c2614c82 --------- Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Guillim <guillim@users.noreply.github.com> Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com> Co-authored-by: Weiko <corentin@twenty.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com> Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Baptiste Devessier <baptiste@devessier.fr> Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com> Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Co-authored-by: prastoin <paul@twenty.com> Co-authored-by: Vicky Wang <157669812+vickywxng@users.noreply.github.com> Co-authored-by: Vicky Wang <vw92@cornell.edu> Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
@ -70,6 +70,7 @@ export type Agent = {
|
||||
name: Scalars['String']['output'];
|
||||
prompt: Scalars['String']['output'];
|
||||
responseFormat?: Maybe<Scalars['JSON']['output']>;
|
||||
roleId?: Maybe<Scalars['UUID']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
@ -1003,6 +1004,7 @@ export type Mutation = {
|
||||
__typename?: 'Mutation';
|
||||
activateWorkflowVersion: Scalars['Boolean']['output'];
|
||||
activateWorkspace: Workspace;
|
||||
assignRoleToAgent: Scalars['Boolean']['output'];
|
||||
authorizeApp: AuthorizeApp;
|
||||
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
|
||||
checkoutSession: BillingSessionOutput;
|
||||
@ -1049,6 +1051,7 @@ export type Mutation = {
|
||||
getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput;
|
||||
impersonate: ImpersonateOutput;
|
||||
publishServerlessFunction: ServerlessFunction;
|
||||
removeRoleFromAgent: Scalars['Boolean']['output'];
|
||||
renewToken: AuthTokens;
|
||||
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
|
||||
resendWorkspaceInvitation: SendInvitationsOutput;
|
||||
@ -1103,6 +1106,12 @@ export type MutationActivateWorkspaceArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationAssignRoleToAgentArgs = {
|
||||
agentId: Scalars['UUID']['input'];
|
||||
roleId: Scalars['UUID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationAuthorizeAppArgs = {
|
||||
clientId: Scalars['String']['input'];
|
||||
codeChallenge?: InputMaybe<Scalars['String']['input']>;
|
||||
@ -1317,6 +1326,11 @@ export type MutationPublishServerlessFunctionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemoveRoleFromAgentArgs = {
|
||||
agentId: Scalars['UUID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationRenewTokenArgs = {
|
||||
appToken: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
@ -62,6 +62,7 @@ export type Agent = {
|
||||
name: Scalars['String'];
|
||||
prompt: Scalars['String'];
|
||||
responseFormat?: Maybe<Scalars['JSON']>;
|
||||
roleId?: Maybe<Scalars['UUID']>;
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
@ -952,6 +953,7 @@ export type Mutation = {
|
||||
__typename?: 'Mutation';
|
||||
activateWorkflowVersion: Scalars['Boolean'];
|
||||
activateWorkspace: Workspace;
|
||||
assignRoleToAgent: Scalars['Boolean'];
|
||||
authorizeApp: AuthorizeApp;
|
||||
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
|
||||
checkoutSession: BillingSessionOutput;
|
||||
@ -996,6 +998,7 @@ export type Mutation = {
|
||||
getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput;
|
||||
impersonate: ImpersonateOutput;
|
||||
publishServerlessFunction: ServerlessFunction;
|
||||
removeRoleFromAgent: Scalars['Boolean'];
|
||||
renewToken: AuthTokens;
|
||||
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
|
||||
resendWorkspaceInvitation: SendInvitationsOutput;
|
||||
@ -1046,6 +1049,12 @@ export type MutationActivateWorkspaceArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationAssignRoleToAgentArgs = {
|
||||
agentId: Scalars['UUID'];
|
||||
roleId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationAuthorizeAppArgs = {
|
||||
clientId: Scalars['String'];
|
||||
codeChallenge?: InputMaybe<Scalars['String']>;
|
||||
@ -1240,6 +1249,11 @@ export type MutationPublishServerlessFunctionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemoveRoleFromAgentArgs = {
|
||||
agentId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationRenewTokenArgs = {
|
||||
appToken: Scalars['String'];
|
||||
};
|
||||
@ -3180,6 +3194,21 @@ export type UpdateWorkflowVersionStepMutationVariables = Exact<{
|
||||
|
||||
export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null } };
|
||||
|
||||
export type AssignRoleToAgentMutationVariables = Exact<{
|
||||
agentId: Scalars['UUID'];
|
||||
roleId: Scalars['UUID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AssignRoleToAgentMutation = { __typename?: 'Mutation', assignRoleToAgent: boolean };
|
||||
|
||||
export type RemoveRoleFromAgentMutationVariables = Exact<{
|
||||
agentId: Scalars['UUID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type RemoveRoleFromAgentMutation = { __typename?: 'Mutation', removeRoleFromAgent: boolean };
|
||||
|
||||
export type UpdateOneAgentMutationVariables = Exact<{
|
||||
input: UpdateAgentInput;
|
||||
}>;
|
||||
@ -3192,7 +3221,7 @@ export type FindOneAgentQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null } };
|
||||
export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: any | null } };
|
||||
|
||||
export type SubmitFormStepMutationVariables = Exact<{
|
||||
input: SubmitFormStepInput;
|
||||
@ -6569,6 +6598,69 @@ export function useUpdateWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati
|
||||
export type UpdateWorkflowVersionStepMutationHookResult = ReturnType<typeof useUpdateWorkflowVersionStepMutation>;
|
||||
export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult<UpdateWorkflowVersionStepMutation>;
|
||||
export type UpdateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<UpdateWorkflowVersionStepMutation, UpdateWorkflowVersionStepMutationVariables>;
|
||||
export const AssignRoleToAgentDocument = gql`
|
||||
mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) {
|
||||
assignRoleToAgent(agentId: $agentId, roleId: $roleId)
|
||||
}
|
||||
`;
|
||||
export type AssignRoleToAgentMutationFn = Apollo.MutationFunction<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useAssignRoleToAgentMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useAssignRoleToAgentMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useAssignRoleToAgentMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [assignRoleToAgentMutation, { data, loading, error }] = useAssignRoleToAgentMutation({
|
||||
* variables: {
|
||||
* agentId: // value for 'agentId'
|
||||
* roleId: // value for 'roleId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useAssignRoleToAgentMutation(baseOptions?: Apollo.MutationHookOptions<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>(AssignRoleToAgentDocument, options);
|
||||
}
|
||||
export type AssignRoleToAgentMutationHookResult = ReturnType<typeof useAssignRoleToAgentMutation>;
|
||||
export type AssignRoleToAgentMutationResult = Apollo.MutationResult<AssignRoleToAgentMutation>;
|
||||
export type AssignRoleToAgentMutationOptions = Apollo.BaseMutationOptions<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>;
|
||||
export const RemoveRoleFromAgentDocument = gql`
|
||||
mutation RemoveRoleFromAgent($agentId: UUID!) {
|
||||
removeRoleFromAgent(agentId: $agentId)
|
||||
}
|
||||
`;
|
||||
export type RemoveRoleFromAgentMutationFn = Apollo.MutationFunction<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useRemoveRoleFromAgentMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useRemoveRoleFromAgentMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useRemoveRoleFromAgentMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [removeRoleFromAgentMutation, { data, loading, error }] = useRemoveRoleFromAgentMutation({
|
||||
* variables: {
|
||||
* agentId: // value for 'agentId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useRemoveRoleFromAgentMutation(baseOptions?: Apollo.MutationHookOptions<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>(RemoveRoleFromAgentDocument, options);
|
||||
}
|
||||
export type RemoveRoleFromAgentMutationHookResult = ReturnType<typeof useRemoveRoleFromAgentMutation>;
|
||||
export type RemoveRoleFromAgentMutationResult = Apollo.MutationResult<RemoveRoleFromAgentMutation>;
|
||||
export type RemoveRoleFromAgentMutationOptions = Apollo.BaseMutationOptions<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
|
||||
export const UpdateOneAgentDocument = gql`
|
||||
mutation UpdateOneAgent($input: UpdateAgentInput!) {
|
||||
updateOneAgent(input: $input) {
|
||||
@ -6616,6 +6708,7 @@ export const FindOneAgentDocument = gql`
|
||||
prompt
|
||||
modelId
|
||||
responseFormat
|
||||
roleId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { WorkflowAiAgentAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||
@ -8,8 +11,13 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components
|
||||
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
import { IconSettings, IconTool, useIcons } from 'twenty-ui/display';
|
||||
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
|
||||
import {
|
||||
WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID,
|
||||
WorkflowAiAgentTabId,
|
||||
} from '../constants/workflow-ai-agent-tabs';
|
||||
import { useAgentRoleAssignment } from '../hooks/useAgentRoleAssignment';
|
||||
import { useAgentUpdateFormState } from '../hooks/useAgentUpdateFormState';
|
||||
import { useAiAgentOutputSchema } from '../hooks/useAiAgentOutputSchema';
|
||||
import { useAiModelOptions } from '../hooks/useAiModelOptions';
|
||||
@ -22,6 +30,11 @@ const StyledErrorMessage = styled.div`
|
||||
margin-top: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledTabList = styled(TabList)`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
type WorkflowEditActionAiAgentProps = {
|
||||
action: WorkflowAiAgentAction;
|
||||
actionOptions:
|
||||
@ -43,8 +56,10 @@ export const WorkflowEditActionAiAgent = ({
|
||||
defaultTitle: 'AI Agent',
|
||||
});
|
||||
|
||||
const agentId = action.settings.input.agentId;
|
||||
|
||||
const { formValues, handleFieldChange, loading } = useAgentUpdateFormState({
|
||||
agentId: action.settings.input.agentId,
|
||||
agentId,
|
||||
readonly: actionOptions.readonly === true,
|
||||
});
|
||||
|
||||
@ -59,10 +74,32 @@ export const WorkflowEditActionAiAgent = ({
|
||||
|
||||
const noModelsAvailable = modelOptions.length === 0;
|
||||
|
||||
const activeTabId = useRecoilComponentValueV2(
|
||||
activeTabIdComponentState,
|
||||
WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID,
|
||||
);
|
||||
|
||||
const { rolesOptions, selectedRole, handleRoleChange } =
|
||||
useAgentRoleAssignment(agentId);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: WorkflowAiAgentTabId.SETTINGS,
|
||||
title: t`Settings`,
|
||||
Icon: IconSettings,
|
||||
},
|
||||
{ id: WorkflowAiAgentTabId.TOOLS, title: t`Tools`, Icon: IconTool },
|
||||
];
|
||||
|
||||
return loading ? (
|
||||
<RightDrawerSkeletonLoader />
|
||||
) : (
|
||||
<>
|
||||
<StyledTabList
|
||||
tabs={tabs}
|
||||
behaveAsLinks={false}
|
||||
componentInstanceId={WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID}
|
||||
/>
|
||||
<WorkflowStepHeader
|
||||
onTitleChange={(newName: string) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
@ -77,41 +114,55 @@ export const WorkflowEditActionAiAgent = ({
|
||||
disabled={actionOptions.readonly}
|
||||
/>
|
||||
<WorkflowStepBody>
|
||||
<div>
|
||||
<Select
|
||||
dropdownId="select-model"
|
||||
label={t`AI Model`}
|
||||
options={modelOptions}
|
||||
value={formValues.modelId}
|
||||
onChange={(value) => handleFieldChange('modelId', value)}
|
||||
disabled={actionOptions.readonly || noModelsAvailable}
|
||||
emptyOption={{
|
||||
label: t`No AI models available`,
|
||||
value: '',
|
||||
}}
|
||||
/>
|
||||
{activeTabId === WorkflowAiAgentTabId.SETTINGS && (
|
||||
<>
|
||||
<div>
|
||||
<Select
|
||||
dropdownId="select-model"
|
||||
label={t`AI Model`}
|
||||
options={modelOptions}
|
||||
value={formValues.modelId}
|
||||
onChange={(value) => handleFieldChange('modelId', value)}
|
||||
disabled={actionOptions.readonly || noModelsAvailable}
|
||||
emptyOption={{
|
||||
label: t`No AI models available`,
|
||||
value: '',
|
||||
}}
|
||||
/>
|
||||
|
||||
{noModelsAvailable && (
|
||||
<StyledErrorMessage>
|
||||
{t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`}
|
||||
</StyledErrorMessage>
|
||||
)}
|
||||
</div>
|
||||
<FormTextFieldInput
|
||||
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
|
||||
label={t`Instructions for AI`}
|
||||
placeholder={t`Describe what you want the AI to do...`}
|
||||
readonly={actionOptions.readonly}
|
||||
defaultValue={formValues.prompt}
|
||||
onChange={(value) => handleFieldChange('prompt', value)}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
multiline
|
||||
/>
|
||||
<WorkflowOutputSchemaBuilder
|
||||
fields={outputFields}
|
||||
onChange={handleOutputSchemaChange}
|
||||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
{noModelsAvailable && (
|
||||
<StyledErrorMessage>
|
||||
{t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`}
|
||||
</StyledErrorMessage>
|
||||
)}
|
||||
</div>
|
||||
<FormTextFieldInput
|
||||
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
|
||||
label={t`Instructions for AI`}
|
||||
placeholder={t`Describe what you want the AI to do...`}
|
||||
readonly={actionOptions.readonly}
|
||||
defaultValue={formValues.prompt}
|
||||
onChange={(value) => handleFieldChange('prompt', value)}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
multiline
|
||||
/>
|
||||
<WorkflowOutputSchemaBuilder
|
||||
fields={outputFields}
|
||||
onChange={handleOutputSchemaChange}
|
||||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{activeTabId === WorkflowAiAgentTabId.TOOLS && (
|
||||
<Select
|
||||
dropdownId="select-agent-role"
|
||||
label={t`Assign Role`}
|
||||
options={[{ label: t`No role`, value: '' }, ...rolesOptions]}
|
||||
value={selectedRole || ''}
|
||||
onChange={handleRoleChange}
|
||||
disabled={actionOptions.readonly}
|
||||
/>
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
export enum WorkflowAiAgentTabId {
|
||||
SETTINGS = 'settings',
|
||||
TOOLS = 'tools',
|
||||
}
|
||||
|
||||
export const WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID =
|
||||
'WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID';
|
||||
@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ASSIGN_ROLE_TO_AGENT = gql`
|
||||
mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) {
|
||||
assignRoleToAgent(agentId: $agentId, roleId: $roleId)
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REMOVE_ROLE_FROM_AGENT = gql`
|
||||
mutation RemoveRoleFromAgent($agentId: UUID!) {
|
||||
removeRoleFromAgent(agentId: $agentId)
|
||||
}
|
||||
`;
|
||||
@ -9,6 +9,7 @@ export const FIND_ONE_AGENT = gql`
|
||||
prompt
|
||||
modelId
|
||||
responseFormat
|
||||
roleId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { workflowAiAgentSelectedRoleState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentSelectedRoleState';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import {
|
||||
useAssignRoleToAgentMutation,
|
||||
useFindOneAgentQuery,
|
||||
useGetRolesQuery,
|
||||
useRemoveRoleFromAgentMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const useAgentRoleAssignment = (agentId: string) => {
|
||||
const [workflowAiAgentSelectedRole, setWorkflowAiAgentSelectedRole] =
|
||||
useRecoilState(workflowAiAgentSelectedRoleState);
|
||||
|
||||
useFindOneAgentQuery({
|
||||
variables: { id: agentId },
|
||||
skip: !agentId,
|
||||
onCompleted: (data) => {
|
||||
setWorkflowAiAgentSelectedRole(data.findOneAgent.roleId);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: rolesData } = useGetRolesQuery();
|
||||
const [assignRoleToAgent] = useAssignRoleToAgentMutation();
|
||||
const [removeRoleFromAgent] = useRemoveRoleFromAgentMutation();
|
||||
|
||||
const handleRoleChange = async (roleId: string) => {
|
||||
if (roleId === '') {
|
||||
await handleRoleRemove();
|
||||
} else {
|
||||
setWorkflowAiAgentSelectedRole(roleId);
|
||||
await assignRoleToAgent({ variables: { agentId, roleId } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleRemove = async () => {
|
||||
setWorkflowAiAgentSelectedRole(undefined);
|
||||
await removeRoleFromAgent({ variables: { agentId } });
|
||||
};
|
||||
|
||||
const rolesOptions =
|
||||
rolesData?.getRoles?.map((role) => ({
|
||||
label: role.label,
|
||||
value: role.id,
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
selectedRole: workflowAiAgentSelectedRole,
|
||||
handleRoleChange,
|
||||
rolesOptions,
|
||||
};
|
||||
};
|
||||
@ -1,9 +1,9 @@
|
||||
import { useMutation, useQuery } from '@apollo/client';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useFindOneAgentQuery } from '~/generated/graphql';
|
||||
import { UPDATE_ONE_AGENT } from '../graphql/mutations/updateOneAgent';
|
||||
import { FIND_ONE_AGENT } from '../graphql/queries/findOneAgent';
|
||||
|
||||
type AgentFormValues = {
|
||||
name: string;
|
||||
@ -24,7 +24,7 @@ export const useAgentUpdateFormState = ({
|
||||
modelId: '',
|
||||
});
|
||||
|
||||
const { loading } = useQuery(FIND_ONE_AGENT, {
|
||||
const { loading } = useFindOneAgentQuery({
|
||||
variables: { id: agentId },
|
||||
skip: !agentId,
|
||||
onCompleted: (data) => {
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const workflowAiAgentSelectedRoleState = atom<string | undefined>({
|
||||
key: 'workflowAiAgentSelectedRoleState',
|
||||
default: undefined,
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RenameUserWorkspaceRoleToRoleTargets1749000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'RenameUserWorkspaceRoleToRoleTargets1749000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspaceRole" ADD "agentId" uuid`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspaceRole" ALTER COLUMN "userWorkspaceId" DROP NOT NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspaceRole" ADD CONSTRAINT "CHK_role_targets_either_agent_or_user" CHECK (((("agentId" IS NOT NULL) AND ("userWorkspaceId" IS NULL)) OR (("agentId" IS NULL) AND ("userWorkspaceId" IS NOT NULL))))`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspaceRole" RENAME TO "roleTargets"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER INDEX "core"."IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_ROLE_ID_UNIQUE" RENAME TO "IDX_ROLE_TARGETS_UNIQUE"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER INDEX "core"."IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_WORKSPACE_ID" RENAME TO "IDX_ROLE_TARGETS_WORKSPACE_ID"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."roleTargets" DROP CONSTRAINT "FK_0b70755f23a3705f1bea0ddc7d4"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ROLE_TARGETS_AGENT_ID" ON "core"."roleTargets" ("agentId")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."roleTargets" ADD CONSTRAINT "FK_d5838ba43033ee6266d8928d7d7" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."roleTargets" DROP CONSTRAINT "FK_d5838ba43033ee6266d8928d7d7"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "core"."IDX_ROLE_TARGETS_AGENT_ID"`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER INDEX "core"."IDX_ROLE_TARGETS_UNIQUE" RENAME TO "IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_ROLE_ID_UNIQUE"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER INDEX "core"."IDX_ROLE_TARGETS_WORKSPACE_ID" RENAME TO "IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_WORKSPACE_ID"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."roleTargets" RENAME TO "userWorkspaceRole"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspaceRole" ADD CONSTRAINT "FK_0b70755f23a3705f1bea0ddc7d4" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspaceRole" DROP CONSTRAINT "CHK_role_targets_either_agent_or_user"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspaceRole" ALTER COLUMN "userWorkspaceId" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspaceRole" DROP COLUMN "agentId"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,7 @@ import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-run
|
||||
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 { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
|
||||
const graphqlQueryResolvers = [
|
||||
@ -45,7 +45,7 @@ const graphqlQueryResolvers = [
|
||||
WorkspaceQueryHookModule,
|
||||
WorkspaceQueryRunnerModule,
|
||||
PermissionsModule,
|
||||
TypeOrmModule.forFeature([UserWorkspaceRoleEntity], 'core'),
|
||||
TypeOrmModule.forFeature([RoleTargetsEntity], 'core'),
|
||||
UserRoleModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import { OpenAPIV3_1 } from 'openapi-types';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FieldMetadataSettings,
|
||||
NumberDataType,
|
||||
} 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 { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils';
|
||||
import {
|
||||
computeDepthParameters,
|
||||
computeEndingBeforeParameters,
|
||||
@ -18,11 +15,10 @@ import {
|
||||
computeOrderByParameters,
|
||||
computeStartingAfterParameters,
|
||||
} from 'src/engine/core-modules/open-api/utils/parameters.utils';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
|
||||
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils';
|
||||
|
||||
type Property = OpenAPIV3_1.SchemaObject;
|
||||
|
||||
@ -32,65 +28,6 @@ type Properties = {
|
||||
|
||||
type OpenApiExample = Record<string, FieldMetadataDefaultValue>;
|
||||
|
||||
const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
|
||||
if (forResponse) {
|
||||
return true;
|
||||
}
|
||||
switch (field.name) {
|
||||
case 'id':
|
||||
case 'createdAt':
|
||||
case 'updatedAt':
|
||||
case 'deletedAt':
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldProperties = (field: FieldMetadataEntity): Property => {
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.UUID: {
|
||||
return { type: 'string', format: 'uuid' };
|
||||
}
|
||||
case FieldMetadataType.TEXT:
|
||||
case FieldMetadataType.RICH_TEXT: {
|
||||
return { type: 'string' };
|
||||
}
|
||||
case FieldMetadataType.DATE_TIME: {
|
||||
return { type: 'string', format: 'date-time' };
|
||||
}
|
||||
case FieldMetadataType.DATE: {
|
||||
return { type: 'string', format: 'date' };
|
||||
}
|
||||
case FieldMetadataType.NUMBER: {
|
||||
const settings =
|
||||
field.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>;
|
||||
|
||||
if (
|
||||
settings?.dataType === NumberDataType.FLOAT ||
|
||||
(isDefined(settings?.decimals) && settings.decimals > 0)
|
||||
) {
|
||||
return { type: 'number' };
|
||||
}
|
||||
|
||||
return { type: 'integer' };
|
||||
}
|
||||
case FieldMetadataType.NUMERIC:
|
||||
case FieldMetadataType.POSITION: {
|
||||
return { type: 'number' };
|
||||
}
|
||||
case FieldMetadataType.BOOLEAN: {
|
||||
return { type: 'boolean' };
|
||||
}
|
||||
case FieldMetadataType.RAW_JSON: {
|
||||
return { type: 'object' };
|
||||
}
|
||||
default: {
|
||||
return { type: 'string' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSchemaComponentsExample = (
|
||||
item: ObjectMetadataEntity,
|
||||
): OpenApiExample => {
|
||||
@ -132,261 +69,6 @@ const getSchemaComponentsExample = (
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getSchemaComponentsProperties = ({
|
||||
item,
|
||||
forResponse,
|
||||
}: {
|
||||
item: ObjectMetadataEntity;
|
||||
forResponse: boolean;
|
||||
}): Properties => {
|
||||
return item.fields.reduce((node, field) => {
|
||||
if (
|
||||
!isFieldAvailable(field, forResponse) ||
|
||||
field.type === FieldMetadataType.TS_VECTOR
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
field.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
) {
|
||||
return {
|
||||
...node,
|
||||
[`${field.name}Id`]: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
field.settings?.relationType === RelationType.ONE_TO_MANY
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
|
||||
let itemProperty = {} as Property;
|
||||
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.MULTI_SELECT:
|
||||
itemProperty = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: field.options.map(
|
||||
(option: { value: string }) => option.value,
|
||||
),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.SELECT:
|
||||
itemProperty = {
|
||||
type: 'string',
|
||||
enum: field.options.map((option: { value: string }) => option.value),
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.ARRAY:
|
||||
itemProperty = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.RATING:
|
||||
itemProperty = {
|
||||
type: 'string',
|
||||
enum: field.options.map((option: { value: string }) => option.value),
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.LINKS:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
primaryLinkLabel: {
|
||||
type: 'string',
|
||||
},
|
||||
primaryLinkUrl: {
|
||||
type: 'string',
|
||||
},
|
||||
secondaryLinks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A secondary link',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
format: 'uri',
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.CURRENCY:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
amountMicros: {
|
||||
type: 'number',
|
||||
},
|
||||
currencyCode: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.FULL_NAME:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
firstName: {
|
||||
type: 'string',
|
||||
},
|
||||
lastName: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.ADDRESS:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
addressStreet1: {
|
||||
type: 'string',
|
||||
},
|
||||
addressStreet2: {
|
||||
type: 'string',
|
||||
},
|
||||
addressCity: {
|
||||
type: 'string',
|
||||
},
|
||||
addressPostcode: {
|
||||
type: 'string',
|
||||
},
|
||||
addressState: {
|
||||
type: 'string',
|
||||
},
|
||||
addressCountry: {
|
||||
type: 'string',
|
||||
},
|
||||
addressLat: {
|
||||
type: 'number',
|
||||
},
|
||||
addressLng: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.ACTOR:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'EMAIL',
|
||||
'CALENDAR',
|
||||
'WORKFLOW',
|
||||
'API',
|
||||
'IMPORT',
|
||||
'MANUAL',
|
||||
'SYSTEM',
|
||||
'WEBHOOK',
|
||||
],
|
||||
},
|
||||
...(forResponse
|
||||
? {
|
||||
workspaceMemberId: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.EMAILS:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
primaryEmail: {
|
||||
type: 'string',
|
||||
},
|
||||
additionalEmails: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.PHONES:
|
||||
itemProperty = {
|
||||
properties: {
|
||||
additionalPhones: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
primaryPhoneCountryCode: {
|
||||
type: 'string',
|
||||
},
|
||||
primaryPhoneCallingCode: {
|
||||
type: 'string',
|
||||
},
|
||||
primaryPhoneNumber: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.RICH_TEXT_V2:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
blocknote: {
|
||||
type: 'string',
|
||||
},
|
||||
markdown: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
itemProperty = getFieldProperties(field);
|
||||
break;
|
||||
}
|
||||
|
||||
if (field.description) {
|
||||
itemProperty.description = field.description;
|
||||
}
|
||||
|
||||
if (Object.keys(itemProperty).length) {
|
||||
return { ...node, [field.name]: itemProperty };
|
||||
}
|
||||
|
||||
return node;
|
||||
}, {} as Properties);
|
||||
};
|
||||
|
||||
const getSchemaComponentsRelationProperties = (
|
||||
item: ObjectMetadataEntity,
|
||||
): Properties => {
|
||||
@ -461,7 +143,10 @@ const computeSchemaComponent = ({
|
||||
const result: OpenAPIV3_1.SchemaObject = {
|
||||
type: 'object',
|
||||
description: item.description,
|
||||
properties: getSchemaComponentsProperties({ item, forResponse }),
|
||||
properties: convertObjectMetadataToSchemaProperties({
|
||||
item,
|
||||
forResponse,
|
||||
}) as Properties,
|
||||
...(!forResponse ? { example: getSchemaComponentsExample(item) } : {}),
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
import { AgentRoleService } from './agent-role.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature(
|
||||
[AgentEntity, RoleEntity, RoleTargetsEntity],
|
||||
'core',
|
||||
),
|
||||
AgentModule,
|
||||
],
|
||||
providers: [AgentRoleService],
|
||||
exports: [AgentRoleService],
|
||||
})
|
||||
export class AgentRoleModule {}
|
||||
@ -0,0 +1,364 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
import { AgentRoleService } from './agent-role.service';
|
||||
|
||||
describe('AgentRoleService', () => {
|
||||
let service: AgentRoleService;
|
||||
let agentRepository: Repository<AgentEntity>;
|
||||
let roleRepository: Repository<RoleEntity>;
|
||||
let roleTargetsRepository: Repository<RoleTargetsEntity>;
|
||||
|
||||
const testWorkspaceId = 'test-workspace-id';
|
||||
let testAgent: AgentEntity;
|
||||
let testRole: RoleEntity;
|
||||
let testRole2: RoleEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AgentRoleService,
|
||||
{
|
||||
provide: getRepositoryToken(AgentEntity, 'core'),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RoleEntity, 'core'),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RoleTargetsEntity, 'core'),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AgentRoleService>(AgentRoleService);
|
||||
agentRepository = module.get<Repository<AgentEntity>>(
|
||||
getRepositoryToken(AgentEntity, 'core'),
|
||||
);
|
||||
roleRepository = module.get<Repository<RoleEntity>>(
|
||||
getRepositoryToken(RoleEntity, 'core'),
|
||||
);
|
||||
roleTargetsRepository = module.get<Repository<RoleTargetsEntity>>(
|
||||
getRepositoryToken(RoleTargetsEntity, 'core'),
|
||||
);
|
||||
|
||||
// Setup test data
|
||||
testAgent = {
|
||||
id: 'test-agent-id',
|
||||
name: 'Test Agent',
|
||||
description: 'Test agent for unit tests',
|
||||
prompt: 'You are a test agent',
|
||||
modelId: 'gpt-4o' as ModelId,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as AgentEntity;
|
||||
|
||||
testRole = {
|
||||
id: 'test-role-id',
|
||||
label: 'Test Role',
|
||||
description: 'Test role for unit tests',
|
||||
canUpdateAllSettings: false,
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isEditable: true,
|
||||
} as RoleEntity;
|
||||
|
||||
testRole2 = {
|
||||
id: 'test-role-2-id',
|
||||
label: 'Test Role 2',
|
||||
description: 'Second test role for unit tests',
|
||||
canUpdateAllSettings: true,
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isEditable: true,
|
||||
} as RoleEntity;
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('assignRoleToAgent', () => {
|
||||
it('should successfully assign a role to an agent', async () => {
|
||||
// Arrange
|
||||
const newRoleTarget = {
|
||||
id: 'new-role-target-id',
|
||||
roleId: testRole.id,
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as RoleTargetsEntity;
|
||||
|
||||
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
|
||||
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole);
|
||||
jest.spyOn(roleTargetsRepository, 'findOne').mockResolvedValue(null);
|
||||
jest
|
||||
.spyOn(roleTargetsRepository, 'save')
|
||||
.mockResolvedValue(newRoleTarget);
|
||||
jest
|
||||
.spyOn(roleTargetsRepository, 'delete')
|
||||
.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
// Act
|
||||
await service.assignRoleToAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
roleId: testRole.id,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(agentRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: testAgent.id, workspaceId: testWorkspaceId },
|
||||
});
|
||||
expect(roleRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: testRole.id, workspaceId: testWorkspaceId },
|
||||
});
|
||||
expect(roleTargetsRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
agentId: testAgent.id,
|
||||
roleId: testRole.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
},
|
||||
});
|
||||
expect(roleTargetsRepository.save).toHaveBeenCalledWith({
|
||||
roleId: testRole.id,
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
});
|
||||
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
id: expect.any(Object), // Not(newRoleTarget.id)
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace existing role when assigning a new role to an agent', async () => {
|
||||
// Arrange
|
||||
const newRoleTarget = {
|
||||
id: 'new-role-target-id',
|
||||
roleId: testRole2.id,
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as RoleTargetsEntity;
|
||||
|
||||
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
|
||||
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole2);
|
||||
jest.spyOn(roleTargetsRepository, 'findOne').mockResolvedValue(null);
|
||||
jest
|
||||
.spyOn(roleTargetsRepository, 'save')
|
||||
.mockResolvedValue(newRoleTarget);
|
||||
jest
|
||||
.spyOn(roleTargetsRepository, 'delete')
|
||||
.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
// Act
|
||||
await service.assignRoleToAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
roleId: testRole2.id,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(roleTargetsRepository.save).toHaveBeenCalledWith({
|
||||
roleId: testRole2.id,
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
});
|
||||
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
id: expect.any(Object), // Not(newRoleTarget.id)
|
||||
});
|
||||
});
|
||||
|
||||
it('should not create duplicate role target when assigning the same role', async () => {
|
||||
// Arrange
|
||||
const existingRoleTarget = {
|
||||
id: 'existing-role-target-id',
|
||||
roleId: testRole.id,
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as RoleTargetsEntity;
|
||||
|
||||
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
|
||||
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole);
|
||||
jest
|
||||
.spyOn(roleTargetsRepository, 'findOne')
|
||||
.mockResolvedValue(existingRoleTarget);
|
||||
|
||||
// Act
|
||||
await service.assignRoleToAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
roleId: testRole.id,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(roleTargetsRepository.save).not.toHaveBeenCalled();
|
||||
expect(roleTargetsRepository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw AgentException when agent does not exist', async () => {
|
||||
// Arrange
|
||||
const nonExistentAgentId = 'non-existent-agent-id';
|
||||
|
||||
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
service.assignRoleToAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: nonExistentAgentId,
|
||||
roleId: testRole.id,
|
||||
}),
|
||||
).rejects.toThrow(AgentException);
|
||||
|
||||
await expect(
|
||||
service.assignRoleToAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: nonExistentAgentId,
|
||||
roleId: testRole.id,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
message: `Agent with id ${nonExistentAgentId} not found in workspace`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw AgentException when role does not exist', async () => {
|
||||
// Arrange
|
||||
const nonExistentRoleId = 'non-existent-role-id';
|
||||
|
||||
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
|
||||
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
service.assignRoleToAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
roleId: nonExistentRoleId,
|
||||
}),
|
||||
).rejects.toThrow(AgentException);
|
||||
|
||||
await expect(
|
||||
service.assignRoleToAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
roleId: nonExistentRoleId,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
message: `Role with id ${nonExistentRoleId} not found in workspace`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw AgentException when agent belongs to different workspace', async () => {
|
||||
// Arrange
|
||||
const differentWorkspaceId = 'different-workspace-id';
|
||||
|
||||
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
service.assignRoleToAgent({
|
||||
workspaceId: differentWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
roleId: testRole.id,
|
||||
}),
|
||||
).rejects.toThrow(AgentException);
|
||||
|
||||
await expect(
|
||||
service.assignRoleToAgent({
|
||||
workspaceId: differentWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
roleId: testRole.id,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
message: `Agent with id ${testAgent.id} not found in workspace`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRoleFromAgent', () => {
|
||||
it('should successfully remove role from agent', async () => {
|
||||
// Arrange
|
||||
jest
|
||||
.spyOn(roleTargetsRepository, 'delete')
|
||||
.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
// Act
|
||||
await service.removeRoleFromAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw error when removing role from agent that has no role', async () => {
|
||||
// Arrange
|
||||
jest
|
||||
.spyOn(roleTargetsRepository, 'delete')
|
||||
.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await expect(
|
||||
service.removeRoleFromAgent({
|
||||
workspaceId: testWorkspaceId,
|
||||
agentId: testAgent.id,
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
|
||||
agentId: testAgent.id,
|
||||
workspaceId: testWorkspaceId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,113 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Not, Repository } from 'typeorm';
|
||||
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AgentRoleService {
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity, 'core')
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
@InjectRepository(RoleTargetsEntity, 'core')
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
) {}
|
||||
|
||||
public async assignRoleToAgent({
|
||||
workspaceId,
|
||||
agentId,
|
||||
roleId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
roleId: string;
|
||||
}): Promise<void> {
|
||||
const validationResult = await this.validateAssignRoleInput({
|
||||
agentId,
|
||||
workspaceId,
|
||||
roleId,
|
||||
});
|
||||
|
||||
if (validationResult?.roleToAssignIsSameAsCurrentRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newRoleTarget = await this.roleTargetsRepository.save({
|
||||
roleId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.roleTargetsRepository.delete({
|
||||
agentId,
|
||||
workspaceId,
|
||||
id: Not(newRoleTarget.id),
|
||||
});
|
||||
}
|
||||
|
||||
public async removeRoleFromAgent({
|
||||
workspaceId,
|
||||
agentId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}): Promise<void> {
|
||||
await this.roleTargetsRepository.delete({
|
||||
agentId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
private async validateAssignRoleInput({
|
||||
agentId,
|
||||
workspaceId,
|
||||
roleId,
|
||||
}: {
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
roleId: string;
|
||||
}) {
|
||||
const agent = await this.agentRepository.findOne({
|
||||
where: { id: agentId, workspaceId },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
throw new AgentException(
|
||||
`Agent with id ${agentId} not found in workspace`,
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const role = await this.roleRepository.findOne({
|
||||
where: { id: roleId, workspaceId },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new AgentException(
|
||||
`Role with id ${roleId} not found in workspace`,
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const existingRoleTarget = await this.roleTargetsRepository.findOne({
|
||||
where: {
|
||||
agentId,
|
||||
roleId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
roleToAssignIsSameAsCurrentRole: Boolean(existingRoleTarget),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject } from 'ai';
|
||||
import { generateObject, generateText } from 'ai';
|
||||
|
||||
import {
|
||||
ModelId,
|
||||
@ -10,16 +10,21 @@ import {
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
|
||||
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
|
||||
import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod';
|
||||
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
|
||||
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
|
||||
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
|
||||
import { convertOutputSchemaToZod } from './utils/convert-output-schema-to-zod';
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
object: object;
|
||||
result: {
|
||||
textResponse: string;
|
||||
structuredOutput?: object;
|
||||
};
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
@ -29,7 +34,10 @@ export interface AgentExecutionResult {
|
||||
|
||||
@Injectable()
|
||||
export class AgentExecutionService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly agentToolService: AgentToolService,
|
||||
) {}
|
||||
|
||||
private getModel = (modelId: ModelId, provider: ModelProvider) => {
|
||||
switch (provider) {
|
||||
@ -103,18 +111,52 @@ export class AgentExecutionService {
|
||||
|
||||
await this.validateApiKey(provider);
|
||||
|
||||
const output = await generateObject({
|
||||
const tools = await this.agentToolService.generateToolsForAgent(
|
||||
agent.id,
|
||||
agent.workspaceId,
|
||||
);
|
||||
|
||||
const textResponse = await generateText({
|
||||
system: AGENT_SYSTEM_PROMPTS.AGENT_EXECUTION,
|
||||
model: this.getModel(agent.modelId, provider),
|
||||
prompt: resolveInput(agent.prompt, context) as string,
|
||||
tools,
|
||||
maxSteps: AGENT_CONFIG.MAX_STEPS,
|
||||
});
|
||||
|
||||
if (Object.keys(schema).length === 0) {
|
||||
return {
|
||||
result: { textResponse: textResponse.text },
|
||||
usage: textResponse.usage,
|
||||
};
|
||||
}
|
||||
|
||||
const output = await generateObject({
|
||||
system: AGENT_SYSTEM_PROMPTS.OUTPUT_GENERATOR,
|
||||
model: this.getModel(agent.modelId, provider),
|
||||
prompt: `Based on the following execution results, generate the structured output according to the schema:
|
||||
|
||||
Execution Results: ${textResponse.text}
|
||||
|
||||
Please generate the structured output based on the execution results and context above.`,
|
||||
schema: convertOutputSchemaToZod(schema),
|
||||
});
|
||||
|
||||
return {
|
||||
object: output.object,
|
||||
result: {
|
||||
textResponse: textResponse.text,
|
||||
structuredOutput: output.object,
|
||||
},
|
||||
usage: {
|
||||
promptTokens: output.usage?.promptTokens ?? 0,
|
||||
completionTokens: output.usage?.completionTokens ?? 0,
|
||||
totalTokens: output.usage?.totalTokens,
|
||||
promptTokens:
|
||||
(textResponse.usage?.promptTokens ?? 0) +
|
||||
(output.usage?.promptTokens ?? 0),
|
||||
completionTokens:
|
||||
(textResponse.usage?.completionTokens ?? 0) +
|
||||
(output.usage?.completionTokens ?? 0),
|
||||
totalTokens:
|
||||
(textResponse.usage?.totalTokens ?? 0) +
|
||||
(output.usage?.totalTokens ?? 0),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@ -0,0 +1,856 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { ToolSet } from 'ai';
|
||||
import {
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
import {
|
||||
generateBulkDeleteToolSchema,
|
||||
generateFindToolSchema,
|
||||
getRecordInputSchema,
|
||||
} from './utils/agent-tool-schema.utils';
|
||||
import { isWorkflowRelatedObject } from './utils/is-workflow-related-object.util';
|
||||
|
||||
@Injectable()
|
||||
export class AgentToolService {
|
||||
constructor(
|
||||
private readonly agentService: AgentService,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
) {}
|
||||
|
||||
async generateToolsForAgent(
|
||||
agentId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ToolSet> {
|
||||
try {
|
||||
const agent = await this.agentService.findOneAgent(agentId, workspaceId);
|
||||
|
||||
if (!agent.roleId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const role = await this.roleRepository.findOne({
|
||||
where: {
|
||||
id: agent.roleId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { data: rolesPermissions } =
|
||||
await this.workspacePermissionsCacheService.getRolesPermissionsFromCache(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const objectPermissions = rolesPermissions[agent.roleId];
|
||||
|
||||
if (!objectPermissions) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const tools: ToolSet = {};
|
||||
|
||||
const allObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
const filteredObjectMetadata = allObjectMetadata.filter(
|
||||
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
|
||||
);
|
||||
|
||||
filteredObjectMetadata.forEach((objectMetadata) => {
|
||||
const objectPermission = objectPermissions[objectMetadata.id];
|
||||
|
||||
if (!objectPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (objectPermission.canUpdate) {
|
||||
tools[`create_${objectMetadata.nameSingular}`] = {
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.createRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`update_${objectMetadata.nameSingular}`] = {
|
||||
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.updateRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canRead) {
|
||||
tools[`find_${objectMetadata.nameSingular}`] = {
|
||||
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination. Returns an array of matching records with their full data.`,
|
||||
parameters: generateFindToolSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.findRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`find_one_${objectMetadata.nameSingular}`] = {
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to retrieve'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.findOneRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canSoftDelete) {
|
||||
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to soft delete'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
|
||||
parameters: generateBulkDeleteToolSchema(),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canDestroy) {
|
||||
tools[`destroy_${objectMetadata.nameSingular}`] = {
|
||||
description: `Permanently delete a ${objectMetadata.labelSingular} record from the database. This action is irreversible and completely removes all data. Use with extreme caution - consider soft delete for temporary removal.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
'The unique UUID of the record to permanently delete',
|
||||
),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.destroyRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`destroy_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Permanently delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. This action is irreversible and completely removes all data from all specified records. Use with extreme caution.`,
|
||||
parameters: generateBulkDeleteToolSchema(),
|
||||
execute: async (parameters) => {
|
||||
return this.destroyManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return tools;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async findRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { limit = 100, offset = 0, ...searchCriteria } = parameters;
|
||||
|
||||
const whereConditions = this.buildWhereConditions(searchCriteria);
|
||||
|
||||
const records = await repository.find({
|
||||
where: whereConditions,
|
||||
take: limit as number,
|
||||
skip: offset as number,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records,
|
||||
count: records.length,
|
||||
message: `Found ${records.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} records`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildWhereConditions(
|
||||
searchCriteria: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const whereConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(searchCriteria).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const nestedConditions = this.buildNestedWhereConditions(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (Object.keys(nestedConditions).length > 0) {
|
||||
whereConditions[key] = nestedConditions;
|
||||
} else {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
whereConditions[key] = filterCondition;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
whereConditions[key] = value;
|
||||
});
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
|
||||
private buildNestedWhereConditions(
|
||||
nestedValue: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nestedConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => {
|
||||
if (
|
||||
nestedFieldValue === undefined ||
|
||||
nestedFieldValue === null ||
|
||||
nestedFieldValue === ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nestedFieldValue === 'object' &&
|
||||
!Array.isArray(nestedFieldValue)
|
||||
) {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
nestedFieldValue as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
nestedConditions[nestedKey] = filterCondition;
|
||||
}
|
||||
} else {
|
||||
nestedConditions[nestedKey] = nestedFieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
return nestedConditions;
|
||||
}
|
||||
|
||||
private parseFilterCondition(filterValue: Record<string, unknown>): unknown {
|
||||
if ('eq' in filterValue) {
|
||||
return filterValue.eq;
|
||||
}
|
||||
if ('neq' in filterValue) {
|
||||
return Not(filterValue.neq);
|
||||
}
|
||||
if ('gt' in filterValue) {
|
||||
return MoreThan(filterValue.gt);
|
||||
}
|
||||
if ('gte' in filterValue) {
|
||||
return MoreThanOrEqual(filterValue.gte);
|
||||
}
|
||||
if ('lt' in filterValue) {
|
||||
return LessThan(filterValue.lt);
|
||||
}
|
||||
if ('lte' in filterValue) {
|
||||
return LessThanOrEqual(filterValue.lte);
|
||||
}
|
||||
if ('in' in filterValue) {
|
||||
return In(filterValue.in as string[]);
|
||||
}
|
||||
if ('like' in filterValue) {
|
||||
return Like(filterValue.like as string);
|
||||
}
|
||||
if ('ilike' in filterValue) {
|
||||
return Like(filterValue.ilike as string);
|
||||
}
|
||||
if ('startsWith' in filterValue) {
|
||||
return Like(`${filterValue.startsWith}%`);
|
||||
}
|
||||
if ('is' in filterValue) {
|
||||
if (filterValue.is === 'NULL') {
|
||||
return IsNull();
|
||||
}
|
||||
if (filterValue.is === 'NOT_NULL') {
|
||||
return Not(IsNull());
|
||||
}
|
||||
}
|
||||
if ('isEmptyArray' in filterValue) {
|
||||
return [];
|
||||
}
|
||||
if ('containsIlike' in filterValue) {
|
||||
return Like(`%${filterValue.containsIlike}%`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findOneRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required',
|
||||
message: `Failed to find ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record,
|
||||
message: `Found ${objectName} record`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} record`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async createRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const createdRecord = await repository.save(parameters);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.CREATED,
|
||||
records: [createdRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: createdRecord,
|
||||
message: `Successfully created ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to create ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id, ...updateData } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for update',
|
||||
message: `Failed to update ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.update(id as string, updateData);
|
||||
|
||||
const updatedRecord = await repository.findOne({
|
||||
where: { id: id as string },
|
||||
});
|
||||
|
||||
if (!updatedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve updated record',
|
||||
message: `Failed to update ${objectName}: Could not retrieve updated record`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.UPDATED,
|
||||
records: [updatedRecord],
|
||||
workspaceId,
|
||||
beforeRecords: [existingRecord],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: updatedRecord,
|
||||
message: `Successfully updated ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to update ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for soft delete',
|
||||
message: `Failed to soft delete ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete(id);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for destroy',
|
||||
message: `Failed to destroy ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.remove(existingRecord);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully destroyed ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in;
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in as string[];
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to destroy',
|
||||
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.delete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async emitDatabaseEvent({
|
||||
objectName,
|
||||
action,
|
||||
records,
|
||||
workspaceId,
|
||||
beforeRecords,
|
||||
}: {
|
||||
objectName: string;
|
||||
action: DatabaseEventAction;
|
||||
records: Record<string, unknown>[];
|
||||
workspaceId: string;
|
||||
beforeRecords?: Record<string, unknown>[];
|
||||
}) {
|
||||
const objectMetadata =
|
||||
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
nameSingular: objectName,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
if (!objectMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: objectName,
|
||||
action,
|
||||
events: records.map((record) => {
|
||||
const beforeRecord = beforeRecords?.find((r) => r.id === record.id);
|
||||
|
||||
return {
|
||||
recordId: record.id as string,
|
||||
objectMetadata,
|
||||
properties: {
|
||||
before: beforeRecord || undefined,
|
||||
after:
|
||||
action === DatabaseEventAction.DELETED ||
|
||||
action === DatabaseEventAction.DESTROYED
|
||||
? undefined
|
||||
: (record as Record<string, unknown>),
|
||||
},
|
||||
};
|
||||
}),
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -3,27 +3,42 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
|
||||
import { AgentExecutionService } from './agent-execution.service';
|
||||
import { AgentToolService } from './agent-tool.service';
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentResolver } from './agent.resolver';
|
||||
import { AgentService } from './agent.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AgentEntity, FeatureFlag], 'core'),
|
||||
TypeOrmModule.forFeature(
|
||||
[AgentEntity, RoleEntity, RoleTargetsEntity],
|
||||
'core',
|
||||
),
|
||||
AiModule,
|
||||
ThrottlerModule,
|
||||
AuditModule,
|
||||
FeatureFlagModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
],
|
||||
providers: [
|
||||
AgentResolver,
|
||||
AgentService,
|
||||
AgentExecutionService,
|
||||
AgentToolService,
|
||||
],
|
||||
providers: [AgentResolver, AgentService, AgentExecutionService],
|
||||
exports: [
|
||||
AgentService,
|
||||
AgentExecutionService,
|
||||
AgentToolService,
|
||||
TypeOrmModule.forFeature([AgentEntity], 'core'),
|
||||
],
|
||||
})
|
||||
|
||||
@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
@ -13,6 +14,8 @@ export class AgentService {
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity, 'core')
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
@InjectRepository(RoleTargetsEntity, 'core')
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
) {}
|
||||
|
||||
async findManyAgents(workspaceId: string) {
|
||||
@ -34,7 +37,18 @@ export class AgentService {
|
||||
);
|
||||
}
|
||||
|
||||
return agent;
|
||||
const roleTarget = await this.roleTargetsRepository.findOne({
|
||||
where: {
|
||||
agentId: id,
|
||||
workspaceId,
|
||||
},
|
||||
select: ['roleId'],
|
||||
});
|
||||
|
||||
return {
|
||||
...agent,
|
||||
roleId: roleTarget?.roleId || null,
|
||||
};
|
||||
}
|
||||
|
||||
async createOneAgent(
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export const AGENT_CONFIG = {
|
||||
MAX_STEPS: 10,
|
||||
};
|
||||
@ -0,0 +1,71 @@
|
||||
export const AGENT_SYSTEM_PROMPTS = {
|
||||
AGENT_EXECUTION: `You are an AI agent node in a workflow builder system with access to comprehensive database operations. Your role is to process inputs, execute actions using available tools, and provide structured outputs that can be used by subsequent workflow nodes.
|
||||
|
||||
AVAILABLE DATABASE OPERATIONS:
|
||||
You have access to full CRUD operations for all standard objects in the system:
|
||||
- CREATE: create_[object] - Create new records (e.g., create_person, create_company, create_opportunity)
|
||||
- READ: find_[object] and find_one_[object] - Search and retrieve records
|
||||
- UPDATE: update_[object] - Modify existing records
|
||||
- DELETE: soft_delete_[object] and destroy_[object] - Remove records (soft or permanent)
|
||||
|
||||
Common objects include: person, company, opportunity, task, note etc. and any custom objects.
|
||||
|
||||
CRITICAL PERMISSION CHECK:
|
||||
Before attempting any operation, you MUST first check if you have the required tools available. If you do NOT have the necessary tools to perform the requested operation, you MUST immediately respond with:
|
||||
"I cannot perform this operation because I don't have the necessary permissions. Please check that I have been assigned the appropriate role for this workspace."
|
||||
|
||||
DO NOT describe what you would do, DO NOT list steps, DO NOT simulate the operation. Simply state that you cannot perform the action due to missing permissions.
|
||||
|
||||
Your responsibilities:
|
||||
1. FIRST check if you have the required tools for the requested operation
|
||||
2. If tools are NOT available, immediately state you lack permissions - do not proceed further
|
||||
3. If tools ARE available, analyze the input context and prompt carefully
|
||||
4. Use available database tools when the request involves data operations
|
||||
5. For any request to create, read, update, or delete records, use the appropriate tools
|
||||
6. If no database operations are needed, process the request directly with your analysis
|
||||
|
||||
Workflow context:
|
||||
- You are part of a larger workflow system where your output may be used by other nodes
|
||||
- Maintain consistency and reliability in your responses
|
||||
- Consider the broader workflow context when making decisions
|
||||
- If you encounter data or perform actions, document them clearly in your response
|
||||
|
||||
Tool usage guidelines:
|
||||
- ALWAYS use tools for database operations - do not simulate or describe them
|
||||
- Use create_[object] tools when asked to create new records
|
||||
- Use find_[object] tools when asked to search or retrieve records
|
||||
- Use update_[object] tools when asked to modify existing records
|
||||
- Use soft_delete_[object] or destroy_[object] when asked to remove records
|
||||
- Always verify tool results and handle errors appropriately
|
||||
- Provide context about what tools you used and why
|
||||
- If a tool fails, explain the issue and suggest alternatives
|
||||
|
||||
CRITICAL: When users ask you to perform any database operation (create, find, update, delete), you MUST use the appropriate tools. Do not just describe what you would do - actually execute the operations using the available tools. If you cannot execute the operation due to lack of permissions or roles, you MUST state this clearly in your response.
|
||||
|
||||
Important: After your response, the system will call generateObject to convert your output into a structured format according to a specific schema. Therefore:
|
||||
- Provide comprehensive information in your response
|
||||
- Include all relevant data you've gathered or processed
|
||||
- Structure your response logically so it can be easily parsed
|
||||
- Mention any important context, decisions, or actions taken
|
||||
- Include tool execution results in your response`,
|
||||
|
||||
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
|
||||
|
||||
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, data retrieval, or other actions.
|
||||
|
||||
Your responsibilities:
|
||||
1. Analyze the execution results from the AI agent (including any tool outputs)
|
||||
2. Extract relevant information and data points from both text responses and tool results
|
||||
3. Structure the data according to the provided schema
|
||||
4. Ensure all required fields are populated with appropriate values
|
||||
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
|
||||
6. Maintain data integrity and consistency
|
||||
|
||||
Guidelines:
|
||||
- Focus on extracting and structuring the most relevant information
|
||||
- If the execution results contain tool outputs, incorporate that data appropriately
|
||||
- If certain schema fields cannot be populated from the results, use null or appropriate default values
|
||||
- Preserve the context and meaning from the original execution results
|
||||
- Ensure the output is clean, well-formatted, and ready for workflow consumption
|
||||
- Pay special attention to any data returned from tool executions (database queries, record creation, etc.)`,
|
||||
};
|
||||
@ -32,6 +32,9 @@ export class AgentDTO {
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
responseFormat: object;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
roleId?: string;
|
||||
|
||||
@HideField()
|
||||
workspaceId: string;
|
||||
|
||||
|
||||
@ -0,0 +1,836 @@
|
||||
import { jsonSchema } from 'ai';
|
||||
import { JSONSchema7Definition } from 'json-schema';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
export const getRecordInputSchema = (objectMetadata: ObjectMetadataEntity) => {
|
||||
return jsonSchema({
|
||||
type: 'object',
|
||||
properties: convertObjectMetadataToSchemaProperties({
|
||||
item: objectMetadata,
|
||||
forResponse: false,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export const generateFindToolSchema = (
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
) => {
|
||||
const schemaProperties: Record<string, JSONSchema7Definition> = {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of records to return (default: 100)',
|
||||
default: 100,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of records to skip (default: 0)',
|
||||
default: 0,
|
||||
},
|
||||
};
|
||||
|
||||
objectMetadata.fields.forEach((field: FieldMetadataEntity) => {
|
||||
if (shouldExcludeFieldFromAgentToolSchema(field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterSchema = generateFieldFilterJsonSchema(field);
|
||||
|
||||
if (filterSchema) {
|
||||
schemaProperties[field.name] = filterSchema;
|
||||
}
|
||||
});
|
||||
|
||||
return jsonSchema({
|
||||
type: 'object',
|
||||
properties: schemaProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const generateFieldFilterJsonSchema = (
|
||||
field: FieldMetadataEntity,
|
||||
): JSONSchema7Definition | null => {
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.UUID:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (UUID field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Not equals',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
description: 'In array of values',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.TEXT:
|
||||
case FieldMetadataType.RICH_TEXT:
|
||||
case FieldMetadataType.RICH_TEXT_V2:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (text field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'Equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'Not equals',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'In array of values',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'Case-sensitive pattern match (use % for wildcards)',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'Case-insensitive pattern match (use % for wildcards)',
|
||||
},
|
||||
startsWith: {
|
||||
type: 'string',
|
||||
description: 'Starts with',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.NUMERIC:
|
||||
case FieldMetadataType.POSITION:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (number field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'number',
|
||||
description: 'Equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'number',
|
||||
description: 'Not equals',
|
||||
},
|
||||
gt: {
|
||||
type: 'number',
|
||||
description: 'Greater than',
|
||||
},
|
||||
gte: {
|
||||
type: 'number',
|
||||
description: 'Greater than or equal',
|
||||
},
|
||||
lt: {
|
||||
type: 'number',
|
||||
description: 'Less than',
|
||||
},
|
||||
lte: {
|
||||
type: 'number',
|
||||
description: 'Less than or equal',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'number',
|
||||
},
|
||||
description: 'In array of values',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.BOOLEAN:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (boolean field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'boolean',
|
||||
description: 'Equals',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
case FieldMetadataType.DATE:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (date field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Equals (ISO datetime string)',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Not equals (ISO datetime string)',
|
||||
},
|
||||
gt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Greater than (ISO datetime string)',
|
||||
},
|
||||
gte: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Greater than or equal (ISO datetime string)',
|
||||
},
|
||||
lt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Less than (ISO datetime string)',
|
||||
},
|
||||
lte: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Less than or equal (ISO datetime string)',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
description: 'In array of values (ISO datetime strings)',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.SELECT: {
|
||||
const enumValues =
|
||||
field.options?.map((option: { value: string }) => option.value) || [];
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (select field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
enum: enumValues,
|
||||
description: 'Equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
enum: enumValues,
|
||||
description: 'Not equals',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: enumValues,
|
||||
},
|
||||
description: 'In array of values',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case FieldMetadataType.MULTI_SELECT: {
|
||||
const enumValues =
|
||||
field.options?.map((option: { value: string }) => option.value) || [];
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (multi-select field)`,
|
||||
properties: {
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: enumValues,
|
||||
},
|
||||
description: 'Contains any of these values',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
isEmptyArray: {
|
||||
type: 'boolean',
|
||||
description: 'Is empty array',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case FieldMetadataType.RATING: {
|
||||
const enumValues =
|
||||
field.options?.map((option: { value: string }) => option.value) || [];
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (rating field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
enum: enumValues,
|
||||
description: 'Equals',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: enumValues,
|
||||
},
|
||||
description: 'In array of values',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case FieldMetadataType.ARRAY:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (array field)`,
|
||||
properties: {
|
||||
containsIlike: {
|
||||
type: 'string',
|
||||
description: 'Contains case-insensitive substring',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
isEmptyArray: {
|
||||
type: 'boolean',
|
||||
description: 'Is empty array',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.CURRENCY:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (currency field)`,
|
||||
properties: {
|
||||
amountMicros: {
|
||||
type: 'object',
|
||||
description: 'Filter by amount',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'number',
|
||||
description: 'Amount equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'number',
|
||||
description: 'Amount not equals',
|
||||
},
|
||||
gt: {
|
||||
type: 'number',
|
||||
description: 'Amount greater than',
|
||||
},
|
||||
gte: {
|
||||
type: 'number',
|
||||
description: 'Amount greater than or equal',
|
||||
},
|
||||
lt: {
|
||||
type: 'number',
|
||||
description: 'Amount less than',
|
||||
},
|
||||
lte: {
|
||||
type: 'number',
|
||||
description: 'Amount less than or equal',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'number',
|
||||
},
|
||||
description: 'Amount in array of values',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Amount is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
currencyCode: {
|
||||
type: 'object',
|
||||
description: 'Filter by currency code',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'Currency code equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'Currency code not equals',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Currency code in array of values',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Currency code is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.FULL_NAME:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (full name field)`,
|
||||
properties: {
|
||||
firstName: {
|
||||
type: 'object',
|
||||
description: 'Filter by first name',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'First name equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'First name not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'First name case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'First name case-insensitive pattern match',
|
||||
},
|
||||
startsWith: {
|
||||
type: 'string',
|
||||
description: 'First name starts with',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'First name is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
lastName: {
|
||||
type: 'object',
|
||||
description: 'Filter by last name',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'Last name equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'Last name not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'Last name case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'Last name case-insensitive pattern match',
|
||||
},
|
||||
startsWith: {
|
||||
type: 'string',
|
||||
description: 'Last name starts with',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Last name is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.ADDRESS:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (address field)`,
|
||||
properties: {
|
||||
addressStreet1: {
|
||||
type: 'object',
|
||||
description: 'Filter by street 1',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'Street 1 equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'Street 1 not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'Street 1 case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'Street 1 case-insensitive pattern match',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Street 1 is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
addressCity: {
|
||||
type: 'object',
|
||||
description: 'Filter by city',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'City equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'City not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'City case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'City case-insensitive pattern match',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'City is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
addressCountry: {
|
||||
type: 'object',
|
||||
description: 'Filter by country',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'Country equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'Country not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'Country case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'Country case-insensitive pattern match',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Country is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.EMAILS:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (emails field)`,
|
||||
properties: {
|
||||
primaryEmail: {
|
||||
type: 'object',
|
||||
description: 'Filter by primary email',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
description: 'Primary email equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
description: 'Primary email not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'Primary email case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'Primary email case-insensitive pattern match',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Primary email is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.PHONES:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (phones field)`,
|
||||
properties: {
|
||||
primaryPhoneNumber: {
|
||||
type: 'object',
|
||||
description: 'Filter by primary phone number',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'Primary phone number equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'Primary phone number not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Primary phone number case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Primary phone number case-insensitive pattern match',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Primary phone number is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.LINKS:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (links field)`,
|
||||
properties: {
|
||||
primaryLinkUrl: {
|
||||
type: 'object',
|
||||
description: 'Filter by primary link URL',
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
format: 'uri',
|
||||
description: 'Primary link URL equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
format: 'uri',
|
||||
description: 'Primary link URL not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'Primary link URL case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'Primary link URL case-insensitive pattern match',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Primary link URL is null or not null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case FieldMetadataType.RELATION:
|
||||
if (
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
field.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
) {
|
||||
const fieldName = `${field.name}Id`;
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${fieldName} (relation field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Related record ID equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Related record ID not equals',
|
||||
},
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
description: 'Related record ID in array of values',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Related record ID is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
case FieldMetadataType.RAW_JSON:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (raw JSON field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'Raw JSON equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'Raw JSON not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'Raw JSON case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'Raw JSON case-insensitive pattern match',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Raw JSON is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'object',
|
||||
description: `Filter by ${field.name} (string field)`,
|
||||
properties: {
|
||||
eq: {
|
||||
type: 'string',
|
||||
description: 'Equals',
|
||||
},
|
||||
neq: {
|
||||
type: 'string',
|
||||
description: 'Not equals',
|
||||
},
|
||||
like: {
|
||||
type: 'string',
|
||||
description: 'Case-sensitive pattern match',
|
||||
},
|
||||
ilike: {
|
||||
type: 'string',
|
||||
description: 'Case-insensitive pattern match',
|
||||
},
|
||||
is: {
|
||||
type: 'string',
|
||||
enum: ['NULL', 'NOT_NULL'],
|
||||
description: 'Is null or not null',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const generateBulkDeleteToolSchema = () => {
|
||||
return jsonSchema({
|
||||
type: 'object',
|
||||
properties: {
|
||||
filter: {
|
||||
type: 'object',
|
||||
description: 'Filter criteria to select records for bulk delete',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'object',
|
||||
description: 'Filter to select records to delete',
|
||||
properties: {
|
||||
in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
description: 'Array of record IDs to delete',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,18 @@
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
|
||||
|
||||
const WORKFLOW_OBJECT_NAMES = ['workflow', 'workflowVersion', 'workflowRun'];
|
||||
|
||||
export const isWorkflowRelatedObject = (
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
): boolean => {
|
||||
if (objectMetadata.standardId) {
|
||||
return (
|
||||
objectMetadata.standardId === STANDARD_OBJECT_IDS.workflow ||
|
||||
objectMetadata.standardId === STANDARD_OBJECT_IDS.workflowVersion ||
|
||||
objectMetadata.standardId === STANDARD_OBJECT_IDS.workflowRun
|
||||
);
|
||||
}
|
||||
|
||||
return WORKFLOW_OBJECT_NAMES.includes(objectMetadata.nameSingular);
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
|
||||
export const shouldExcludeFieldFromAgentToolSchema = (
|
||||
field: FieldMetadataEntity,
|
||||
excludeId = true,
|
||||
): boolean => {
|
||||
const excludedFieldNames = [
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
'searchVector',
|
||||
'createdBy',
|
||||
];
|
||||
|
||||
if (excludeId) {
|
||||
excludedFieldNames.push('id');
|
||||
}
|
||||
|
||||
return excludedFieldNames.includes(field.name) || field.isSystem;
|
||||
};
|
||||
@ -1,16 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'core'),
|
||||
TypeOrmModule.forFeature([RoleEntity, RoleTargetsEntity], 'core'),
|
||||
FeatureFlagModule,
|
||||
TypeOrmModule.forFeature([UserWorkspace], 'core'),
|
||||
UserRoleModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
|
||||
@ -4,7 +4,7 @@ import { Relation } from 'typeorm';
|
||||
|
||||
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
|
||||
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { SettingPermissionDTO } from 'src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto';
|
||||
|
||||
@ObjectType('Role')
|
||||
@ -25,7 +25,7 @@ export class RoleDTO {
|
||||
isEditable: boolean;
|
||||
|
||||
@HideField()
|
||||
userWorkspaceRoles: Relation<UserWorkspaceRoleEntity[]>;
|
||||
roleTargets: Relation<RoleTargetsEntity[]>;
|
||||
|
||||
@Field(() => [WorkspaceMember], { nullable: true })
|
||||
workspaceMembers?: WorkspaceMember[];
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@ -13,16 +14,15 @@ import {
|
||||
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
@Entity('userWorkspaceRole')
|
||||
@Unique('IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_ROLE_ID_UNIQUE', [
|
||||
'userWorkspaceId',
|
||||
'roleId',
|
||||
])
|
||||
@Index('IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_WORKSPACE_ID', [
|
||||
'userWorkspaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
export class UserWorkspaceRoleEntity {
|
||||
@Entity('roleTargets')
|
||||
@Unique('IDX_ROLE_TARGETS_UNIQUE', ['userWorkspaceId', 'roleId', 'agentId'])
|
||||
@Index('IDX_ROLE_TARGETS_WORKSPACE_ID', ['userWorkspaceId', 'workspaceId'])
|
||||
@Index('IDX_ROLE_TARGETS_AGENT_ID', ['agentId'])
|
||||
@Check(
|
||||
'CHK_role_targets_either_agent_or_user',
|
||||
'("agentId" IS NOT NULL AND "userWorkspaceId" IS NULL) OR ("agentId" IS NULL AND "userWorkspaceId" IS NOT NULL)',
|
||||
)
|
||||
export class RoleTargetsEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ -32,15 +32,18 @@ export class UserWorkspaceRoleEntity {
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
roleId: string;
|
||||
|
||||
@ManyToOne(() => RoleEntity, (role) => role.userWorkspaceRoles, {
|
||||
@ManyToOne(() => RoleEntity, (role) => role.roleTargets, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'roleId' })
|
||||
role: Relation<RoleEntity>;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
userWorkspaceId: string;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
agentId: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity';
|
||||
|
||||
@Entity('role')
|
||||
@ -56,10 +56,10 @@ export class RoleEntity {
|
||||
isEditable: boolean;
|
||||
|
||||
@OneToMany(
|
||||
() => UserWorkspaceRoleEntity,
|
||||
(userWorkspaceRole: UserWorkspaceRoleEntity) => userWorkspaceRole.role,
|
||||
() => RoleTargetsEntity,
|
||||
(roleTargets: RoleTargetsEntity) => roleTargets.role,
|
||||
)
|
||||
userWorkspaceRoles: Relation<UserWorkspaceRoleEntity[]>;
|
||||
roleTargets: Relation<RoleTargetsEntity[]>;
|
||||
|
||||
@OneToMany(
|
||||
() => ObjectPermissionEntity,
|
||||
|
||||
@ -5,8 +5,10 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
|
||||
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { RoleResolver } from 'src/engine/metadata-modules/role/role.resolver';
|
||||
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
|
||||
@ -16,9 +18,10 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity], 'core'),
|
||||
TypeOrmModule.forFeature([RoleEntity, RoleTargetsEntity], 'core'),
|
||||
TypeOrmModule.forFeature([UserWorkspace, Workspace], 'core'),
|
||||
UserRoleModule,
|
||||
AgentRoleModule,
|
||||
PermissionsModule,
|
||||
UserWorkspaceModule,
|
||||
ObjectPermissionModule,
|
||||
|
||||
@ -8,6 +8,8 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
@ -15,9 +17,11 @@ import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-mem
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
|
||||
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
|
||||
import { UpsertObjectPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-object-permissions.input';
|
||||
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
|
||||
@ -55,6 +59,7 @@ export class RoleResolver {
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly objectPermissionService: ObjectPermissionService,
|
||||
private readonly settingPermissionService: SettingPermissionService,
|
||||
private readonly agentRoleService: AgentRoleService,
|
||||
) {}
|
||||
|
||||
@Query(() => [RoleDTO])
|
||||
@ -174,6 +179,36 @@ export class RoleResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async assignRoleToAgent(
|
||||
@Args('agentId', { type: () => UUIDScalarType }) agentId: string,
|
||||
@Args('roleId', { type: () => UUIDScalarType }) roleId: string,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
await this.agentRoleService.assignRoleToAgent({
|
||||
agentId,
|
||||
roleId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async removeRoleFromAgent(
|
||||
@Args('agentId', { type: () => UUIDScalarType }) agentId: string,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
await this.agentRoleService.removeRoleFromAgent({
|
||||
agentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ResolveField('workspaceMembers', () => [WorkspaceMember])
|
||||
async getWorkspaceMembersAssignedToRole(
|
||||
@Parent() role: RoleDTO,
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
UpdateRoleInput,
|
||||
UpdateRolePayload,
|
||||
} from 'src/engine/metadata-modules/role/dtos/update-role-input.dto';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { isArgDefinedIfProvidedOrThrow } from 'src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util';
|
||||
@ -27,6 +28,8 @@ export class RoleService {
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
@InjectRepository(RoleTargetsEntity, 'core')
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
) {}
|
||||
@ -36,11 +39,7 @@ export class RoleService {
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
relations: [
|
||||
'userWorkspaceRoles',
|
||||
'settingPermissions',
|
||||
'objectPermissions',
|
||||
],
|
||||
relations: ['roleTargets', 'settingPermissions', 'objectPermissions'],
|
||||
});
|
||||
}
|
||||
|
||||
@ -53,7 +52,7 @@ export class RoleService {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
relations: ['userWorkspaceRoles', 'settingPermissions'],
|
||||
relations: ['roleTargets', 'settingPermissions'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,31 +1,19 @@
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
export const fromRoleEntityToRoleDto = ({
|
||||
id,
|
||||
label,
|
||||
canUpdateAllSettings,
|
||||
description,
|
||||
icon,
|
||||
isEditable,
|
||||
userWorkspaceRoles,
|
||||
canReadAllObjectRecords,
|
||||
canUpdateAllObjectRecords,
|
||||
canSoftDeleteAllObjectRecords,
|
||||
canDestroyAllObjectRecords,
|
||||
}: RoleEntity): RoleDTO => {
|
||||
export const fromRoleEntityToRoleDto = (role: RoleEntity): RoleDTO => {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
canUpdateAllSettings,
|
||||
description,
|
||||
icon,
|
||||
isEditable,
|
||||
userWorkspaceRoles,
|
||||
canReadAllObjectRecords,
|
||||
canUpdateAllObjectRecords,
|
||||
canSoftDeleteAllObjectRecords,
|
||||
canDestroyAllObjectRecords,
|
||||
id: role.id,
|
||||
label: role.label,
|
||||
canUpdateAllSettings: role.canUpdateAllSettings,
|
||||
description: role.description,
|
||||
icon: role.icon,
|
||||
isEditable: role.isEditable,
|
||||
canReadAllObjectRecords: role.canReadAllObjectRecords,
|
||||
canUpdateAllObjectRecords: role.canUpdateAllObjectRecords,
|
||||
canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords,
|
||||
canDestroyAllObjectRecords: role.canDestroyAllObjectRecords,
|
||||
roleTargets: role.roleTargets,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -2,14 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'core'),
|
||||
TypeOrmModule.forFeature([RoleEntity, RoleTargetsEntity], 'core'),
|
||||
TypeOrmModule.forFeature([UserWorkspace], 'core'),
|
||||
WorkspacePermissionsCacheModule,
|
||||
],
|
||||
|
||||
@ -10,8 +10,8 @@ import {
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
@ -20,8 +20,8 @@ export class UserRoleService {
|
||||
constructor(
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
@InjectRepository(UserWorkspaceRoleEntity, 'core')
|
||||
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
|
||||
@InjectRepository(RoleTargetsEntity, 'core')
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
@ -47,16 +47,16 @@ export class UserRoleService {
|
||||
return;
|
||||
}
|
||||
|
||||
const newUserWorkspaceRole = await this.userWorkspaceRoleRepository.save({
|
||||
const newRoleTarget = await this.roleTargetsRepository.save({
|
||||
roleId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.userWorkspaceRoleRepository.delete({
|
||||
await this.roleTargetsRepository.delete({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
id: Not(newUserWorkspaceRole.id),
|
||||
id: Not(newRoleTarget.id),
|
||||
});
|
||||
|
||||
await this.workspacePermissionsCacheService.recomputeUserWorkspaceRoleMapCache(
|
||||
@ -98,7 +98,7 @@ export class UserRoleService {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const allUserWorkspaceRoles = await this.userWorkspaceRoleRepository.find({
|
||||
const allRoleTargets = await this.roleTargetsRepository.find({
|
||||
where: {
|
||||
userWorkspaceId: In(userWorkspaceIds),
|
||||
workspaceId,
|
||||
@ -110,20 +110,19 @@ export class UserRoleService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!allUserWorkspaceRoles.length) {
|
||||
if (!allRoleTargets.length) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rolesMap = new Map<string, RoleEntity[]>();
|
||||
|
||||
for (const userWorkspaceId of userWorkspaceIds) {
|
||||
const userWorkspaceRolesOfUserWorkspace = allUserWorkspaceRoles.filter(
|
||||
(userWorkspaceRole) =>
|
||||
userWorkspaceRole.userWorkspaceId === userWorkspaceId,
|
||||
const roleTargetsOfUserWorkspace = allRoleTargets.filter(
|
||||
(roleTarget) => roleTarget.userWorkspaceId === userWorkspaceId,
|
||||
);
|
||||
|
||||
const rolesOfUserWorkspace = userWorkspaceRolesOfUserWorkspace
|
||||
.map((userWorkspaceRole) => userWorkspaceRole.role)
|
||||
const rolesOfUserWorkspace = roleTargetsOfUserWorkspace
|
||||
.map((roleTarget) => roleTarget.role)
|
||||
.filter(isDefined);
|
||||
|
||||
rolesMap.set(userWorkspaceId, rolesOfUserWorkspace);
|
||||
|
||||
@ -3,8 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
|
||||
import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
@ -15,7 +15,7 @@ import { WorkspacePermissionsCacheService } from './workspace-permissions-cache.
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Workspace], 'core'),
|
||||
TypeOrmModule.forFeature(
|
||||
[ObjectMetadataEntity, RoleEntity, UserWorkspaceRoleEntity],
|
||||
[ObjectMetadataEntity, RoleEntity, RoleTargetsEntity],
|
||||
'core',
|
||||
),
|
||||
WorkspaceCacheStorageModule,
|
||||
|
||||
@ -10,8 +10,8 @@ import { In, Repository } from 'typeorm';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type';
|
||||
import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service';
|
||||
import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
|
||||
@ -35,8 +35,8 @@ export class WorkspacePermissionsCacheService {
|
||||
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
@InjectRepository(UserWorkspaceRoleEntity, 'core')
|
||||
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
|
||||
@InjectRepository(RoleTargetsEntity, 'core')
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
private readonly workspacePermissionsCacheStorageService: WorkspacePermissionsCacheStorageService,
|
||||
) {}
|
||||
|
||||
@ -264,14 +264,14 @@ export class WorkspacePermissionsCacheService {
|
||||
}: {
|
||||
workspaceId: string;
|
||||
}): Promise<UserWorkspaceRoleMap> {
|
||||
const userWorkspaceRoleMap = await this.userWorkspaceRoleRepository.find({
|
||||
const roleTargetsMap = await this.roleTargetsRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return userWorkspaceRoleMap.reduce((acc, userWorkspaceRole) => {
|
||||
acc[userWorkspaceRole.userWorkspaceId] = userWorkspaceRole.roleId;
|
||||
return roleTargetsMap.reduce((acc, roleTarget) => {
|
||||
acc[roleTarget.userWorkspaceId] = roleTarget.roleId;
|
||||
|
||||
return acc;
|
||||
}, {} as UserWorkspaceRoleMap);
|
||||
|
||||
@ -17,6 +17,7 @@ export class TwentyORMGlobalManager {
|
||||
workspaceEntity: Type<T>,
|
||||
options?: {
|
||||
shouldBypassPermissionChecks?: boolean;
|
||||
roleId?: string;
|
||||
},
|
||||
): Promise<WorkspaceRepository<T>>;
|
||||
|
||||
@ -25,6 +26,7 @@ export class TwentyORMGlobalManager {
|
||||
objectMetadataName: string,
|
||||
options?: {
|
||||
shouldBypassPermissionChecks?: boolean;
|
||||
roleId?: string;
|
||||
},
|
||||
): Promise<WorkspaceRepository<T>>;
|
||||
|
||||
@ -33,6 +35,7 @@ export class TwentyORMGlobalManager {
|
||||
workspaceEntityOrObjectMetadataName: Type<T> | string,
|
||||
options: {
|
||||
shouldBypassPermissionChecks?: boolean;
|
||||
roleId?: string;
|
||||
} = {
|
||||
shouldBypassPermissionChecks: false,
|
||||
},
|
||||
@ -53,6 +56,7 @@ export class TwentyORMGlobalManager {
|
||||
const repository = workspaceDataSource.getRepository<T>(
|
||||
objectMetadataName,
|
||||
options.shouldBypassPermissionChecks,
|
||||
options.roleId,
|
||||
);
|
||||
|
||||
return repository;
|
||||
|
||||
@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ObjectLiteral, Repository } from 'typeorm';
|
||||
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
|
||||
import { WorkspaceDatasourceFactory } from 'src/engine/twenty-orm/factories/workspace-datasource.factory';
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
@ -13,8 +13,8 @@ import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manag
|
||||
@Injectable()
|
||||
export class TwentyORMManager {
|
||||
constructor(
|
||||
@InjectRepository(UserWorkspaceRoleEntity, 'core')
|
||||
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
|
||||
@InjectRepository(RoleTargetsEntity, 'core')
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
private readonly workspaceDataSourceFactory: WorkspaceDatasourceFactory,
|
||||
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
|
||||
) {}
|
||||
@ -53,14 +53,14 @@ export class TwentyORMManager {
|
||||
let roleId: string | undefined;
|
||||
|
||||
if (isDefined(userWorkspaceId)) {
|
||||
const userWorkspaceRole = await this.userWorkspaceRoleRepository.findOne({
|
||||
const roleTarget = await this.roleTargetsRepository.findOne({
|
||||
where: {
|
||||
userWorkspaceId,
|
||||
workspaceId: workspaceId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
roleId = userWorkspaceRole?.roleId;
|
||||
roleId = roleTarget?.roleId;
|
||||
}
|
||||
|
||||
const shouldBypassPermissionChecks = !!isExecutedByApiKey;
|
||||
|
||||
@ -7,7 +7,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.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';
|
||||
@ -23,7 +23,7 @@ import { PgPoolSharedModule } from './pg-shared-pool/pg-shared-pool.module';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature(
|
||||
[ObjectMetadataEntity, UserWorkspaceRoleEntity, Workspace],
|
||||
[ObjectMetadataEntity, RoleTargetsEntity, Workspace],
|
||||
'core',
|
||||
),
|
||||
DataSourceModule,
|
||||
|
||||
@ -0,0 +1,343 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FieldMetadataSettings,
|
||||
NumberDataType,
|
||||
} 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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
export type SchemaObject = {
|
||||
type: string;
|
||||
format?: string;
|
||||
enum?: string[];
|
||||
items?: SchemaObject;
|
||||
properties?: Record<string, SchemaObject>;
|
||||
description?: string;
|
||||
[key: string]:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| string[]
|
||||
| SchemaObject
|
||||
| Record<string, SchemaObject>
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
|
||||
if (forResponse) {
|
||||
return true;
|
||||
}
|
||||
switch (field.name) {
|
||||
case 'id':
|
||||
case 'createdAt':
|
||||
case 'updatedAt':
|
||||
case 'deletedAt':
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldProperties = (field: FieldMetadataEntity): SchemaObject => {
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.UUID: {
|
||||
return { type: 'string', format: 'uuid' };
|
||||
}
|
||||
case FieldMetadataType.TEXT:
|
||||
case FieldMetadataType.RICH_TEXT: {
|
||||
return { type: 'string' };
|
||||
}
|
||||
case FieldMetadataType.DATE_TIME: {
|
||||
return { type: 'string', format: 'date-time' };
|
||||
}
|
||||
case FieldMetadataType.DATE: {
|
||||
return { type: 'string', format: 'date' };
|
||||
}
|
||||
case FieldMetadataType.NUMBER: {
|
||||
const settings =
|
||||
field.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>;
|
||||
|
||||
if (
|
||||
settings?.dataType === NumberDataType.FLOAT ||
|
||||
(isDefined(settings?.decimals) && settings.decimals > 0)
|
||||
) {
|
||||
return { type: 'number' };
|
||||
}
|
||||
|
||||
return { type: 'integer' };
|
||||
}
|
||||
case FieldMetadataType.NUMERIC:
|
||||
case FieldMetadataType.POSITION: {
|
||||
return { type: 'number' };
|
||||
}
|
||||
case FieldMetadataType.BOOLEAN: {
|
||||
return { type: 'boolean' };
|
||||
}
|
||||
case FieldMetadataType.RAW_JSON: {
|
||||
return { type: 'object' };
|
||||
}
|
||||
default: {
|
||||
return { type: 'string' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const convertObjectMetadataToSchemaProperties = ({
|
||||
item,
|
||||
forResponse,
|
||||
}: {
|
||||
item: ObjectMetadataEntity;
|
||||
forResponse: boolean;
|
||||
}) => {
|
||||
return item.fields.reduce((node, field) => {
|
||||
if (
|
||||
!isFieldAvailable(field, forResponse) ||
|
||||
field.type === FieldMetadataType.TS_VECTOR
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
field.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
) {
|
||||
return {
|
||||
...node,
|
||||
[`${field.name}Id`]: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
field.settings?.relationType === RelationType.ONE_TO_MANY
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
|
||||
let itemProperty = {} as SchemaObject;
|
||||
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.MULTI_SELECT:
|
||||
itemProperty = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: field.options.map(
|
||||
(option: { value: string }) => option.value,
|
||||
),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.SELECT:
|
||||
itemProperty = {
|
||||
type: 'string',
|
||||
enum: field.options.map((option: { value: string }) => option.value),
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.ARRAY:
|
||||
itemProperty = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.RATING:
|
||||
itemProperty = {
|
||||
type: 'string',
|
||||
enum: field.options.map((option: { value: string }) => option.value),
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.LINKS:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
primaryLinkLabel: {
|
||||
type: 'string',
|
||||
},
|
||||
primaryLinkUrl: {
|
||||
type: 'string',
|
||||
},
|
||||
secondaryLinks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A secondary link',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
format: 'uri',
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.CURRENCY:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
amountMicros: {
|
||||
type: 'number',
|
||||
},
|
||||
currencyCode: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.FULL_NAME:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
firstName: {
|
||||
type: 'string',
|
||||
},
|
||||
lastName: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.ADDRESS:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
addressStreet1: {
|
||||
type: 'string',
|
||||
},
|
||||
addressStreet2: {
|
||||
type: 'string',
|
||||
},
|
||||
addressCity: {
|
||||
type: 'string',
|
||||
},
|
||||
addressPostcode: {
|
||||
type: 'string',
|
||||
},
|
||||
addressState: {
|
||||
type: 'string',
|
||||
},
|
||||
addressCountry: {
|
||||
type: 'string',
|
||||
},
|
||||
addressLat: {
|
||||
type: 'number',
|
||||
},
|
||||
addressLng: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.ACTOR:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'EMAIL',
|
||||
'CALENDAR',
|
||||
'WORKFLOW',
|
||||
'API',
|
||||
'IMPORT',
|
||||
'MANUAL',
|
||||
'SYSTEM',
|
||||
'WEBHOOK',
|
||||
],
|
||||
},
|
||||
...(forResponse
|
||||
? {
|
||||
workspaceMemberId: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.EMAILS:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
primaryEmail: {
|
||||
type: 'string',
|
||||
},
|
||||
additionalEmails: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.PHONES:
|
||||
itemProperty = {
|
||||
properties: {
|
||||
additionalPhones: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
primaryPhoneCountryCode: {
|
||||
type: 'string',
|
||||
},
|
||||
primaryPhoneCallingCode: {
|
||||
type: 'string',
|
||||
},
|
||||
primaryPhoneNumber: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.RICH_TEXT_V2:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
blocknote: {
|
||||
type: 'string',
|
||||
},
|
||||
markdown: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
itemProperty = getFieldProperties(field);
|
||||
break;
|
||||
}
|
||||
|
||||
if (field.description) {
|
||||
itemProperty.description = field.description;
|
||||
}
|
||||
|
||||
if (Object.keys(itemProperty).length) {
|
||||
return { ...node, [field.name]: itemProperty };
|
||||
}
|
||||
|
||||
return node;
|
||||
}, {});
|
||||
};
|
||||
@ -12,9 +12,9 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
|
||||
@ -29,7 +29,7 @@ describe('WorkspaceManagerService', () => {
|
||||
let dataSourceRepository: Repository<DataSourceEntity>;
|
||||
let workspaceFieldMetadataRepository: Repository<FieldMetadataEntity>;
|
||||
let workspaceDataSourceService: WorkspaceDataSourceService;
|
||||
let userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>;
|
||||
let roleTargetsRepository: Repository<RoleTargetsEntity>;
|
||||
let roleRepository: Repository<RoleEntity>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -71,7 +71,7 @@ describe('WorkspaceManagerService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspaceRoleEntity, 'core'),
|
||||
provide: getRepositoryToken(RoleTargetsEntity, 'core'),
|
||||
useValue: {
|
||||
delete: jest.fn(),
|
||||
},
|
||||
@ -134,9 +134,9 @@ describe('WorkspaceManagerService', () => {
|
||||
workspaceDataSourceService = module.get<WorkspaceDataSourceService>(
|
||||
WorkspaceDataSourceService,
|
||||
);
|
||||
userWorkspaceRoleRepository = module.get<
|
||||
Repository<UserWorkspaceRoleEntity>
|
||||
>(getRepositoryToken(UserWorkspaceRoleEntity, 'core'));
|
||||
roleTargetsRepository = module.get<Repository<RoleTargetsEntity>>(
|
||||
getRepositoryToken(RoleTargetsEntity, 'core'),
|
||||
);
|
||||
roleRepository = module.get<Repository<RoleEntity>>(
|
||||
getRepositoryToken(RoleEntity, 'core'),
|
||||
);
|
||||
@ -159,7 +159,7 @@ describe('WorkspaceManagerService', () => {
|
||||
expect(dataSourceRepository.delete).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
expect(userWorkspaceRoleRepository.delete).toHaveBeenCalledWith({
|
||||
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
expect(roleRepository.delete).toHaveBeenCalledWith({
|
||||
|
||||
@ -8,9 +8,9 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
@ -35,7 +35,7 @@ import { WorkspaceManagerService } from './workspace-manager.service';
|
||||
RoleModule,
|
||||
UserRoleModule,
|
||||
TypeOrmModule.forFeature(
|
||||
[FieldMetadataEntity, UserWorkspaceRoleEntity, RoleEntity],
|
||||
[FieldMetadataEntity, RoleTargetsEntity, RoleEntity],
|
||||
'core',
|
||||
),
|
||||
],
|
||||
|
||||
@ -10,9 +10,9 @@ import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-s
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
|
||||
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
|
||||
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
|
||||
@ -38,10 +38,10 @@ export class WorkspaceManagerService {
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(UserWorkspaceRoleEntity, 'core')
|
||||
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
@InjectRepository(RoleTargetsEntity, 'core')
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
) {}
|
||||
|
||||
public async init({
|
||||
@ -139,10 +139,10 @@ export class WorkspaceManagerService {
|
||||
});
|
||||
this.logger.log(`workspace ${workspaceId} field metadata deleted`);
|
||||
|
||||
await this.userWorkspaceRoleRepository.delete({
|
||||
await this.roleTargetsRepository.delete({
|
||||
workspaceId,
|
||||
});
|
||||
this.logger.log(`workspace ${workspaceId} user workspace role deleted`);
|
||||
this.logger.log(`workspace ${workspaceId} role targets deleted`);
|
||||
|
||||
await this.roleRepository.delete({
|
||||
workspaceId,
|
||||
|
||||
@ -69,7 +69,7 @@ export class AiAgentWorkflowAction implements WorkflowExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
const executionResult = await this.agentExecutionService.executeAgent({
|
||||
const { result, usage } = await this.agentExecutionService.executeAgent({
|
||||
agent,
|
||||
context,
|
||||
schema: step.settings.outputSchema,
|
||||
@ -77,11 +77,13 @@ export class AiAgentWorkflowAction implements WorkflowExecutor {
|
||||
|
||||
await this.aiBillingService.calculateAndBillUsage(
|
||||
agent.modelId,
|
||||
executionResult.usage,
|
||||
usage,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return { result: executionResult.object };
|
||||
return {
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AgentException) {
|
||||
return {
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
export const AGENT_GQL_FIELDS = `
|
||||
id
|
||||
name
|
||||
description
|
||||
prompt
|
||||
modelId
|
||||
responseFormat
|
||||
roleId
|
||||
createdAt
|
||||
updatedAt
|
||||
`;
|
||||
|
||||
export const AGENT_TOOL_GQL_FIELDS = `
|
||||
id
|
||||
name
|
||||
description
|
||||
type
|
||||
config
|
||||
createdAt
|
||||
updatedAt
|
||||
`;
|
||||
@ -0,0 +1,251 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
|
||||
import { createAgentOperation } from 'test/integration/graphql/utils/create-agent-operation-factory.util';
|
||||
import { deleteAgentOperation } from 'test/integration/graphql/utils/delete-agent-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
import { updateAgentOperation } from 'test/integration/graphql/utils/update-agent-operation-factory.util';
|
||||
|
||||
describe('agentResolver', () => {
|
||||
describe('createOneAgent', () => {
|
||||
it('should create an agent successfully', async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: 'Test AI Agent Admin',
|
||||
description: 'A test AI agent created by admin',
|
||||
prompt: 'You are a helpful AI assistant for testing.',
|
||||
modelId: 'gpt-4o',
|
||||
responseFormat: { type: 'json_object' },
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.createOneAgent).toBeDefined();
|
||||
expect(response.body.data.createOneAgent.id).toBeDefined();
|
||||
expect(response.body.data.createOneAgent.name).toBe(
|
||||
'Test AI Agent Admin',
|
||||
);
|
||||
expect(response.body.data.createOneAgent.description).toBe(
|
||||
'A test AI agent created by admin',
|
||||
);
|
||||
expect(response.body.data.createOneAgent.prompt).toBe(
|
||||
'You are a helpful AI assistant for testing.',
|
||||
);
|
||||
expect(response.body.data.createOneAgent.modelId).toBe('gpt-4o');
|
||||
expect(response.body.data.createOneAgent.responseFormat).toEqual({
|
||||
type: 'json_object',
|
||||
});
|
||||
await makeGraphqlAPIRequest(
|
||||
deleteAgentOperation(response.body.data.createOneAgent.id),
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate required fields and return error', async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: undefined as any,
|
||||
description: 'Agent without required fields',
|
||||
prompt: undefined as any,
|
||||
modelId: undefined as any,
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toContain(
|
||||
'Field "name" of required type "String!" was not provided',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneAgent', () => {
|
||||
let testAgentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: 'Test Agent for Find',
|
||||
description: 'A test agent for find operations',
|
||||
prompt: 'You are a test agent for finding.',
|
||||
modelId: 'gpt-4o',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
testAgentId = response.body.data.createOneAgent.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
|
||||
});
|
||||
it('should find agent by ID successfully', async () => {
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query FindOneAgent($input: AgentIdInput!) {
|
||||
findOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { id: testAgentId } },
|
||||
};
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(response.body.data.findOneAgent).toBeDefined();
|
||||
expect(response.body.data.findOneAgent.id).toBe(testAgentId);
|
||||
expect(response.body.data.findOneAgent.name).toBe('Test Agent for Find');
|
||||
});
|
||||
it('should return 404 error for non-existent agent', async () => {
|
||||
const nonExistentId = '00000000-0000-0000-0000-000000000000';
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query FindOneAgent($input: AgentIdInput!) {
|
||||
findOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { id: nonExistentId } },
|
||||
};
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findManyAgents', () => {
|
||||
const testAgentIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const operation = createAgentOperation({
|
||||
name: `Test Agent ${i + 1}`,
|
||||
description: `A test agent ${i + 1} for find many operations`,
|
||||
prompt: `You are test agent ${i + 1}.`,
|
||||
modelId: 'gpt-4o',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
testAgentIds.push(response.body.data.createOneAgent.id);
|
||||
}
|
||||
});
|
||||
afterAll(async () => {
|
||||
for (const agentId of testAgentIds) {
|
||||
await makeGraphqlAPIRequest(deleteAgentOperation(agentId));
|
||||
}
|
||||
});
|
||||
it('should find all agents successfully', async () => {
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query FindManyAgents {
|
||||
findManyAgents {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(response.body.data.findManyAgents).toBeDefined();
|
||||
expect(Array.isArray(response.body.data.findManyAgents)).toBe(true);
|
||||
expect(response.body.data.findManyAgents.length).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
);
|
||||
const testAgentNames = response.body.data.findManyAgents
|
||||
.filter((agent: any) => testAgentIds.includes(agent.id))
|
||||
.map((agent: any) => agent.name);
|
||||
|
||||
expect(testAgentNames).toContain('Test Agent 1');
|
||||
expect(testAgentNames).toContain('Test Agent 2');
|
||||
expect(testAgentNames).toContain('Test Agent 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOneAgent', () => {
|
||||
let testAgentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: 'Original Test Agent',
|
||||
description: 'Original description',
|
||||
prompt: 'Original prompt',
|
||||
modelId: 'gpt-4o',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
testAgentId = response.body.data.createOneAgent.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
|
||||
});
|
||||
it('should update an agent successfully', async () => {
|
||||
const operation = updateAgentOperation({
|
||||
id: testAgentId,
|
||||
name: 'Updated Test Agent Admin',
|
||||
description: 'Updated description',
|
||||
prompt: 'Updated prompt for admin',
|
||||
modelId: 'gpt-4o-mini',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.updateOneAgent).toBeDefined();
|
||||
expect(response.body.data.updateOneAgent.id).toBe(testAgentId);
|
||||
expect(response.body.data.updateOneAgent.name).toBe(
|
||||
'Updated Test Agent Admin',
|
||||
);
|
||||
expect(response.body.data.updateOneAgent.description).toBe(
|
||||
'Updated description',
|
||||
);
|
||||
expect(response.body.data.updateOneAgent.prompt).toBe(
|
||||
'Updated prompt for admin',
|
||||
);
|
||||
expect(response.body.data.updateOneAgent.modelId).toBe('gpt-4o-mini');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOneAgent', () => {
|
||||
let testAgentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: 'Agent to Delete',
|
||||
description: 'This agent will be deleted',
|
||||
prompt: 'You are an agent that will be deleted.',
|
||||
modelId: 'gpt-4o',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
testAgentId = response.body.data.createOneAgent.id;
|
||||
});
|
||||
it('should delete an agent successfully', async () => {
|
||||
const operation = deleteAgentOperation(testAgentId);
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.deleteOneAgent).toBeDefined();
|
||||
expect(response.body.data.deleteOneAgent.id).toBe(testAgentId);
|
||||
expect(response.body.data.deleteOneAgent.name).toBe('Agent to Delete');
|
||||
const findQueryData = {
|
||||
query: gql`
|
||||
query FindOneAgent($input: AgentIdInput!) {
|
||||
findOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { id: testAgentId } },
|
||||
};
|
||||
const findResponse = await makeGraphqlAPIRequest(findQueryData);
|
||||
|
||||
expect(findResponse.body.errors).toBeDefined();
|
||||
expect(findResponse.body.errors[0].message).toContain('not found');
|
||||
});
|
||||
it('should return 404 error for non-existent agent', async () => {
|
||||
const nonExistentId = '00000000-0000-0000-0000-000000000000';
|
||||
const operation = deleteAgentOperation(nonExistentId);
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toContain('not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
|
||||
|
||||
export const createAgentOperation = ({
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
modelId,
|
||||
responseFormat,
|
||||
}: {
|
||||
name: string;
|
||||
description?: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
responseFormat?: object;
|
||||
}) => ({
|
||||
query: gql`
|
||||
mutation CreateOneAgent($input: CreateAgentInput!) {
|
||||
createOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
modelId,
|
||||
responseFormat,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,15 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
|
||||
|
||||
export const deleteAgentOperation = (id: string) => ({
|
||||
query: gql`
|
||||
mutation DeleteOneAgent($input: AgentIdInput!) {
|
||||
deleteOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: { id },
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
|
||||
|
||||
export const updateAgentOperation = ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
modelId,
|
||||
responseFormat,
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
modelId?: string;
|
||||
responseFormat?: object;
|
||||
}) => ({
|
||||
query: gql`
|
||||
mutation UpdateOneAgent($input: UpdateAgentInput!) {
|
||||
updateOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
id,
|
||||
...(name && { name }),
|
||||
...(description && { description }),
|
||||
...(prompt && { prompt }),
|
||||
...(modelId && { modelId }),
|
||||
...(responseFormat && { responseFormat }),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,954 @@
|
||||
import {
|
||||
AgentToolTestContext,
|
||||
createAgentToolTestModule,
|
||||
createMockRepository,
|
||||
createTestRecord,
|
||||
createTestRecords,
|
||||
expectErrorResult,
|
||||
expectSuccessResult,
|
||||
setupBasicPermissions,
|
||||
setupRepositoryMock,
|
||||
} from './utils/agent-tool-test-utils';
|
||||
|
||||
describe('AgentToolService Integration', () => {
|
||||
let context: AgentToolTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
context = await createAgentToolTestModule();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.module.close();
|
||||
});
|
||||
|
||||
describe('Tool Generation', () => {
|
||||
it('should generate complete tool set for agent with full permissions', async () => {
|
||||
const roleWithFullPermissions = {
|
||||
...context.testRole,
|
||||
canDestroyAllObjectRecords: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(roleWithFullPermissions);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[context.testObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: true,
|
||||
canDestroy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([context.testObjectMetadata]);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
|
||||
expect(tools).toBeDefined();
|
||||
expect(Object.keys(tools)).toHaveLength(8);
|
||||
expect(Object.keys(tools)).toContain('create_testObject');
|
||||
expect(Object.keys(tools)).toContain('update_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_one_testObject');
|
||||
expect(Object.keys(tools)).toContain('soft_delete_testObject');
|
||||
expect(Object.keys(tools)).toContain('soft_delete_many_testObject');
|
||||
expect(Object.keys(tools)).toContain('destroy_testObject');
|
||||
expect(Object.keys(tools)).toContain('destroy_many_testObject');
|
||||
});
|
||||
|
||||
it('should generate read-only tools for agent with read permissions only', async () => {
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(context.testRole);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[context.testObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: false,
|
||||
canSoftDelete: false,
|
||||
canDestroy: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([context.testObjectMetadata]);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
|
||||
expect(tools).toBeDefined();
|
||||
expect(Object.keys(tools)).toHaveLength(2);
|
||||
expect(Object.keys(tools)).toContain('find_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_one_testObject');
|
||||
expect(Object.keys(tools)).not.toContain('create_testObject');
|
||||
expect(Object.keys(tools)).not.toContain('update_testObject');
|
||||
});
|
||||
|
||||
it('should return empty tools for agent without role', async () => {
|
||||
const agentWithoutRole = { ...context.testAgent, roleId: null };
|
||||
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(agentWithoutRole as any);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
|
||||
expect(tools).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty tools when role does not exist', async () => {
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest.spyOn(context.roleRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
|
||||
expect(tools).toEqual({});
|
||||
});
|
||||
|
||||
it('should filter out workflow-related objects', async () => {
|
||||
const workflowObject = {
|
||||
...context.testObjectMetadata,
|
||||
nameSingular: 'workflow',
|
||||
namePlural: 'workflows',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(context.testRole);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[workflowObject.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: true,
|
||||
canDestroy: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([workflowObject]);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
|
||||
expect(tools).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create Record Operations', () => {
|
||||
it('should create a record successfully', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
const testRecord = createTestRecord('test-record-id', {
|
||||
name: 'Test Record',
|
||||
description: 'Test description',
|
||||
});
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.save.mockResolvedValue(testRecord);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const createTool = tools['create_testObject'];
|
||||
|
||||
expect(createTool).toBeDefined();
|
||||
|
||||
if (!createTool.execute) {
|
||||
throw new Error(
|
||||
'Create tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createTool.execute(
|
||||
{ name: 'Test Record', description: 'Test description' },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Test Record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Successfully created testObject');
|
||||
expect(result.record).toEqual(testRecord);
|
||||
expect(mockRepository.save).toHaveBeenCalledWith({
|
||||
name: 'Test Record',
|
||||
description: 'Test description',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle create record errors gracefully', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.save.mockRejectedValue(
|
||||
new Error('Database constraint violation'),
|
||||
);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const createTool = tools['create_testObject'];
|
||||
|
||||
expect(createTool).toBeDefined();
|
||||
|
||||
if (!createTool.execute) {
|
||||
throw new Error(
|
||||
'Create tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createTool.execute(
|
||||
{ name: 'Test Record' },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Test Record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectErrorResult(
|
||||
result,
|
||||
'Database constraint violation',
|
||||
'Failed to create testObject',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Find Record Operations', () => {
|
||||
it('should find records with basic parameters', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
const testRecords = createTestRecords(3);
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.find.mockResolvedValue(testRecords);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const findTool = tools['find_testObject'];
|
||||
|
||||
expect(findTool).toBeDefined();
|
||||
|
||||
if (!findTool.execute) {
|
||||
throw new Error(
|
||||
'Find tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await findTool.execute(
|
||||
{ limit: 10, offset: 0 },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Find records',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Found 3 testObject records');
|
||||
expect(result.records).toEqual(testRecords);
|
||||
expect(result.count).toBe(3);
|
||||
expect(mockRepository.find).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
take: 10,
|
||||
skip: 0,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should find one record by ID', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
const testRecord = createTestRecord('test-record-id', {
|
||||
name: 'Test Record',
|
||||
});
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.findOne.mockResolvedValue(testRecord);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const findOneTool = tools['find_one_testObject'];
|
||||
|
||||
expect(findOneTool).toBeDefined();
|
||||
|
||||
if (!findOneTool.execute) {
|
||||
throw new Error(
|
||||
'Find one tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await findOneTool.execute(
|
||||
{ id: 'test-record-id' },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Find one record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Found testObject record');
|
||||
expect(result.record).toEqual(testRecord);
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'test-record-id' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle find one record not found', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const findOneTool = tools['find_one_testObject'];
|
||||
|
||||
expect(findOneTool).toBeDefined();
|
||||
|
||||
if (!findOneTool.execute) {
|
||||
throw new Error(
|
||||
'Find one tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await findOneTool.execute(
|
||||
{ id: 'non-existent-id' },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Find one record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectErrorResult(
|
||||
result,
|
||||
'Record not found',
|
||||
'Failed to find testObject: Record with ID non-existent-id not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle find one record without ID', async () => {
|
||||
setupBasicPermissions(context);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const findOneTool = tools['find_one_testObject'];
|
||||
|
||||
expect(findOneTool).toBeDefined();
|
||||
|
||||
if (!findOneTool.execute) {
|
||||
throw new Error(
|
||||
'Find one tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await findOneTool.execute(
|
||||
{},
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Find one record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectErrorResult(
|
||||
result,
|
||||
'Record ID is required',
|
||||
'Failed to find testObject: Record ID is required',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Record Operations', () => {
|
||||
it('should update a record successfully', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
const existingRecord = createTestRecord('test-record-id', {
|
||||
name: 'Old Name',
|
||||
description: 'Old description',
|
||||
});
|
||||
const updatedRecord = createTestRecord('test-record-id', {
|
||||
name: 'New Name',
|
||||
description: 'New description',
|
||||
});
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(context.testObjectMetadata);
|
||||
mockRepository.findOne
|
||||
.mockResolvedValueOnce(existingRecord)
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
mockRepository.update.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const updateTool = tools['update_testObject'];
|
||||
|
||||
expect(updateTool).toBeDefined();
|
||||
|
||||
if (!updateTool.execute) {
|
||||
throw new Error(
|
||||
'Update tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await updateTool.execute(
|
||||
{
|
||||
id: 'test-record-id',
|
||||
name: 'New Name',
|
||||
description: 'New description',
|
||||
},
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Update record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Successfully updated testObject');
|
||||
expect(result.record).toEqual(updatedRecord);
|
||||
expect(mockRepository.update).toHaveBeenCalledWith('test-record-id', {
|
||||
name: 'New Name',
|
||||
description: 'New description',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle update record not found', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const updateTool = tools['update_testObject'];
|
||||
|
||||
expect(updateTool).toBeDefined();
|
||||
|
||||
if (!updateTool.execute) {
|
||||
throw new Error(
|
||||
'Update tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await updateTool.execute(
|
||||
{
|
||||
id: 'non-existent-id',
|
||||
name: 'New Name',
|
||||
},
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Update record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectErrorResult(
|
||||
result,
|
||||
'Record not found',
|
||||
'Failed to update testObject: Record with ID non-existent-id not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Soft Delete Operations', () => {
|
||||
it('should soft delete a single record', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
const existingRecord = createTestRecord('test-record-id', {
|
||||
name: 'Test Record',
|
||||
});
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.findOne.mockResolvedValue(existingRecord);
|
||||
mockRepository.softDelete.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const softDeleteTool = tools['soft_delete_testObject'];
|
||||
|
||||
expect(softDeleteTool).toBeDefined();
|
||||
|
||||
if (!softDeleteTool.execute) {
|
||||
throw new Error(
|
||||
'Soft delete tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await softDeleteTool.execute(
|
||||
{ id: 'test-record-id' },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Soft delete record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Successfully soft deleted testObject');
|
||||
expect(mockRepository.softDelete).toHaveBeenCalledWith('test-record-id');
|
||||
});
|
||||
|
||||
it('should soft delete multiple records', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
const existingRecords = createTestRecords(3);
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.find.mockResolvedValue(existingRecords);
|
||||
mockRepository.softDelete.mockResolvedValue({ affected: 3 } as any);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const softDeleteManyTool = tools['soft_delete_many_testObject'];
|
||||
|
||||
expect(softDeleteManyTool).toBeDefined();
|
||||
|
||||
if (!softDeleteManyTool.execute) {
|
||||
throw new Error(
|
||||
'Soft delete many tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await softDeleteManyTool.execute(
|
||||
{
|
||||
filter: { id: { in: ['record-1', 'record-2', 'record-3'] } },
|
||||
},
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Soft delete many records',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(
|
||||
result,
|
||||
'Successfully soft deleted 3 testObject records',
|
||||
);
|
||||
expect(mockRepository.softDelete).toHaveBeenCalledWith({
|
||||
id: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destroy Operations', () => {
|
||||
it('should destroy a single record', async () => {
|
||||
const roleWithDestroyPermission = {
|
||||
...context.testRole,
|
||||
canDestroyAllObjectRecords: true,
|
||||
};
|
||||
const mockRepository = createMockRepository();
|
||||
const existingRecord = createTestRecord('test-record-id', {
|
||||
name: 'Test Record',
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(roleWithDestroyPermission);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[context.testObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: true,
|
||||
canDestroy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([context.testObjectMetadata]);
|
||||
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.findOne.mockResolvedValue(existingRecord);
|
||||
mockRepository.remove.mockResolvedValue(existingRecord);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const destroyTool = tools['destroy_testObject'];
|
||||
|
||||
expect(destroyTool).toBeDefined();
|
||||
|
||||
if (!destroyTool.execute) {
|
||||
throw new Error(
|
||||
'Destroy tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await destroyTool.execute(
|
||||
{ id: 'test-record-id' },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Destroy record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Successfully destroyed testObject');
|
||||
expect(mockRepository.remove).toHaveBeenCalledWith(existingRecord);
|
||||
});
|
||||
|
||||
it('should destroy multiple records', async () => {
|
||||
const roleWithDestroyPermission = {
|
||||
...context.testRole,
|
||||
canDestroyAllObjectRecords: true,
|
||||
};
|
||||
const mockRepository = createMockRepository();
|
||||
const existingRecords = createTestRecords(3);
|
||||
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(roleWithDestroyPermission);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[context.testObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: true,
|
||||
canDestroy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([context.testObjectMetadata]);
|
||||
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.find.mockResolvedValue(existingRecords);
|
||||
mockRepository.remove.mockResolvedValue(existingRecords);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const destroyManyTool = tools['destroy_many_testObject'];
|
||||
|
||||
expect(destroyManyTool).toBeDefined();
|
||||
|
||||
if (!destroyManyTool.execute) {
|
||||
throw new Error(
|
||||
'Destroy many tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await destroyManyTool.execute(
|
||||
{
|
||||
filter: { id: { in: ['record-1', 'record-2', 'record-3'] } },
|
||||
},
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Destroy many records',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(
|
||||
result,
|
||||
'Successfully destroyed 3 testObject records',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty search criteria in find records', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
const testRecords = createTestRecords(2);
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.find.mockResolvedValue(testRecords);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const findTool = tools['find_testObject'];
|
||||
|
||||
expect(findTool).toBeDefined();
|
||||
|
||||
if (!findTool.execute) {
|
||||
throw new Error(
|
||||
'Find tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await findTool.execute(
|
||||
{},
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Find records',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Found 2 testObject records');
|
||||
expect(mockRepository.find).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
take: 100,
|
||||
skip: 0,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null and undefined values in search criteria', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
const testRecords = createTestRecords(1);
|
||||
|
||||
setupBasicPermissions(context);
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.find.mockResolvedValue(testRecords);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const findTool = tools['find_testObject'];
|
||||
|
||||
expect(findTool).toBeDefined();
|
||||
|
||||
if (!findTool.execute) {
|
||||
throw new Error(
|
||||
'Find tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await findTool.execute(
|
||||
{
|
||||
name: null,
|
||||
description: undefined,
|
||||
status: '',
|
||||
validField: 'valid value',
|
||||
},
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Find records',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Found 1 testObject records');
|
||||
expect(mockRepository.find).toHaveBeenCalledWith({
|
||||
where: { validField: 'valid value' },
|
||||
take: 100,
|
||||
skip: 0,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple object metadata with different permissions', async () => {
|
||||
const secondObjectMetadata = {
|
||||
...context.testObjectMetadata,
|
||||
id: 'second-object-id',
|
||||
nameSingular: 'secondObject',
|
||||
namePlural: 'secondObjects',
|
||||
labelSingular: 'Second Object',
|
||||
labelPlural: 'Second Objects',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(context.testRole);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[context.testObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: false,
|
||||
canDestroy: false,
|
||||
},
|
||||
[secondObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: false,
|
||||
canSoftDelete: true,
|
||||
canDestroy: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([context.testObjectMetadata, secondObjectMetadata]);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
|
||||
expect(tools).toBeDefined();
|
||||
expect(Object.keys(tools)).toHaveLength(8);
|
||||
expect(Object.keys(tools)).toContain('create_testObject');
|
||||
expect(Object.keys(tools)).toContain('update_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_one_testObject');
|
||||
expect(Object.keys(tools)).toContain('soft_delete_secondObject');
|
||||
expect(Object.keys(tools)).toContain('soft_delete_many_secondObject');
|
||||
expect(Object.keys(tools)).not.toContain('soft_delete_testObject');
|
||||
expect(Object.keys(tools)).not.toContain('create_secondObject');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,262 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
export interface AgentToolTestContext {
|
||||
module: TestingModule;
|
||||
agentToolService: AgentToolService;
|
||||
agentService: AgentService;
|
||||
objectMetadataService: ObjectMetadataService;
|
||||
roleRepository: Repository<RoleEntity>;
|
||||
workspacePermissionsCacheService: WorkspacePermissionsCacheService;
|
||||
twentyORMGlobalManager: TwentyORMGlobalManager;
|
||||
testAgent: AgentEntity & { roleId: string | null };
|
||||
testRole: RoleEntity;
|
||||
testObjectMetadata: ObjectMetadataEntity;
|
||||
testWorkspaceId: string;
|
||||
testAgentId: string;
|
||||
testRoleId: string;
|
||||
}
|
||||
|
||||
export const createAgentToolTestModule =
|
||||
async (): Promise<AgentToolTestContext> => {
|
||||
const testWorkspaceId = 'test-workspace-id';
|
||||
const testAgentId = 'test-agent-id';
|
||||
const testRoleId = 'test-role-id';
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
AgentToolService,
|
||||
{
|
||||
provide: AgentService,
|
||||
useValue: {
|
||||
findOneAgent: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RoleEntity, 'core'),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ObjectMetadataService,
|
||||
useValue: {
|
||||
findManyWithinWorkspace: jest.fn(),
|
||||
findOneWithinWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyORMGlobalManager,
|
||||
useValue: {
|
||||
getRepositoryForWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceEventEmitter,
|
||||
useValue: {
|
||||
emit: jest.fn(),
|
||||
emitDatabaseBatchEvent: jest.fn(),
|
||||
emitCustomBatchEvent: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspacePermissionsCacheService,
|
||||
useValue: {
|
||||
getRolesPermissionsFromCache: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const agentToolService = module.get<AgentToolService>(AgentToolService);
|
||||
const agentService = module.get<AgentService>(AgentService);
|
||||
const objectMetadataService = module.get<ObjectMetadataService>(
|
||||
ObjectMetadataService,
|
||||
);
|
||||
const roleRepository = module.get<Repository<RoleEntity>>(
|
||||
getRepositoryToken(RoleEntity, 'core'),
|
||||
);
|
||||
const workspacePermissionsCacheService =
|
||||
module.get<WorkspacePermissionsCacheService>(
|
||||
WorkspacePermissionsCacheService,
|
||||
);
|
||||
const twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
|
||||
TwentyORMGlobalManager,
|
||||
);
|
||||
|
||||
const testAgent: AgentEntity & { roleId: string | null } = {
|
||||
id: testAgentId,
|
||||
name: 'Test Agent',
|
||||
description: 'Test agent for integration tests',
|
||||
prompt: 'You are a test agent',
|
||||
modelId: 'gpt-4o',
|
||||
responseFormat: {},
|
||||
workspaceId: testWorkspaceId,
|
||||
workspace: {} as any,
|
||||
roleId: testRoleId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const testRole: RoleEntity = {
|
||||
id: testRoleId,
|
||||
label: 'Test Role',
|
||||
description: 'Test role for integration tests',
|
||||
canUpdateAllSettings: false,
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isEditable: true,
|
||||
} as RoleEntity;
|
||||
|
||||
const testObjectMetadata = {
|
||||
id: 'test-object-id',
|
||||
standardId: null,
|
||||
dataSourceId: 'test-data-source-id',
|
||||
nameSingular: 'testObject',
|
||||
namePlural: 'testObjects',
|
||||
labelSingular: 'Test Object',
|
||||
labelPlural: 'Test Objects',
|
||||
description: 'Test object for integration tests',
|
||||
icon: 'IconTest',
|
||||
targetTableName: 'test_objects',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
isAuditLogged: true,
|
||||
isSearchable: false,
|
||||
shortcut: '',
|
||||
isLabelSyncedWithName: false,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
fields: [],
|
||||
indexMetadatas: [],
|
||||
targetRelationFields: [],
|
||||
dataSource: {} as any,
|
||||
objectPermissions: [],
|
||||
};
|
||||
|
||||
return {
|
||||
module,
|
||||
agentToolService,
|
||||
agentService,
|
||||
objectMetadataService,
|
||||
roleRepository,
|
||||
workspacePermissionsCacheService,
|
||||
twentyORMGlobalManager,
|
||||
testAgent,
|
||||
testRole,
|
||||
testObjectMetadata,
|
||||
testWorkspaceId,
|
||||
testAgentId,
|
||||
testRoleId,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockRepository = () => ({
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
});
|
||||
|
||||
export const setupBasicPermissions = (context: AgentToolTestContext) => {
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(context.testRole);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[context.testObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: true,
|
||||
canDestroy: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([context.testObjectMetadata]);
|
||||
};
|
||||
|
||||
export const setupRepositoryMock = (
|
||||
context: AgentToolTestContext,
|
||||
mockRepository: any,
|
||||
) => {
|
||||
jest
|
||||
.spyOn(context.twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue(mockRepository);
|
||||
};
|
||||
|
||||
export const createTestRecord = (
|
||||
id: string,
|
||||
data: Record<string, any> = {},
|
||||
) => ({
|
||||
id,
|
||||
name: `Test Record ${id}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...data,
|
||||
});
|
||||
|
||||
export const createTestRecords = (
|
||||
count: number,
|
||||
baseData: Record<string, any> = {},
|
||||
) => {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createTestRecord(`record-${i + 1}`, baseData),
|
||||
);
|
||||
};
|
||||
|
||||
export const expectSuccessResult = (result: any, expectedMessage?: string) => {
|
||||
expect(result.success).toBe(true);
|
||||
if (expectedMessage) {
|
||||
expect(result.message).toContain(expectedMessage);
|
||||
}
|
||||
};
|
||||
|
||||
export const expectErrorResult = (
|
||||
result: any,
|
||||
expectedError?: string,
|
||||
expectedMessage?: string,
|
||||
) => {
|
||||
expect(result.success).toBe(false);
|
||||
if (expectedError) {
|
||||
expect(result.error).toBe(expectedError);
|
||||
}
|
||||
if (expectedMessage) {
|
||||
expect(result.message).toContain(expectedMessage);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user