Automatically clean up soft-deleted records after X days. (#14862)

Closes #14726

### Added
- `trashRetentionDays` field to workspace entity (default: 14 days)  
- Automated trash cleanup using BullMQ jobs  
- Daily cron (00:10 UTC) that enqueues cleanup jobs for all active
workspaces
- Per-workspace limit: 100k records deleted per day  
- Calendar-based retention: records deleted on day X are cleaned up X+14
days later (at midnight UTC boundaries)

### Architecture
- **Cron (WorkspaceTrashCleanupCronJob):** Runs daily, enqueues jobs in
parallel for all workspaces
- **Job (WorkspaceTrashCleanupJob):** Processes individual workspace
cleanup
- **Service (WorkspaceTrashCleanupService):** Discovers tables with
`deletedAt`, deletes old records with quota enforcement
- **Command:** `npx nx run twenty-server:command
cron:workspace:cleanup-trash` to register the cron

### Testing
- Unit tests for service with 100% coverage of public API  
- Tested quota enforcement, error handling, and edge cases

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdullah.
2025-10-14 21:49:40 +05:00
committed by GitHub
parent bca79f91ac
commit d750df7fff
26 changed files with 714 additions and 28 deletions
File diff suppressed because one or more lines are too long
@@ -4077,6 +4077,7 @@ export type UpdateWorkspaceInput = {
isTwoFactorAuthenticationEnforced?: InputMaybe<Scalars['Boolean']>;
logo?: InputMaybe<Scalars['String']>;
subdomain?: InputMaybe<Scalars['String']>;
trashRetentionDays?: InputMaybe<Scalars['Float']>;
};
export type UpsertFieldPermissionsInput = {
@@ -4362,6 +4363,7 @@ export type Workspace = {
logo?: Maybe<Scalars['String']>;
metadataVersion: Scalars['Float'];
subdomain: Scalars['String'];
trashRetentionDays: Scalars['Float'];
updatedAt: Scalars['DateTime'];
version?: Maybe<Scalars['String']>;
viewFields?: Maybe<Array<CoreViewField>>;
@@ -57,6 +57,7 @@ const mockWorkspace = {
customUrl: 'test.com',
},
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
};
const createMockOptions = (): Options<any> => ({
@@ -24,6 +24,7 @@ export type CurrentWorkspace = Pick<
| 'workspaceUrls'
| 'metadataVersion'
| 'isTwoFactorAuthenticationEnforced'
| 'trashRetentionDays'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
defaultAgent?: { id: string } | null;
@@ -50,6 +50,7 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
},
],
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
});
},
});
@@ -10,14 +10,16 @@ type SettingsCounterProps = {
minValue?: number;
maxValue?: number;
disabled?: boolean;
showButtons?: boolean;
};
const StyledCounterContainer = styled.div`
const StyledCounterContainer = styled.div<{ showButtons: boolean }>`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
margin-left: auto;
width: ${({ theme }) => theme.spacing(30)};
width: ${({ theme, showButtons }) =>
showButtons ? theme.spacing(30) : theme.spacing(16)};
`;
const StyledTextInput = styled(SettingsTextInput)`
@@ -34,11 +36,12 @@ export const SettingsCounter = ({
value,
onChange,
minValue = 0,
maxValue = 100,
maxValue,
disabled = false,
showButtons = true,
}: SettingsCounterProps) => {
const handleIncrementCounter = () => {
if (value < maxValue) {
if (maxValue === undefined || value < maxValue) {
onChange(value + 1);
}
};
@@ -60,7 +63,7 @@ export const SettingsCounter = ({
return;
}
if (castedNumber > maxValue) {
if (maxValue !== undefined && castedNumber > maxValue) {
onChange(maxValue);
return;
}
@@ -68,14 +71,16 @@ export const SettingsCounter = ({
};
return (
<StyledCounterContainer>
<IconButton
size="small"
Icon={IconMinus}
variant="secondary"
onClick={handleDecrementCounter}
disabled={disabled}
/>
<StyledCounterContainer showButtons={showButtons}>
{showButtons && (
<IconButton
size="small"
Icon={IconMinus}
variant="secondary"
onClick={handleDecrementCounter}
disabled={disabled}
/>
)}
<StyledTextInput
instanceId="settings-counter-input"
name="counter"
@@ -84,13 +89,15 @@ export const SettingsCounter = ({
onChange={handleTextInputChange}
disabled={disabled}
/>
<IconButton
size="small"
Icon={IconPlus}
variant="secondary"
onClick={handleIncrementCounter}
disabled={disabled}
/>
{showButtons && (
<IconButton
size="small"
Icon={IconPlus}
variant="secondary"
onClick={handleIncrementCounter}
disabled={disabled}
/>
)}
</StyledCounterContainer>
);
};
@@ -17,6 +17,7 @@ type SettingsOptionCardContentCounterProps = {
onChange: (value: number) => void;
minValue?: number;
maxValue?: number;
showButtons?: boolean;
};
export const SettingsOptionCardContentCounter = ({
@@ -28,6 +29,7 @@ export const SettingsOptionCardContentCounter = ({
onChange,
minValue,
maxValue,
showButtons = true,
}: SettingsOptionCardContentCounterProps) => {
return (
<StyledSettingsOptionCardContent disabled={disabled}>
@@ -50,6 +52,7 @@ export const SettingsOptionCardContentCounter = ({
minValue={minValue}
maxValue={maxValue}
disabled={disabled}
showButtons={showButtons}
/>
</StyledSettingsOptionCardContent>
);
@@ -25,6 +25,7 @@ const SettingsOptionCardContentCounterWrapper = (
disabled={args.disabled}
minValue={args.minValue}
maxValue={args.maxValue}
showButtons={args.showButtons}
/>
</StyledContainer>
);
@@ -50,6 +51,7 @@ export const Default: Story = {
value: 5,
minValue: 1,
maxValue: 10,
showButtons: true,
},
argTypes: {
Icon: { control: false },
@@ -64,6 +66,7 @@ export const WithoutIcon: Story = {
value: 20,
minValue: 10,
maxValue: 50,
showButtons: true,
},
};
@@ -76,5 +79,17 @@ export const Disabled: Story = {
disabled: true,
minValue: 1,
maxValue: 10,
showButtons: true,
},
};
export const WithoutButtons: Story = {
args: {
Icon: IconUsers,
title: 'Trash Retention',
description: 'Adjust the number of days before deletion',
value: 14,
minValue: 0,
showButtons: false,
},
};
@@ -79,6 +79,7 @@ export const USER_QUERY_FRAGMENT = gql`
id
}
isTwoFactorAuthenticationEnforced
trashRetentionDays
}
availableWorkspaces {
...AvailableWorkspacesFragment
@@ -1,19 +1,24 @@
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { useDebouncedCallback } from 'use-debounce';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useRecoilValue } from 'recoil';
import { ApolloError } from '@apollo/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import { H2Title, IconLock } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { H2Title, IconLock, IconTrash } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
width: 100%;
@@ -32,8 +37,50 @@ const StyledSection = styled(Section)`
export const SettingsSecurity = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const [updateWorkspace] = useUpdateWorkspaceMutation();
const saveWorkspace = useDebouncedCallback(async (value: number) => {
try {
if (!currentWorkspace?.id) {
throw new Error('User is not logged in');
}
await updateWorkspace({
variables: {
input: {
trashRetentionDays: value,
},
},
});
} catch (err) {
enqueueErrorSnackBar({
apolloError: err instanceof ApolloError ? err : undefined,
});
}
}, 500);
const handleTrashRetentionDaysChange = (value: number) => {
if (!currentWorkspace) {
return;
}
if (value === currentWorkspace.trashRetentionDays) {
return;
}
setCurrentWorkspace({
...currentWorkspace,
trashRetentionDays: value,
});
saveWorkspace(value);
};
return (
<SubMenuTopBarContainer
@@ -82,6 +129,23 @@ export const SettingsSecurity = () => {
<ToggleImpersonate />
</Section>
)}
<Section>
<H2Title
title={t`Other`}
description={t`Other security settings`}
/>
<Card rounded>
<SettingsOptionCardContentCounter
Icon={IconTrash}
title={t`Erasure of soft-deleted records`}
description={t`Permanent deletion. Enter the number of days.`}
value={currentWorkspace?.trashRetentionDays ?? 14}
onChange={handleTrashRetentionDaysChange}
minValue={0}
showButtons={false}
/>
</Card>
</Section>
</StyledMainContent>
</SettingsPageContainer>
</SubMenuTopBarContainer>
@@ -84,6 +84,7 @@ export const mockCurrentWorkspace: Workspace = {
createdAt: '2023-04-26T10:23:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
metadataVersion: 1,
trashRetentionDays: 14,
currentBillingSubscription: {
__typename: 'BillingSubscription',
id: '7efbc3f7-6e5e-4128-957e-8d86808cdf6a',
@@ -151,6 +152,7 @@ export const mockCurrentWorkspace: Workspace = {
databaseSchema: '',
databaseUrl: '',
isTwoFactorAuthenticationEnforced: false,
__typename: 'Workspace',
};
export const mockedWorkspaceMemberData: WorkspaceMember = {