Compare commits

...

5 Commits

Author SHA1 Message Date
Lucas Bordeau 0091792d78 Ok 2026-03-20 17:11:39 +01:00
Lucas Bordeau eb46ca4355 Merge branch 'main' into issue-17117 2026-03-19 18:20:50 +01:00
Abdul Rahman a9f8a7e1fa Fix: prevent record navigation when clicking Remove from favorite in nav sidebar (#18760)
https://github.com/user-attachments/assets/96abf04d-726a-4225-846e-e5d701a583a2

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-19 14:29:12 +00:00
Thomas Trompette 5526d2e5d0 Separate metadata and object record publisher (#18740)
- Separate both publishers
- Renaming
- Removal of the old SSE endpoint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-19 13:24:38 +00:00
Samuel Arbibe 9d289b5e10 fix: Introduce useRefetchFindManyRecords to explicitly refetch find many queries after incremental updates and skip optimistic effects during the update. 2026-03-10 18:40:04 +01:00
48 changed files with 1596 additions and 2474 deletions
@@ -3687,21 +3687,6 @@ export type ObjectStandardOverrides = {
translations?: Maybe<Scalars['JSON']>;
};
export type OnDbEvent = {
__typename?: 'OnDbEvent';
action: DatabaseEventAction;
eventDate: Scalars['DateTime'];
objectNameSingular: Scalars['String'];
record: Scalars['JSON'];
updatedFields?: Maybe<Array<Scalars['String']>>;
};
export type OnDbEventInput = {
action?: InputMaybe<DatabaseEventAction>;
objectNameSingular?: InputMaybe<Scalars['String']>;
recordId?: InputMaybe<Scalars['UUID']>;
};
/** Onboarding status */
export enum OnboardingStatus {
BOOK_ONBOARDING = 'BOOK_ONBOARDING',
@@ -4738,7 +4723,6 @@ export type StandardOverrides = {
export type Subscription = {
__typename?: 'Subscription';
logicFunctionLogs: LogicFunctionLogs;
onDbEvent: OnDbEvent;
onEventSubscription?: Maybe<EventSubscription>;
};
@@ -4748,11 +4732,6 @@ export type SubscriptionLogicFunctionLogsArgs = {
};
export type SubscriptionOnDbEventArgs = {
input: OnDbEventInput;
};
export type SubscriptionOnEventSubscriptionArgs = {
eventStreamId: Scalars['String'];
};
@@ -58,15 +58,17 @@ export const computeContextStoreFilters = ({
]);
}
if (contextStoreTargetedRecordsRule.mode === 'selection') {
if (contextStoreTargetedRecordsRule.selectedRecordIds.length === 0) {
return { id: { in: [] } };
}
queryFilter = makeAndFilterVariables([
recordGqlFilterForAnyFieldFilter,
contextStoreTargetedRecordsRule.selectedRecordIds.length > 0
? {
id: {
in: contextStoreTargetedRecordsRule.selectedRecordIds,
},
}
: undefined,
{
id: {
in: contextStoreTargetedRecordsRule.selectedRecordIds,
},
},
computeRecordGqlOperationFilter({
filterValueDependencies,
fields: objectMetadataItem?.fields ?? [],
@@ -1,6 +1,7 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { renderHook } from '@testing-library/react';
@@ -15,6 +16,9 @@ jest.mock('@/object-record/hooks/useRefetchAggregateQueries', () => ({
refetchAggregateQueries: jest.fn(),
}),
}));
jest.mock('@/object-record/hooks/useRefetchFindManyRecords', () => ({
useRefetchFindManyRecords: jest.fn(),
}));
jest.mock('@/object-record/hooks/useIncrementalFetchAndMutateRecords');
const mockUseObjectMetadataItem = jest.mocked(useObjectMetadataItem);
@@ -25,6 +29,7 @@ const mockUseUpdateManyRecords = jest.mocked(useUpdateManyRecords);
const mockUseIncrementalFetchAndMutateRecords = jest.mocked(
useIncrementalFetchAndMutateRecords,
);
const mockUseRefetchFindManyRecords = jest.mocked(useRefetchFindManyRecords);
describe('useIncrementalUpdateManyRecords', () => {
const mockUpdateManyRecords = jest.fn();
@@ -53,6 +58,11 @@ describe('useIncrementalUpdateManyRecords', () => {
updateProgress: mockUpdateProgress,
cancel: jest.fn(),
});
const mockRefetchFindManyRecords = jest.fn();
mockUseRefetchFindManyRecords.mockReturnValue({
refetchFindManyRecords: mockRefetchFindManyRecords,
});
});
it('should call incrementalFetchAndMutate and execute mutations via useUpdateManyRecords', async () => {
@@ -81,6 +91,7 @@ describe('useIncrementalUpdateManyRecords', () => {
delayInMsBetweenRequests: 50,
skipRegisterObjectOperation: true,
skipRefetchAggregateQueries: true,
skipOptimisticEffect: true,
abortSignal: expect.any(AbortSignal),
});
expect(mockUpdateProgress).toHaveBeenCalledWith(2, 2);
@@ -102,6 +113,10 @@ describe('useIncrementalUpdateManyRecords', () => {
},
},
});
const { refetchFindManyRecords } =
mockUseRefetchFindManyRecords.mock.results[0].value;
expect(refetchFindManyRecords).toHaveBeenCalled();
});
it('should pass abortSignal to updateManyRecords', async () => {
@@ -1,11 +1,12 @@
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMoreRecordsWithPagination';
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50;
@@ -39,6 +40,10 @@ export const useIncrementalUpdateManyRecords = <
const { refetchAggregateQueries } = useRefetchAggregateQueries();
const { refetchFindManyRecords } = useRefetchFindManyRecords({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const {
incrementalFetchAndMutate,
progress,
@@ -68,6 +73,7 @@ export const useIncrementalUpdateManyRecords = <
delayInMsBetweenRequests: delayInMsBetweenMutations,
skipRegisterObjectOperation: true,
skipRefetchAggregateQueries: true,
skipOptimisticEffect: true,
abortSignal,
});
@@ -78,6 +84,7 @@ export const useIncrementalUpdateManyRecords = <
},
);
} finally {
await refetchFindManyRecords();
await refetchAggregateQueries({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
@@ -0,0 +1,24 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { capitalize } from 'twenty-shared/utils';
export const useRefetchFindManyRecords = ({
objectMetadataNamePlural,
}: {
objectMetadataNamePlural: string;
}) => {
const apolloCoreClient = useApolloCoreClient();
const refetchFindManyRecords = async () => {
const findManyRecordsQueryName = `FindMany${capitalize(
objectMetadataNamePlural,
)}`;
await apolloCoreClient.refetchQueries({
include: [findManyRecordsQueryName],
});
};
return {
refetchFindManyRecords,
};
};
@@ -1,6 +1,7 @@
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-board/constants/RecordBoardClickOutsideListenerId';
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
@@ -37,6 +38,7 @@ export const RecordBoardClickOutsideEffect = () => {
PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID,
RECORD_BOARD_CARD_CLICK_OUTSIDE_ID,
LINK_CHIP_CLICK_OUTSIDE_ID,
SIDE_PANEL_CLICK_OUTSIDE_ID,
],
listenerId: RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID,
refs: [],
@@ -1,6 +1,7 @@
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/useLeaveTableFocus';
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
@@ -30,6 +31,7 @@ export const RecordTableBodyFocusClickOutsideEffect = ({
COMMAND_MENU_CLICK_OUTSIDE_ID,
PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID,
MODAL_BACKDROP_CLICK_OUTSIDE_ID,
SIDE_PANEL_CLICK_OUTSIDE_ID,
],
listenerId: RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID,
refs: [tableBodyRef],
@@ -1,15 +1,22 @@
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { UpdateMultipleRecordsFooter } from '@/object-record/record-update-multiple/components/UpdateMultipleRecordsFooter';
import { UpdateMultipleRecordsForm } from '@/object-record/record-update-multiple/components/UpdateMultipleRecordsForm';
import { useUpdateMultipleRecordsActions } from '@/object-record/record-update-multiple/hooks/useUpdateMultipleRecordsActions';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer';
import { SidePanelProvider } from '@/ui/layout/side-panel/contexts/SidePanelContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID =
'update-multiple-records-confirmation';
const StyledShowPageRightContainer = styled.div`
display: flex;
flex-direction: column;
@@ -41,23 +48,30 @@ export const UpdateMultipleRecordsContainer = ({
contextStoreInstanceId,
});
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
contextStoreInstanceId,
);
const hasSelectedRecords = contextStoreNumberOfSelectedRecords > 0;
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { enqueueErrorSnackBar } = useSnackBar();
const { openModal } = useModal();
const { closeSidePanelMenu } = useSidePanelMenu();
const [fieldUpdates, setFieldUpdates] = useState<UpdateMultipleRecordsState>(
{},
);
const handleUpdate = async () => {
const handleUpdateClick = () => {
openModal(UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID);
};
const handleConfirmedUpdate = async () => {
try {
const count = await updateRecords(fieldUpdates);
if (count !== undefined) {
enqueueSuccessSnackBar({
message: t`Successfully updated ${count} records`,
});
closeSidePanelMenu();
}
await updateRecords(fieldUpdates);
closeSidePanelMenu();
} catch (error) {
enqueueErrorSnackBar({
message:
@@ -99,12 +113,20 @@ export const UpdateMultipleRecordsContainer = ({
<UpdateMultipleRecordsFooter
isUpdating={isUpdating}
progress={progress}
onUpdate={handleUpdate}
onUpdate={handleUpdateClick}
onCancel={handleCancel}
isUpdateDisabled={!hasChanges}
isUpdateDisabled={!hasChanges || !hasSelectedRecords}
/>
</StyledShowPageRightContainer>
</ShowPageContainer>
<ConfirmationModal
modalInstanceId={UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID}
title={t`Update ${contextStoreNumberOfSelectedRecords} records`}
subtitle={t`This will modify ${contextStoreNumberOfSelectedRecords} records. This action cannot be undone.`}
onConfirmClick={handleConfirmedUpdate}
confirmButtonText={t`Update records`}
confirmButtonAccent="blue"
/>
</SidePanelProvider>
);
};
@@ -62,13 +62,8 @@ export const useUpdateMultipleRecordsActions = ({
filter: graphqlFilter,
});
const updateRecords = async (fieldsToUpdate: Record<string, any>) => {
const count = await incrementalUpdateManyRecords(fieldsToUpdate);
return count;
};
return {
updateRecords,
updateRecords: incrementalUpdateManyRecords,
isUpdating,
progress,
cancel,
@@ -40,8 +40,7 @@ export const SettingsNavigationDrawerItem = ({
to={href}
Icon={item.Icon}
active={isActive}
soon={item.soon}
isNew={item.isNew}
modifier={item.modifier}
onClick={item.onClick}
/>
</AdvancedSettingsWrapper>
@@ -56,8 +55,7 @@ export const SettingsNavigationDrawerItem = ({
to={href || undefined}
Icon={item.Icon}
active={isActive}
soon={item.soon}
isNew={item.isNew}
modifier={item.modifier}
onClick={item.onClick}
/>
);
@@ -7,7 +7,10 @@ import { billingState } from '@/client-config/states/billingState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { type NavigationDrawerItemIndentationLevel } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import {
type NavigationDrawerItemIndentationLevel,
type NavigationDrawerItemModifier,
} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
@@ -57,8 +60,7 @@ export type SettingsNavigationItem = {
isHidden?: boolean;
subItems?: SettingsNavigationItem[];
isAdvanced?: boolean;
soon?: boolean;
isNew?: boolean;
modifier?: NavigationDrawerItemModifier;
};
const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
@@ -179,7 +181,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
isHidden:
!isApplicationEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
isNew: true,
modifier: 'new',
},
{
label: t`AI`,
@@ -187,7 +189,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconSparkles,
isHidden:
!isAIEnabled || !permissionMap[PermissionFlagType.WORKSPACE],
isNew: true,
modifier: 'new',
},
{
label: t`Security`,
@@ -1,5 +1,6 @@
import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter';
import { SidePanelWidthEffect } from '@/side-panel/components/SidePanelWidthEffect';
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useSidePanelCloseAnimationCompleteCleanup } from '@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup';
import {
@@ -10,6 +11,7 @@ import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingS
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { ParentClickOutsideIdContext } from '@/ui/utilities/pointer-event/contexts/ParentClickOutsideIdContext';
import { ResizablePanelGap } from '@/ui/layout/resizable-panel/components/ResizablePanelGap';
import { SIDE_PANEL_CONSTRAINTS } from '@/side-panel/constants/SidePanelConstraints';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
@@ -138,11 +140,16 @@ export const SidePanelForDesktop = () => {
isResizing={isResizing}
onTransitionEnd={handleTransitionEnd}
data-side-panel=""
data-click-outside-id={SIDE_PANEL_CLICK_OUTSIDE_ID}
>
<StyledSidePanel>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
{shouldShowContent && <SidePanelRouter />}
<ParentClickOutsideIdContext.Provider
value={SIDE_PANEL_CLICK_OUTSIDE_ID}
>
{shouldShowContent && <SidePanelRouter />}
</ParentClickOutsideIdContext.Provider>
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
@@ -0,0 +1 @@
export const SIDE_PANEL_CLICK_OUTSIDE_ID = 'side-panel';
@@ -45,7 +45,7 @@ export const SignInAppNavigationDrawerMock = ({
label={t`Search`}
Icon={IconSearch}
onClick={() => {}}
keyboard={[getOsControlSymbol(), 'K']}
modifier={{ keyboard: [getOsControlSymbol(), 'K'] }}
/>
<NavigationDrawerItem
label={t`Settings`}
@@ -10,6 +10,7 @@ import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContaine
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { ParentClickOutsideIdContext } from '@/ui/utilities/pointer-event/contexts/ParentClickOutsideIdContext';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -141,6 +142,7 @@ export const DropdownInternalContainer = ({
};
const { excludedClickOutsideId } = useContext(ClickOutsideListenerContext);
const parentClickOutsideId = useContext(ParentClickOutsideIdContext);
return (
<>
@@ -162,7 +164,11 @@ export const DropdownInternalContainer = ({
isDropdownInModal={isDropdownInModal}
>
<OverlayContainer>
<StyledDropdownInsideContainer id={dropdownId} data-select-disable>
<StyledDropdownInsideContainer
id={dropdownId}
data-select-disable
data-click-outside-id={parentClickOutsideId}
>
{dropdownComponents}
</StyledDropdownInsideContainer>
</OverlayContainer>
@@ -37,6 +37,11 @@ const DEFAULT_INDENTATION_LEVEL = 1;
export type NavigationDrawerItemIndentationLevel = 1 | 2;
export type NavigationDrawerItemModifier =
| 'soon'
| 'new'
| { keyboard: string[] };
export type NavigationDrawerItemProps = {
className?: string;
label: string;
@@ -48,17 +53,12 @@ export type NavigationDrawerItemProps = {
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
iconColor?: string | null;
active?: boolean;
danger?: boolean;
soon?: boolean;
isNew?: boolean;
count?: number;
keyboard?: string[];
modifier?: NavigationDrawerItemModifier;
rightOptions?: ReactNode;
alwaysShowRightOptions?: boolean;
isDragging?: boolean;
isRightOptionsDropdownOpen?: boolean;
triggerEvent?: TriggerEventType;
mouseUpNavigation?: boolean;
preventCollapseOnMobile?: boolean;
isSelectedInEditMode?: boolean;
variant?: 'default' | 'tertiary';
@@ -67,14 +67,13 @@ export type NavigationDrawerItemProps = {
type StyledItemProps = Pick<
NavigationDrawerItemProps,
| 'active'
| 'danger'
| 'indentationLevel'
| 'soon'
| 'to'
| 'isDragging'
| 'isSelectedInEditMode'
| 'variant'
> & {
isSoon: boolean;
isNavigationDrawerExpanded: boolean;
hasRightOptions: boolean;
href?: string;
@@ -92,14 +91,11 @@ const StyledItem = styled.button<StyledItemProps>`
: '1px solid transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${({ active, danger, soon, variant }) => {
color: ${({ active, isSoon, variant }) => {
if (active === true) {
return themeCssVariables.font.color.primary;
}
if (danger === true) {
return themeCssVariables.color.red;
}
if (soon === true) {
if (isSoon) {
return themeCssVariables.font.color.light;
}
if (variant === 'tertiary') {
@@ -107,8 +103,8 @@ const StyledItem = styled.button<StyledItemProps>`
}
return themeCssVariables.font.color.secondary;
}};
cursor: ${({ soon, isDragging }) =>
isDragging ? 'grabbing' : soon ? 'default' : 'pointer'};
cursor: ${({ isSoon, isDragging }) =>
isDragging ? 'grabbing' : isSoon ? 'default' : 'pointer'};
display: flex;
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
@@ -122,7 +118,7 @@ const StyledItem = styled.button<StyledItemProps>`
? themeCssVariables.spacing['0.5']
: themeCssVariables.spacing[1]};
padding-top: ${themeCssVariables.spacing[1]};
pointer-events: ${({ soon }) => (soon ? 'none' : 'auto')};
pointer-events: ${({ isSoon }) => (isSoon ? 'none' : 'auto')};
text-decoration: none;
user-select: none;
width: ${({ isNavigationDrawerExpanded, hasRightOptions }) =>
@@ -132,10 +128,7 @@ const StyledItem = styled.button<StyledItemProps>`
&:hover {
background: ${themeCssVariables.background.transparent.light};
color: ${({ danger }) =>
danger
? themeCssVariables.color.red
: themeCssVariables.font.color.primary};
color: ${themeCssVariables.font.color.primary};
}
&:hover .keyboard-shortcuts {
@@ -172,20 +165,6 @@ const StyledItemSecondaryLabel = styled.span`
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledItemCount = styled.span`
align-items: center;
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.rounded};
color: ${themeCssVariables.grayScale.gray1};
display: flex;
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
height: 16px;
justify-content: center;
margin-left: auto;
width: 16px;
`;
const StyledKeyBoardShortcut = styled.span`
align-items: center;
background: ${themeCssVariables.background.transparent.lighter};
@@ -277,18 +256,13 @@ export const NavigationDrawerItem = ({
to,
onClick,
active,
danger,
soon,
isNew,
count,
keyboard,
modifier,
subItemState,
rightOptions,
alwaysShowRightOptions = false,
isDragging,
isRightOptionsDropdownOpen,
triggerEvent,
mouseUpNavigation = false,
preventCollapseOnMobile = false,
isSelectedInEditMode = false,
variant = 'default',
@@ -301,10 +275,15 @@ export const NavigationDrawerItem = ({
const { navigationItemId } = useNavigationDrawerTooltip(label, to);
const isSoon = modifier === 'soon';
const isNew = modifier === 'new';
const keyboardKeys =
isDefined(modifier) && typeof modifier === 'object'
? modifier.keyboard
: undefined;
const showBreadcrumb = indentationLevel === 2;
const showStyledSpacer = Boolean(
soon || isNew || count || keyboard || rightOptions,
);
const showStyledSpacer = isDefined(modifier) || isDefined(rightOptions);
const handleMobileNavigation = () => {
if (isMobile && !preventCollapseOnMobile) {
@@ -314,6 +293,7 @@ export const NavigationDrawerItem = ({
const isExternalLink =
isDefined(to) && (to.startsWith('http://') || to.startsWith('https://'));
const isInternalLink = isDefined(to) && !isExternalLink;
const handleExternalLinkClick = () => {
handleMobileNavigation();
@@ -332,40 +312,37 @@ export const NavigationDrawerItem = ({
triggerEvent,
});
const elementType = isExternalLink
? 'a'
: isInternalLink
? Link
: isDefined(rightOptions)
? 'div'
: undefined;
return (
<StyledNavigationDrawerItemContainer>
<StyledItem
id={navigationItemId}
className={`navigation-drawer-item ${className || ''}`}
onClick={
mouseUpNavigation ? onClick : handleMouseDownNavigationClickClick
}
onMouseDown={mouseUpNavigation ? undefined : handleMouseDown}
onClick={handleMouseDownNavigationClickClick}
onMouseDown={handleMouseDown}
active={active}
aria-selected={active}
danger={danger}
soon={soon}
isSoon={isSoon}
variant={variant}
as={
to
? isExternalLink
? 'a'
: Link
: isDefined(rightOptions)
? 'div'
: undefined
}
role={to ? undefined : isDefined(rightOptions) ? 'button' : undefined}
to={isExternalLink ? undefined : to}
href={isExternalLink ? to : undefined}
target={isExternalLink ? '_blank' : undefined}
rel={isExternalLink ? 'noopener noreferrer' : undefined}
draggable={to && !isExternalLink ? false : undefined}
indentationLevel={indentationLevel}
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
isDragging={isDragging}
hasRightOptions={isDefined(rightOptions)}
isSelectedInEditMode={isSelectedInEditMode}
as={elementType}
role={!to && isDefined(rightOptions) ? 'button' : undefined}
to={isInternalLink ? to : undefined}
href={isExternalLink ? to : undefined}
target={isExternalLink ? '_blank' : undefined}
rel={isExternalLink ? 'noopener noreferrer' : undefined}
draggable={isInternalLink ? false : undefined}
>
<StyledItemElementsContainer>
{showBreadcrumb && (
@@ -419,7 +396,7 @@ export const NavigationDrawerItem = ({
{showStyledSpacer && <StyledSpacer />}
{soon && (
{isSoon && (
<NavigationDrawerAnimatedCollapseWrapper>
<Pill label={t`Soon`} />
</NavigationDrawerAnimatedCollapseWrapper>
@@ -431,23 +408,25 @@ export const NavigationDrawerItem = ({
</NavigationDrawerAnimatedCollapseWrapper>
)}
{!!count && (
<NavigationDrawerAnimatedCollapseWrapper>
<StyledItemCount>{count}</StyledItemCount>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{keyboard && (
{isDefined(keyboardKeys) && (
<NavigationDrawerAnimatedCollapseWrapper>
<StyledKeyBoardShortcut className="keyboard-shortcuts">
<Label>{keyboard}</Label>
<Label>{keyboardKeys}</Label>
</StyledKeyBoardShortcut>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{isDefined(rightOptions) && (
<NavigationDrawerAnimatedCollapseWrapper>
<StyledRightOptionsContainer>
{/* When StyledItem renders as a Link, we need both handlers to
prevent navigation when interacting with rightOptions:
- onMouseDown: stops useMouseDownNavigation from calling navigate()
- onClickCapture: prevents the native <a> follow since the child's
stopPropagation blocks Link's own preventDefault */}
<StyledRightOptionsContainer
onMouseDown={(e) => e.stopPropagation()}
onClickCapture={(e) => e.preventDefault()}
>
<StyledRightOptionsVisbility
data-visible={
isMobile ||
@@ -14,10 +14,7 @@ export const NavigationDrawerSubItem = ({
to,
onClick,
active,
danger,
soon,
count,
keyboard,
modifier,
subItemState,
rightOptions,
isDragging,
@@ -37,10 +34,7 @@ export const NavigationDrawerSubItem = ({
to={to}
onClick={onClick}
active={active}
danger={danger}
soon={soon}
count={count}
keyboard={keyboard}
modifier={modifier}
rightOptions={rightOptions}
isDragging={isDragging}
isSelectedInEditMode={isSelectedInEditMode}
@@ -86,24 +86,19 @@ export const Default: Story = {
label="Notifications"
to="/inbox"
Icon={IconBell}
soon={true}
modifier="soon"
/>
<NavigationDrawerItem
label="Search"
Icon={IconSearch}
keyboard={[`${getOsControlSymbol()}`, 'K']}
modifier={{ keyboard: [`${getOsControlSymbol()}`, 'K'] }}
/>
<NavigationDrawerItem
label="Settings"
to="/settings/profile"
Icon={IconSettings}
/>
<NavigationDrawerItem
label="Tasks"
to="/tasks"
Icon={IconCheckbox}
count={2}
/>
<NavigationDrawerItem label="Tasks" to="/tasks" Icon={IconCheckbox} />
</NavigationDrawerSection>
<NavigationDrawerSection>
@@ -105,23 +105,14 @@ export const NewPill: Story = {
args={{
label: 'New Feature',
Icon: IconSearch,
isNew: true,
modifier: 'new',
}}
/>
<Story
args={{
label: 'New Feature with Count',
label: 'Feature with Keyboard Shortcut',
Icon: IconSearch,
isNew: true,
count: 5,
}}
/>
<Story
args={{
label: 'New Feature with Keyboard Shortcut',
Icon: IconSearch,
isNew: true,
keyboard: [getOsControlSymbol(), 'N'],
modifier: { keyboard: [getOsControlSymbol(), 'N'] },
}}
/>
</StyledContainer>
@@ -195,12 +186,6 @@ export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = {
pseudo: { hover: ['.hover'] },
catalog: {
dimensions: [
{
name: 'danger',
values: [true, false],
props: (danger: boolean) => ({ danger }),
labels: (danger: boolean) => (danger ? 'Danger' : 'No Danger'),
},
{
name: 'active',
values: [true, false],
@@ -216,23 +201,19 @@ export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = {
},
{
name: 'adornments',
values: [
'Without Adornments',
'Soon Pill',
'New Pill',
'Count',
'Keyboard Keys',
],
values: ['Without Modifier', 'Soon', 'New', 'Keyboard Keys'],
props: (adornmentName: string) =>
adornmentName === 'Soon Pill'
? { soon: true }
: adornmentName === 'New Pill'
? { isNew: true }
: adornmentName === 'Count'
? { count: 3 }
: adornmentName === 'Keyboard Keys'
? { keyboard: [getOsControlSymbol(), 'K'] }
: {},
adornmentName === 'Soon'
? { modifier: 'soon' }
: adornmentName === 'New'
? { modifier: 'new' }
: adornmentName === 'Keyboard Keys'
? {
modifier: {
keyboard: [getOsControlSymbol(), 'K'],
},
}
: {},
},
],
},
@@ -0,0 +1,5 @@
import { createContext } from 'react';
export const ParentClickOutsideIdContext = createContext<string | undefined>(
undefined,
);
@@ -1236,107 +1236,6 @@ enum PageLayoutType {
DASHBOARD
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type RecordIdentifier {
id: UUID!
labelIdentifier: String!
imageIdentifier: String
}
type NavigationMenuItem {
id: UUID!
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type ObjectRecordEventProperties {
updatedFields: [String!]
before: JSON
after: JSON
diff: JSON
}
type MetadataEvent {
type: MetadataEventAction!
metadataName: String!
recordId: String!
properties: ObjectRecordEventProperties!
updatedCollectionHash: String
}
"""Metadata Event Action"""
enum MetadataEventAction {
CREATED
UPDATED
DELETED
}
type ObjectRecordEvent {
action: DatabaseEventAction!
objectNameSingular: String!
recordId: String!
userId: String
workspaceMemberId: String
properties: ObjectRecordEventProperties!
}
"""Database Event Action"""
enum DatabaseEventAction {
CREATED
UPDATED
DELETED
DESTROYED
RESTORED
UPSERTED
}
type ObjectRecordEventWithQueryIds {
queryIds: [String!]!
objectRecordEvent: ObjectRecordEvent!
}
type EventSubscription {
eventStreamId: String!
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
metadataEvents: [MetadataEvent!]!
}
type OnDbEvent {
action: DatabaseEventAction!
objectNameSingular: String!
eventDate: DateTime!
record: JSON!
updatedFields: [String!]
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
@@ -1523,6 +1422,14 @@ type ApprovedAccessDomain {
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type WorkspaceInvitation {
id: UUID!
email: String!
@@ -1958,6 +1865,91 @@ type WorkspaceInviteHashValid {
isValid: Boolean!
}
type RecordIdentifier {
id: UUID!
labelIdentifier: String!
imageIdentifier: String
}
type NavigationMenuItem {
id: UUID!
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type ObjectRecordEventProperties {
updatedFields: [String!]
before: JSON
after: JSON
diff: JSON
}
type MetadataEvent {
type: MetadataEventAction!
metadataName: String!
recordId: String!
properties: ObjectRecordEventProperties!
updatedCollectionHash: String
}
"""Metadata Event Action"""
enum MetadataEventAction {
CREATED
UPDATED
DELETED
}
type ObjectRecordEvent {
action: DatabaseEventAction!
objectNameSingular: String!
recordId: String!
userId: String
workspaceMemberId: String
properties: ObjectRecordEventProperties!
}
"""Database Event Action"""
enum DatabaseEventAction {
CREATED
UPDATED
DELETED
DESTROYED
RESTORED
UPSERTED
}
type ObjectRecordEventWithQueryIds {
queryIds: [String!]!
objectRecordEvent: ObjectRecordEvent!
}
type EventSubscription {
eventStreamId: String!
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
metadataEvents: [MetadataEvent!]!
}
type LogicFunctionExecutionResult {
"""Execution result in JSON format"""
data: JSON
@@ -4316,17 +4308,10 @@ enum FileFolder {
}
type Subscription {
onDbEvent(input: OnDbEventInput!): OnDbEvent!
onEventSubscription(eventStreamId: String!): EventSubscription
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
}
input OnDbEventInput {
action: DatabaseEventAction
objectNameSingular: String
recordId: UUID
}
input LogicFunctionLogsInput {
applicationId: UUID
applicationUniversalIdentifier: UUID
@@ -963,101 +963,6 @@ export interface PageLayout {
export type PageLayoutType = 'RECORD_INDEX' | 'RECORD_PAGE' | 'DASHBOARD'
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface RecordIdentifier {
id: Scalars['UUID']
labelIdentifier: Scalars['String']
imageIdentifier?: Scalars['String']
__typename: 'RecordIdentifier'
}
export interface NavigationMenuItem {
id: Scalars['UUID']
userWorkspaceId?: Scalars['UUID']
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
targetRecordIdentifier?: RecordIdentifier
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface ObjectRecordEventProperties {
updatedFields?: Scalars['String'][]
before?: Scalars['JSON']
after?: Scalars['JSON']
diff?: Scalars['JSON']
__typename: 'ObjectRecordEventProperties'
}
export interface MetadataEvent {
type: MetadataEventAction
metadataName: Scalars['String']
recordId: Scalars['String']
properties: ObjectRecordEventProperties
updatedCollectionHash?: Scalars['String']
__typename: 'MetadataEvent'
}
/** Metadata Event Action */
export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED'
export interface ObjectRecordEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
recordId: Scalars['String']
userId?: Scalars['String']
workspaceMemberId?: Scalars['String']
properties: ObjectRecordEventProperties
__typename: 'ObjectRecordEvent'
}
/** Database Event Action */
export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED'
export interface ObjectRecordEventWithQueryIds {
queryIds: Scalars['String'][]
objectRecordEvent: ObjectRecordEvent
__typename: 'ObjectRecordEventWithQueryIds'
}
export interface EventSubscription {
eventStreamId: Scalars['String']
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
metadataEvents: MetadataEvent[]
__typename: 'EventSubscription'
}
export interface OnDbEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
eventDate: Scalars['DateTime']
record: Scalars['JSON']
updatedFields?: Scalars['String'][]
__typename: 'OnDbEvent'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
@@ -1241,6 +1146,15 @@ export interface ApprovedAccessDomain {
__typename: 'ApprovedAccessDomain'
}
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface WorkspaceInvitation {
id: Scalars['UUID']
email: Scalars['String']
@@ -1687,6 +1601,83 @@ export interface WorkspaceInviteHashValid {
__typename: 'WorkspaceInviteHashValid'
}
export interface RecordIdentifier {
id: Scalars['UUID']
labelIdentifier: Scalars['String']
imageIdentifier?: Scalars['String']
__typename: 'RecordIdentifier'
}
export interface NavigationMenuItem {
id: Scalars['UUID']
userWorkspaceId?: Scalars['UUID']
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
targetRecordIdentifier?: RecordIdentifier
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface ObjectRecordEventProperties {
updatedFields?: Scalars['String'][]
before?: Scalars['JSON']
after?: Scalars['JSON']
diff?: Scalars['JSON']
__typename: 'ObjectRecordEventProperties'
}
export interface MetadataEvent {
type: MetadataEventAction
metadataName: Scalars['String']
recordId: Scalars['String']
properties: ObjectRecordEventProperties
updatedCollectionHash?: Scalars['String']
__typename: 'MetadataEvent'
}
/** Metadata Event Action */
export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED'
export interface ObjectRecordEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
recordId: Scalars['String']
userId?: Scalars['String']
workspaceMemberId?: Scalars['String']
properties: ObjectRecordEventProperties
__typename: 'ObjectRecordEvent'
}
/** Database Event Action */
export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED'
export interface ObjectRecordEventWithQueryIds {
queryIds: Scalars['String'][]
objectRecordEvent: ObjectRecordEvent
__typename: 'ObjectRecordEventWithQueryIds'
}
export interface EventSubscription {
eventStreamId: Scalars['String']
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
metadataEvents: MetadataEvent[]
__typename: 'EventSubscription'
}
export interface LogicFunctionExecutionResult {
/** Execution result in JSON format */
data?: Scalars['JSON']
@@ -2881,7 +2872,6 @@ export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'AppTarball'
export interface Subscription {
onDbEvent: OnDbEvent
onEventSubscription?: EventSubscription
logicFunctionLogs: LogicFunctionLogs
__typename: 'Subscription'
@@ -3897,100 +3887,6 @@ export interface PageLayoutGenqlSelection{
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface RecordIdentifierGenqlSelection{
id?: boolean | number
labelIdentifier?: boolean | number
imageIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NavigationMenuItemGenqlSelection{
id?: boolean | number
userWorkspaceId?: boolean | number
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
targetRecordIdentifier?: RecordIdentifierGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventPropertiesGenqlSelection{
updatedFields?: boolean | number
before?: boolean | number
after?: boolean | number
diff?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MetadataEventGenqlSelection{
type?: boolean | number
metadataName?: boolean | number
recordId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
updatedCollectionHash?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
recordId?: boolean | number
userId?: boolean | number
workspaceMemberId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventWithQueryIdsGenqlSelection{
queryIds?: boolean | number
objectRecordEvent?: ObjectRecordEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventSubscriptionGenqlSelection{
eventStreamId?: boolean | number
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
metadataEvents?: MetadataEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface OnDbEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
eventDate?: boolean | number
record?: boolean | number
updatedFields?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
@@ -4181,6 +4077,16 @@ export interface ApprovedAccessDomainGenqlSelection{
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface WorkspaceInvitationGenqlSelection{
id?: boolean | number
email?: boolean | number
@@ -4676,6 +4582,80 @@ export interface WorkspaceInviteHashValidGenqlSelection{
__scalar?: boolean | number
}
export interface RecordIdentifierGenqlSelection{
id?: boolean | number
labelIdentifier?: boolean | number
imageIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NavigationMenuItemGenqlSelection{
id?: boolean | number
userWorkspaceId?: boolean | number
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
targetRecordIdentifier?: RecordIdentifierGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventPropertiesGenqlSelection{
updatedFields?: boolean | number
before?: boolean | number
after?: boolean | number
diff?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MetadataEventGenqlSelection{
type?: boolean | number
metadataName?: boolean | number
recordId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
updatedCollectionHash?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
recordId?: boolean | number
userId?: boolean | number
workspaceMemberId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventWithQueryIdsGenqlSelection{
queryIds?: boolean | number
objectRecordEvent?: ObjectRecordEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventSubscriptionGenqlSelection{
eventStreamId?: boolean | number
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
metadataEvents?: MetadataEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface LogicFunctionExecutionResultGenqlSelection{
/** Execution result in JSON format */
data?: boolean | number
@@ -6266,15 +6246,12 @@ export interface WorkspaceMigrationInput {actions: WorkspaceMigrationDeleteActio
export interface WorkspaceMigrationDeleteActionInput {type: WorkspaceMigrationActionType,metadataName: AllMetadataName,universalIdentifier: Scalars['String']}
export interface SubscriptionGenqlSelection{
onDbEvent?: (OnDbEventGenqlSelection & { __args: {input: OnDbEventInput} })
onEventSubscription?: (EventSubscriptionGenqlSelection & { __args: {eventStreamId: Scalars['String']} })
logicFunctionLogs?: (LogicFunctionLogsGenqlSelection & { __args: {input: LogicFunctionLogsInput} })
__typename?: boolean | number
__scalar?: boolean | number
}
export interface OnDbEventInput {action?: (DatabaseEventAction | null),objectNameSingular?: (Scalars['String'] | null),recordId?: (Scalars['UUID'] | null)}
export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null),applicationUniversalIdentifier?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),id?: (Scalars['UUID'] | null),universalIdentifier?: (Scalars['UUID'] | null)}
@@ -6822,78 +6799,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
return RecordIdentifier_possibleTypes.includes(obj.__typename)
}
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename)
}
const MetadataEvent_possibleTypes: string[] = ['MetadataEvent']
export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"')
return MetadataEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent']
export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"')
return ObjectRecordEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds']
export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"')
return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename)
}
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
return EventSubscription_possibleTypes.includes(obj.__typename)
}
const OnDbEvent_possibleTypes: string[] = ['OnDbEvent']
export const isOnDbEvent = (obj?: { __typename?: any } | null): obj is OnDbEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isOnDbEvent"')
return OnDbEvent_possibleTypes.includes(obj.__typename)
}
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
@@ -7062,6 +6967,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const WorkspaceInvitation_possibleTypes: string[] = ['WorkspaceInvitation']
export const isWorkspaceInvitation = (obj?: { __typename?: any } | null): obj is WorkspaceInvitation => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceInvitation"')
@@ -7550,6 +7463,62 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
return RecordIdentifier_possibleTypes.includes(obj.__typename)
}
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename)
}
const MetadataEvent_possibleTypes: string[] = ['MetadataEvent']
export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"')
return MetadataEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent']
export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"')
return ObjectRecordEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds']
export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"')
return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename)
}
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
return EventSubscription_possibleTypes.includes(obj.__typename)
}
const LogicFunctionExecutionResult_possibleTypes: string[] = ['LogicFunctionExecutionResult']
export const isLogicFunctionExecutionResult = (obj?: { __typename?: any } | null): obj is LogicFunctionExecutionResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionExecutionResult"')
@@ -8591,29 +8560,6 @@ export const enumPageLayoutType = {
DASHBOARD: 'DASHBOARD' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumMetadataEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const
}
export const enumDatabaseEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const,
DESTROYED: 'DESTROYED' as const,
RESTORED: 'RESTORED' as const,
UPSERTED: 'UPSERTED' as const
}
export const enumBillingPlanKey = {
PRO: 'PRO' as const,
ENTERPRISE: 'ENTERPRISE' as const
@@ -8692,6 +8638,29 @@ export const enumRelationType = {
MANY_TO_ONE: 'MANY_TO_ONE' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumMetadataEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const
}
export const enumDatabaseEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const,
DESTROYED: 'DESTROYED' as const,
RESTORED: 'RESTORED' as const,
UPSERTED: 'UPSERTED' as const
}
export const enumLogicFunctionExecutionStatus = {
IDLE: 'IDLE' as const,
SUCCESS: 'SUCCESS' as const,
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
import { registerEnumType } from '@nestjs/graphql';
export enum DatabaseEventAction {
CREATED = 'created',
UPDATED = 'updated',
@@ -6,3 +8,8 @@ export enum DatabaseEventAction {
RESTORED = 'restored',
UPSERTED = 'upserted',
}
registerEnumType(DatabaseEventAction, {
name: 'DatabaseEventAction',
description: 'Database Event Action',
});
@@ -20,7 +20,7 @@ import { CallWebhookJobsJob } from 'src/engine/metadata-modules/webhook/jobs/cal
import { WorkspaceEventBatchForWebhook } from 'src/engine/metadata-modules/webhook/types/workspace-event-batch-for-webhook.type';
import { CallDatabaseEventTriggerJobsJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/call-database-event-trigger-jobs.job';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job';
@Injectable()
@@ -32,7 +32,7 @@ export class EntityEventsToDbListener {
private readonly webhookQueueService: MessageQueueService,
@InjectMessageQueue(MessageQueue.triggerQueue)
private readonly triggerQueueService: MessageQueueService,
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
private readonly objectRecordEventPublisher: ObjectRecordEventPublisher,
) {}
@OnDatabaseBatchEvent('*', DatabaseEventAction.CREATED)
@@ -79,7 +79,7 @@ export class EntityEventsToDbListener {
};
const promises = [
this.workspaceEventEmitterService.publish(batchEvent),
this.objectRecordEventPublisher.publish(batchEvent),
this.webhookQueueService.add<WorkspaceEventBatchForWebhook<T>>(
CallWebhookJobsJob.name,
batchEventForWebhook,
@@ -65,7 +65,6 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
import { MetadataEventEmitterModule } from 'src/engine/metadata-event-emitter/metadata-event-emitter.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
@@ -110,7 +109,6 @@ import { FileModule } from './file/file.module';
PostgresCredentialsModule,
WorkflowApiModule,
WorkspaceEventEmitterModule,
MetadataEventEmitterModule,
ActorModule,
TelemetryModule,
AdminPanelModule,
@@ -1,14 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { MetadataEventsToDbListener } from 'src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener';
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Global()
@Module({
imports: [SubscriptionsModule, WorkspaceCacheModule],
providers: [MetadataEventEmitter, MetadataEventsToDbListener],
exports: [MetadataEventEmitter],
})
export class MetadataEventEmitterModule {}
@@ -6,7 +6,7 @@ import { Process } from 'src/engine/core-modules/message-queue/decorators/proces
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import { CallWebhookJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job';
import { type CallMetadataWebhookJobData } from 'src/engine/metadata-modules/webhook/types/webhook-job-data.type';
@@ -1,29 +0,0 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { ObjectRecord } from 'twenty-shared/types';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
registerEnumType(DatabaseEventAction, {
name: 'DatabaseEventAction',
description: 'Database Event Action',
});
@ObjectType('OnDbEvent')
export class OnDbEventDTO {
@Field(() => DatabaseEventAction)
action: DatabaseEventAction;
@Field(() => String)
objectNameSingular: string;
@Field()
eventDate: Date;
@Field(() => GraphQLJSON)
record: ObjectRecord;
@Field(() => [String], { nullable: true })
updatedFields?: string[];
}
@@ -1,16 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class OnDbEventInput {
@Field(() => DatabaseEventAction, { nullable: true })
action?: DatabaseEventAction;
@Field(() => String, { nullable: true })
objectNameSingular?: string;
@Field(() => UUIDScalarType, { nullable: true })
recordId?: string;
}
@@ -1,5 +1,4 @@
export enum SubscriptionChannel {
DATABASE_EVENT_CHANNEL = 'DATABASE_EVENT_CHANNEL',
LOGIC_FUNCTION_LOGS_CHANNEL = 'LOGIC_FUNCTION_LOGS_CHANNEL',
EVENT_STREAM_CHANNEL = 'EVENT_STREAM_CHANNEL',
}
@@ -10,9 +10,7 @@ import {
} from 'src/engine/subscriptions/event-stream.exception';
@Catch(EventStreamException)
export class WorkspaceEventEmitterExceptionFilter
implements GqlExceptionFilter
{
export class EventStreamExceptionFilter implements GqlExceptionFilter {
catch(exception: EventStreamException) {
switch (exception.code) {
case EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS:
@@ -19,10 +19,8 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { EVENT_STREAM_TTL_MS } from 'src/engine/subscriptions/constants/event-stream-ttl.constant';
import { AddQuerySubscriptionInput } from 'src/engine/subscriptions/dtos/add-query-subscription.input';
import { EventSubscriptionDTO } from 'src/engine/subscriptions/dtos/event-subscription.dto';
import { OnDbEventDTO } from 'src/engine/subscriptions/dtos/on-db-event.dto';
import { OnDbEventInput } from 'src/engine/subscriptions/dtos/on-db-event.input';
import { RemoveQueryFromEventStreamInput } from 'src/engine/subscriptions/dtos/remove-query-subscription.input';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { EventStreamExceptionFilter } from 'src/engine/subscriptions/event-stream-exception.filter';
import {
EventStreamException,
EventStreamExceptionCode,
@@ -30,57 +28,19 @@ import {
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/workspace-event-emitter/utils/wrap-async-iterator-with-lifecycle';
import { WorkspaceEventEmitterExceptionFilter } from 'src/engine/workspace-event-emitter/workspace-event-emitter-exception.filter';
import { eventStreamIdToChannelId } from './utils/get-channel-id-from-event-stream-id';
import { eventStreamIdToChannelId } from 'src/engine/subscriptions/utils/get-channel-id-from-event-stream-id';
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/subscriptions/utils/wrap-async-iterator-with-lifecycle';
@MetadataResolver()
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(
WorkspaceEventEmitterExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class WorkspaceEventEmitterResolver {
@UseFilters(EventStreamExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
export class EventStreamResolver {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
) {}
@Subscription(() => OnDbEventDTO, {
filter: (
payload: { onDbEvent: OnDbEventDTO },
variables: { input: OnDbEventInput },
) => {
const isActionMatching =
!isDefined(variables.input.action) ||
payload.onDbEvent.action === variables.input.action;
const isObjectNameSingularMatching =
!isDefined(variables.input.objectNameSingular) ||
payload.onDbEvent.objectNameSingular ===
variables.input.objectNameSingular;
const isRecordIdMatching =
!isDefined(variables.input.recordId) ||
payload.onDbEvent.record.id === variables.input.recordId;
return (
isActionMatching && isObjectNameSingularMatching && isRecordIdMatching
);
},
})
onDbEvent(
@Args('input') _: OnDbEventInput,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
return this.subscriptionService.subscribe({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
workspaceId: workspace.id,
});
}
@Subscription(() => EventSubscriptionDTO, {
nullable: true,
resolve: (
@@ -6,8 +6,8 @@ import { isDefined } from 'twenty-shared/utils';
import { getWorkspaceAuthContext } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { computeMetadataEventName } from 'src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util';
import { MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { computeMetadataEventName } from 'src/engine/subscriptions/metadata-event/utils/compute-metadata-event-name.util';
import {
AllMetadataEventName,
AllMetadataEventType,
@@ -0,0 +1,250 @@
import { Injectable } from '@nestjs/common';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
@Injectable()
export class MetadataEventPublisher {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async publish(metadataEventBatch: MetadataEventBatch): Promise<void> {
const workspaceId = metadataEventBatch.workspaceId;
const activeStreamIds =
await this.eventStreamService.getActiveStreamIds(workspaceId);
if (activeStreamIds.length === 0) {
return;
}
const streamsData = await this.eventStreamService.getStreamsData(
workspaceId,
activeStreamIds,
);
const enrichedBatch =
await this.enrichMetadataEventBatch(metadataEventBatch);
const streamIdsToRemove: string[] = [];
for (const [streamChannelId, streamData] of streamsData) {
if (!isDefined(streamData)) {
streamIdsToRemove.push(streamChannelId);
continue;
}
await this.publishToStream({
streamChannelId,
metadataEventBatch: enrichedBatch,
});
}
await this.eventStreamService.removeFromActiveStreams(
workspaceId,
streamIdsToRemove,
);
}
private async enrichMetadataEventBatch(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
switch (metadataEventBatch.metadataName) {
case 'fieldMetadata':
return this.enrichFieldMetadataEventsWithRelations(
metadataEventBatch as MetadataEventBatch<'fieldMetadata'>,
);
case 'navigationMenuItem':
return this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch as MetadataEventBatch<'navigationMenuItem'>,
);
case 'objectMetadata':
return this.resolveObjectMetadataStandardOverrides(
metadataEventBatch as MetadataEventBatch<'objectMetadata'>,
);
default:
return metadataEventBatch;
}
}
private async publishToStream({
streamChannelId,
metadataEventBatch,
}: {
streamChannelId: string;
metadataEventBatch: MetadataEventBatch;
}): Promise<void> {
if (!isNonEmptyArray(metadataEventBatch.events)) {
return;
}
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
...metadataEvent,
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
}));
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: [],
metadataEvents,
};
await this.subscriptionService.publishToEventStream({
workspaceId: metadataEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload,
});
}
private async enrichFieldMetadataEventsWithRelations(
metadataEventBatch: MetadataEventBatch<'fieldMetadata'>,
): Promise<MetadataEventBatch<'fieldMetadata'>> {
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: metadataEventBatch.workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const enrichedEvents = metadataEventBatch.events.map((event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const enrichedAfter = enrichFieldMetadataEventWithRelations({
record: event.properties.after as Record<string, unknown>,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch: MetadataEventBatch<'navigationMenuItem'>,
): Promise<MetadataEventBatch<'navigationMenuItem'>> {
const enrichedEvents = await Promise.all(
metadataEventBatch.events.map(async (event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const after = event.properties.after as Record<string, unknown>;
const targetRecordId = after.targetRecordId as string | undefined;
const targetObjectMetadataId = after.targetObjectMetadataId as
| string
| undefined;
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
return event;
}
const targetRecordIdentifier =
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId: metadataEventBatch.workspaceId,
},
);
const enrichedAfter: Record<string, unknown> = {
...after,
targetRecordIdentifier,
};
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
}),
);
return { ...metadataEventBatch, events: enrichedEvents };
}
private resolveObjectMetadataStandardOverrides(
metadataEventBatch: MetadataEventBatch<'objectMetadata'>,
): MetadataEventBatch<'objectMetadata'> {
const enrichedEvents = metadataEventBatch.events.map((event) => {
const enrichedProperties = { ...event.properties };
if (
'before' in enrichedProperties &&
isDefined(enrichedProperties.before)
) {
enrichedProperties.before =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.before as Record<string, unknown>,
) as typeof enrichedProperties.before;
}
if (
'after' in enrichedProperties &&
isDefined(enrichedProperties.after)
) {
enrichedProperties.after =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.after as Record<string, unknown>,
) as typeof enrichedProperties.after;
}
return { ...event, properties: enrichedProperties } as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private applyStandardOverridesToObjectMetadataRecord(
record: Record<string, unknown>,
): Record<string, unknown> {
const standardOverrides = record.standardOverrides as
| Record<string, unknown>
| null
| undefined;
if (!isDefined(standardOverrides)) {
return record;
}
const resolved = { ...record };
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
if (isDefined(standardOverrides[key])) {
resolved[key] = standardOverrides[key];
}
}
return resolved;
}
}
@@ -6,12 +6,12 @@ import { AllMetadataName } from 'twenty-shared/metadata';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher';
import { type AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
@Injectable()
@@ -19,7 +19,7 @@ export class MetadataEventsToDbListener {
constructor(
@InjectMessageQueue(MessageQueue.webhookQueue)
private readonly webhookQueueService: MessageQueueService,
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
private readonly metadataEventPublisher: MetadataEventPublisher,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@@ -68,7 +68,7 @@ export class MetadataEventsToDbListener {
const updatedCollectionHash = cacheHashes[cacheKeyName];
await this.workspaceEventEmitterService.publish({
await this.metadataEventPublisher.publish({
...metadataEventBatch,
updatedCollectionHash,
});
@@ -22,8 +22,7 @@ import { type EventStreamData } from 'src/engine/subscriptions/types/event-strea
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
jest.mock(
'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util',
@@ -70,8 +69,8 @@ const buildFlatFieldMetadataMaps = (
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatFieldMetadata>,
);
describe('WorkspaceEventEmitterService', () => {
let service: WorkspaceEventEmitterService;
describe('ObjectRecordEventPublisher', () => {
let service: ObjectRecordEventPublisher;
let mockSubscriptionService: jest.Mocked<
Pick<SubscriptionService, 'publish' | 'publishToEventStream'>
>;
@@ -273,7 +272,7 @@ describe('WorkspaceEventEmitterService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkspaceEventEmitterService,
ObjectRecordEventPublisher,
{
provide: SubscriptionService,
useValue: mockSubscriptionService,
@@ -302,19 +301,11 @@ describe('WorkspaceEventEmitterService', () => {
provide: CommonSelectFieldsHelper,
useValue: new CommonSelectFieldsHelper(),
},
{
provide: NavigationMenuItemRecordIdentifierService,
useValue: {
enrichNavigationMenuItemEventsWithRecordIdentifiers: jest
.fn()
.mockResolvedValue(undefined),
},
},
],
}).compile();
service = module.get<WorkspaceEventEmitterService>(
WorkspaceEventEmitterService,
service = module.get<ObjectRecordEventPublisher>(
ObjectRecordEventPublisher,
);
});
@@ -13,7 +13,6 @@ import {
import {
combineFilters,
isDefined,
isNonEmptyArray,
isRecordGqlOperationSignature,
} from 'twenty-shared/utils';
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
@@ -25,20 +24,14 @@ import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
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 { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/workspace-event-emitter/utils/enrich-field-metadata-event-with-relations.util';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map';
import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type';
import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type';
import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import {
@@ -54,8 +47,9 @@ import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twent
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { parseEventNameOrThrow } from 'src/engine/workspace-event-emitter/utils/parse-event-name';
@Injectable()
export class WorkspaceEventEmitterService {
export class ObjectRecordEventPublisher {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
@@ -64,57 +58,12 @@ export class WorkspaceEventEmitterService {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly commonSelectFieldsHelper: CommonSelectFieldsHelper,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async publish(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): Promise<void> {
if (!this.isMetadataEventBatch(eventBatch)) {
await this.publishToLegacyChannel(eventBatch);
}
await this.publishToEventStreams(eventBatch);
}
private isMetadataEventBatch(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): eventBatch is MetadataEventBatch {
return 'metadataName' in eventBatch;
}
private async publishToLegacyChannel(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
): Promise<void> {
const [nameSingular, operation] = eventBatch.name.split('.');
for (const eventData of eventBatch.events) {
const { record, updatedFields } = transformEventToWebhookEvent({
eventName: eventBatch.name,
event: eventData,
});
const event = {
action: operation,
objectNameSingular: nameSingular,
eventDate: new Date(),
record,
...(updatedFields && { updatedFields }),
};
await this.subscriptionService.publish({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
workspaceId: eventBatch.workspaceId,
payload: { onDbEvent: event },
});
}
}
private async publishToEventStreams(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): Promise<void> {
const workspaceId = eventBatch.workspaceId;
const isMetadata = this.isMetadataEventBatch(eventBatch);
const activeStreamIds =
await this.eventStreamService.getActiveStreamIds(workspaceId);
@@ -128,53 +77,28 @@ export class WorkspaceEventEmitterService {
activeStreamIds,
);
const { permissionsContext, flatWorkspaceMemberMaps } =
await this.fetchObjectRecordStreamContext(workspaceId);
const streamIdsToRemove: string[] = [];
const objectRecordStreamContext = !isMetadata
? await this.fetchObjectRecordStreamContext(workspaceId)
: undefined;
const enrichedMetadataEventBatch = isMetadata
? await this.enrichFieldMetadataEventsWithRelations(
eventBatch as MetadataEventBatch,
)
.then((batch) =>
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
batch,
),
)
.then((batch) => this.resolveObjectMetadataStandardOverrides(batch))
: undefined;
for (const [streamChannelId, streamData] of streamsData) {
if (!isDefined(streamData)) {
streamIdsToRemove.push(streamChannelId);
continue;
}
if (isMetadata && isDefined(enrichedMetadataEventBatch)) {
await this.processMetadataStreamEvents(
streamChannelId,
streamData,
enrichedMetadataEventBatch,
);
} else if (!isMetadata) {
if (Object.keys(streamData.queries).length === 0) {
continue;
}
if (!isDefined(objectRecordStreamContext)) {
continue;
}
await this.processObjectRecordStreamEvents(
streamChannelId,
streamData,
eventBatch as WorkspaceEventBatch<ObjectRecordEvent>,
objectRecordStreamContext.permissionsContext,
objectRecordStreamContext.flatWorkspaceMemberMaps,
);
if (Object.keys(streamData.queries).length === 0) {
continue;
}
await this.processObjectRecordStreamEvents({
streamChannelId,
streamData,
workspaceEventBatch: eventBatch,
permissionsContext,
flatWorkspaceMemberMaps,
});
}
await this.eventStreamService.removeFromActiveStreams(
@@ -193,198 +117,25 @@ export class WorkspaceEventEmitterService {
return { permissionsContext, flatWorkspaceMemberMaps };
}
private async processMetadataStreamEvents(
streamChannelId: string,
_streamData: EventStreamData,
metadataEventBatch: MetadataEventBatch,
): Promise<void> {
if (!isNonEmptyArray(metadataEventBatch.events)) {
return;
}
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
...metadataEvent,
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
}));
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: [],
metadataEvents,
};
await this.subscriptionService.publishToEventStream({
workspaceId: metadataEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload,
});
}
private async enrichFieldMetadataEventsWithRelations(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
if (metadataEventBatch.metadataName !== 'fieldMetadata') {
return metadataEventBatch;
}
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: metadataEventBatch.workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const enrichedEvents = metadataEventBatch.events.map((event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const enrichedAfter = enrichFieldMetadataEventWithRelations({
record: event.properties.after as Record<string, unknown>,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
if (metadataEventBatch.metadataName !== 'navigationMenuItem') {
return metadataEventBatch;
}
const enrichedEvents = await Promise.all(
metadataEventBatch.events.map(async (event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const after = event.properties.after as Record<string, unknown>;
const targetRecordId = after.targetRecordId as string | undefined;
const targetObjectMetadataId = after.targetObjectMetadataId as
| string
| undefined;
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
return event;
}
const targetRecordIdentifier =
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId: metadataEventBatch.workspaceId,
},
);
const enrichedAfter: Record<string, unknown> = {
...after,
targetRecordIdentifier,
};
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
}),
);
return { ...metadataEventBatch, events: enrichedEvents };
}
private resolveObjectMetadataStandardOverrides(
metadataEventBatch: MetadataEventBatch,
): MetadataEventBatch {
if (metadataEventBatch.metadataName !== 'objectMetadata') {
return metadataEventBatch;
}
const enrichedEvents = metadataEventBatch.events.map((event) => {
const enrichedProperties = { ...event.properties };
if (
'before' in enrichedProperties &&
isDefined(enrichedProperties.before)
) {
enrichedProperties.before =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.before as Record<string, unknown>,
) as typeof enrichedProperties.before;
}
if (
'after' in enrichedProperties &&
isDefined(enrichedProperties.after)
) {
enrichedProperties.after =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.after as Record<string, unknown>,
) as typeof enrichedProperties.after;
}
return { ...event, properties: enrichedProperties } as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private applyStandardOverridesToObjectMetadataRecord(
record: Record<string, unknown>,
): Record<string, unknown> {
const standardOverrides = record.standardOverrides as
| Record<string, unknown>
| null
| undefined;
if (!isDefined(standardOverrides)) {
return record;
}
const resolved = { ...record };
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
if (isDefined(standardOverrides[key])) {
resolved[key] = standardOverrides[key];
}
}
return resolved;
}
private async processObjectRecordStreamEvents(
streamChannelId: string,
streamData: EventStreamData,
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
private async processObjectRecordStreamEvents({
streamChannelId,
streamData,
workspaceEventBatch,
permissionsContext,
flatWorkspaceMemberMaps,
}: {
streamChannelId: string;
streamData: EventStreamData;
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>;
permissionsContext: {
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
userWorkspaceRoleMap: Record<string, string>;
rolesPermissions: ObjectsPermissionsByRoleId;
},
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps,
): Promise<void> {
};
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps;
}): Promise<void> {
const { userWorkspaceId } = streamData.authContext;
if (!isDefined(userWorkspaceId)) {
@@ -470,7 +221,9 @@ export class WorkspaceEventEmitterService {
if (matchedEvents.length > 0) {
await this.enrichEventBatchWithNestedRelations({
objectMetadata: workspaceEventBatch.objectMetadata,
events: matchedEvents.map((e) => e.objectRecordEvent),
events: matchedEvents.map(
(matchedEvent) => matchedEvent.objectRecordEvent,
),
streamData,
permissionsContext,
workspaceId: workspaceEventBatch.workspaceId,
@@ -1,14 +1,26 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { EventStreamResolver } from 'src/engine/subscriptions/event-stream.resolver';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher';
import { MetadataEventsToDbListener } from 'src/engine/subscriptions/metadata-event/metadata-events-to-db.listener';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Global()
@Module({
imports: [
RedisClientModule,
@@ -16,8 +28,26 @@ import { SubscriptionService } from 'src/engine/subscriptions/subscription.servi
CacheLockModule,
MetricsModule,
TypeOrmModule.forFeature([WorkspaceEntity]),
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NavigationMenuItemModule,
],
providers: [
SubscriptionService,
EventStreamService,
EventStreamResolver,
ObjectRecordEventPublisher,
MetadataEventPublisher,
MetadataEventEmitter,
MetadataEventsToDbListener,
ProcessNestedRelationsHelper,
ProcessNestedRelationsV2Helper,
CommonSelectFieldsHelper,
],
exports: [
SubscriptionService,
ObjectRecordEventPublisher,
MetadataEventEmitter,
],
providers: [SubscriptionService, EventStreamService],
exports: [SubscriptionService, EventStreamService],
})
export class SubscriptionsModule {}
@@ -1,512 +0,0 @@
import { isMetadataRecordMatchingFilter } from 'src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util';
const record = {
id: '1',
name: 'Test Object',
label: 'testObject',
isActive: true,
count: 42,
};
describe('isMetadataRecordMatchingFilter', () => {
describe('empty filters', () => {
it('should match any record when filter is empty', () => {
expect(isMetadataRecordMatchingFilter({ record, filter: {} })).toBe(true);
});
});
describe('and filter', () => {
it('should match when all sub-filters match', () => {
const filter = {
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: true } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should not match when any sub-filter fails', () => {
const filter = {
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: false } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should match when and array is empty', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { and: [] },
}),
).toBe(true);
});
it('should throw when and value is not an array', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { and: 'invalid' } as any,
}),
).toThrow('Unexpected value for "and" filter');
});
});
describe('or filter', () => {
it('should match when any sub-filter matches', () => {
const filter = {
or: [{ name: { eq: 'Wrong Name' } }, { name: { eq: 'Test Object' } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should not match when no sub-filter matches', () => {
const filter = {
or: [{ name: { eq: 'Wrong' } }, { name: { eq: 'Also Wrong' } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should match when or array is empty', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { or: [] },
}),
).toBe(true);
});
it('should treat or with an object as an and', () => {
const filter = {
or: { name: { eq: 'Test Object' } },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should throw when or value is neither array nor object', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { or: 'invalid' } as any,
}),
).toThrow('Unexpected value for "or" filter');
});
});
describe('not filter', () => {
it('should negate a matching filter', () => {
const filter = { not: { name: { eq: 'Test Object' } } };
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should negate a non-matching filter', () => {
const filter = { not: { name: { eq: 'Wrong' } } };
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should match when not contains an empty object', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { not: {} },
}),
).toBe(true);
});
});
describe('implicit and (multi-key filter)', () => {
it('should treat multiple keys as an implicit AND', () => {
const filter = {
name: { eq: 'Test Object' },
isActive: { eq: true },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should fail if any key in implicit AND does not match', () => {
const filter = {
name: { eq: 'Test Object' },
isActive: { eq: false },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
});
describe('eq operator', () => {
it('should match equal string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { eq: 'Test Object' } },
}),
).toBe(true);
});
it('should not match different string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { eq: 'Other' } },
}),
).toBe(false);
});
it('should match equal boolean values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { isActive: { eq: true } },
}),
).toBe(true);
});
});
describe('neq operator', () => {
it('should match when values are different', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { neq: 'Other' } },
}),
).toBe(true);
});
it('should not match when values are equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { neq: 'Test Object' } },
}),
).toBe(false);
});
});
describe('in operator', () => {
it('should match when value is in the array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: ['Test Object', 'Other'] } },
}),
).toBe(true);
});
it('should not match when value is not in the array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: ['A', 'B'] } },
}),
).toBe(false);
});
it('should return false when in value is not an array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: 'not-array' } } as any,
}),
).toBe(false);
});
});
describe('is operator', () => {
it('should match NULL for undefined values', () => {
expect(
isMetadataRecordMatchingFilter({
record: { ...record, optional: undefined },
filter: { optional: { is: 'NULL' } },
}),
).toBe(true);
});
it('should not match NULL for defined values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { is: 'NULL' } },
}),
).toBe(false);
});
it('should match NOT_NULL for defined values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { is: 'NOT_NULL' } },
}),
).toBe(true);
});
it('should not match NOT_NULL for undefined values', () => {
expect(
isMetadataRecordMatchingFilter({
record: { ...record, optional: undefined },
filter: { optional: { is: 'NOT_NULL' } },
}),
).toBe(false);
});
});
describe('like operator', () => {
it('should match with wildcard prefix', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: '%Object' } },
}),
).toBe(true);
});
it('should match with wildcard suffix', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: 'Test%' } },
}),
).toBe(true);
});
it('should match with wildcards on both sides', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: '%est Obj%' } },
}),
).toBe(true);
});
it('should not match when pattern does not match', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: 'wrong%' } },
}),
).toBe(false);
});
it('should escape regex special characters in pattern', () => {
const specialRecord = { ...record, name: 'foo.bar' };
expect(
isMetadataRecordMatchingFilter({
record: specialRecord,
filter: { name: { like: 'foo.bar' } },
}),
).toBe(true);
// A dot in the pattern should NOT match any character
expect(
isMetadataRecordMatchingFilter({
record: { ...record, name: 'fooXbar' },
filter: { name: { like: 'foo.bar' } },
}),
).toBe(false);
});
it('should return false for non-string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { like: '42' } },
}),
).toBe(false);
});
});
describe('ilike operator', () => {
it('should match case-insensitively', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { ilike: '%test object%' } },
}),
).toBe(true);
});
it('should escape regex special characters', () => {
const specialRecord = { ...record, name: 'foo(bar)' };
expect(
isMetadataRecordMatchingFilter({
record: specialRecord,
filter: { name: { ilike: 'FOO(BAR)' } },
}),
).toBe(true);
});
it('should return false for non-string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { ilike: '42' } },
}),
).toBe(false);
});
});
describe('gt operator', () => {
it('should match when value is greater', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 40 } },
}),
).toBe(true);
});
it('should not match when value is equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 42 } },
}),
).toBe(false);
});
it('should not match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 50 } },
}),
).toBe(false);
});
});
describe('gte operator', () => {
it('should match when value is greater or equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gte: 42 } },
}),
).toBe(true);
});
it('should not match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gte: 43 } },
}),
).toBe(false);
});
});
describe('lt operator', () => {
it('should match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lt: 50 } },
}),
).toBe(true);
});
it('should not match when value is equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lt: 42 } },
}),
).toBe(false);
});
});
describe('lte operator', () => {
it('should match when value is less or equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lte: 42 } },
}),
).toBe(true);
});
it('should not match when value is greater', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lte: 41 } },
}),
).toBe(false);
});
});
describe('unknown operator', () => {
it('should throw for unsupported operators', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { name: { unknownOp: 'value' } } as any,
}),
).toThrow('Unsupported filter operator');
});
});
describe('nested logical operators', () => {
it('should handle deeply nested and/or/not', () => {
const filter = {
and: [
{
or: [
{ name: { eq: 'Wrong' } },
{ not: { isActive: { eq: false } } },
],
},
{ count: { gte: 40 } },
],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should handle not with nested and', () => {
const filter = {
not: {
and: [{ name: { eq: 'Test Object' } }, { count: { gt: 100 } }],
},
};
// name matches but count > 100 fails, so AND = false, NOT = true
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
});
describe('edge cases', () => {
it('should skip non-object field filters', () => {
// When fieldFilter is a primitive, it is skipped (returns true)
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: 'Test Object' },
}),
).toBe(true);
});
it('should skip undefined field filters', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: undefined },
}),
).toBe(true);
});
it('should handle records with missing fields', () => {
expect(
isMetadataRecordMatchingFilter({
record: { id: '1' },
filter: { name: { eq: 'Test' } },
}),
).toBe(false);
});
});
});
@@ -1,198 +0,0 @@
import { isObject } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type MetadataFilter = Record<string, unknown>;
type MetadataScalarFilter = {
is?: 'NULL' | 'NOT_NULL';
eq?: unknown;
neq?: unknown;
in?: unknown[];
like?: string;
ilike?: string;
gt?: number;
gte?: number;
lt?: number;
lte?: number;
};
const isEmptyFilter = (filter: MetadataFilter): boolean =>
Object.keys(filter).length === 0;
const isAndFilter = (filter: MetadataFilter): boolean =>
'and' in filter && isDefined(filter.and);
const isOrFilter = (filter: MetadataFilter): boolean =>
'or' in filter && isDefined(filter.or);
const isNotFilter = (filter: MetadataFilter): boolean =>
'not' in filter && isDefined(filter.not);
const isImplicitAndFilter = (filter: MetadataFilter): boolean =>
Object.keys(filter).length > 1;
const escapeRegExp = (str: string): string =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const isMetadataRecordMatchingFilter = ({
record,
filter,
}: {
record: Record<string, unknown>;
filter: MetadataFilter;
}): boolean => {
if (isEmptyFilter(filter)) {
return true;
}
if (isImplicitAndFilter(filter)) {
return Object.entries(filter).every(([key, value]) =>
isMetadataRecordMatchingFilter({
record,
filter: { [key]: value },
}),
);
}
if (isAndFilter(filter)) {
const andValue = filter.and;
if (!Array.isArray(andValue)) {
throw new Error(
'Unexpected value for "and" filter: ' + JSON.stringify(andValue),
);
}
return (
andValue.length === 0 ||
andValue.every((subFilter: MetadataFilter) =>
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
)
);
}
if (isOrFilter(filter)) {
const orValue = filter.or;
if (Array.isArray(orValue)) {
return (
orValue.length === 0 ||
orValue.some((subFilter: MetadataFilter) =>
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
)
);
}
if (isObject(orValue)) {
return isMetadataRecordMatchingFilter({
record,
filter: orValue as MetadataFilter,
});
}
throw new Error(
'Unexpected value for "or" filter: ' + JSON.stringify(orValue),
);
}
if (isNotFilter(filter)) {
const notValue = filter.not;
if (isObject(notValue) && isEmptyFilter(notValue as MetadataFilter)) {
return true;
}
return !isMetadataRecordMatchingFilter({
record,
filter: notValue as MetadataFilter,
});
}
return Object.entries(filter).every(([fieldName, fieldFilter]) => {
if (!isDefined(fieldFilter) || !isObject(fieldFilter)) {
return true;
}
const recordValue = record[fieldName];
return isScalarValueMatchingFilter(
recordValue,
fieldFilter as MetadataScalarFilter,
);
});
};
const isScalarValueMatchingFilter = (
value: unknown,
fieldFilter: MetadataScalarFilter,
): boolean => {
if ('is' in fieldFilter) {
if (fieldFilter.is === 'NULL') {
return !isDefined(value);
}
return isDefined(value);
}
if ('eq' in fieldFilter) {
return value === fieldFilter.eq;
}
if ('neq' in fieldFilter) {
return value !== fieldFilter.neq;
}
if ('in' in fieldFilter) {
if (!Array.isArray(fieldFilter.in)) {
return false;
}
return fieldFilter.in.includes(value);
}
if ('like' in fieldFilter) {
if (typeof value !== 'string') {
return false;
}
const pattern = String(fieldFilter.like)
.split('%')
.map(escapeRegExp)
.join('.*');
return new RegExp(`^${pattern}$`).test(value);
}
if ('ilike' in fieldFilter) {
if (typeof value !== 'string') {
return false;
}
const pattern = String(fieldFilter.ilike)
.split('%')
.map(escapeRegExp)
.join('.*');
return new RegExp(`^${pattern}$`, 'i').test(value);
}
if ('gt' in fieldFilter) {
return isDefined(value) && (value as number) > (fieldFilter.gt as number);
}
if ('gte' in fieldFilter) {
return isDefined(value) && (value as number) >= (fieldFilter.gte as number);
}
if ('lt' in fieldFilter) {
return isDefined(value) && (value as number) < (fieldFilter.lt as number);
}
if ('lte' in fieldFilter) {
return isDefined(value) && (value as number) <= (fieldFilter.lte as number);
}
throw new Error(
'Unsupported filter operator: ' + JSON.stringify(fieldFilter),
);
};
@@ -1,32 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceEventEmitterResolver } from 'src/engine/workspace-event-emitter/workspace-event-emitter.resolver';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
@Global()
@Module({
imports: [
SubscriptionsModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NavigationMenuItemModule,
],
providers: [
WorkspaceEventEmitter,
WorkspaceEventEmitterService,
WorkspaceEventEmitterResolver,
ProcessNestedRelationsHelper,
ProcessNestedRelationsV2Helper,
CommonSelectFieldsHelper,
],
exports: [WorkspaceEventEmitter, WorkspaceEventEmitterService],
providers: [WorkspaceEventEmitter],
exports: [WorkspaceEventEmitter],
})
export class WorkspaceEventEmitterModule {}
@@ -8,7 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
import { FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
import { ALL_MANY_TO_ONE_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import {