feat(workflow): Pick Record load balanced strategy (3/3) (#21902)
## Overview Final PR in the Pick Record stack. Adds the **Load Balanced** strategy: pick the candidate that currently has the *fewest related records*. This is the "fair assignment" mode — e.g. assign a new company to the account owner who currently owns the fewest companies, or route a lead to the rep with the fewest open opportunities. **Stacked on #21900** (which is stacked on #21899) — merge in order. This PR's diff against `main` includes PRs 1 & 2 until they merge. ## What changed - Widened the `strategy` enum to add `LOAD_BALANCED`, and added an optional `loadBalance: { objectNameSingular, fieldName }` to the action input. - Editor: selecting **Load balanced** reveals a **Balance by** object picker and a **Count by** field picker (the related object's many-to-one relation fields). - Executor: for each candidate, counts records of the chosen related object whose chosen relation points at that candidate, then selects the least-loaded one. ## How it works Given pool = workspace members and config `{ objectNameSingular: "opportunity", fieldName: "pointOfContact" }`, the executor counts, per member, the opportunities whose `pointOfContact` is that member, and picks the member with the lowest count. ## Design decisions & tradeoffs 1. **No persistent state — computed live each run.** Unlike round robin, load balancing reads current data, so there's no cursor to store. Correct by construction even under concurrency (each run recomputes counts); the only caveat is two simultaneous runs can both see the same "least loaded" candidate before either assignment lands (a small, self-correcting skew), which is inherent to load-balancing and acceptable. 2. **Count via per-candidate queries.** One filtered count per candidate (`{ [relationField]: { id: { eq: candidateId } } }`), run in parallel. For the realistic pool sizes this targets (a team), this is simple and clear. A single `group_by` aggregate would scale better for very large pools — noted as a future optimization, deliberately not done to keep the logic obvious. 3. **Deterministic tie-break.** Candidates are pre-sorted by id (shared with round robin), and the first minimum wins — so equal-load ties resolve deterministically rather than arbitrarily. 4. **`Count by` lists all many-to-one relations of the chosen object** (not filtered to those targeting the pool object). Keeps the editor simple; picking an unrelated field just yields zero counts, which is visibly wrong. Filtering options to relations that target the pool object is a nice follow-up. 5. **Filter on the counted set** (e.g. only *open* opportunities) is intentionally out of scope for this first cut — documented as a follow-up. ## Testing Added `pick-record-load-balanced-workflow.integration-spec.ts`: creates two fresh companies (0 related opportunities each), attaches one opportunity to the second, configures `LOAD_BALANCED` counting opportunities by `company`, and asserts the step picks the **first** company (0 < 1). Passes locally alongside the random and round-robin tests (3 suites / 4 tests). `typecheck` + `lint:diff-with-main` green for shared/server/front. ## The full stack 1. #21899 — Random (the action + the whole scaffold) 2. #21900 — Round robin (atomic Redis cursor) 3. this — Load balanced Together these enable round-robin / load-balanced / random **assignment workflows** in Twenty, composed via the standard variable picker (assign the chosen record downstream with `{{step.<id>.id}}`). https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8 --- _Generated by [Claude Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21902?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+128
@@ -10,7 +10,9 @@ import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||
import { useObjectMetadataSelectHelpers } from '@/object-metadata/hooks/useObjectMetadataSelectHelpers';
|
||||
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
|
||||
import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SelectControl } from '@/ui/input/components/SelectControl';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
@@ -50,10 +52,14 @@ type WorkflowEditActionPickRecordProps = {
|
||||
type PickRecordStrategy =
|
||||
WorkflowPickRecordAction['settings']['input']['strategy'];
|
||||
|
||||
type PickRecordLoadBalance =
|
||||
WorkflowPickRecordAction['settings']['input']['loadBalance'];
|
||||
|
||||
type PickRecordFormData = {
|
||||
objectNameSingular: string;
|
||||
strategy: PickRecordStrategy;
|
||||
recordIds: string[];
|
||||
loadBalance: PickRecordLoadBalance;
|
||||
};
|
||||
|
||||
export const WorkflowEditActionPickRecord = ({
|
||||
@@ -74,6 +80,7 @@ export const WorkflowEditActionPickRecord = ({
|
||||
objectNameSingular: action.settings.input.objectName,
|
||||
strategy: action.settings.input.strategy,
|
||||
recordIds: action.settings.input.recordIds,
|
||||
loadBalance: action.settings.input.loadBalance,
|
||||
}));
|
||||
|
||||
const isFormDisabled = actionOptions.readonly ?? false;
|
||||
@@ -81,11 +88,45 @@ export const WorkflowEditActionPickRecord = ({
|
||||
const strategyOptions: SelectOption<PickRecordStrategy>[] = [
|
||||
{ label: t`Random`, value: 'RANDOM' },
|
||||
{ label: t`Round robin`, value: 'ROUND_ROBIN' },
|
||||
{ label: t`Load balanced`, value: 'LOAD_BALANCED' },
|
||||
];
|
||||
|
||||
const loadBalanceObjectDropdownId = `workflow-edit-action-pick-record-load-balance-object-${action.id}`;
|
||||
|
||||
const loadBalanceObjectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.nameSingular === formData.loadBalance?.objectNameSingular,
|
||||
);
|
||||
|
||||
const loadBalanceObjectOption = loadBalanceObjectMetadataItem
|
||||
? {
|
||||
label: loadBalanceObjectMetadataItem.labelPlural,
|
||||
value: loadBalanceObjectMetadataItem.nameSingular,
|
||||
...getSelectIconPropsFromObjectMetadataItem(
|
||||
loadBalanceObjectMetadataItem,
|
||||
),
|
||||
}
|
||||
: { label: i18n._(defaultSelectedOptionMessage), value: '' };
|
||||
|
||||
const loadBalanceFieldOptions: SelectOption<string>[] = (
|
||||
loadBalanceObjectMetadataItem?.fields ?? []
|
||||
)
|
||||
.filter(
|
||||
(field) =>
|
||||
isManyToOneRelationField(field) &&
|
||||
field.relation?.targetObjectMetadata?.nameSingular ===
|
||||
formData.objectNameSingular,
|
||||
)
|
||||
.map((field) => ({ label: field.label, value: field.name }));
|
||||
|
||||
const hasLoadBalanceFieldOptions = loadBalanceFieldOptions.length > 0;
|
||||
|
||||
const selectedObjectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.nameSingular === formData.objectNameSingular,
|
||||
);
|
||||
|
||||
const loadBalancePoolObjectLabel =
|
||||
selectedObjectMetadataItem?.labelSingular ?? t`the selected object`;
|
||||
|
||||
const selectedOption = selectedObjectMetadataItem
|
||||
? {
|
||||
label: selectedObjectMetadataItem.labelPlural,
|
||||
@@ -108,6 +149,10 @@ export const WorkflowEditActionPickRecord = ({
|
||||
objectName: updatedFormData.objectNameSingular,
|
||||
strategy: updatedFormData.strategy,
|
||||
recordIds: updatedFormData.recordIds,
|
||||
loadBalance:
|
||||
updatedFormData.strategy === 'LOAD_BALANCED'
|
||||
? updatedFormData.loadBalance
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -130,6 +175,9 @@ export const WorkflowEditActionPickRecord = ({
|
||||
...formData,
|
||||
objectNameSingular: value,
|
||||
recordIds: [],
|
||||
loadBalance: isDefined(formData.loadBalance)
|
||||
? { ...formData.loadBalance, fieldName: '' }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
setFormData(newFormData);
|
||||
@@ -165,6 +213,35 @@ export const WorkflowEditActionPickRecord = ({
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const handleLoadBalanceObjectChange = (objectNameSingular: string) => {
|
||||
if (isFormDisabled === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFormData: PickRecordFormData = {
|
||||
...formData,
|
||||
loadBalance: { objectNameSingular, fieldName: '' },
|
||||
};
|
||||
|
||||
setFormData(newFormData);
|
||||
saveAction(newFormData);
|
||||
closeDropdown(loadBalanceObjectDropdownId);
|
||||
};
|
||||
|
||||
const handleLoadBalanceFieldChange = (fieldName: string) => {
|
||||
if (isFormDisabled === true || !isDefined(formData.loadBalance)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFormData: PickRecordFormData = {
|
||||
...formData,
|
||||
loadBalance: { ...formData.loadBalance, fieldName },
|
||||
};
|
||||
|
||||
setFormData(newFormData);
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowStepBody>
|
||||
@@ -200,6 +277,57 @@ export const WorkflowEditActionPickRecord = ({
|
||||
onChange={handleStrategyChange}
|
||||
/>
|
||||
|
||||
{formData.strategy === 'LOAD_BALANCED' && (
|
||||
<>
|
||||
<StyledObjectSelectContainer>
|
||||
<StyledLabel>{t`Balance by`}</StyledLabel>
|
||||
<Dropdown
|
||||
dropdownId={loadBalanceObjectDropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponent={
|
||||
<SelectControl
|
||||
isDisabled={isFormDisabled}
|
||||
selectedOption={loadBalanceObjectOption}
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
!isFormDisabled && (
|
||||
<WorkflowObjectDropdownContent
|
||||
onOptionClick={handleLoadBalanceObjectChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
dropdownOffset={{ y: 4 }}
|
||||
/>
|
||||
</StyledObjectSelectContainer>
|
||||
|
||||
{isDefined(loadBalanceObjectMetadataItem) && (
|
||||
<StyledObjectSelectContainer>
|
||||
<Select
|
||||
dropdownId={`workflow-edit-action-pick-record-load-balance-field-${action.id}`}
|
||||
label={t`Count by`}
|
||||
fullWidth
|
||||
disabled={isFormDisabled || !hasLoadBalanceFieldOptions}
|
||||
emptyOption={{
|
||||
label: hasLoadBalanceFieldOptions
|
||||
? i18n._(defaultSelectedOptionMessage)
|
||||
: t`No relation to count by`,
|
||||
value: '',
|
||||
}}
|
||||
value={formData.loadBalance?.fieldName ?? ''}
|
||||
options={loadBalanceFieldOptions}
|
||||
onChange={handleLoadBalanceFieldChange}
|
||||
/>
|
||||
{!hasLoadBalanceFieldOptions && (
|
||||
<InputHint>
|
||||
{t`${loadBalanceObjectMetadataItem.labelPlural} have no relation to ${loadBalancePoolObjectLabel}. Pick a different object to balance by.`}
|
||||
</InputHint>
|
||||
)}
|
||||
</StyledObjectSelectContainer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<HorizontalSeparator noMargin />
|
||||
|
||||
{isDefined(selectedObjectMetadataItem) && (
|
||||
|
||||
+5
-1
@@ -29,7 +29,7 @@ You help users create and manage automation workflows.
|
||||
## Key Concepts
|
||||
|
||||
- **Triggers**: DATABASE_EVENT, MANUAL, CRON, WEBHOOK
|
||||
- **Steps**: CREATE_RECORD, SEND_EMAIL, CODE, LOGIC_FUNCTION, etc.
|
||||
- **Steps**: CREATE_RECORD, SEND_EMAIL, CODE, LOGIC_FUNCTION, PICK_RECORD, etc.
|
||||
- **Data flow**: Use {{stepId.fieldName}} to reference previous step outputs
|
||||
- **Relationships**: Use nested objects like {"company": {"id": "{{reference}}"}}
|
||||
|
||||
@@ -67,6 +67,10 @@ LOGIC_FUNCTION steps execute logic functions provided by installed applications.
|
||||
{ "stepType": "LOGIC_FUNCTION", "workflowVersionId": "<version-id>", "defaultSettings": { "input": { "logicFunctionId": "<logic-function-id>" } } }
|
||||
3. Or when using \`create_complete_workflow\`, include a step with type "LOGIC_FUNCTION" and settings.input.logicFunctionId.
|
||||
|
||||
## PICK_RECORD Steps
|
||||
|
||||
PICK_RECORD selects one record from a candidate pool (settings.input.recordIds) and outputs it for later steps to reference — useful for assignment workflows like picking an owner. Set settings.input.strategy to RANDOM, ROUND_ROBIN, or LOAD_BALANCED; LOAD_BALANCED also needs settings.input.loadBalance.{objectNameSingular, fieldName} to pick the candidate with the fewest related records.
|
||||
|
||||
## Critical Notes
|
||||
|
||||
Always rely on tool schema definitions:
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { type WorkflowPickRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
export const getPickRecordLoadBalanceConfigError = ({
|
||||
step,
|
||||
objectIdByNameSingular,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
step: WorkflowPickRecordAction;
|
||||
objectIdByNameSingular: Record<string, string>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}): string | undefined => {
|
||||
const input = step.settings?.input;
|
||||
|
||||
if (!isDefined(input) || input.strategy !== 'LOAD_BALANCED') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stepName = step.name ?? step.id;
|
||||
const loadBalance = input.loadBalance;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(loadBalance?.objectNameSingular) ||
|
||||
!isNonEmptyString(loadBalance?.fieldName)
|
||||
) {
|
||||
return `Step "${stepName}" uses load balancing but is missing the object and field to count by.`;
|
||||
}
|
||||
|
||||
const poolObjectId = objectIdByNameSingular[input.objectName];
|
||||
|
||||
if (!isDefined(poolObjectId)) {
|
||||
return `Step "${stepName}" picks from object "${input.objectName}" which does not exist in this workspace.`;
|
||||
}
|
||||
|
||||
const loadBalanceObjectId =
|
||||
objectIdByNameSingular[loadBalance.objectNameSingular];
|
||||
|
||||
if (!isDefined(loadBalanceObjectId)) {
|
||||
return `Step "${stepName}" balances load by object "${loadBalance.objectNameSingular}" which does not exist in this workspace.`;
|
||||
}
|
||||
|
||||
const countByField = Object.values(
|
||||
flatFieldMetadataMaps.byUniversalIdentifier,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.find(
|
||||
(field) =>
|
||||
field.objectMetadataId === loadBalanceObjectId &&
|
||||
field.name === loadBalance.fieldName,
|
||||
);
|
||||
|
||||
const countsRelatedRecordsForPool =
|
||||
isDefined(countByField) &&
|
||||
isFlatFieldMetadataOfType(countByField, FieldMetadataType.RELATION) &&
|
||||
countByField.settings?.relationType === RelationType.MANY_TO_ONE &&
|
||||
countByField.relationTargetObjectMetadataId === poolObjectId;
|
||||
|
||||
if (!countsRelatedRecordsForPool) {
|
||||
return `Step "${stepName}" must balance load by a many-to-one relation on "${loadBalance.objectNameSingular}" that points to "${input.objectName}".`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
+21
-1
@@ -27,6 +27,7 @@ import {
|
||||
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
|
||||
import { getPickRecordLoadBalanceConfigError } from 'src/modules/workflow/workflow-builder/workflow-validation/utils/get-pick-record-load-balance-config-error.util';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
type WorkflowAiAgentAction,
|
||||
@@ -590,7 +591,7 @@ export class WorkflowValidationWorkspaceService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { objectIdByNameSingular } =
|
||||
const { objectIdByNameSingular, flatFieldMetadataMaps } =
|
||||
await this.workflowCommonWorkspaceService.getFlatEntityMaps(workspaceId);
|
||||
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
@@ -618,6 +619,25 @@ export class WorkflowValidationWorkspaceService {
|
||||
message: `Step "${step.name ?? step.id}" targets object "${objectName}" which does not exist in this workspace.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.type === WorkflowActionType.PICK_RECORD) {
|
||||
const loadBalanceError = getPickRecordLoadBalanceConfigError({
|
||||
step,
|
||||
objectIdByNameSingular,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (isDefined(loadBalanceError)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'INVALID_STEP_PARAMS',
|
||||
message: loadBalanceError,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+83
-2
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type ObjectRecord } from 'twenty-shared/types';
|
||||
import { resolveInput } from 'twenty-shared/utils';
|
||||
import { isDefined, resolveInput } from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decora
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import { GroupByRecordsService } from 'src/engine/core-modules/record-crud/services/group-by-records.service';
|
||||
import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
@@ -21,15 +22,21 @@ import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/fi
|
||||
import { isWorkflowPickRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-pick-record-action.guard';
|
||||
import {
|
||||
type WorkflowPickRecordActionInput,
|
||||
type WorkflowPickRecordLoadBalance,
|
||||
type WorkflowPickRecordStrategy,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type';
|
||||
|
||||
const ROUND_ROBIN_CURSOR_TTL_MS = 1000 * 60 * 60 * 24 * 90;
|
||||
|
||||
type PickRecordExecutionContext = Awaited<
|
||||
ReturnType<WorkflowExecutionContextService['getExecutionContext']>
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class PickRecordWorkflowAction implements WorkflowAction {
|
||||
constructor(
|
||||
private readonly findRecordsService: FindRecordsService,
|
||||
private readonly groupByRecordsService: GroupByRecordsService,
|
||||
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
|
||||
@InjectCacheStorage(CacheStorageNamespace.ModuleWorkflow)
|
||||
private readonly cacheStorageService: CacheStorageService,
|
||||
@@ -53,7 +60,7 @@ export class PickRecordWorkflowAction implements WorkflowAction {
|
||||
);
|
||||
}
|
||||
|
||||
const { objectName, strategy, recordIds } = resolveInput(
|
||||
const { objectName, strategy, recordIds, loadBalance } = resolveInput(
|
||||
step.settings.input,
|
||||
context,
|
||||
) as WorkflowPickRecordActionInput;
|
||||
@@ -94,6 +101,27 @@ export class PickRecordWorkflowAction implements WorkflowAction {
|
||||
String(recordA.id).localeCompare(String(recordB.id)),
|
||||
);
|
||||
|
||||
if (strategy === 'LOAD_BALANCED') {
|
||||
if (!isDefined(loadBalance)) {
|
||||
return {
|
||||
error:
|
||||
'Pick record action is missing its load balancing configuration',
|
||||
};
|
||||
}
|
||||
|
||||
const leastLoadedResult = await this.getLeastLoadedIndex({
|
||||
orderedRecords,
|
||||
loadBalance,
|
||||
executionContext,
|
||||
});
|
||||
|
||||
if ('error' in leastLoadedResult) {
|
||||
return { error: leastLoadedResult.error };
|
||||
}
|
||||
|
||||
return { result: orderedRecords[leastLoadedResult.index] };
|
||||
}
|
||||
|
||||
const pickedIndex = await this.getPickedIndex({
|
||||
strategy,
|
||||
candidateCount: orderedRecords.length,
|
||||
@@ -104,6 +132,59 @@ export class PickRecordWorkflowAction implements WorkflowAction {
|
||||
return { result: orderedRecords[pickedIndex] };
|
||||
}
|
||||
|
||||
private async getLeastLoadedIndex({
|
||||
orderedRecords,
|
||||
loadBalance,
|
||||
executionContext,
|
||||
}: {
|
||||
orderedRecords: ObjectRecord[];
|
||||
loadBalance: WorkflowPickRecordLoadBalance;
|
||||
executionContext: PickRecordExecutionContext;
|
||||
}): Promise<{ index: number } | { error: string }> {
|
||||
const candidateIds = orderedRecords.map((record) => String(record.id));
|
||||
|
||||
const groupByOutput = await this.groupByRecordsService.execute({
|
||||
objectName: loadBalance.objectNameSingular,
|
||||
groupBy: [{ [loadBalance.fieldName]: { id: true } }],
|
||||
filter: { [loadBalance.fieldName]: { id: { in: candidateIds } } },
|
||||
authContext: executionContext.authContext,
|
||||
rolePermissionConfig: executionContext.rolePermissionConfig,
|
||||
});
|
||||
|
||||
if (!groupByOutput.success) {
|
||||
return {
|
||||
error:
|
||||
groupByOutput.error ||
|
||||
groupByOutput.message ||
|
||||
'Pick record action failed to count related records',
|
||||
};
|
||||
}
|
||||
|
||||
const countByCandidateId = new Map<string, number>();
|
||||
|
||||
for (const group of groupByOutput.result?.groups ?? []) {
|
||||
const candidateId = group.dimensions[0];
|
||||
|
||||
if (isDefined(candidateId)) {
|
||||
countByCandidateId.set(String(candidateId), Number(group.value ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
let leastLoadedIndex = 0;
|
||||
let leastLoadedCount = countByCandidateId.get(candidateIds[0]) ?? 0;
|
||||
|
||||
for (let index = 1; index < candidateIds.length; index++) {
|
||||
const candidateCount = countByCandidateId.get(candidateIds[index]) ?? 0;
|
||||
|
||||
if (candidateCount < leastLoadedCount) {
|
||||
leastLoadedIndex = index;
|
||||
leastLoadedCount = candidateCount;
|
||||
}
|
||||
}
|
||||
|
||||
return { index: leastLoadedIndex };
|
||||
}
|
||||
|
||||
private async getPickedIndex({
|
||||
strategy,
|
||||
candidateCount,
|
||||
|
||||
+10
-1
@@ -1,7 +1,16 @@
|
||||
export type WorkflowPickRecordStrategy = 'RANDOM' | 'ROUND_ROBIN';
|
||||
export type WorkflowPickRecordStrategy =
|
||||
| 'RANDOM'
|
||||
| 'ROUND_ROBIN'
|
||||
| 'LOAD_BALANCED';
|
||||
|
||||
export type WorkflowPickRecordLoadBalance = {
|
||||
objectNameSingular: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
export type WorkflowPickRecordActionInput = {
|
||||
objectName: string;
|
||||
strategy: WorkflowPickRecordStrategy;
|
||||
recordIds: string[];
|
||||
loadBalance?: WorkflowPickRecordLoadBalance;
|
||||
};
|
||||
|
||||
+47
@@ -1,6 +1,8 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type ActorMetadata } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
@@ -21,7 +23,12 @@ import {
|
||||
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util';
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { getPickRecordLoadBalanceConfigError } from 'src/modules/workflow/workflow-builder/workflow-validation/utils/get-pick-record-load-balance-config-error.util';
|
||||
import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
type WorkflowPickRecordAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowRunnerWorkspaceService } from 'src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service';
|
||||
import { WORKFLOW_VERSION_STATUS_UPDATED } from 'src/modules/workflow/workflow-status/constants/workflow-version-status-updated.constants';
|
||||
import { type WorkflowVersionStatusUpdate } from 'src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job';
|
||||
@@ -131,6 +138,11 @@ export class WorkflowTriggerWorkspaceService {
|
||||
|
||||
assertVersionCanBeActivated(workflowVersion, workflow);
|
||||
|
||||
await this.assertPickRecordLoadBalanceConfigIsValid({
|
||||
steps: workflowVersion.steps ?? [],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.codeStepBuildService.buildCodeStepsFromSourceForSteps({
|
||||
workspaceId,
|
||||
steps: workflowVersion.steps ?? [],
|
||||
@@ -155,6 +167,41 @@ export class WorkflowTriggerWorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
private async assertPickRecordLoadBalanceConfigIsValid({
|
||||
steps,
|
||||
workspaceId,
|
||||
}: {
|
||||
steps: WorkflowAction[];
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const pickRecordSteps = steps.filter(
|
||||
(step): step is WorkflowPickRecordAction =>
|
||||
step.type === WorkflowActionType.PICK_RECORD,
|
||||
);
|
||||
|
||||
if (pickRecordSteps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { objectIdByNameSingular, flatFieldMetadataMaps } =
|
||||
await this.workflowCommonWorkspaceService.getFlatEntityMaps(workspaceId);
|
||||
|
||||
for (const step of pickRecordSteps) {
|
||||
const loadBalanceError = getPickRecordLoadBalanceConfigError({
|
||||
step,
|
||||
objectIdByNameSingular,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (isDefined(loadBalanceError)) {
|
||||
throw new WorkflowTriggerException(
|
||||
loadBalanceError,
|
||||
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deactivateWorkflowVersion(
|
||||
workflowVersionId: string,
|
||||
workspaceId: string,
|
||||
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
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}`);
|
||||
|
||||
const graphql = async (query: string, variables?: Record<string, unknown>) => {
|
||||
const response = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({ query, variables });
|
||||
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
return response.body.data;
|
||||
};
|
||||
|
||||
describe('Pick Record Workflow - load balanced (e2e)', () => {
|
||||
let createdWorkflowId: string | null = null;
|
||||
let createdWorkflowVersionId: string | null = null;
|
||||
let pickRecordStepId: string | null = null;
|
||||
let leastLoadedCompanyId: string | null = null;
|
||||
let mostLoadedCompanyId: string | null = null;
|
||||
let createdOpportunityId: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const createWorkflowData = await graphql(`
|
||||
mutation CreateWorkflow {
|
||||
createWorkflow(data: { name: "Pick Record Load Balanced Test" }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
createdWorkflowId = createWorkflowData.createWorkflow.id;
|
||||
|
||||
const getWorkflowData = await graphql(
|
||||
`
|
||||
query GetWorkflow($id: UUID!) {
|
||||
workflow(filter: { id: { eq: $id } }) {
|
||||
versions {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: createdWorkflowId },
|
||||
);
|
||||
|
||||
createdWorkflowVersionId =
|
||||
getWorkflowData.workflow.versions.edges[0].node.id;
|
||||
|
||||
await graphql(
|
||||
`
|
||||
mutation UpdateWorkflowVersion($id: UUID!, $data: WorkflowVersionUpdateInput!) {
|
||||
updateWorkflowVersion(id: $id, data: $data) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
id: createdWorkflowVersionId,
|
||||
data: {
|
||||
trigger: {
|
||||
name: 'Manual Trigger',
|
||||
type: 'MANUAL',
|
||||
settings: { outputSchema: {} },
|
||||
nextStepIds: [],
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await graphql(
|
||||
`
|
||||
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
|
||||
createWorkflowVersionStep(input: $input) {
|
||||
stepsDiff
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
input: {
|
||||
workflowVersionId: createdWorkflowVersionId,
|
||||
stepType: 'PICK_RECORD',
|
||||
parentStepId: 'trigger',
|
||||
position: { x: 200, y: 0 },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const getStepsData = await graphql(
|
||||
`
|
||||
query GetWorkflowVersion($id: UUID!) {
|
||||
workflowVersion(filter: { id: { eq: $id } }) {
|
||||
steps
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: createdWorkflowVersionId },
|
||||
);
|
||||
|
||||
const pickRecordStep = getStepsData.workflowVersion.steps.find(
|
||||
(step: { type: string }) => step.type === 'PICK_RECORD',
|
||||
);
|
||||
|
||||
pickRecordStepId = pickRecordStep.id;
|
||||
|
||||
// Two fresh companies start with zero related opportunities; adding one
|
||||
// opportunity to the second makes the first the least loaded candidate.
|
||||
const companyOneData = await graphql(`
|
||||
mutation {
|
||||
createCompany(data: { name: "Pick Record LB company one" }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const companyTwoData = await graphql(`
|
||||
mutation {
|
||||
createCompany(data: { name: "Pick Record LB company two" }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// The executor orders candidates by id before selecting. Attach the
|
||||
// opportunity to the id-first company so the least-loaded candidate is the
|
||||
// id-second one: load balancing must pick it, which also guards against a
|
||||
// regression where a broken count would fall back to the first candidate.
|
||||
const [firstSortedCompanyId, secondSortedCompanyId] = [
|
||||
companyOneData.createCompany.id,
|
||||
companyTwoData.createCompany.id,
|
||||
].sort((idA: string, idB: string) => idA.localeCompare(idB));
|
||||
|
||||
mostLoadedCompanyId = firstSortedCompanyId;
|
||||
leastLoadedCompanyId = secondSortedCompanyId;
|
||||
|
||||
const opportunityData = await graphql(
|
||||
`
|
||||
mutation CreateOpportunity($companyId: UUID!) {
|
||||
createOpportunity(
|
||||
data: { name: "Pick Record LB Opportunity", companyId: $companyId }
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ companyId: mostLoadedCompanyId },
|
||||
);
|
||||
|
||||
createdOpportunityId = opportunityData.createOpportunity.id;
|
||||
|
||||
await graphql(
|
||||
`
|
||||
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
|
||||
updateWorkflowVersionStep(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
input: {
|
||||
workflowVersionId: createdWorkflowVersionId,
|
||||
step: {
|
||||
...pickRecordStep,
|
||||
settings: {
|
||||
...pickRecordStep.settings,
|
||||
input: {
|
||||
objectName: 'company',
|
||||
strategy: 'LOAD_BALANCED',
|
||||
recordIds: [leastLoadedCompanyId, mostLoadedCompanyId],
|
||||
loadBalance: {
|
||||
objectNameSingular: 'opportunity',
|
||||
fieldName: 'company',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const activateData = await graphql(
|
||||
`
|
||||
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
|
||||
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
|
||||
}
|
||||
`,
|
||||
{ workflowVersionId: createdWorkflowVersionId },
|
||||
);
|
||||
|
||||
expect(activateData.activateWorkflowVersion).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdOpportunityId) {
|
||||
await graphql(
|
||||
`
|
||||
mutation DeleteOpportunity($id: UUID!) {
|
||||
deleteOpportunity(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: createdOpportunityId },
|
||||
);
|
||||
}
|
||||
|
||||
for (const companyId of [leastLoadedCompanyId, mostLoadedCompanyId]) {
|
||||
if (companyId) {
|
||||
await graphql(
|
||||
`
|
||||
mutation DeleteCompany($id: UUID!) {
|
||||
deleteCompany(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: companyId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (createdWorkflowId) {
|
||||
await graphql(
|
||||
`
|
||||
mutation DestroyWorkflow($id: ID!) {
|
||||
destroyWorkflow(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: createdWorkflowId },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('picks the candidate with the fewest related records', async () => {
|
||||
const workflowRunId = await runWorkflowVersion({
|
||||
workflowVersionId: createdWorkflowVersionId!,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
|
||||
|
||||
expect(workflowRun?.status).toBe('COMPLETED');
|
||||
|
||||
const result = workflowRun?.state?.stepInfos?.[pickRecordStepId!]
|
||||
?.result as { id: string } | undefined;
|
||||
|
||||
await destroyWorkflowRun(workflowRunId);
|
||||
|
||||
expect(result?.id).toBe(leastLoadedCompanyId);
|
||||
});
|
||||
|
||||
it('rejects activation when the count-by relation points to another object', async () => {
|
||||
const createData = await graphql(`
|
||||
mutation CreateWorkflow {
|
||||
createWorkflow(data: { name: "Pick Record LB misrouted relation" }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const misroutedWorkflowId = createData.createWorkflow.id;
|
||||
|
||||
try {
|
||||
const workflowData = await graphql(
|
||||
`
|
||||
query GetWorkflow($id: UUID!) {
|
||||
workflow(filter: { id: { eq: $id } }) {
|
||||
versions {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: misroutedWorkflowId },
|
||||
);
|
||||
|
||||
const misroutedWorkflowVersionId =
|
||||
workflowData.workflow.versions.edges[0].node.id;
|
||||
|
||||
await graphql(
|
||||
`
|
||||
mutation UpdateWorkflowVersion($id: UUID!, $data: WorkflowVersionUpdateInput!) {
|
||||
updateWorkflowVersion(id: $id, data: $data) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
id: misroutedWorkflowVersionId,
|
||||
data: {
|
||||
trigger: {
|
||||
name: 'Manual Trigger',
|
||||
type: 'MANUAL',
|
||||
settings: { outputSchema: {} },
|
||||
nextStepIds: [],
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await graphql(
|
||||
`
|
||||
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
|
||||
createWorkflowVersionStep(input: $input) {
|
||||
stepsDiff
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
input: {
|
||||
workflowVersionId: misroutedWorkflowVersionId,
|
||||
stepType: 'PICK_RECORD',
|
||||
parentStepId: 'trigger',
|
||||
position: { x: 200, y: 0 },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const stepsData = await graphql(
|
||||
`
|
||||
query GetWorkflowVersion($id: UUID!) {
|
||||
workflowVersion(filter: { id: { eq: $id } }) {
|
||||
steps
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: misroutedWorkflowVersionId },
|
||||
);
|
||||
|
||||
const pickRecordStep = stepsData.workflowVersion.steps.find(
|
||||
(step: { type: string }) => step.type === 'PICK_RECORD',
|
||||
);
|
||||
|
||||
// pointOfContact is a many-to-one relation on opportunity, but it points
|
||||
// to person, not the company pool — the silent misrouting the guard blocks.
|
||||
await graphql(
|
||||
`
|
||||
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
|
||||
updateWorkflowVersionStep(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
input: {
|
||||
workflowVersionId: misroutedWorkflowVersionId,
|
||||
step: {
|
||||
...pickRecordStep,
|
||||
settings: {
|
||||
...pickRecordStep.settings,
|
||||
input: {
|
||||
objectName: 'company',
|
||||
strategy: 'LOAD_BALANCED',
|
||||
recordIds: [],
|
||||
loadBalance: {
|
||||
objectNameSingular: 'opportunity',
|
||||
fieldName: 'pointOfContact',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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: misroutedWorkflowVersionId },
|
||||
});
|
||||
|
||||
expect(activateResponse.body.errors).toBeDefined();
|
||||
expect(activateResponse.body.errors[0].message).toContain(
|
||||
'many-to-one relation',
|
||||
);
|
||||
expect(activateResponse.body.data?.activateWorkflowVersion).not.toBe(true);
|
||||
} finally {
|
||||
await graphql(
|
||||
`
|
||||
mutation DestroyWorkflow($id: ID!) {
|
||||
destroyWorkflow(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: misroutedWorkflowId },
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
import { isDefined } from '@/utils';
|
||||
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
|
||||
|
||||
export const workflowPickRecordStrategySchema = z.enum([
|
||||
'RANDOM',
|
||||
'ROUND_ROBIN',
|
||||
'LOAD_BALANCED',
|
||||
]);
|
||||
|
||||
export const workflowPickRecordActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
objectName: z.string(),
|
||||
strategy: workflowPickRecordStrategySchema,
|
||||
recordIds: z.array(z.string()),
|
||||
}),
|
||||
input: z
|
||||
.object({
|
||||
objectName: z.string(),
|
||||
strategy: workflowPickRecordStrategySchema,
|
||||
recordIds: z.array(z.string()),
|
||||
loadBalance: z
|
||||
.object({
|
||||
objectNameSingular: z.string(),
|
||||
fieldName: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (
|
||||
input.strategy === 'LOAD_BALANCED' &&
|
||||
!isDefined(input.loadBalance)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['loadBalance'],
|
||||
message: 'loadBalance is required when strategy is LOAD_BALANCED',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
+20
@@ -174,6 +174,26 @@ describe('searchVariableInOutputSchema - record output schema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PICK_RECORD routing', () => {
|
||||
it('routes a Pick Record output variable through the record schema', () => {
|
||||
const result = searchVariableInOutputSchema({
|
||||
schema: mockRecordSchema,
|
||||
stepType: 'PICK_RECORD',
|
||||
stepName: 'Pick Company',
|
||||
rawVariableName: '{{step1.name}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Company Name',
|
||||
variablePathLabel: 'Pick Company > Company Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
compositeFieldSubFieldName: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle undefined recordOutputSchema', () => {
|
||||
const result = searchVariableThroughRecordOutputSchema({
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ const RECORD_STEP_TYPES = [
|
||||
'UPDATE_RECORD',
|
||||
'DELETE_RECORD',
|
||||
'UPSERT_RECORD',
|
||||
'PICK_RECORD',
|
||||
];
|
||||
|
||||
const isRecordOutputSchemaV2 = (
|
||||
|
||||
Reference in New Issue
Block a user