diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 11ff685dec..aa87f332bd 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -554,6 +554,7 @@ export enum WorkflowActionType { IF_ELSE = 'IF_ELSE', ITERATOR = 'ITERATOR', LOGIC_FUNCTION = 'LOGIC_FUNCTION', + PICK_RECORD = 'PICK_RECORD', SEND_EMAIL = 'SEND_EMAIL', UPDATE_RECORD = 'UPDATE_RECORD', UPSERT_RECORD = 'UPSERT_RECORD' diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 306ac064b6..f69a6a9b94 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -19,6 +19,7 @@ import { type workflowIteratorActionSchema, type workflowLogicFunctionActionSchema, type workflowManualTriggerSchema, + type workflowPickRecordActionSchema, type workflowRunSchema, type workflowRunStateSchema, type workflowRunStatusSchema, @@ -56,6 +57,9 @@ export type WorkflowUpsertRecordAction = z.infer< export type WorkflowFindRecordsAction = z.infer< typeof workflowFindRecordsActionSchema >; +export type WorkflowPickRecordAction = z.infer< + typeof workflowPickRecordActionSchema +>; export type WorkflowDelayAction = z.infer; export type WorkflowFilterAction = z.infer; export type WorkflowFormAction = z.infer; @@ -79,6 +83,7 @@ export type WorkflowAction = | WorkflowDeleteRecordAction | WorkflowUpsertRecordAction | WorkflowFindRecordsAction + | WorkflowPickRecordAction | WorkflowFilterAction | WorkflowIfElseAction | WorkflowFormAction diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx index fee1fe3085..395a79e2f1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx @@ -19,6 +19,7 @@ import { WorkflowEditActionFormFiller } from '@/workflow/workflow-steps/workflow import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest'; import { WorkflowEditActionIfElse } from '@/workflow/workflow-steps/workflow-actions/if-else-action/components/WorkflowEditActionIfElse'; import { WorkflowEditActionIterator } from '@/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator'; +import { WorkflowEditActionPickRecord } from '@/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord'; import { WorkflowEditActionLogicFunction } from '@/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction'; import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm'; import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm'; @@ -199,6 +200,18 @@ export const WorkflowRunStepNodeDetail = ({ ); } + case 'PICK_RECORD': { + return ( + + ); + } + case 'FORM': { return ( + ); + } + case 'FORM': { return ( ; icon: string; }> = [ @@ -22,4 +24,5 @@ export const RECORD_ACTIONS: Array<{ DELETE_RECORD_ACTION, FIND_RECORDS_ACTION, UPSERT_RECORD_ACTION, + PICK_RECORD_ACTION, ]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/actions/PickRecordAction.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/actions/PickRecordAction.ts new file mode 100644 index 0000000000..a085287de5 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/actions/PickRecordAction.ts @@ -0,0 +1,11 @@ +import { type WorkflowActionType } from '@/workflow/types/Workflow'; + +export const PICK_RECORD_ACTION: { + defaultLabel: string; + type: Extract; + icon: string; +} = { + defaultLabel: 'Pick Record', + type: 'PICK_RECORD', + icon: 'IconArrowsShuffle', +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord.tsx new file mode 100644 index 0000000000..c60ab8c040 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord.tsx @@ -0,0 +1,184 @@ +import { styled } from '@linaria/react'; +import { i18n } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react/macro'; +import { useEffect, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { HorizontalSeparator } from 'twenty-ui/layout'; +import { useDebouncedCallback } from 'use-debounce'; + +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { useObjectMetadataSelectHelpers } from '@/object-metadata/hooks/useObjectMetadataSelectHelpers'; +import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker'; +import { SelectControl } from '@/ui/input/components/SelectControl'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { type WorkflowPickRecordAction } from '@/workflow/types/Workflow'; +import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; +import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter'; +import { WorkflowObjectDropdownContent } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowObjectDropdownContent'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledLabel = styled.span` + color: ${themeCssVariables.font.color.light}; + display: block; + font-size: ${themeCssVariables.font.size.xs}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + margin-bottom: ${themeCssVariables.spacing[1]}; +`; + +const StyledObjectSelectContainer = styled.div` + width: 100%; +`; + +const defaultSelectedOptionMessage = msg`Select an option`; + +type WorkflowEditActionPickRecordProps = { + action: WorkflowPickRecordAction; + actionOptions: + | { + readonly: true; + } + | { + readonly?: false; + onActionUpdate: (action: WorkflowPickRecordAction) => void; + }; +}; + +type PickRecordFormData = { + objectNameSingular: string; + recordIds: string[]; +}; + +export const WorkflowEditActionPickRecord = ({ + action, + actionOptions, +}: WorkflowEditActionPickRecordProps) => { + const { t } = useLingui(); + const { getSelectIconPropsFromObjectMetadataItem } = + useObjectMetadataSelectHelpers(); + + const dropdownId = `workflow-edit-action-pick-record-object-name-${action.id}`; + + const { closeDropdown } = useCloseDropdown(); + + const { objectMetadataItems } = useFilteredObjectMetadataItems(); + + const [formData, setFormData] = useState(() => ({ + objectNameSingular: action.settings.input.objectName, + recordIds: action.settings.input.recordIds, + })); + + const isFormDisabled = actionOptions.readonly ?? false; + + const selectedObjectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === formData.objectNameSingular, + ); + const selectedOption = selectedObjectMetadataItem + ? { + label: selectedObjectMetadataItem.labelPlural, + value: selectedObjectMetadataItem.nameSingular, + ...getSelectIconPropsFromObjectMetadataItem(selectedObjectMetadataItem), + } + : { label: i18n._(defaultSelectedOptionMessage), value: '' }; + + const saveAction = useDebouncedCallback( + async (updatedFormData: PickRecordFormData) => { + if (actionOptions.readonly === true) { + return; + } + + actionOptions.onActionUpdate({ + ...action, + settings: { + ...action.settings, + input: { + objectName: updatedFormData.objectNameSingular, + strategy: action.settings.input.strategy, + recordIds: updatedFormData.recordIds, + }, + }, + }); + }, + 1_000, + ); + + useEffect(() => { + return () => { + saveAction.flush(); + }; + }, [saveAction]); + + const handleObjectChange = (value: string) => { + if (actionOptions.readonly === true) { + return; + } + + const newFormData: PickRecordFormData = { + objectNameSingular: value, + recordIds: [], + }; + + setFormData(newFormData); + saveAction(newFormData); + closeDropdown(dropdownId); + }; + + const handleRecordIdsChange = (recordIds: string[]) => { + if (isFormDisabled === true) { + return; + } + + const newFormData: PickRecordFormData = { + ...formData, + recordIds, + }; + + setFormData(newFormData); + saveAction(newFormData); + }; + + return ( + <> + + + {t`Object`} + + } + dropdownComponents={ + !isFormDisabled && ( + + ) + } + dropdownOffset={{ y: 4 }} + /> + + + + + {isDefined(selectedObjectMetadataItem) && ( + + handleRecordIdsChange(Array.isArray(value) ? value : []) + } + readonly={isFormDisabled} + /> + )} + + {!actionOptions.readonly && } + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts index 211154f73f..6a5c3f073e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts @@ -12,6 +12,7 @@ export const getActionIcon = (actionType: WorkflowActionType) => { case 'DELETE_RECORD': case 'UPSERT_RECORD': case 'FIND_RECORDS': + case 'PICK_RECORD': return RECORD_ACTIONS.find((item) => item.type === actionType)?.icon; case 'AI_AGENT': return AI_ACTIONS.find((item) => item.type === actionType)?.icon; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts index eb6f4c17a8..26f368aaf5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts @@ -17,6 +17,7 @@ export const getActionIconColorOrThrow = ( case 'DELETE_RECORD': case 'UPSERT_RECORD': case 'FIND_RECORDS': + case 'PICK_RECORD': return themeCssVariables.font.color.tertiary; case 'FORM': return themeCssVariables.color.orange; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts index 89d8f2532e..b35a9c9480 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts @@ -167,7 +167,8 @@ export const computeStepOutputSchema = ({ case 'CREATE_RECORD': case 'UPDATE_RECORD': case 'DELETE_RECORD': - case 'UPSERT_RECORD': { + case 'UPSERT_RECORD': + case 'PICK_RECORD': { const objectName = step.settings?.input?.objectName; if (!isDefined(objectName)) { diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts index 27134b74bd..46da8a1890 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts @@ -98,6 +98,7 @@ export class WorkflowSchemaWorkspaceService { case WorkflowActionType.UPDATE_RECORD: case WorkflowActionType.DELETE_RECORD: case WorkflowActionType.UPSERT_RECORD: + case WorkflowActionType.PICK_RECORD: return this.computeRecordOutputSchema({ objectType: step.settings.input.objectName, workspaceId, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts index f0baad9fde..b41451df54 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts @@ -54,6 +54,11 @@ const VARIABLE_CONSUMING_ACTION_TYPES = new Set([ ...RECORD_CRUD_ACTION_TYPES, ]); +const OBJECT_TARGETING_ACTION_TYPES = new Set([ + ...RECORD_CRUD_ACTION_TYPES, + WorkflowActionType.PICK_RECORD, +]); + @Injectable() export class WorkflowValidationWorkspaceService { constructor( @@ -578,7 +583,7 @@ export class WorkflowValidationWorkspaceService { steps: WorkflowAction[]; }): Promise { const recordSteps = steps.filter((step) => - RECORD_CRUD_ACTION_TYPES.has(step.type), + OBJECT_TARGETING_ACTION_TYPES.has(step.type), ); if (recordSteps.length === 0) { diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index a7bda9589f..515c7d22c2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -442,6 +442,28 @@ export class WorkflowVersionStepOperationsWorkspaceService { }, }; } + case WorkflowActionType.PICK_RECORD: { + const activeObjectMetadataItem = + await this.objectMetadataRepository.findOne({ + where: { workspaceId, isActive: true, isSystem: false }, + }); + + return { + builtStep: { + ...baseStep, + name: 'Pick Record', + type: WorkflowActionType.PICK_RECORD, + settings: { + ...BASE_STEP_DEFINITION, + input: { + objectName: activeObjectMetadataItem?.nameSingular || '', + strategy: 'RANDOM', + recordIds: [], + }, + }, + }, + }; + } case WorkflowActionType.FORM: { return { builtStep: { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts index 6096e2112e..e97bee8510 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts @@ -21,6 +21,7 @@ import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/ import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action'; import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action'; import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action'; +import { PickRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action'; import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action'; import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action'; import { WorkflowActionType } from 'twenty-shared/workflow'; @@ -35,6 +36,7 @@ export class WorkflowActionFactory { private readonly updateRecordWorkflowAction: UpdateRecordWorkflowAction, private readonly deleteRecordWorkflowAction: DeleteRecordWorkflowAction, private readonly findRecordsWorkflowAction: FindRecordsWorkflowAction, + private readonly pickRecordWorkflowAction: PickRecordWorkflowAction, private readonly formWorkflowAction: FormWorkflowAction, private readonly filterWorkflowAction: FilterWorkflowAction, private readonly ifElseWorkflowAction: IfElseWorkflowAction, @@ -67,6 +69,8 @@ export class WorkflowActionFactory { return this.deleteRecordWorkflowAction; case WorkflowActionType.FIND_RECORDS: return this.findRecordsWorkflowAction; + case WorkflowActionType.PICK_RECORD: + return this.pickRecordWorkflowAction; case WorkflowActionType.FORM: return this.formWorkflowAction; case WorkflowActionType.FILTER: diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-pick-record-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-pick-record-action.guard.ts new file mode 100644 index 0000000000..a62ce1b445 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-pick-record-action.guard.ts @@ -0,0 +1,11 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; +import { + type WorkflowAction, + type WorkflowPickRecordAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const isWorkflowPickRecordAction = ( + action: WorkflowAction, +): action is WorkflowPickRecordAction => { + return action.type === WorkflowActionType.PICK_RECORD; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action.ts new file mode 100644 index 0000000000..04566175ca --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { type ObjectRecord } from 'twenty-shared/types'; +import { resolveInput } from 'twenty-shared/utils'; + +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; + +import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; +import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; +import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; +import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; +import { isWorkflowPickRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-pick-record-action.guard'; +import { type WorkflowPickRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type'; + +@Injectable() +export class PickRecordWorkflowAction implements WorkflowAction { + constructor( + private readonly findRecordsService: FindRecordsService, + private readonly workflowExecutionContextService: WorkflowExecutionContextService, + ) {} + + async execute({ + currentStepId, + steps, + context, + runInfo, + }: WorkflowActionInput): Promise { + const step = findStepOrThrow({ + steps, + stepId: currentStepId, + }); + + if (!isWorkflowPickRecordAction(step)) { + throw new WorkflowStepExecutorException( + 'Step is not a pick record action', + WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE, + ); + } + + const { objectName, recordIds } = resolveInput( + step.settings.input, + context, + ) as WorkflowPickRecordActionInput; + + if (!isNonEmptyString(objectName) || !Array.isArray(recordIds)) { + return { error: 'Pick record action received invalid input' }; + } + + if (recordIds.length === 0) { + return { error: 'Pick record action has no candidate records' }; + } + + const executionContext = + await this.workflowExecutionContextService.getExecutionContext(runInfo); + + const findRecordsOutput = await this.findRecordsService.execute({ + objectName, + filter: { id: { in: recordIds } }, + authContext: executionContext.authContext, + rolePermissionConfig: executionContext.rolePermissionConfig, + shouldBuildEffectiveSelectFields: false, + }); + + if (!findRecordsOutput.success) { + return { error: findRecordsOutput.error || findRecordsOutput.message }; + } + + const candidateRecords = (findRecordsOutput.result?.records ?? + []) as ObjectRecord[]; + + if (candidateRecords.length === 0) { + return { + error: 'Pick record action could not find any of the candidate records', + }; + } + + const pickedRecord = + candidateRecords[Math.floor(Math.random() * candidateRecords.length)]; + + return { result: pickedRecord }; + } +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts index a5ca784071..1571b707e6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts @@ -10,6 +10,7 @@ import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-e import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action'; import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action'; import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action'; +import { PickRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action'; import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action'; import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action'; import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module'; @@ -31,6 +32,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow UpdateRecordWorkflowAction, DeleteRecordWorkflowAction, FindRecordsWorkflowAction, + PickRecordWorkflowAction, ], exports: [ CreateRecordWorkflowAction, @@ -38,6 +40,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow UpdateRecordWorkflowAction, DeleteRecordWorkflowAction, FindRecordsWorkflowAction, + PickRecordWorkflowAction, ], }) export class RecordCRUDActionModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type.ts new file mode 100644 index 0000000000..615144b8da --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type.ts @@ -0,0 +1,7 @@ +export type WorkflowPickRecordStrategy = 'RANDOM'; + +export type WorkflowPickRecordActionInput = { + objectName: string; + strategy: WorkflowPickRecordStrategy; + recordIds: string[]; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type.ts index 74a4e5564f..a0894f0c5a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type.ts @@ -1,3 +1,4 @@ +import { type WorkflowPickRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type'; import { type WorkflowUpsertRecordActionInput, type WorkflowCreateRecordActionInput, @@ -26,3 +27,7 @@ export type WorkflowFindRecordsActionSettings = BaseWorkflowActionSettings & { export type WorkflowUpsertRecordActionSettings = BaseWorkflowActionSettings & { input: WorkflowUpsertRecordActionInput; }; + +export type WorkflowPickRecordActionSettings = BaseWorkflowActionSettings & { + input: WorkflowPickRecordActionInput; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts index a4593845a5..ff84995c32 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts @@ -13,6 +13,7 @@ import { type WorkflowCreateRecordActionSettings, type WorkflowDeleteRecordActionSettings, type WorkflowFindRecordsActionSettings, + type WorkflowPickRecordActionSettings, type WorkflowUpdateRecordActionSettings, type WorkflowUpsertRecordActionSettings, } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type'; @@ -42,6 +43,7 @@ export type WorkflowActionSettings = | WorkflowDeleteRecordActionSettings | WorkflowUpsertRecordActionSettings | WorkflowFindRecordsActionSettings + | WorkflowPickRecordActionSettings | WorkflowFormActionSettings | WorkflowFilterActionSettings | WorkflowIfElseActionSettings diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts index 64220d0ab5..93528086ef 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts @@ -14,6 +14,7 @@ import { type WorkflowCreateRecordActionSettings, type WorkflowDeleteRecordActionSettings, type WorkflowFindRecordsActionSettings, + type WorkflowPickRecordActionSettings, type WorkflowUpdateRecordActionSettings, type WorkflowUpsertRecordActionSettings, } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type'; @@ -77,6 +78,11 @@ export type WorkflowFindRecordsAction = BaseWorkflowAction & { settings: WorkflowFindRecordsActionSettings; }; +export type WorkflowPickRecordAction = BaseWorkflowAction & { + type: WorkflowActionType.PICK_RECORD; + settings: WorkflowPickRecordActionSettings; +}; + export type WorkflowFormAction = BaseWorkflowAction & { type: WorkflowActionType.FORM; settings: WorkflowFormActionSettings; @@ -126,6 +132,7 @@ export type WorkflowAction = | WorkflowDeleteRecordAction | WorkflowUpsertRecordAction | WorkflowFindRecordsAction + | WorkflowPickRecordAction | WorkflowFormAction | WorkflowFilterAction | WorkflowIfElseAction diff --git a/packages/twenty-server/test/integration/graphql/suites/workflow/pick-record-workflow.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workflow/pick-record-workflow.integration-spec.ts new file mode 100644 index 0000000000..5447ae1f22 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/workflow/pick-record-workflow.integration-spec.ts @@ -0,0 +1,255 @@ +import request from 'supertest'; +import { + destroyWorkflowRun, + runWorkflowVersion, + waitForWorkflowCompletion, +} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util'; + +const client = request(`http://localhost:${APP_PORT}`); + +describe('Pick Record Workflow (e2e)', () => { + let createdWorkflowId: string | null = null; + let createdWorkflowVersionId: string | null = null; + let pickRecordStepId: string | null = null; + let candidateRecordIds: string[] = []; + + beforeAll(async () => { + const createWorkflowResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation CreateWorkflow { + createWorkflow(data: { name: "Pick Record Test Workflow" }) { + id + } + } + `, + }); + + expect(createWorkflowResponse.body.errors).toBeUndefined(); + createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id; + + const getWorkflowResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + query GetWorkflow($id: UUID!) { + workflow(filter: { id: { eq: $id } }) { + id + versions { + edges { + node { + id + } + } + } + } + } + `, + variables: { id: createdWorkflowId }, + }); + + createdWorkflowVersionId = + getWorkflowResponse.body.data.workflow.versions.edges[0].node.id; + + const updateTriggerResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation UpdateWorkflowVersion($id: UUID!, $data: WorkflowVersionUpdateInput!) { + updateWorkflowVersion(id: $id, data: $data) { + id + } + } + `, + variables: { + id: createdWorkflowVersionId, + data: { + trigger: { + name: 'Manual Trigger', + type: 'MANUAL', + settings: { outputSchema: {} }, + nextStepIds: [], + position: { x: 0, y: 0 }, + }, + }, + }, + }); + + expect(updateTriggerResponse.body.errors).toBeUndefined(); + + const createStepResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) { + createWorkflowVersionStep(input: $input) { + stepsDiff + } + } + `, + variables: { + input: { + workflowVersionId: createdWorkflowVersionId, + stepType: 'PICK_RECORD', + parentStepId: 'trigger', + position: { x: 200, y: 0 }, + }, + }, + }); + + expect(createStepResponse.body.errors).toBeUndefined(); + + const getStepsResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + query GetWorkflowVersion($id: UUID!) { + workflowVersion(filter: { id: { eq: $id } }) { + id + steps + } + } + `, + variables: { id: createdWorkflowVersionId }, + }); + + const steps = getStepsResponse.body.data.workflowVersion.steps; + const pickRecordStep = steps.find( + (step: { type: string }) => step.type === 'PICK_RECORD', + ); + + expect(pickRecordStep).toBeDefined(); + pickRecordStepId = pickRecordStep.id; + + const companiesResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + query Companies { + companies(first: 2) { + edges { + node { + id + } + } + } + } + `, + }); + + expect(companiesResponse.body.errors).toBeUndefined(); + candidateRecordIds = companiesResponse.body.data.companies.edges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + + expect(candidateRecordIds.length).toBe(2); + + const updateStepResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) { + updateWorkflowVersionStep(input: $input) { + id + } + } + `, + variables: { + input: { + workflowVersionId: createdWorkflowVersionId, + step: { + ...pickRecordStep, + settings: { + ...pickRecordStep.settings, + input: { + objectName: 'company', + strategy: 'RANDOM', + recordIds: candidateRecordIds, + }, + }, + }, + }, + }, + }); + + expect(updateStepResponse.body.errors).toBeUndefined(); + + const activateResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation ActivateWorkflowVersion($workflowVersionId: UUID!) { + activateWorkflowVersion(workflowVersionId: $workflowVersionId) + } + `, + variables: { workflowVersionId: createdWorkflowVersionId }, + }); + + expect(activateResponse.body.errors).toBeUndefined(); + expect(activateResponse.body.data.activateWorkflowVersion).toBe(true); + }); + + afterAll(async () => { + if (createdWorkflowId) { + await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation DestroyWorkflow($id: ID!) { + destroyWorkflow(id: $id) { + id + } + } + `, + variables: { id: createdWorkflowId }, + }); + } + }); + + const pickRecordOnce = async (): Promise<{ id: string }> => { + const workflowRunId = await runWorkflowVersion({ + workflowVersionId: createdWorkflowVersionId!, + payload: {}, + }); + + const workflowRun = await waitForWorkflowCompletion(workflowRunId); + + expect(workflowRun?.status).toBe('COMPLETED'); + expect(workflowRun?.state?.stepInfos?.[pickRecordStepId!]?.status).toBe( + 'SUCCESS', + ); + + const result = workflowRun?.state?.stepInfos?.[pickRecordStepId!] + ?.result as { id: string } | undefined; + + await destroyWorkflowRun(workflowRunId); + + expect(result?.id).toBeDefined(); + + return result!; + }; + + it('picks a record and exposes it as the step output', async () => { + const pickedRecord = await pickRecordOnce(); + + expect(candidateRecordIds).toContain(pickedRecord.id); + }); + + it('only ever picks records from the configured pool', async () => { + for (let runIndex = 0; runIndex < 5; runIndex++) { + const pickedRecord = await pickRecordOnce(); + + expect(candidateRecordIds).toContain(pickedRecord.id); + } + }); +}); diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index a61d70d8e9..ff515f1a80 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -64,6 +64,11 @@ export { workflowLogicFunctionActionSchema } from './schemas/logic-function-acti export { workflowLogicFunctionActionSettingsSchema } from './schemas/logic-function-action-settings-schema'; export { workflowManualTriggerSchema } from './schemas/manual-trigger-schema'; export { objectRecordSchema } from './schemas/object-record-schema'; +export { workflowPickRecordActionSchema } from './schemas/pick-record-action-schema'; +export { + workflowPickRecordStrategySchema, + workflowPickRecordActionSettingsSchema, +} from './schemas/pick-record-action-settings-schema'; export { workflowSendEmailActionSchema } from './schemas/send-email-action-schema'; export type { WorkflowEmailFiles } from './schemas/send-email-action-settings-schema'; export { diff --git a/packages/twenty-shared/src/workflow/schemas/pick-record-action-schema.ts b/packages/twenty-shared/src/workflow/schemas/pick-record-action-schema.ts new file mode 100644 index 0000000000..d45968d5da --- /dev/null +++ b/packages/twenty-shared/src/workflow/schemas/pick-record-action-schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { baseWorkflowActionSchema } from './base-workflow-action-schema'; +import { workflowPickRecordActionSettingsSchema } from './pick-record-action-settings-schema'; + +export const workflowPickRecordActionSchema = baseWorkflowActionSchema.extend({ + type: z.literal('PICK_RECORD'), + settings: workflowPickRecordActionSettingsSchema, +}); diff --git a/packages/twenty-shared/src/workflow/schemas/pick-record-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/pick-record-action-settings-schema.ts new file mode 100644 index 0000000000..7a55874c1e --- /dev/null +++ b/packages/twenty-shared/src/workflow/schemas/pick-record-action-settings-schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema'; + +export const workflowPickRecordStrategySchema = z.enum(['RANDOM']); + +export const workflowPickRecordActionSettingsSchema = + baseWorkflowActionSettingsSchema.extend({ + input: z.object({ + objectName: z.string(), + strategy: workflowPickRecordStrategySchema, + recordIds: z.array(z.string()), + }), + }); diff --git a/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts b/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts index 0097fe7539..8d79a234b1 100644 --- a/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts @@ -12,6 +12,7 @@ import { workflowHttpRequestActionSchema } from './http-request-action-schema'; import { workflowIfElseActionSchema } from './if-else-action-schema'; import { workflowIteratorActionSchema } from './iterator-action-schema'; import { workflowLogicFunctionActionSchema } from './logic-function-action-schema'; +import { workflowPickRecordActionSchema } from './pick-record-action-schema'; import { workflowSendEmailActionSchema } from './send-email-action-schema'; import { workflowUpdateRecordActionSchema } from './update-record-action-schema'; import { workflowUpsertRecordActionSchema } from './upsert-record-action-schema'; @@ -27,6 +28,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [ workflowDeleteRecordActionSchema, workflowUpsertRecordActionSchema, workflowFindRecordsActionSchema, + workflowPickRecordActionSchema, workflowFormActionSchema, workflowHttpRequestActionSchema, workflowAiAgentActionSchema, diff --git a/packages/twenty-shared/src/workflow/types/WorkflowActionType.ts b/packages/twenty-shared/src/workflow/types/WorkflowActionType.ts index fd933daeea..840239d487 100644 --- a/packages/twenty-shared/src/workflow/types/WorkflowActionType.ts +++ b/packages/twenty-shared/src/workflow/types/WorkflowActionType.ts @@ -8,6 +8,7 @@ export enum WorkflowActionType { DELETE_RECORD = 'DELETE_RECORD', UPSERT_RECORD = 'UPSERT_RECORD', FIND_RECORDS = 'FIND_RECORDS', + PICK_RECORD = 'PICK_RECORD', FORM = 'FORM', FILTER = 'FILTER', IF_ELSE = 'IF_ELSE',