fix rls create new record with composite (#17243)
## Context buildValueFromFilter was not handling composite filters which was needed for RLS. This PR implements that and fix some issues with RLS Tested with a few composite + relation fields <img width="559" height="206" alt="Screenshot 2026-01-19 at 15 38 42" src="https://github.com/user-attachments/assets/d64afa6e-3e12-4843-a215-a693665112c5" />
This commit is contained in:
+112
-34
@@ -7,12 +7,22 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
|
||||
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
|
||||
import { buildCompositeValueFromSubField } from '@/object-record/record-table/utils/buildValueFromFilter';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { RelationType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
const mergeCompositeValues = (
|
||||
existingValue: unknown,
|
||||
incomingValue: unknown,
|
||||
) =>
|
||||
isPlainObject(existingValue) && isPlainObject(incomingValue)
|
||||
? { ...existingValue, ...incomingValue }
|
||||
: incomingValue;
|
||||
|
||||
export const useBuildRecordInputFromRLSPredicates = ({
|
||||
objectMetadataItem,
|
||||
@@ -37,9 +47,60 @@ export const useBuildRecordInputFromRLSPredicates = ({
|
||||
objectMetadataItem.id,
|
||||
);
|
||||
|
||||
const buildRecordInputFromRLSPredicates = (): Partial<ObjectRecord> => {
|
||||
const recordInput: Partial<ObjectRecord> = {};
|
||||
const getRecordInputFieldName = (fieldMetadataItem: {
|
||||
name: string;
|
||||
type: string;
|
||||
settings?: { relationType?: RelationType };
|
||||
}) =>
|
||||
fieldMetadataItem.type === 'RELATION' &&
|
||||
fieldMetadataItem.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
? `${fieldMetadataItem.name}Id`
|
||||
: fieldMetadataItem.name;
|
||||
|
||||
const getWorkspaceMemberFieldValue = ({
|
||||
workspaceMemberFieldMetadataId,
|
||||
workspaceMemberSubFieldName,
|
||||
}: {
|
||||
workspaceMemberFieldMetadataId?: string | null;
|
||||
workspaceMemberSubFieldName?: string | null;
|
||||
}) => {
|
||||
const workspaceMemberFieldMetadataItem =
|
||||
workspaceMemberObjectMetadataItem?.fields.find(
|
||||
(field) => field.id === workspaceMemberFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!isDefined(workspaceMemberFieldMetadataItem)) {
|
||||
throw new Error(
|
||||
`Workspace member field metadata item not found for id: ${workspaceMemberFieldMetadataId}`,
|
||||
);
|
||||
}
|
||||
|
||||
let workspaceMemberFieldValue =
|
||||
currentWorkspaceMemberRecord?.[workspaceMemberFieldMetadataItem.name];
|
||||
|
||||
if (isCompositeFieldType(workspaceMemberFieldMetadataItem.type)) {
|
||||
if (!workspaceMemberSubFieldName) {
|
||||
throw new Error(
|
||||
`Workspace member subfield name not found for field: ${workspaceMemberFieldMetadataItem.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const compositeValue = workspaceMemberFieldValue as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
workspaceMemberFieldValue = compositeValue?.[workspaceMemberSubFieldName];
|
||||
}
|
||||
|
||||
if (isUndefined(workspaceMemberFieldValue)) {
|
||||
throw new Error(
|
||||
`Current workspace member field value not found for field: ${workspaceMemberFieldMetadataItem.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return workspaceMemberFieldValue;
|
||||
};
|
||||
|
||||
const buildRecordInputFromRLSPredicates = (): Partial<ObjectRecord> => {
|
||||
const rlsPredicates = objectPermissions.rowLevelPermissionPredicates.filter(
|
||||
(predicate) => predicate.objectMetadataId === objectMetadataItem.id,
|
||||
);
|
||||
@@ -57,6 +118,8 @@ export const useBuildRecordInputFromRLSPredicates = ({
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
const recordInputFromDynamicFilters: Partial<ObjectRecord> = {};
|
||||
|
||||
rlsPredicatesAsRecordFilters.forEach((filter) => {
|
||||
const fieldMetadataItem = fieldMetadataItemMap.get(
|
||||
filter.fieldMetadataId,
|
||||
@@ -67,35 +130,42 @@ export const useBuildRecordInputFromRLSPredicates = ({
|
||||
}
|
||||
|
||||
if (isDefined(filter.rlsDynamicValue)) {
|
||||
const workspaceMemberFieldMetadataItem =
|
||||
workspaceMemberObjectMetadataItem?.fields.find(
|
||||
(field) =>
|
||||
field.id ===
|
||||
filter.rlsDynamicValue?.workspaceMemberFieldMetadataId,
|
||||
);
|
||||
const recordInputField = getRecordInputFieldName(fieldMetadataItem);
|
||||
const currentWorkspaceMemberFieldValue = getWorkspaceMemberFieldValue({
|
||||
workspaceMemberFieldMetadataId:
|
||||
filter.rlsDynamicValue?.workspaceMemberFieldMetadataId,
|
||||
workspaceMemberSubFieldName:
|
||||
filter.rlsDynamicValue?.workspaceMemberSubFieldName,
|
||||
});
|
||||
|
||||
if (!isDefined(workspaceMemberFieldMetadataItem)) {
|
||||
throw new Error(
|
||||
`Workspace member field metadata item not found for id: ${filter.rlsDynamicValue?.workspaceMemberFieldMetadataId}`,
|
||||
);
|
||||
if (isCompositeFieldType(fieldMetadataItem.type)) {
|
||||
if (!filter.subFieldName) {
|
||||
throw new Error(
|
||||
`Subfield name not found for composite field: ${fieldMetadataItem.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const compositeValue = buildCompositeValueFromSubField({
|
||||
compositeFieldType: fieldMetadataItem.type,
|
||||
subFieldName: filter.subFieldName,
|
||||
value: currentWorkspaceMemberFieldValue,
|
||||
});
|
||||
|
||||
if (!compositeValue) {
|
||||
throw new Error(
|
||||
`Composite subfield not found for field: ${fieldMetadataItem.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
recordInputFromDynamicFilters[recordInputField] =
|
||||
mergeCompositeValues(
|
||||
recordInputFromDynamicFilters[recordInputField],
|
||||
compositeValue,
|
||||
);
|
||||
} else {
|
||||
recordInputFromDynamicFilters[recordInputField] =
|
||||
currentWorkspaceMemberFieldValue;
|
||||
}
|
||||
|
||||
const recordInputField =
|
||||
fieldMetadataItem.type === 'RELATION' &&
|
||||
fieldMetadataItem.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
? `${fieldMetadataItem.name}Id`
|
||||
: fieldMetadataItem.name;
|
||||
|
||||
const currentWorkspaceMemberFieldValue =
|
||||
currentWorkspaceMemberRecord?.[workspaceMemberFieldMetadataItem.name];
|
||||
|
||||
if (isUndefined(currentWorkspaceMemberFieldValue)) {
|
||||
throw new Error(
|
||||
`Current workspace member field value not found for field: ${workspaceMemberFieldMetadataItem.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
recordInput[recordInputField] = currentWorkspaceMemberFieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,16 +175,24 @@ export const useBuildRecordInputFromRLSPredicates = ({
|
||||
(filter) => !isDefined(filter.rlsDynamicValue),
|
||||
);
|
||||
|
||||
const recordInputFromFilters = buildRecordInputFromFilter({
|
||||
const recordInputFromStaticFilters = buildRecordInputFromFilter({
|
||||
currentRecordFilters: staticFilters,
|
||||
objectMetadataItem,
|
||||
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
...recordInput,
|
||||
...recordInputFromFilters,
|
||||
const mergedRecordInput: Partial<ObjectRecord> = {
|
||||
...recordInputFromDynamicFilters,
|
||||
};
|
||||
|
||||
Object.entries(recordInputFromStaticFilters).forEach(([key, value]) => {
|
||||
mergedRecordInput[key] = mergeCompositeValues(
|
||||
mergedRecordInput[key],
|
||||
value,
|
||||
);
|
||||
});
|
||||
|
||||
return mergedRecordInput;
|
||||
};
|
||||
|
||||
return { buildRecordInputFromRLSPredicates };
|
||||
|
||||
+7
-3
@@ -77,15 +77,19 @@ export const useCreateNewIndexRecord = ({
|
||||
buildRecordInputFromRLSPredicates();
|
||||
const recordInputFromFilters = buildRecordInputFromFilters();
|
||||
|
||||
const mergedRecordInput = {
|
||||
...recordInputFromRLSPredicates,
|
||||
...recordInputFromFilters,
|
||||
...recordInput,
|
||||
};
|
||||
|
||||
const recordIndexOpenRecordIn = snapshot
|
||||
.getLoadable(recordIndexOpenRecordInState)
|
||||
.getValue();
|
||||
|
||||
const createdRecord = await createOneRecord({
|
||||
id: recordId,
|
||||
...recordInputFromRLSPredicates,
|
||||
...recordInputFromFilters,
|
||||
...recordInput,
|
||||
...mergedRecordInput,
|
||||
});
|
||||
|
||||
if (
|
||||
|
||||
+17
-2
@@ -3,7 +3,15 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { buildValueFromFilter } from '@/object-record/record-table/utils/buildValueFromFilter';
|
||||
import { type ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
const mergeCompositeValues = (
|
||||
existingValue: unknown,
|
||||
incomingValue: unknown,
|
||||
) =>
|
||||
isPlainObject(existingValue) && isPlainObject(incomingValue)
|
||||
? { ...existingValue, ...incomingValue }
|
||||
: incomingValue;
|
||||
|
||||
export const buildRecordInputFromFilter = ({
|
||||
currentRecordFilters,
|
||||
@@ -37,10 +45,17 @@ export const buildRecordInputFromFilter = ({
|
||||
}
|
||||
recordInput[`${fieldMetadataItem.name}Id`] = value;
|
||||
} else {
|
||||
recordInput[fieldMetadataItem.name] = buildValueFromFilter({
|
||||
const value = buildValueFromFilter({
|
||||
filter,
|
||||
options: fieldMetadataItem.options ?? undefined,
|
||||
});
|
||||
if (!isDefined(value)) {
|
||||
return;
|
||||
}
|
||||
recordInput[fieldMetadataItem.name] = mergeCompositeValues(
|
||||
recordInput[fieldMetadataItem.name],
|
||||
value,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+331
-126
@@ -1,122 +1,84 @@
|
||||
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
|
||||
import {
|
||||
type RecordFilter,
|
||||
type RecordFilterToRecordInputOperand,
|
||||
} from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import {
|
||||
FILTER_OPERANDS_MAP,
|
||||
getRecordFilterOperands,
|
||||
} from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from 'twenty-shared/constants';
|
||||
import {
|
||||
compositeTypeDefinitions,
|
||||
FieldMetadataType,
|
||||
ViewFilterOperand,
|
||||
type FieldMetadataOptions,
|
||||
} from 'twenty-shared/types';
|
||||
import { assertUnreachable, parseJson } from 'twenty-shared/utils';
|
||||
import { RelationType } from '~/generated-metadata/graphql';
|
||||
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
|
||||
|
||||
export const buildValueFromFilter = ({
|
||||
filter,
|
||||
options,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
}: {
|
||||
filter: RecordFilter;
|
||||
options?: FieldMetadataItemOption[];
|
||||
type FilterOption = {
|
||||
label: string;
|
||||
position: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type CompositeValue = Record<string, unknown>;
|
||||
|
||||
type ValueComputeContext = {
|
||||
value: string;
|
||||
operand: ViewFilterOperand;
|
||||
options?: FilterOption[] | null;
|
||||
relationType?: RelationType;
|
||||
currentWorkspaceMember?: CurrentWorkspaceMember;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (isCompositeFieldType(filter.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter.type === 'RAW_JSON') {
|
||||
return;
|
||||
}
|
||||
|
||||
const operands = FILTER_OPERANDS_MAP[filter.type];
|
||||
if (!operands.some((operand) => operand === filter.operand)) {
|
||||
throw new Error('Operand not supported for this field type');
|
||||
}
|
||||
|
||||
switch (filter.type) {
|
||||
case 'TEXT': {
|
||||
return computeValueFromFilterText(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['TEXT'][number],
|
||||
filter.value,
|
||||
);
|
||||
}
|
||||
case 'RATING':
|
||||
return computeValueFromFilterRating(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['RATING'][number],
|
||||
filter.value,
|
||||
options,
|
||||
);
|
||||
case 'DATE_TIME':
|
||||
case 'DATE':
|
||||
return computeValueFromFilterDate(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['DATE_TIME'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'NUMBER':
|
||||
return computeValueFromFilterNumber(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['NUMBER'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'BOOLEAN':
|
||||
return computeValueFromFilterBoolean(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['BOOLEAN'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'TS_VECTOR':
|
||||
return computeValueFromFilterTSVector(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['TS_VECTOR'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'ARRAY':
|
||||
return computeValueFromFilterArray(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['ARRAY'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'SELECT':
|
||||
return computeValueFromFilterSelect(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['SELECT'][number],
|
||||
filter.value,
|
||||
options,
|
||||
);
|
||||
case 'MULTI_SELECT':
|
||||
return computeValueFromFilterMultiSelect(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['MULTI_SELECT'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'RELATION': {
|
||||
return computeValueFromFilterRelation(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['RELATION'][number],
|
||||
filter.value,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
);
|
||||
}
|
||||
case 'UUID':
|
||||
return computeValueFromFilterUUID(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['UUID'][number],
|
||||
filter.value,
|
||||
);
|
||||
default:
|
||||
assertUnreachable(filter.type);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterText = (
|
||||
operand: RecordFilterToRecordInputOperand<'TEXT'>,
|
||||
type ValueHandler = (context: ValueComputeContext) => unknown;
|
||||
|
||||
type CompositeFilterContext = {
|
||||
filter: RecordFilter;
|
||||
relationType?: RelationType;
|
||||
currentWorkspaceMember?: CurrentWorkspaceMember;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const getCompositeSubFieldProperty = ({
|
||||
compositeFieldType,
|
||||
subFieldName,
|
||||
}: {
|
||||
compositeFieldType: FieldMetadataType;
|
||||
subFieldName: string;
|
||||
}) => {
|
||||
const compositeType = compositeTypeDefinitions.get(compositeFieldType);
|
||||
if (!compositeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
return compositeType.properties.find(
|
||||
(property) => property.name === subFieldName,
|
||||
);
|
||||
};
|
||||
|
||||
type ContainsBasedOperand =
|
||||
| ViewFilterOperand.CONTAINS
|
||||
| ViewFilterOperand.DOES_NOT_CONTAIN
|
||||
| ViewFilterOperand.IS_EMPTY
|
||||
| ViewFilterOperand.IS_NOT_EMPTY;
|
||||
|
||||
const computeValueFromContainsOperand = (
|
||||
operand: ContainsBasedOperand,
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.CONTAINS:
|
||||
return value;
|
||||
case ViewFilterOperand.IS_NOT_EMPTY:
|
||||
return value;
|
||||
case ViewFilterOperand.IS_EMPTY:
|
||||
case ViewFilterOperand.DOES_NOT_CONTAIN:
|
||||
case ViewFilterOperand.IS_EMPTY:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
@@ -151,17 +113,15 @@ const computeValueFromFilterNumber = (
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
//TODO: we shouln't create values from those filters as it makes no sense for the user
|
||||
// TODO: we shouln't create values from those filters as it makes no sense for the user
|
||||
case ViewFilterOperand.GREATER_THAN_OR_EQUAL:
|
||||
return Number(value) + 1;
|
||||
case ViewFilterOperand.LESS_THAN_OR_EQUAL:
|
||||
return Number(value) - 1;
|
||||
case ViewFilterOperand.IS_NOT_EMPTY:
|
||||
return Number(value);
|
||||
case ViewFilterOperand.IS:
|
||||
return Number(value);
|
||||
case ViewFilterOperand.IS_NOT:
|
||||
return undefined;
|
||||
case ViewFilterOperand.IS_EMPTY:
|
||||
return undefined;
|
||||
default:
|
||||
@@ -181,28 +141,12 @@ const computeValueFromFilterBoolean = (
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterArray = (
|
||||
operand: RecordFilterToRecordInputOperand<'ARRAY'>,
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.CONTAINS:
|
||||
case ViewFilterOperand.IS_NOT_EMPTY:
|
||||
return value;
|
||||
case ViewFilterOperand.DOES_NOT_CONTAIN:
|
||||
case ViewFilterOperand.IS_EMPTY:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterRating = (
|
||||
operand: RecordFilterToRecordInputOperand<'RATING'>,
|
||||
value: string,
|
||||
options?: FieldMetadataItemOption[],
|
||||
options?: FilterOption[] | null,
|
||||
) => {
|
||||
const option = options?.find((option) => option.label === value);
|
||||
const option = options?.find((optionItem) => optionItem.label === value);
|
||||
if (!option) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -213,15 +157,15 @@ const computeValueFromFilterRating = (
|
||||
return option.value;
|
||||
case ViewFilterOperand.GREATER_THAN_OR_EQUAL: {
|
||||
const plusOne = options?.find(
|
||||
(opt) => opt.position === option.position + 1,
|
||||
(optionItem) => optionItem.position === option.position + 1,
|
||||
)?.value;
|
||||
return plusOne ? plusOne : option.value;
|
||||
return plusOne ?? option.value;
|
||||
}
|
||||
case ViewFilterOperand.LESS_THAN_OR_EQUAL: {
|
||||
const minusOne = options?.find(
|
||||
(opt) => opt.position === option.position - 1,
|
||||
(optionItem) => optionItem.position === option.position - 1,
|
||||
)?.value;
|
||||
return minusOne ? minusOne : option.value;
|
||||
return minusOne ?? option.value;
|
||||
}
|
||||
case ViewFilterOperand.IS_EMPTY:
|
||||
return undefined;
|
||||
@@ -233,14 +177,16 @@ const computeValueFromFilterRating = (
|
||||
const computeValueFromFilterSelect = (
|
||||
operand: RecordFilterToRecordInputOperand<'SELECT'>,
|
||||
value: string,
|
||||
options?: FieldMetadataItemOption[],
|
||||
options?: FilterOption[] | null,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.IS:
|
||||
case ViewFilterOperand.IS_NOT_EMPTY:
|
||||
try {
|
||||
const valueParsed = parseJson<string[]>(value)?.[0];
|
||||
const option = options?.find((option) => option.value === valueParsed);
|
||||
const option = options?.find(
|
||||
(optionItem) => optionItem.value === valueParsed,
|
||||
);
|
||||
if (!option) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -265,7 +211,7 @@ const computeValueFromFilterMultiSelect = (
|
||||
case ViewFilterOperand.IS_NOT_EMPTY:
|
||||
try {
|
||||
const parsedValue = parseJson<string[]>(value);
|
||||
return parsedValue ? parsedValue : undefined;
|
||||
return parsedValue ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -299,7 +245,7 @@ const computeValueFromFilterRelation = (
|
||||
return parsedValue?.selectedRecordIds?.[0];
|
||||
}
|
||||
}
|
||||
return undefined; //todo
|
||||
return undefined; // todo
|
||||
}
|
||||
case ViewFilterOperand.IS_NOT:
|
||||
case ViewFilterOperand.IS_NOT_EMPTY: // todo
|
||||
@@ -333,3 +279,262 @@ const computeValueFromFilterUUID = (
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const VALUE_HANDLER_REGISTRY: Partial<Record<FieldMetadataType, ValueHandler>> =
|
||||
{
|
||||
[FieldMetadataType.TEXT]: ({ operand, value }) =>
|
||||
computeValueFromContainsOperand(operand as ContainsBasedOperand, value),
|
||||
[FieldMetadataType.ARRAY]: ({ operand, value }) =>
|
||||
computeValueFromContainsOperand(operand as ContainsBasedOperand, value),
|
||||
[FieldMetadataType.RAW_JSON]: ({ operand, value }) =>
|
||||
computeValueFromContainsOperand(operand as ContainsBasedOperand, value),
|
||||
[FieldMetadataType.DATE_TIME]: ({ operand, value }) =>
|
||||
computeValueFromFilterDate(
|
||||
operand as RecordFilterToRecordInputOperand<'DATE_TIME'>,
|
||||
value,
|
||||
),
|
||||
[FieldMetadataType.DATE]: ({ operand, value }) =>
|
||||
computeValueFromFilterDate(
|
||||
operand as RecordFilterToRecordInputOperand<'DATE_TIME'>,
|
||||
value,
|
||||
),
|
||||
[FieldMetadataType.NUMBER]: ({ operand, value }) =>
|
||||
computeValueFromFilterNumber(
|
||||
operand as RecordFilterToRecordInputOperand<'NUMBER'>,
|
||||
value,
|
||||
),
|
||||
[FieldMetadataType.NUMERIC]: ({ operand, value }) =>
|
||||
computeValueFromFilterNumber(
|
||||
operand as RecordFilterToRecordInputOperand<'NUMBER'>,
|
||||
value,
|
||||
),
|
||||
[FieldMetadataType.BOOLEAN]: ({ operand, value }) =>
|
||||
computeValueFromFilterBoolean(
|
||||
operand as RecordFilterToRecordInputOperand<'BOOLEAN'>,
|
||||
value,
|
||||
),
|
||||
[FieldMetadataType.RATING]: ({ operand, value, options }) =>
|
||||
computeValueFromFilterRating(
|
||||
operand as RecordFilterToRecordInputOperand<'RATING'>,
|
||||
value,
|
||||
options,
|
||||
),
|
||||
[FieldMetadataType.SELECT]: ({ operand, value, options }) =>
|
||||
computeValueFromFilterSelect(
|
||||
operand as RecordFilterToRecordInputOperand<'SELECT'>,
|
||||
value,
|
||||
options,
|
||||
),
|
||||
[FieldMetadataType.MULTI_SELECT]: ({ operand, value }) =>
|
||||
computeValueFromFilterMultiSelect(
|
||||
operand as RecordFilterToRecordInputOperand<'MULTI_SELECT'>,
|
||||
value,
|
||||
),
|
||||
[FieldMetadataType.RELATION]: ({
|
||||
operand,
|
||||
value,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
}) =>
|
||||
computeValueFromFilterRelation(
|
||||
operand as RecordFilterToRecordInputOperand<'RELATION'>,
|
||||
value,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
),
|
||||
[FieldMetadataType.TS_VECTOR]: ({ operand, value }) =>
|
||||
computeValueFromFilterTSVector(
|
||||
operand as RecordFilterToRecordInputOperand<'TS_VECTOR'>,
|
||||
value,
|
||||
),
|
||||
[FieldMetadataType.UUID]: ({ operand, value }) =>
|
||||
computeValueFromFilterUUID(
|
||||
operand as RecordFilterToRecordInputOperand<'UUID'>,
|
||||
value,
|
||||
),
|
||||
};
|
||||
|
||||
const COMPOSITE_FIELD_VALUE_TRANSFORMERS: Partial<
|
||||
Record<FieldMetadataType, (value: unknown, subFieldName: string) => unknown>
|
||||
> = {
|
||||
[FieldMetadataType.CURRENCY]: (value, subFieldName) =>
|
||||
subFieldName ===
|
||||
COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES[FieldMetadataType.CURRENCY]
|
||||
.amountMicros
|
||||
? convertCurrencyAmountToCurrencyMicros(Number(value))
|
||||
: value,
|
||||
};
|
||||
|
||||
const SUPPORTED_COMPOSITE_FIELD_TYPES: FieldMetadataType[] = [
|
||||
FieldMetadataType.CURRENCY,
|
||||
FieldMetadataType.ADDRESS,
|
||||
FieldMetadataType.FULL_NAME,
|
||||
FieldMetadataType.LINKS,
|
||||
FieldMetadataType.EMAILS,
|
||||
FieldMetadataType.PHONES,
|
||||
FieldMetadataType.ACTOR,
|
||||
FieldMetadataType.RICH_TEXT_V2,
|
||||
];
|
||||
|
||||
const computeValueFromSubFieldType = ({
|
||||
fieldType,
|
||||
filter,
|
||||
options,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
}: {
|
||||
fieldType: FieldMetadataType;
|
||||
filter: RecordFilter;
|
||||
options?: FieldMetadataOptions | null;
|
||||
relationType?: RelationType;
|
||||
currentWorkspaceMember?: CurrentWorkspaceMember;
|
||||
label?: string;
|
||||
}) => {
|
||||
const handler = VALUE_HANDLER_REGISTRY[fieldType];
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
return handler({
|
||||
value: filter.value,
|
||||
operand: filter.operand,
|
||||
options: options as FilterOption[] | null | undefined,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
});
|
||||
};
|
||||
|
||||
export const buildCompositeValueFromSubField = ({
|
||||
compositeFieldType,
|
||||
subFieldName,
|
||||
value,
|
||||
transformValue,
|
||||
}: {
|
||||
compositeFieldType: FieldMetadataType;
|
||||
subFieldName: string;
|
||||
value: unknown;
|
||||
transformValue?: (value: unknown) => unknown;
|
||||
}): CompositeValue | undefined => {
|
||||
const subFieldProperty = getCompositeSubFieldProperty({
|
||||
compositeFieldType,
|
||||
subFieldName,
|
||||
});
|
||||
|
||||
if (!subFieldProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
[subFieldName]: transformValue ? transformValue(value) : value,
|
||||
};
|
||||
};
|
||||
|
||||
const buildCompositeValueFromFilter = ({
|
||||
filter,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
}: CompositeFilterContext) => {
|
||||
if (!filter.subFieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const compositeFieldType = filter.type as FieldMetadataType;
|
||||
|
||||
if (!SUPPORTED_COMPOSITE_FIELD_TYPES.includes(compositeFieldType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subFieldProperty = getCompositeSubFieldProperty({
|
||||
compositeFieldType,
|
||||
subFieldName: filter.subFieldName,
|
||||
});
|
||||
|
||||
if (!subFieldProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operands = getRecordFilterOperands({
|
||||
filterType: filter.type,
|
||||
subFieldName: filter.subFieldName,
|
||||
});
|
||||
|
||||
if (!operands.some((operand) => operand === filter.operand)) {
|
||||
throw new Error('Operand not supported for this sub field type');
|
||||
}
|
||||
|
||||
const subFieldValue = computeValueFromSubFieldType({
|
||||
fieldType: subFieldProperty.type,
|
||||
filter,
|
||||
options: subFieldProperty.options,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
});
|
||||
|
||||
if (subFieldValue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transformValue = COMPOSITE_FIELD_VALUE_TRANSFORMERS[compositeFieldType];
|
||||
|
||||
return buildCompositeValueFromSubField({
|
||||
compositeFieldType,
|
||||
subFieldName: filter.subFieldName,
|
||||
value: subFieldValue,
|
||||
transformValue: transformValue
|
||||
? (fieldValue) =>
|
||||
transformValue(fieldValue, filter.subFieldName as string)
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const buildValueFromFilter = ({
|
||||
filter,
|
||||
options,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
}: {
|
||||
filter: RecordFilter;
|
||||
options?: FilterOption[] | null;
|
||||
relationType?: RelationType;
|
||||
currentWorkspaceMember?: CurrentWorkspaceMember;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (isCompositeFieldType(filter.type)) {
|
||||
return buildCompositeValueFromFilter({
|
||||
filter,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
if (filter.type === 'RAW_JSON') {
|
||||
return;
|
||||
}
|
||||
|
||||
const operands = FILTER_OPERANDS_MAP[filter.type];
|
||||
if (!operands.some((operand) => operand === filter.operand)) {
|
||||
throw new Error('Operand not supported for this field type');
|
||||
}
|
||||
|
||||
const handler = VALUE_HANDLER_REGISTRY[filter.type as FieldMetadataType];
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
return handler({
|
||||
value: filter.value,
|
||||
operand: filter.operand,
|
||||
options,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
});
|
||||
};
|
||||
|
||||
+29
-2
@@ -1,5 +1,6 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { isFilterOperandExpectingValue } from '@/object-record/object-filter-dropdown/utils/isFilterOperandExpectingValue';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsRolePermissionsObjectLevelObjectFieldPermissionTable } from '@/settings/roles/role-permissions/object-level-permissions/field-permissions/components/SettingsRolePermissionsObjectLevelObjectFieldPermissionTable';
|
||||
import { SettingsRolePermissionsObjectLevelObjectFormObjectLevel } from '@/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectFormObjectLevel';
|
||||
@@ -10,7 +11,7 @@ import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { SettingsPath, type ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import {
|
||||
@@ -105,6 +106,31 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({
|
||||
? getSettingsPath(SettingsPath.AIAgentDetail, { agentId: agent.id })
|
||||
: getSettingsPath(SettingsPath.RoleDetail, { roleId });
|
||||
|
||||
const objectPredicates =
|
||||
settingsDraftRole.rowLevelPermissionPredicates?.filter(
|
||||
(predicate) => predicate.objectMetadataId === objectMetadataItem.id,
|
||||
) ?? [];
|
||||
|
||||
const hasInvalidPredicate = objectPredicates.some((predicate) => {
|
||||
if (isDefined(predicate.workspaceMemberFieldMetadataId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const operand = predicate.operand as unknown as ViewFilterOperand;
|
||||
|
||||
if (!isFilterOperandExpectingValue(operand)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!isDefined(predicate.value) ||
|
||||
predicate.value === '' ||
|
||||
predicate.value === '[]'
|
||||
);
|
||||
});
|
||||
|
||||
const isFinishDisabled = hasInvalidPredicate;
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`2. Set ${objectLabelPlural} permissions`}
|
||||
@@ -115,7 +141,8 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({
|
||||
variant="secondary"
|
||||
size="small"
|
||||
accent="blue"
|
||||
to={finishButtonPath}
|
||||
to={isFinishDisabled ? undefined : finishButtonPath}
|
||||
disabled={isFinishDisabled}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
+14
-4
@@ -3,6 +3,7 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { getFilterTypeFromFieldType } from 'twenty-shared/utils';
|
||||
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { AdvancedFilterFieldSelectSearchInput } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectSearchInput';
|
||||
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
|
||||
@@ -15,6 +16,7 @@ import { objectFilterDropdownSearchInputComponentState } from '@/object-record/o
|
||||
import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems';
|
||||
import { RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
|
||||
@@ -25,6 +27,7 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { useContext } from 'react';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
type SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenuProps =
|
||||
{
|
||||
@@ -53,10 +56,17 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
|
||||
);
|
||||
|
||||
const filteredFieldMetadataItems = filterableFieldMetadataItems
|
||||
.filter((fieldMetadataItem) =>
|
||||
fieldMetadataItem.label
|
||||
.toLocaleLowerCase()
|
||||
.includes(objectFilterDropdownSearchInput.toLocaleLowerCase()),
|
||||
.filter(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.label
|
||||
.toLocaleLowerCase()
|
||||
.includes(objectFilterDropdownSearchInput.toLocaleLowerCase()) &&
|
||||
(RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes(
|
||||
fieldMetadataItem.type,
|
||||
) ||
|
||||
(fieldMetadataItem.type === FieldMetadataType.RELATION &&
|
||||
fieldMetadataItem.relation?.targetObjectMetadata.nameSingular ===
|
||||
CoreObjectNameSingular.WorkspaceMember)),
|
||||
)
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
|
||||
+6
-1
@@ -22,6 +22,7 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
|
||||
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
|
||||
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
|
||||
import { type CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
|
||||
import { RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
@@ -123,7 +124,11 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionMeValueSelec
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetFieldType === FieldMetadataType.RELATION) {
|
||||
if (
|
||||
!RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes(
|
||||
targetFieldType,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
export const RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES = [
|
||||
FieldMetadataType.BOOLEAN,
|
||||
FieldMetadataType.NUMBER,
|
||||
FieldMetadataType.NUMERIC,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
FieldMetadataType.DATE,
|
||||
FieldMetadataType.SELECT,
|
||||
FieldMetadataType.MULTI_SELECT,
|
||||
FieldMetadataType.TEXT,
|
||||
FieldMetadataType.LINKS,
|
||||
FieldMetadataType.PHONES,
|
||||
FieldMetadataType.EMAILS,
|
||||
FieldMetadataType.FULL_NAME,
|
||||
FieldMetadataType.ADDRESS,
|
||||
];
|
||||
+39
-2
@@ -1,16 +1,21 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
RecordFilterGroupLogicalOperator,
|
||||
} from 'twenty-shared/types';
|
||||
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { useChildRecordFiltersAndRecordFilterGroups } from '@/object-record/advanced-filter/hooks/useChildRecordFiltersAndRecordFilterGroups';
|
||||
import { useGetDefaultFieldMetadataItemForFilter } from '@/object-record/advanced-filter/hooks/useGetDefaultFieldMetadataItemForFilter';
|
||||
import { useSetRecordFilterUsedInAdvancedFilterDropdownRow } from '@/object-record/advanced-filter/hooks/useSetRecordFilterUsedInAdvancedFilterDropdownRow';
|
||||
import { rootLevelRecordFilterGroupComponentSelector } from '@/object-record/advanced-filter/states/rootLevelRecordFilterGroupComponentSelector';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { useUpsertRecordFilterGroup } from '@/object-record/record-filter-group/hooks/useUpsertRecordFilterGroup';
|
||||
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
|
||||
import { useCreateEmptyRecordFilterFromFieldMetadataItem } from '@/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem';
|
||||
@@ -18,6 +23,7 @@ import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUp
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType';
|
||||
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
|
||||
type UseRecordLevelPermissionFilterActionsProps = {
|
||||
@@ -51,7 +57,38 @@ export const useRecordLevelPermissionFilterActions = ({
|
||||
});
|
||||
|
||||
const getDefaultFieldMetadataItemForRLS = () => {
|
||||
const fieldMetadataItemForId = availableFieldMetadataItemsForFilter.find(
|
||||
const availableFieldMetadataItemsForRls =
|
||||
availableFieldMetadataItemsForFilter.filter((fieldMetadataItem) => {
|
||||
return (
|
||||
RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes(
|
||||
fieldMetadataItem.type,
|
||||
) ||
|
||||
(fieldMetadataItem.type === FieldMetadataType.RELATION &&
|
||||
fieldMetadataItem.relation?.targetObjectMetadata.nameSingular ===
|
||||
CoreObjectNameSingular.WorkspaceMember)
|
||||
);
|
||||
});
|
||||
|
||||
const nonCompositeFieldMetadataItems = availableFieldMetadataItemsForRls
|
||||
.toSorted((firstField, secondField) =>
|
||||
firstField.label.localeCompare(secondField.label),
|
||||
)
|
||||
.filter((fieldMetadataItem) => {
|
||||
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
|
||||
|
||||
return !isCompositeFieldType(filterType);
|
||||
});
|
||||
|
||||
const defaultFieldMetadataItemForRls =
|
||||
nonCompositeFieldMetadataItems.find(
|
||||
(fieldMetadataItem) => fieldMetadataItem.name !== 'id',
|
||||
) ?? nonCompositeFieldMetadataItems[0];
|
||||
|
||||
if (isDefined(defaultFieldMetadataItemForRls)) {
|
||||
return defaultFieldMetadataItemForRls;
|
||||
}
|
||||
|
||||
const fieldMetadataItemForId = availableFieldMetadataItemsForRls.find(
|
||||
(fieldMetadataItem) => fieldMetadataItem.name === 'id',
|
||||
);
|
||||
|
||||
|
||||
+1
-2
@@ -3,7 +3,7 @@ import {
|
||||
FieldMetadataType,
|
||||
type ObjectRecord,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { type IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
|
||||
@@ -24,7 +24,6 @@ import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-m
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { isPlainObject } from 'src/utils/is-plain-object';
|
||||
|
||||
// TODO: Refacto-common - Rename CommonRecordsToGraphqlConnectionHelper
|
||||
export class ObjectRecordsToGraphqlConnectionHelper {
|
||||
|
||||
+8
-1
@@ -283,8 +283,15 @@ export class WorkspaceInsertQueryBuilder<
|
||||
? this.expressionMap.valuesSet
|
||||
: [this.expressionMap.valuesSet];
|
||||
|
||||
const valuesToInsertFormatted = formatResult<T[]>(
|
||||
valuesToInsert,
|
||||
objectMetadata,
|
||||
this.internalContext.flatObjectMetadataMaps,
|
||||
this.internalContext.flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
validateRLSPredicatesForRecords({
|
||||
records: valuesToInsert,
|
||||
records: valuesToInsertFormatted,
|
||||
objectMetadata,
|
||||
internalContext: this.internalContext,
|
||||
authContext: this.authContext,
|
||||
|
||||
+9
-2
@@ -177,7 +177,7 @@ export class WorkspaceUpdateQueryBuilder<
|
||||
this.applyRowLevelPermissionPredicates();
|
||||
|
||||
const valuesSet = this.expressionMap.valuesSet ?? {};
|
||||
const updatedRecords: T[] = formattedBefore.map(
|
||||
const updatedRecords: T[] = before.map(
|
||||
(record, index) =>
|
||||
({
|
||||
...record,
|
||||
@@ -571,8 +571,15 @@ export class WorkspaceUpdateQueryBuilder<
|
||||
this.internalContext,
|
||||
);
|
||||
|
||||
const updatedRecordsFormatted = formatResult<T[]>(
|
||||
updatedRecords,
|
||||
objectMetadata,
|
||||
this.internalContext.flatObjectMetadataMaps,
|
||||
this.internalContext.flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
validateRLSPredicatesForRecords({
|
||||
records: updatedRecords,
|
||||
records: updatedRecordsFormatted,
|
||||
objectMetadata,
|
||||
internalContext: this.internalContext,
|
||||
authContext: this.authContext,
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
|
||||
|
||||
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 { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util';
|
||||
|
||||
describe('isRecordMatchingRLSRowLevelPermissionPredicate', () => {
|
||||
const createMockFlatObjectMetadata = (
|
||||
fieldMetadataIds: string[],
|
||||
): FlatObjectMetadata =>
|
||||
({
|
||||
id: 'test-object-id',
|
||||
nameSingular: 'test',
|
||||
namePlural: 'tests',
|
||||
labelSingular: 'Test',
|
||||
labelPlural: 'Tests',
|
||||
icon: 'IconTest',
|
||||
targetTableName: 'test',
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isAuditLogged: false,
|
||||
isSearchable: false,
|
||||
workspaceId: 'test-workspace-id',
|
||||
universalIdentifier: 'test-object-id',
|
||||
indexMetadataIds: [],
|
||||
fieldMetadataIds,
|
||||
viewIds: [],
|
||||
applicationId: 'test-application-id',
|
||||
isLabelSyncedWithName: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
shortcut: null,
|
||||
description: null,
|
||||
standardOverrides: null,
|
||||
isUIReadOnly: false,
|
||||
standardId: null,
|
||||
labelIdentifierFieldMetadataId: null,
|
||||
imageIdentifierFieldMetadataId: null,
|
||||
duplicateCriteria: null,
|
||||
}) as FlatObjectMetadata;
|
||||
|
||||
const createMockFlatFieldMetadata = (
|
||||
id: string,
|
||||
name: string,
|
||||
type: FieldMetadataType,
|
||||
settings?: Record<string, unknown>,
|
||||
): FlatFieldMetadata =>
|
||||
({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
label: name,
|
||||
objectMetadataId: 'test-object-id',
|
||||
isLabelSyncedWithName: true,
|
||||
isNullable: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
universalIdentifier: id,
|
||||
viewFieldIds: [],
|
||||
viewFilterIds: [],
|
||||
kanbanAggregateOperationViewIds: [],
|
||||
calendarViewIds: [],
|
||||
mainGroupByFieldMetadataViewIds: [],
|
||||
applicationId: null,
|
||||
settings,
|
||||
}) as unknown as FlatFieldMetadata;
|
||||
|
||||
const buildFlatFieldMetadataMaps = (
|
||||
fields: FlatFieldMetadata[],
|
||||
): FlatEntityMaps<FlatFieldMetadata> => ({
|
||||
byId: fields.reduce(
|
||||
(accumulator, field) => {
|
||||
accumulator[field.id] = field;
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
{} as Record<string, FlatFieldMetadata>,
|
||||
),
|
||||
idByUniversalIdentifier: fields.reduce(
|
||||
(accumulator, field) => {
|
||||
accumulator[field.universalIdentifier] = field.id;
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
universalIdentifiersByApplicationId: {},
|
||||
});
|
||||
|
||||
const fieldMetadata = [
|
||||
createMockFlatFieldMetadata(
|
||||
'job-title-id',
|
||||
'jobTitle',
|
||||
FieldMetadataType.TEXT,
|
||||
),
|
||||
createMockFlatFieldMetadata('name-id', 'name', FieldMetadataType.FULL_NAME),
|
||||
createMockFlatFieldMetadata(
|
||||
'address-id',
|
||||
'address',
|
||||
FieldMetadataType.ADDRESS,
|
||||
),
|
||||
createMockFlatFieldMetadata(
|
||||
'company-id',
|
||||
'company',
|
||||
FieldMetadataType.RELATION,
|
||||
{
|
||||
joinColumnName: 'companyId',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const flatObjectMetadata = createMockFlatObjectMetadata(
|
||||
fieldMetadata.map((field) => field.id),
|
||||
);
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fieldMetadata);
|
||||
|
||||
const baseRecord: ObjectRecord = {
|
||||
jobTitle: 'Engineer',
|
||||
name: {
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
},
|
||||
address: {
|
||||
addressStreet1: 'Main Street',
|
||||
addressCity: 'Paris',
|
||||
},
|
||||
companyId: 'company-1',
|
||||
deletedAt: null,
|
||||
id: 'record-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as ObjectRecord;
|
||||
|
||||
it('returns true for an empty filter on non-deleted record', () => {
|
||||
const result = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: baseRecord,
|
||||
filter: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for deleted records without deletedAt filter', () => {
|
||||
const result = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: { ...baseRecord, deletedAt: new Date().toISOString() },
|
||||
filter: {
|
||||
jobTitle: {
|
||||
eq: 'Engineer',
|
||||
},
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('treats multiple filter keys as an implicit and', () => {
|
||||
const result = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: baseRecord,
|
||||
filter: {
|
||||
jobTitle: {
|
||||
eq: 'Engineer',
|
||||
},
|
||||
name: {
|
||||
firstName: {
|
||||
eq: 'Jane',
|
||||
},
|
||||
},
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('treats "or" with object as an "and"', () => {
|
||||
const matchingResult = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: baseRecord,
|
||||
filter: {
|
||||
or: {
|
||||
jobTitle: {
|
||||
eq: 'Engineer',
|
||||
},
|
||||
name: {
|
||||
lastName: {
|
||||
eq: 'Doe',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
const nonMatchingResult = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: {
|
||||
...baseRecord,
|
||||
name: {
|
||||
...baseRecord.name,
|
||||
lastName: 'Smith',
|
||||
},
|
||||
},
|
||||
filter: {
|
||||
or: {
|
||||
jobTitle: {
|
||||
eq: 'Engineer',
|
||||
},
|
||||
name: {
|
||||
lastName: {
|
||||
eq: 'Doe',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(matchingResult).toBe(true);
|
||||
expect(nonMatchingResult).toBe(false);
|
||||
});
|
||||
|
||||
it('supports "not" filter negation', () => {
|
||||
const result = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: baseRecord,
|
||||
filter: {
|
||||
not: {
|
||||
jobTitle: {
|
||||
eq: 'Engineer',
|
||||
},
|
||||
},
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('matches composite address filters using at least one sub-field', () => {
|
||||
const result = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: baseRecord,
|
||||
filter: {
|
||||
address: {
|
||||
addressStreet1: {
|
||||
eq: 'Main Street',
|
||||
},
|
||||
addressCity: {
|
||||
eq: 'London',
|
||||
},
|
||||
},
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('supports relation join column filters', () => {
|
||||
const result = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: baseRecord,
|
||||
filter: {
|
||||
companyId: {
|
||||
eq: 'company-1',
|
||||
},
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
+28
-22
@@ -207,16 +207,22 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
);
|
||||
}
|
||||
|
||||
const recordFieldValue = record[filterKey];
|
||||
|
||||
if (!isDefined(recordFieldValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (objectMetadataField.type) {
|
||||
case FieldMetadataType.RATING:
|
||||
return isMatchingRatingFilter({
|
||||
ratingFilter: filterValue as RatingFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
case FieldMetadataType.TEXT: {
|
||||
return isMatchingStringFilter({
|
||||
stringFilter: filterValue as StringFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.RICH_TEXT: {
|
||||
@@ -225,35 +231,35 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
// This should be tackled in Q4'24
|
||||
return isMatchingStringFilter({
|
||||
stringFilter: filterValue as StringFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.RICH_TEXT_V2: {
|
||||
return isMatchingRichTextV2Filter({
|
||||
richTextV2Filter: filterValue as RichTextV2Filter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.SELECT:
|
||||
return isMatchingSelectFilter({
|
||||
selectFilter: filterValue as SelectFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
case FieldMetadataType.MULTI_SELECT:
|
||||
return isMatchingMultiSelectFilter({
|
||||
multiSelectFilter: filterValue as MultiSelectFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
case FieldMetadataType.ARRAY: {
|
||||
return isMatchingArrayFilter({
|
||||
arrayFilter: filterValue as ArrayFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.RAW_JSON: {
|
||||
return isMatchingRawJsonFilter({
|
||||
rawJsonFilter: filterValue as RawJsonFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.FULL_NAME: {
|
||||
@@ -263,12 +269,12 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
(fullNameFilter.firstName === undefined ||
|
||||
isMatchingStringFilter({
|
||||
stringFilter: fullNameFilter.firstName,
|
||||
value: record[filterKey].firstName,
|
||||
value: recordFieldValue.firstName,
|
||||
})) &&
|
||||
(fullNameFilter.lastName === undefined ||
|
||||
isMatchingStringFilter({
|
||||
stringFilter: fullNameFilter.lastName,
|
||||
value: record[filterKey].lastName,
|
||||
value: recordFieldValue.lastName,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -293,7 +299,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
|
||||
return isMatchingStringFilter({
|
||||
stringFilter: value,
|
||||
value: record[filterKey][key],
|
||||
value: recordFieldValue[key],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -311,7 +317,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
|
||||
return isMatchingStringFilter({
|
||||
stringFilter: value,
|
||||
value: record[filterKey][key],
|
||||
value: recordFieldValue[key],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -319,32 +325,32 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
case FieldMetadataType.DATE_TIME: {
|
||||
return isMatchingDateFilter({
|
||||
dateFilter: filterValue as DateFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.NUMERIC: {
|
||||
return isMatchingFloatFilter({
|
||||
floatFilter: filterValue as FloatFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.UUID: {
|
||||
return isMatchingUUIDFilter({
|
||||
uuidFilter: filterValue as UUIDFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.BOOLEAN: {
|
||||
return isMatchingBooleanFilter({
|
||||
booleanFilter: filterValue as BooleanFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.CURRENCY: {
|
||||
return isMatchingCurrencyFilter({
|
||||
currencyFilter: filterValue as CurrencyFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.ACTOR: {
|
||||
@@ -354,7 +360,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
actorFilter.name === undefined ||
|
||||
isMatchingStringFilter({
|
||||
stringFilter: actorFilter.name,
|
||||
value: record[filterKey].name,
|
||||
value: recordFieldValue.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -367,7 +373,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
|
||||
return isMatchingStringFilter({
|
||||
stringFilter: emailsFilter.primaryEmail,
|
||||
value: record[filterKey].primaryEmail,
|
||||
value: recordFieldValue.primaryEmail,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.PHONES: {
|
||||
@@ -384,7 +390,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
|
||||
return isMatchingStringFilter({
|
||||
stringFilter: value,
|
||||
value: record[filterKey][key],
|
||||
value: recordFieldValue[key],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -399,7 +405,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
if (isJoinColumn) {
|
||||
return isMatchingUUIDFilter({
|
||||
uuidFilter: filterValue as UUIDFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -410,7 +416,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
case FieldMetadataType.TS_VECTOR: {
|
||||
return isMatchingTSVectorFilter({
|
||||
tsVectorFilter: filterValue as TSVectorFilter,
|
||||
value: record[filterKey],
|
||||
value: recordFieldValue,
|
||||
});
|
||||
}
|
||||
default: {
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { type ObjectRecord } from 'twenty-shared/types';
|
||||
import { type ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
|
||||
@@ -59,7 +60,7 @@ export const validateRLSPredicatesForRecords = <T extends ObjectLiteral>({
|
||||
|
||||
for (const record of records) {
|
||||
const matchesRLS = isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record,
|
||||
record: record as unknown as ObjectRecord,
|
||||
filter: recordFilter,
|
||||
flatObjectMetadata: objectMetadata,
|
||||
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
|
||||
|
||||
@@ -160,6 +160,7 @@ export {
|
||||
export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
|
||||
export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
|
||||
export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string';
|
||||
export { isPlainObject } from './typeguard/isPlainObject';
|
||||
export { throwIfNotDefined } from './typeguard/throwIfNotDefined';
|
||||
export { absoluteUrlSchema } from './url/absoluteUrlSchema';
|
||||
export { buildSignedPath } from './url/buildSignedPath';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const isPlainObject = (
|
||||
value: unknown,
|
||||
): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
Reference in New Issue
Block a user