Compare commits

..
3 Commits
Author SHA1 Message Date
Paul Rastoinandprastoin e23e6616d4 [BUGFIX] Call and await destroyDataSourceForWorkspace to avoid killing poor pod (#10364)
# Motivations
Upgrade migration is not possible atm in production because running over
all workspaces cause cpu raise leading to pod termination
2025-02-20 16:48:32 +01:00
prastoin 1e82e0481e chore(all): 0.42.8 2025-02-20 16:48:29 +01:00
prastoin 9f84de074b chore(all): v0.24.7 2025-02-20 11:10:20 +01:00
54 changed files with 378 additions and 1551 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-e2e-testing",
"version": "0.42.9",
"version": "0.42.8",
"description": "",
"author": "",
"private": true,
@@ -1,60 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '../lib/fixtures/blank-workflow';
test('The workflow run visualizer shows the executed draft version without the last draft changes', async ({
workflowVisualizer,
page,
}) => {
await workflowVisualizer.createInitialTrigger('manual');
const manualTriggerAvailabilitySelect = page.getByRole('button', {
name: 'When record(s) are selected',
});
await manualTriggerAvailabilitySelect.click();
const alwaysAvailableOption = page.getByText(
'When no record(s) are selected',
);
await alwaysAvailableOption.click();
await workflowVisualizer.closeSidePanel();
const { createdStepId: firstStepId } =
await workflowVisualizer.createStep('create-record');
await workflowVisualizer.closeSidePanel();
const launchTestButton = page.getByRole('button', { name: 'Test' });
await launchTestButton.click();
const goToExecutionPageLink = page.getByRole('link', {
name: 'View execution details',
});
const executionPageUrl = await goToExecutionPageLink.getAttribute('href');
expect(executionPageUrl).not.toBeNull();
await workflowVisualizer.deleteStep(firstStepId);
await page.goto(executionPageUrl!);
const workflowRunName = page.getByText('Execution of v1');
await expect(workflowRunName).toBeVisible();
const flowTab = page.getByText('Flow', { exact: true });
await flowTab.click();
const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
await expect(executedFirstStepNode).toBeVisible();
await executedFirstStepNode.click();
await expect(
workflowVisualizer.commandMenu.getByRole('textbox').first(),
).toHaveValue('Create Record');
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-emails",
"version": "0.42.9",
"version": "0.42.8",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-front",
"version": "0.42.9",
"version": "0.42.8",
"private": true,
"type": "module",
"scripts": {
@@ -55,7 +55,6 @@ export const RightDrawerEmailThread = () => {
connectedAccountHandle,
messageChannelLoading,
connectedAccountProvider,
lastMessageExternalId,
} = useRightDrawerEmailThread();
useEffect(() => {
@@ -118,7 +117,7 @@ export const RightDrawerEmailThread = () => {
let url: string;
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT:
url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
url = `https://outlook.office365.com/mail/inbox/id/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.GOOGLE:
@@ -109,7 +109,6 @@ export const useRightDrawerEmailThread = () => {
messageId: true,
messageChannelId: true,
messageThreadExternalId: true,
messageExternalId: true,
},
skip: !lastMessageId || !isMessagesFetchComplete,
});
@@ -145,10 +144,6 @@ export const useRightDrawerEmailThread = () => {
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageThreadExternalId
: null;
const lastMessageExternalId =
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageExternalId
: null;
const connectedAccountHandle =
messageChannelData.length > 0 ? messageChannelData[0].handle : null;
@@ -180,7 +175,6 @@ export const useRightDrawerEmailThread = () => {
connectedAccountProvider,
threadLoading: messagesLoading,
messageChannelLoading,
lastMessageExternalId,
fetchMoreMessages,
};
};
@@ -4,5 +4,4 @@ export type MessageChannelMessageAssociation = {
messageId: string;
messageChannelId: string;
messageThreadExternalId: string;
messageExternalId: string;
};
@@ -421,15 +421,7 @@ export const SettingsRoutes = ({
/>
</>
)}
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={SettingsFeatures.WORKSPACE}
/>
}
>
<Route path={SettingsPath.Lab} element={<SettingsLab />} />
</Route>
<Route path={SettingsPath.Lab} element={<SettingsLab />} />
</Routes>
</Suspense>
);
@@ -6,7 +6,7 @@ import { useCloseSortDropdown } from '@/object-record/object-sort-dropdown/hooks
import { useResetRecordSortDropdownSearchInput } from '@/object-record/object-sort-dropdown/hooks/useResetRecordSortDropdownSearchInput';
import { useResetSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useResetSortDropdown';
import { useToggleSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useToggleSortDropdown';
import { isRecordSortDirectionDropdownMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionDropdownMenuUnfoldedComponentState';
import { isRecordSortDirectionMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionMenuUnfoldedComponentState';
import { objectSortDropdownSearchInputComponentState } from '@/object-record/object-sort-dropdown/states/objectSortDropdownSearchInputComponentState';
import { onSortSelectComponentState } from '@/object-record/object-sort-dropdown/states/onSortSelectScopedState';
import { selectedRecordSortDirectionComponentState } from '@/object-record/object-sort-dropdown/states/selectedRecordSortDirectionComponentState';
@@ -84,7 +84,7 @@ export const ObjectSortDropdownButton = ({
);
const isRecordSortDirectionMenuUnfolded = useRecoilComponentValueV2(
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
isRecordSortDirectionMenuUnfoldedComponentState,
);
const { resetSortDropdown } = useResetSortDropdown();
@@ -168,7 +168,7 @@ export const ObjectSortDropdownButton = ({
useRecoilComponentStateV2(selectedRecordSortDirectionComponentState);
const setIsRecordSortDirectionMenuUnfolded = useSetRecoilComponentStateV2(
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
isRecordSortDirectionMenuUnfoldedComponentState,
);
const handleSortDirectionClick = (sortDirection: RecordSortDirection) => {
@@ -1,19 +1,18 @@
import { isRecordSortDirectionDropdownMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionDropdownMenuUnfoldedComponentState';
import { isRecordSortDirectionMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionMenuUnfoldedComponentState';
import { selectedRecordSortDirectionComponentState } from '@/object-record/object-sort-dropdown/states/selectedRecordSortDirectionComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
export const useResetSortDropdown = () => {
const setIsRecordSortDirectionDropdownMenuUnfolded =
useSetRecoilComponentStateV2(
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
);
const setIsRecordSortDirectionMenuUnfolded = useSetRecoilComponentStateV2(
isRecordSortDirectionMenuUnfoldedComponentState,
);
const setSelectedRecordSortDirection = useSetRecoilComponentStateV2(
selectedRecordSortDirectionComponentState,
);
const resetSortDropdown = () => {
setIsRecordSortDirectionDropdownMenuUnfolded(false);
setIsRecordSortDirectionMenuUnfolded(false);
setSelectedRecordSortDirection('asc');
};
@@ -1,9 +1,9 @@
import { ObjectSortDropdownComponentInstanceContext } from '@/object-record/object-sort-dropdown/states/context/ObjectSortDropdownComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const isRecordSortDirectionDropdownMenuUnfoldedComponentState =
export const isRecordSortDirectionMenuUnfoldedComponentState =
createComponentStateV2<boolean>({
key: 'isRecordSortDirectionDropdownMenuUnfoldedComponentState',
key: 'isRecordSortDirectionMenuUnfoldedComponentState',
defaultValue: false,
componentInstanceContext: ObjectSortDropdownComponentInstanceContext,
});
@@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil';
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useDeleteCombinedViewSorts } from '@/views/hooks/useDeleteCombinedViewSorts';
@@ -23,12 +22,9 @@ export const RecordIndexRemoveSortingModal = ({
const { deleteCombinedViewSort } = useDeleteCombinedViewSorts(recordIndexId);
const { removeRecordSort } = useRemoveRecordSort();
const handleRemoveClick = () => {
fieldMetadataIds.forEach((id) => {
deleteCombinedViewSort(id);
removeRecordSort(id);
});
};
@@ -29,7 +29,7 @@ export const useHandleToggleColumnSort = ({
const { upsertRecordSort } = useUpsertRecordSort();
const handleToggleColumnSort = useCallback(
async (fieldMetadataId: string) => {
(fieldMetadataId: string) => {
const correspondingColumnDefinition = columnDefinitions.find(
(columnDefinition) =>
columnDefinition.fieldMetadataId === fieldMetadataId,
@@ -48,9 +48,8 @@ export const useHandleToggleColumnSort = ({
direction: 'asc',
};
upsertCombinedViewSort(newSort);
upsertRecordSort(newSort);
await upsertCombinedViewSort(newSort);
},
[columnDefinitions, upsertCombinedViewSort, upsertRecordSort],
);
@@ -1,48 +0,0 @@
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useRecoilCallback } from 'recoil';
export const useRemoveRecordSort = () => {
const currentRecordSortsCallbackState = useRecoilComponentCallbackStateV2(
currentRecordSortsComponentState,
);
const removeRecordSort = useRecoilCallback(
({ set, snapshot }) =>
(fieldMetadataId: string) => {
const currentRecordSorts = getSnapshotValue(
snapshot,
currentRecordSortsCallbackState,
);
const hasFoundRecordSortInCurrentRecordSorts = currentRecordSorts.some(
(existingSort) => existingSort.fieldMetadataId === fieldMetadataId,
);
if (hasFoundRecordSortInCurrentRecordSorts) {
set(currentRecordSortsCallbackState, (currentRecordSorts) => {
const newCurrentRecordSorts = [...currentRecordSorts];
const indexOfSortToRemove = newCurrentRecordSorts.findIndex(
(existingSort) =>
existingSort.fieldMetadataId === fieldMetadataId,
);
if (indexOfSortToRemove < 0) {
return newCurrentRecordSorts;
}
newCurrentRecordSorts.splice(indexOfSortToRemove, 1);
return newCurrentRecordSorts;
});
}
},
[currentRecordSortsCallbackState],
);
return {
removeRecordSort,
};
};
@@ -117,8 +117,7 @@ export const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
label: t`Billing`,
path: SettingsPath.Billing,
Icon: IconCurrencyDollar,
isHidden:
!isBillingEnabled || !permissionMap[SettingsFeatures.WORKSPACE],
isHidden: !isBillingEnabled,
},
{
label: t`Roles`,
@@ -182,9 +181,7 @@ export const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
label: t`Lab`,
path: SettingsPath.Lab,
Icon: IconFlask,
isHidden:
!labPublicFeatureFlags.length ||
!permissionMap[SettingsFeatures.WORKSPACE],
isHidden: !labPublicFeatureFlags.length,
},
{
label: t`Releases`,
@@ -1,6 +1,5 @@
import { IconArrowDown, IconArrowUp } from 'twenty-ui';
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
import { useUpsertRecordSort } from '@/object-record/record-sort/hooks/useUpsertRecordSort';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
@@ -14,15 +13,12 @@ type EditableSortChipProps = {
export const EditableSortChip = ({ recordSort }: EditableSortChipProps) => {
const { deleteCombinedViewSort } = useDeleteCombinedViewSorts();
const { removeRecordSort } = useRemoveRecordSort();
const { upsertCombinedViewSort } = useUpsertCombinedViewSorts();
const { upsertRecordSort } = useUpsertRecordSort();
const handleRemoveClick = () => {
deleteCombinedViewSort(recordSort.fieldMetadataId);
removeRecordSort(recordSort.fieldMetadataId);
};
const handleClick = () => {
@@ -17,7 +17,6 @@ import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { SoftDeleteFilterChip } from '@/views/components/SoftDeleteFilterChip';
import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters';
import { useApplyCurrentViewSortsToCurrentRecordSorts } from '@/views/hooks/useApplyCurrentViewSortsToCurrentRecordSorts';
import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters';
import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
@@ -164,14 +163,10 @@ export const ViewBarDetails = ({
const { applyCurrentViewFiltersToCurrentRecordFilters } =
useApplyCurrentViewFiltersToCurrentRecordFilters();
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
const handleCancelClick = () => {
if (isDefined(viewId)) {
resetUnsavedViewStates(viewId);
applyCurrentViewFiltersToCurrentRecordFilters();
applyCurrentViewSortsToCurrentRecordSorts();
toggleSoftDeleteFilterState(false);
}
};
@@ -1,196 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { prefetchViewsState } from '@/prefetch/states/prefetchViewsState';
import { View } from '@/views/types/View';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { mockedViewsData } from '~/testing/mock-data/views';
import { useApplyCurrentViewSortsToCurrentRecordSorts } from '../useApplyCurrentViewSortsToCurrentRecordSorts';
const mockObjectMetadataItemNameSingular = 'company';
describe('useApplyCurrentViewSortsToCurrentRecordSorts', () => {
const mockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === mockObjectMetadataItemNameSingular,
);
if (!isDefined(mockObjectMetadataItem)) {
throw new Error(
'Missing mock object metadata item with name singular "company"',
);
}
const mockFieldMetadataItem = mockObjectMetadataItem.fields.find(
(field) => field.name === 'name',
);
if (!isDefined(mockFieldMetadataItem)) {
throw new Error('Missing mock field metadata item with type TEXT');
}
const mockViewSort: ViewSort = {
__typename: 'ViewSort',
id: 'sort-1',
fieldMetadataId: mockFieldMetadataItem.id,
direction: 'asc',
};
const allCompaniesView = mockedViewsData[0];
const mockView = {
...allCompaniesView,
viewSorts: [mockViewSort],
} satisfies View;
it('should apply sorts from current view', () => {
const { result } = renderHook(
() => {
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return {
applyCurrentViewSortsToCurrentRecordSorts,
currentSorts,
};
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'instanceId',
}),
mockView.id,
);
snapshot.set(prefetchViewsState, [mockView]);
},
}),
},
);
act(() => {
result.current.applyCurrentViewSortsToCurrentRecordSorts();
});
expect(result.current.currentSorts).toEqual([
{
id: mockViewSort.id,
fieldMetadataId: mockViewSort.fieldMetadataId,
direction: mockViewSort.direction,
definition: {
fieldMetadataId: mockViewSort.fieldMetadataId,
iconName: mockFieldMetadataItem.icon ?? '',
label: mockFieldMetadataItem.label,
},
} satisfies RecordSort,
]);
});
it('should not apply sorts when current view is not found', () => {
const { result } = renderHook(
() => {
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return {
applyCurrentViewSortsToCurrentRecordSorts,
currentSorts,
};
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'instanceId',
}),
mockView.id,
);
snapshot.set(prefetchViewsState, []);
},
}),
},
);
act(() => {
result.current.applyCurrentViewSortsToCurrentRecordSorts();
});
expect(result.current.currentSorts).toEqual([]);
});
it('should handle view with empty sorts', () => {
const viewWithNoSorts = {
...mockView,
viewSorts: [],
};
const { result } = renderHook(
() => {
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return {
applyCurrentViewSortsToCurrentRecordSorts,
currentSorts,
};
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'instanceId',
}),
mockView.id,
);
snapshot.set(prefetchViewsState, [viewWithNoSorts]);
},
}),
},
);
act(() => {
result.current.applyCurrentViewSortsToCurrentRecordSorts();
});
expect(result.current.currentSorts).toEqual([]);
});
});
@@ -1,110 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { useApplyViewSortsToCurrentRecordSorts } from '../useApplyViewSortsToCurrentRecordSorts';
const mockObjectMetadataItemNameSingular = 'company';
describe('useApplyViewSortsToCurrentRecordSorts', () => {
const mockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === mockObjectMetadataItemNameSingular,
);
if (!isDefined(mockObjectMetadataItem)) {
throw new Error(
`Missing mock object metadata item with name singular ${mockObjectMetadataItemNameSingular}`,
);
}
const mockFieldMetadataItem = mockObjectMetadataItem.fields.find(
(field) => field.name === 'name',
);
if (!isDefined(mockFieldMetadataItem)) {
throw new Error(`Missing mock field metadata Name`);
}
const mockViewSort: ViewSort = {
__typename: 'ViewSort',
id: 'sort-1',
fieldMetadataId: mockFieldMetadataItem.id,
direction: 'asc',
};
it('should apply view sorts to current record sorts', () => {
const { result } = renderHook(
() => {
const { applyViewSortsToCurrentRecordSorts } =
useApplyViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return { applyViewSortsToCurrentRecordSorts, currentSorts };
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
}),
},
);
act(() => {
result.current.applyViewSortsToCurrentRecordSorts([mockViewSort]);
});
expect(result.current.currentSorts).toEqual([
{
id: mockViewSort.id,
fieldMetadataId: mockViewSort.fieldMetadataId,
direction: mockViewSort.direction,
definition: {
fieldMetadataId: mockViewSort.fieldMetadataId,
label: mockFieldMetadataItem.label,
iconName: mockFieldMetadataItem.icon ?? '',
},
} satisfies RecordSort,
]);
});
it('should handle empty view sorts array', () => {
const { result } = renderHook(
() => {
const { applyViewSortsToCurrentRecordSorts } =
useApplyViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return { applyViewSortsToCurrentRecordSorts, currentSorts };
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
}),
},
);
act(() => {
result.current.applyViewSortsToCurrentRecordSorts([]);
});
expect(result.current.currentSorts).toEqual([]);
});
});
@@ -1,46 +0,0 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { useSortableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-sort/hooks/useSortableFieldMetadataItemsInRecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useApplyCurrentViewSortsToCurrentRecordSorts = () => {
const currentViewId = useRecoilComponentValueV2(
contextStoreCurrentViewIdComponentState,
);
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
const setCurrentRecordSorts = useSetRecoilComponentStateV2(
currentRecordSortsComponentState,
);
const { sortableFieldMetadataItems } =
useSortableFieldMetadataItemsInRecordIndexContext();
const applyCurrentViewSortsToCurrentRecordSorts = () => {
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: sortableFieldMetadataItems,
});
if (isDefined(currentView)) {
setCurrentRecordSorts(
mapViewSortsToSorts(currentView.viewSorts, sortDefinitions),
);
}
};
return {
applyCurrentViewSortsToCurrentRecordSorts,
};
};
@@ -1,29 +0,0 @@
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { useSortableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-sort/hooks/useSortableFieldMetadataItemsInRecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewSort } from '@/views/types/ViewSort';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
export const useApplyViewSortsToCurrentRecordSorts = () => {
const setCurrentRecordSorts = useSetRecoilComponentStateV2(
currentRecordSortsComponentState,
);
const { sortableFieldMetadataItems } =
useSortableFieldMetadataItemsInRecordIndexContext();
const applyViewSortsToCurrentRecordSorts = (viewSorts: ViewSort[]) => {
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: sortableFieldMetadataItems,
});
const recordSortsToApply = mapViewSortsToSorts(viewSorts, sortDefinitions);
setCurrentRecordSorts(recordSortsToApply);
};
return {
applyViewSortsToCurrentRecordSorts,
};
};
@@ -16,7 +16,10 @@ export const WorkflowRunVisualizerContent = ({
return (
<>
<WorkflowRunVisualizerEffect workflowRun={workflowRun} />
<WorkflowRunVisualizerEffect
workflowRun={workflowRun}
workflowVersionId={workflowVersion.id}
/>
<WorkflowDiagramCanvasReadonly versionStatus={workflowVersion.status} />
</>
@@ -1,12 +0,0 @@
import { flowState } from '@/workflow/states/flowState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useFlowOrThrow = () => {
const flow = useRecoilValue(flowState);
if (!isDefined(flow)) {
throw new Error('Expected the flow to be defined');
}
return flow;
};
@@ -1,13 +0,0 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import { createState } from '@ui/utilities/state/utils/createState';
export const flowState = createState<
| {
trigger: WorkflowTrigger | null;
steps: WorkflowAction[] | null;
}
| undefined
>({
key: 'flowState',
defaultValue: undefined,
});
@@ -0,0 +1,6 @@
import { createState } from '@ui/utilities/state/utils/createState';
export const workflowVersionIdState = createState<string | undefined>({
key: 'workflowVersionIdState',
defaultValue: undefined,
});
@@ -1,19 +1,17 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { findStepPosition } from '@/workflow/utils/findStepPosition';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { isDefined } from 'twenty-shared';
export const getStepDefinitionOrThrow = ({
stepId,
trigger,
steps,
workflowVersion,
}: {
stepId: string;
trigger: WorkflowTrigger | null;
steps: Array<WorkflowAction> | null;
workflowVersion: WorkflowVersion;
}) => {
if (stepId === TRIGGER_STEP_ID) {
if (!isDefined(trigger)) {
if (!isDefined(workflowVersion.trigger)) {
return {
type: 'trigger',
definition: undefined,
@@ -22,18 +20,18 @@ export const getStepDefinitionOrThrow = ({
return {
type: 'trigger',
definition: trigger,
definition: workflowVersion.trigger,
} as const;
}
if (!isDefined(steps)) {
if (!isDefined(workflowVersion.steps)) {
throw new Error(
'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one',
);
}
const selectedNodePosition = findStepPosition({
steps,
steps: workflowVersion.steps,
stepId: stepId,
});
if (!isDefined(selectedNodePosition)) {
@@ -1,5 +1,4 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { flowState } from '@/workflow/states/flowState';
import { workflowLastCreatedStepIdState } from '@/workflow/states/workflowLastCreatedStepIdState';
import {
WorkflowVersion,
@@ -21,7 +20,6 @@ export const WorkflowDiagramEffect = ({
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined;
}) => {
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const setFlow = useSetRecoilState(flowState);
const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
({ snapshot, set }) => {
@@ -69,21 +67,14 @@ export const WorkflowDiagramEffect = ({
useEffect(() => {
const currentVersion = workflowWithCurrentVersion?.currentVersion;
if (!isDefined(currentVersion)) {
setFlow(undefined);
setWorkflowDiagram(undefined);
return;
}
setFlow({
trigger: currentVersion.trigger,
steps: currentVersion.steps,
});
computeAndMergeNewWorkflowDiagram(currentVersion);
}, [
computeAndMergeNewWorkflowDiagram,
setFlow,
setWorkflowDiagram,
workflowWithCurrentVersion?.currentVersion,
]);
@@ -1,4 +1,4 @@
import { flowState } from '@/workflow/states/flowState';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
@@ -7,25 +7,18 @@ import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
export const WorkflowRunVisualizerEffect = ({
workflowVersionId,
workflowRun,
}: {
workflowVersionId: string;
workflowRun: WorkflowRun;
}) => {
const setFlow = useSetRecoilState(flowState);
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => {
if (!isDefined(workflowRun.output)) {
setFlow(undefined);
return;
}
setFlow({
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
});
}, [setFlow, workflowRun.output]);
setWorkflowVersionId(workflowVersionId);
}, [setWorkflowVersionId, workflowVersionId]);
useEffect(() => {
if (!isDefined(workflowRun.output)) {
@@ -1,5 +1,5 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { flowState } from '@/workflow/states/flowState';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
import { markLeafNodes } from '@/workflow/workflow-diagram/utils/markLeafNodes';
@@ -14,21 +14,12 @@ export const WorkflowVersionVisualizerEffect = ({
}) => {
const workflowVersion = useWorkflowVersion(workflowVersionId);
const setFlow = useSetRecoilState(flowState);
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => {
if (!isDefined(workflowVersion)) {
setFlow(undefined);
return;
}
setFlow({
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
});
}, [setFlow, workflowVersion]);
setWorkflowVersionId(workflowVersionId);
}, [setWorkflowVersionId, workflowVersionId]);
useEffect(() => {
if (!isDefined(workflowVersion)) {
@@ -1,4 +1,3 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
@@ -12,8 +11,6 @@ export const RightDrawerWorkflowEditStepContent = ({
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const flow = useFlowOrThrow();
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
@@ -29,8 +26,7 @@ export const RightDrawerWorkflowEditStepContent = ({
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
workflowVersion={workflow.currentVersion}
onActionUpdate={updateStep}
onTriggerUpdate={updateTrigger}
/>
@@ -1,25 +1,22 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { RightDrawerWorkflowViewStepContent } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStepContent';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const RightDrawerWorkflowViewStep = () => {
const flow = useFlowOrThrow();
const workflowVersionId = useRecoilValue(workflowVersionIdState);
if (!isDefined(workflowVersionId)) {
throw new Error('Expected a workflow version id');
}
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to view its details.',
);
const workflowVersion = useWorkflowVersion(workflowVersionId);
if (!isDefined(workflowVersion)) {
return null;
}
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
readonly
/>
<RightDrawerWorkflowViewStepContent workflowVersion={workflowVersion} />
);
};
@@ -0,0 +1,26 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const RightDrawerWorkflowViewStepContent = ({
workflowVersion,
}: {
workflowVersion: WorkflowVersion;
}) => {
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to edit it.',
);
}
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
workflowVersion={workflowVersion}
readonly
/>
);
};
@@ -1,4 +1,8 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import {
WorkflowAction,
WorkflowTrigger,
WorkflowVersion,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormCreateRecord';
@@ -21,34 +25,30 @@ const WorkflowEditActionFormServerlessFunction = lazy(() =>
})),
);
type WorkflowStepDetailProps = {
stepId: string;
trigger: WorkflowTrigger | null;
steps: Array<WorkflowAction> | null;
} & (
type WorkflowStepDetailProps =
| {
stepId: string;
workflowVersion: WorkflowVersion;
readonly: true;
onTriggerUpdate?: undefined;
onActionUpdate?: undefined;
}
| {
stepId: string;
workflowVersion: WorkflowVersion;
readonly?: false;
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
onActionUpdate: (action: WorkflowAction) => void;
}
);
};
export const WorkflowStepDetail = ({
stepId,
trigger,
steps,
workflowVersion,
...props
}: WorkflowStepDetailProps) => {
const stepDefinition = getStepDefinitionOrThrow({
stepId,
trigger,
steps,
workflowVersion,
});
if (!isDefined(stepDefinition) || !isDefined(stepDefinition.definition)) {
return null;
@@ -1,4 +1,3 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
@@ -25,32 +24,27 @@ export const useAvailableVariablesInWorkflowStep = ({
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
const flow = useFlowOrThrow();
if (!isDefined(workflowSelectedNode) || !isDefined(workflow)) {
return [];
}
const trigger = flow.trigger;
const steps = flow.steps;
const stepDefinition = getStepDefinitionOrThrow({
stepId: workflowSelectedNode,
trigger,
steps,
workflowVersion: workflow.currentVersion,
});
if (
!isDefined(stepDefinition) ||
stepDefinition.type === 'trigger' ||
!isDefined(steps)
!isDefined(workflow.currentVersion.steps)
) {
return [];
}
const previousSteps = [];
for (const step of steps) {
for (const step of workflow.currentVersion.steps) {
if (step.id === workflowSelectedNode) {
break;
}
@@ -60,32 +54,34 @@ export const useAvailableVariablesInWorkflowStep = ({
const result = [];
const filteredTriggerOutputSchema = filterOutputSchema(
trigger?.settings?.outputSchema as OutputSchema | undefined,
workflow.currentVersion.trigger?.settings?.outputSchema as
| OutputSchema
| undefined,
objectNameSingularToSelect,
);
if (
isDefined(trigger) &&
isDefined(workflow.currentVersion.trigger) &&
isDefined(filteredTriggerOutputSchema) &&
!isEmptyObject(filteredTriggerOutputSchema)
) {
const triggerIconKey =
trigger.type === 'DATABASE_EVENT'
workflow.currentVersion.trigger.type === 'DATABASE_EVENT'
? getTriggerIcon({
type: trigger.type,
type: workflow.currentVersion.trigger.type,
eventName: splitWorkflowTriggerEventName(
trigger.settings?.eventName,
workflow.currentVersion.trigger.settings?.eventName,
).event,
})
: getTriggerIcon({
type: trigger.type,
type: workflow.currentVersion.trigger.type,
});
result.push({
id: 'trigger',
name: isDefined(trigger.name)
? trigger.name
: getTriggerStepName(trigger),
name: isDefined(workflow.currentVersion.trigger.name)
? workflow.currentVersion.trigger.name
: getTriggerStepName(workflow.currentVersion.trigger),
icon: triggerIconKey,
outputSchema: filteredTriggerOutputSchema,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-server",
"version": "0.42.9",
"version": "0.42.8",
"description": "",
"author": "",
"private": true,
@@ -54,7 +54,6 @@ type ProcessRichTextFieldsArgs = {
richTextFields: FieldMetadataEntity[];
workspaceId: string;
};
@Command({
name: 'upgrade-0.42:migrate-rich-text-field',
description: 'Migrate RICH_TEXT fields to new composite structure',
@@ -112,10 +111,6 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner {
index,
total: workspaceIds.length,
});
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
}
this.logger.log(chalk.green('Command completed!'));
@@ -170,7 +170,6 @@ export abstract class GraphqlQueryBaseResolverService<
objectMetadataItemWithFieldMaps,
authContext.workspace.id,
options.objectMetadataMaps,
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
);
const resultWithGettersArray = Array.isArray(resultWithGetters)
@@ -18,6 +18,7 @@ import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/works
import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler';
import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler';
import { CompositeInputTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/composite-input-type-definition.factory';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@@ -70,7 +71,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId: string,
objectMetadataMaps: ObjectMetadataMaps,
workspaceId: string,
isNewRelationEnabled: boolean,
): Promise<IConnection<ObjectRecord>> {
return {
...connection,
@@ -82,7 +82,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
),
})),
),
@@ -94,7 +93,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId: string,
objectMetadataMaps: ObjectMetadataMaps,
workspaceId: string,
isNewRelationEnabled: boolean,
) {
return {
...result,
@@ -106,7 +104,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
),
),
),
@@ -118,7 +115,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId: string,
objectMetadataMaps: ObjectMetadataMaps,
workspaceId: string,
isNewRelationEnabled: boolean,
) {
return await Promise.all(
recordArray.map(
@@ -128,7 +124,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
),
),
);
@@ -139,10 +134,14 @@ export class QueryResultGettersFactory {
objectMetadataItemId: string,
objectMetadataMaps: ObjectMetadataMaps,
workspaceId: string,
isNewRelationEnabled: boolean,
): Promise<ObjectRecord> {
const objectMetadataMapItem = objectMetadataMaps.byId[objectMetadataItemId];
const isNewRelationEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsNewRelationEnabled,
workspaceId,
);
const handler = this.getHandler(objectMetadataMapItem.nameSingular);
const relationFields = Object.keys(record)
@@ -192,7 +191,6 @@ export class QueryResultGettersFactory {
relationObjectMetadataItem.id,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
);
} else {
if (!isDefined(relationField.relationTargetObjectMetadataId)) {
@@ -205,7 +203,6 @@ export class QueryResultGettersFactory {
relationField.relationTargetObjectMetadataId,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
);
}
}
@@ -228,7 +225,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId: string,
objectMetadataMaps: ObjectMetadataMaps,
workspaceId: string,
isNewRelationEnabled: boolean,
) {
if (isQueryResultFieldValueAConnection(queryResultField)) {
return await this.processConnection(
@@ -236,7 +232,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
);
} else if (isQueryResultFieldValueANestedRecordArray(queryResultField)) {
return await this.processNestedRecordArray(
@@ -244,7 +239,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
);
} else if (isQueryResultFieldValueARecordArray(queryResultField)) {
return await this.processRecordArray(
@@ -252,7 +246,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
);
} else if (isQueryResultFieldValueARecord(queryResultField)) {
return await this.processRecord(
@@ -260,7 +253,6 @@ export class QueryResultGettersFactory {
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
);
} else {
this.logger.warn(
@@ -277,14 +269,12 @@ export class QueryResultGettersFactory {
objectMetadataItem: ObjectMetadataInterface,
workspaceId: string,
objectMetadataMaps: ObjectMetadataMaps,
isNewRelationEnabled: boolean,
): Promise<any> {
return await this.processQueryResultField(
result,
objectMetadataItem.id,
objectMetadataMaps,
workspaceId,
isNewRelationEnabled,
);
}
@@ -21,7 +21,6 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
import {
RichTextV2Metadata,
richTextV2ValueSchema,
@@ -231,63 +230,6 @@ export class QueryRunnerArgsFactory {
return [key, valueInBothFormats];
}
case FieldMetadataType.LINKS: {
const newPrimaryLinkUrl = lowercaseDomain(value?.primaryLinkUrl);
let secondaryLinks = value?.secondaryLinks;
if (secondaryLinks) {
try {
const secondaryLinksArray = JSON.parse(secondaryLinks);
secondaryLinks = JSON.stringify(
secondaryLinksArray.map((link) => {
return {
...link,
url: lowercaseDomain(link.url),
};
}),
);
} catch {
/* empty */
}
}
return [
key,
{
...value,
primaryLinkUrl: newPrimaryLinkUrl,
secondaryLinks,
},
];
}
case FieldMetadataType.EMAILS: {
let additionalEmails = value?.additionalEmails;
const primaryEmail = value?.primaryEmail
? value.primaryEmail.toLowerCase()
: '';
if (additionalEmails) {
try {
const emailArray = JSON.parse(additionalEmails) as string[];
additionalEmails = JSON.stringify(
emailArray.map((email) => email.toLowerCase()),
);
} catch {
/* empty */
}
}
return [
key,
{
primaryEmail,
additionalEmails,
},
];
}
default:
return [key, value];
}
@@ -1,17 +0,0 @@
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
describe('queryRunner LINKS util', () => {
it('should leave lowcased domain unchanged', () => {
const primaryLinkUrl = 'https://www.example.com/test';
const result = lowercaseDomain(primaryLinkUrl);
expect(result).toBe('https://www.example.com/test');
});
it('should lowercase the domain of the primary link url', () => {
const primaryLinkUrl = 'htTps://wwW.exAmple.coM/TEST';
const result = lowercaseDomain(primaryLinkUrl);
expect(result).toBe('https://www.example.com/TEST');
});
});
@@ -1,7 +0,0 @@
export const lowercaseDomain = (url: string) => {
try {
return new URL(url).toString();
} catch {
return url;
}
};
@@ -34,7 +34,6 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
@@ -42,7 +41,6 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
StripeModule,
DomainManagerModule,
MessageQueueModule,
PermissionsModule,
TypeOrmModule.forFeature(
[
BillingSubscription,
@@ -1,10 +1,9 @@
/* @license Enterprise */
import { UseFilters, UseGuards } from '@nestjs/common';
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { SettingsFeatures } from 'twenty-shared';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingProductInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-product.input';
@@ -27,13 +26,10 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@Resolver()
@UseFilters(PermissionsGraphqlApiExceptionFilter)
export class BillingResolver {
constructor(
private readonly billingSubscriptionService: BillingSubscriptionService,
@@ -59,10 +55,7 @@ export class BillingResolver {
}
@Query(() => BillingSessionOutput)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(SettingsFeatures.WORKSPACE),
)
@UseGuards(WorkspaceAuthGuard)
async billingPortalSession(
@AuthWorkspace() workspace: Workspace,
@Args() { returnUrlPath }: BillingSessionInput,
@@ -141,10 +134,7 @@ export class BillingResolver {
}
@Mutation(() => BillingUpdateOutput)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(SettingsFeatures.WORKSPACE),
)
@UseGuards(WorkspaceAuthGuard)
async updateBillingSubscription(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.applyBillingSubscription(workspace);
@@ -2,20 +2,14 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { LabResolver } from './lab.resolver';
import { LabService } from './services/lab.service';
@Module({
imports: [
TypeOrmModule.forFeature([FeatureFlag, Workspace], 'core'),
FeatureFlagModule,
PermissionsModule,
],
imports: [TypeOrmModule.forFeature([FeatureFlag, Workspace], 'core')],
providers: [LabService, LabResolver],
exports: [LabService],
})
@@ -1,21 +1,16 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { SettingsFeatures } from 'twenty-shared';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input';
import { LabService } from 'src/engine/core-modules/lab/services/lab.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
@UseGuards(SettingsPermissionsGuard(SettingsFeatures.WORKSPACE))
@UseFilters(AuthGraphqlApiExceptionFilter)
export class LabResolver {
constructor(private labService: LabService) {}
@@ -321,9 +321,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
);
if (softDelete) {
await this.workspaceRepository.softDelete({ id });
return workspace;
return await this.workspaceRepository.softDelete({ id });
}
await this.deleteMetadataSchemaCacheAndUserWorkspace(workspace);
@@ -332,9 +330,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
FileWorkspaceFolderDeletionJob.name,
{ workspaceId: id },
);
await this.workspaceRepository.delete(id);
return workspace;
return await this.workspaceRepository.delete(id);
}
async handleRemoveWorkspaceMember(
@@ -278,8 +278,7 @@ export class CleanerWorkspaceService {
if (
daysSinceSoftDeleted >
this.inactiveDaysBeforeDelete - this.inactiveDaysBeforeSoftDelete &&
deletedWorkspacesCount < this.maxNumberOfWorkspacesDeletedPerExecution
this.inactiveDaysBeforeDelete - this.inactiveDaysBeforeSoftDelete
) {
this.logger.log(
`${dryRun ? 'DRY RUN - ' : ''}Destroying workspace ${workspace.id} ${workspace.displayName}`,
@@ -287,16 +286,20 @@ export class CleanerWorkspaceService {
if (!dryRun) {
await this.workspaceService.deleteWorkspace(workspace.id);
}
deletedWorkspacesCount++;
continue;
}
if (workspaceInactivity > this.inactiveDaysBeforeSoftDelete) {
if (
workspaceInactivity > this.inactiveDaysBeforeSoftDelete &&
deletedWorkspacesCount <=
this.maxNumberOfWorkspacesDeletedPerExecution
) {
await this.informWorkspaceMembersAndSoftDeleteWorkspace(
workspace,
workspaceInactivity,
dryRun,
);
deletedWorkspacesCount++;
continue;
}
@@ -29,17 +29,4 @@ describe('getUniqueContactsAndHandles', () => {
'jane@twenty.com',
]);
});
it('should deduplicate handles when they are in different cases', () => {
const contacts: Contact[] = [
{ handle: 'john@twenty.com', displayName: 'John Doe' },
{ handle: 'John@twenty.com', displayName: 'John Doe' },
];
const result = getUniqueContactsAndHandles(contacts);
expect(result.uniqueContacts).toEqual([
{ handle: 'john@twenty.com', displayName: 'John Doe' },
]);
expect(result.uniqueHandles).toEqual(['john@twenty.com']);
});
});
@@ -11,13 +11,9 @@ export function getUniqueContactsAndHandles(contacts: Contact[]): {
return { uniqueContacts: [], uniqueHandles: [] };
}
const uniqueHandles = uniq(
contacts.map((participant) => participant.handle.toLocaleLowerCase()),
);
const uniqueHandles = uniq(contacts.map((participant) => participant.handle));
const uniqueContacts = uniqBy(contacts, (contact) =>
contact.handle.toLocaleLowerCase(),
);
const uniqueContacts = uniqBy(contacts, 'handle');
return { uniqueContacts, uniqueHandles };
}
@@ -1,586 +0,0 @@
import { gql } from 'graphql-tag';
import request from 'supertest';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const client = request(`http://localhost:${APP_PORT}`);
describe('Security permissions', () => {
let originalWorkspaceState;
beforeAll(async () => {
// Store original workspace state
const query = gql`
query getWorkspace {
currentWorkspace {
displayName
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
logo
isPublicInviteLinkEnabled
subdomain
isCustomDomainEnabled
}
}
`;
const response = await makeGraphqlAPIRequest({ query });
originalWorkspaceState = response.body.data.currentWorkspace;
const enablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IsPermissionsEnabled',
true,
);
await makeGraphqlAPIRequest(enablePermissionsQuery);
});
afterAll(async () => {
const disablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IsPermissionsEnabled',
false,
);
await makeGraphqlAPIRequest(disablePermissionsQuery);
// Restore workspace state
const restoreQuery = gql`
mutation updateWorkspace {
updateWorkspace(data: {
displayName: "${originalWorkspaceState.displayName}",
subdomain: "${originalWorkspaceState.subdomain}",
logo: "${originalWorkspaceState.logo}",
isGoogleAuthEnabled: ${originalWorkspaceState.isGoogleAuthEnabled},
isMicrosoftAuthEnabled: ${originalWorkspaceState.isMicrosoftAuthEnabled},
isPasswordAuthEnabled: ${originalWorkspaceState.isPasswordAuthEnabled}
isPublicInviteLinkEnabled: ${originalWorkspaceState.isPublicInviteLinkEnabled}
}) {
id
}
}
`;
await makeGraphqlAPIRequest({ query: restoreQuery });
});
describe('security permissions', () => {
describe('microsoft auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isMicrosoftAuthEnabled: false }) {
id
isMicrosoftAuthEnabled
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isMicrosoftAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isMicrosoftAuthEnabled: true }) {
id
isMicrosoftAuthEnabled
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('google auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isGoogleAuthEnabled: false }) {
id
isGoogleAuthEnabled
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isGoogleAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isGoogleAuthEnabled: true }) {
id
isGoogleAuthEnabled
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('password auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPasswordAuthEnabled: false }) {
id
isPasswordAuthEnabled
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isPasswordAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPasswordAuthEnabled: true }) {
id
isPasswordAuthEnabled
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('public invite link', () => {
it('should update isPublicInviteLinkEnabled when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPublicInviteLinkEnabled: false }) {
id
isPublicInviteLinkEnabled
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isPublicInviteLinkEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPublicInviteLinkEnabled: true }) {
id
isPublicInviteLinkEnabled
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});
describe('workspace permissions', () => {
describe('delete workspace', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation DeleteCurrentWorkspace {
deleteCurrentWorkspace {
id
__typename
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('display name update', () => {
it('should update workspace display name when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { displayName: "New Workspace Name" }) {
id
displayName
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.displayName).toBe('New Workspace Name');
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { displayName: "Another New Workspace Name" }) {
id
displayName
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('subdomain update', () => {
it('should update workspace subdomain when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "new-subdomain" }) {
id
subdomain
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.subdomain).toBe('new-subdomain');
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "another-new-subdomain" }) {
id
subdomain
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('custom domain update', () => {
it('should update workspace custom domain when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { customDomain: null }) {
id
customDomain
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.customDomain).toBe(null);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { customDomain: "another-new-custom-domain" }) {
id
customDomain
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('logo update', () => {
it('should update workspace logo when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { logo: "new-logo" }) {
id
logo
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.logo).toContain('new-logo');
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { logo: "another-new-logo" }) {
id
logo
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});
});
@@ -71,6 +71,247 @@ describe('WorkspaceResolver', () => {
await makeGraphqlAPIRequest({ query: restoreQuery });
});
describe('security permissions', () => {
describe('microsoft auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isMicrosoftAuthEnabled: false }) {
id
isMicrosoftAuthEnabled
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isMicrosoftAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isMicrosoftAuthEnabled: true }) {
id
isMicrosoftAuthEnabled
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('google auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isGoogleAuthEnabled: false }) {
id
isGoogleAuthEnabled
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isGoogleAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isGoogleAuthEnabled: true }) {
id
isGoogleAuthEnabled
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('password auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPasswordAuthEnabled: false }) {
id
isPasswordAuthEnabled
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isPasswordAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPasswordAuthEnabled: true }) {
id
isPasswordAuthEnabled
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('public invite link', () => {
it('should update isPublicInviteLinkEnabled when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPublicInviteLinkEnabled: false }) {
id
isPublicInviteLinkEnabled
}
}
`,
};
return client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isPublicInviteLinkEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPublicInviteLinkEnabled: true }) {
id
isPublicInviteLinkEnabled
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});
describe('workspace permissions', () => {
describe('delete workspace', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
@@ -342,143 +583,4 @@ describe('WorkspaceResolver', () => {
});
});
});
describe('billing', () => {
describe('updateBillingSubscription', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation UpdateBillingSubscription {
updateBillingSubscription {
success
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('billingPortalSession', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
query BillingPortalSession($returnUrlPath: String!) {
billingPortalSession(returnUrlPath: $returnUrlPath) {
url
}
}
`,
variables: {
returnUrlPath: '/settings/billing',
},
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});
describe('lab', () => {
describe('updateLabPublicFeatureFlag', () => {
it('should update feature flag when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation UpdateLabPublicFeatureFlag(
$input: UpdateLabPublicFeatureFlagInput!
) {
updateLabPublicFeatureFlag(input: $input) {
id
key
value
}
}
`,
variables: {
input: {
publicFeatureFlag: 'TestFeature',
value: true,
},
},
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe('Invalid feature flag key'); // this error shows that update has been attempted after the permission check
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation UpdateLabPublicFeatureFlag(
$input: UpdateLabPublicFeatureFlagInput!
) {
updateLabPublicFeatureFlag(input: $input) {
id
key
value
}
}
`,
variables: {
input: {
publicFeatureFlag: 'TestFeature',
value: false,
},
},
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-shared",
"version": "0.42.9",
"version": "0.42.8",
"license": "AGPL-3.0",
"main": "./dist/index.js",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-ui",
"version": "0.42.9",
"version": "0.42.8",
"type": "module",
"main": "./src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-website",
"version": "0.42.9",
"version": "0.42.8",
"private": true,
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-website node ../../node_modules/nx/bin/nx.js",