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:
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;
|
||||
|
||||
+1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+3
@@ -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>
|
||||
);
|
||||
|
||||
+15
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user