Add backfill installation feature for pre-installed apps (#22199)

## After

<img width="1070" height="514" alt="image"
src="https://github.com/user-attachments/assets/9cbd2ff5-1678-4c2f-84da-074568f37c51"
/>


## Summary
Adds the ability to backfill application installations across all
existing workspaces. This allows admins to retroactively install a
pre-registered application on every active and suspended workspace
through a background job, making the feature idempotent and
non-blocking.

## Key Changes

- **Backend Service**: Added `backfillApplicationOnAllWorkspaces()`
method to `PreInstalledAppsService` that:
  - Validates the application registration exists
  - Iterates through all workspaces using `WorkspaceIteratorService`
  - Installs the app on each workspace
  - Swallows `APP_ALREADY_INSTALLED` errors for idempotency
  - Logs success/failure counts

- **Background Job**: Created `BackfillApplicationInstallationJob` to
process backfill requests asynchronously via the message queue

- **GraphQL Mutation**: Added `backfillApplicationInstallation` mutation
to `AdminPanelResolver` that:
  - Validates the application registration exists
  - Enqueues the background job
  - Returns immediately without blocking the request

- **UI Components**: Enhanced
`SettingsAdminApplicationRegistrationGeneralToggles` with:
- New "Pre-install on new workspaces" toggle for the `isPreInstalled`
flag
  - "Backfill on all workspaces" button with confirmation modal
  - Loading state and success/error snack bar feedback

- **Data Model**: Added `isPreInstalled` field to
`UpdateApplicationRegistrationPayload` input type

- **Tests**: Added comprehensive unit tests for
`PreInstalledAppsService.backfillApplicationOnAllWorkspaces()` covering:
  - Missing registration validation
  - Successful multi-workspace installation
  - Idempotent handling of already-installed errors
  - Proper error propagation for unexpected failures

## Implementation Details

The backfill operation is designed to be:
- **Idempotent**: Already-installed apps are skipped without error
- **Non-blocking**: Runs as a background job via message queue
- **Resilient**: Per-workspace failures don't block other installations
- **Observable**: Logs aggregated success/failure counts for monitoring


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22199?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
martmull
2026-06-26 18:21:27 +02:00
committed by GitHub
parent fea2b8736f
commit e747bc3e42
18 changed files with 492 additions and 13 deletions
@@ -4461,6 +4461,7 @@ input UpdateApplicationRegistrationPayload {
oAuthRedirectUris: [String!]
oAuthScopes: [String!]
isListed: Boolean
isPreInstalled: Boolean
}
input CreateApplicationRegistrationVariableInput {
@@ -6518,7 +6518,7 @@ export interface CreateApplicationRegistrationInput {name: Scalars['String'],uni
export interface UpdateApplicationRegistrationInput {id: Scalars['String'],update: UpdateApplicationRegistrationPayload}
export interface UpdateApplicationRegistrationPayload {name?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),isListed?: (Scalars['Boolean'] | null)}
export interface UpdateApplicationRegistrationPayload {name?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),isListed?: (Scalars['Boolean'] | null),isPreInstalled?: (Scalars['Boolean'] | null)}
export interface CreateApplicationRegistrationVariableInput {applicationRegistrationId: Scalars['String'],key: Scalars['String'],value: Scalars['String'],description?: (Scalars['String'] | null),isSecret?: (Scalars['Boolean'] | null)}
@@ -11359,6 +11359,9 @@ export default {
"isListed": [
6
],
"isPreInstalled": [
6
],
"__typename": [
1
]
@@ -399,6 +399,7 @@ export type Mutation = {
__typename?: 'Mutation';
addAiProvider: Scalars['Boolean']['output'];
addModelToProvider: Scalars['Boolean']['output'];
backfillApplicationInstallation: Scalars['Boolean']['output'];
clearMaintenanceMode: Scalars['Boolean']['output'];
createDatabaseConfigVariable: Scalars['Boolean']['output'];
deleteDatabaseConfigVariable: Scalars['Boolean']['output'];
@@ -414,6 +415,7 @@ export type Mutation = {
setAdminAiModelsRecommended: Scalars['Boolean']['output'];
setAdminDefaultAiModel: Scalars['Boolean']['output'];
setMaintenanceMode: Scalars['Boolean']['output'];
updateAdminApplicationRegistration: ApplicationRegistration;
updateAdminApplicationRegistrationVariable: ApplicationRegistrationVariableDto;
updateDatabaseConfigVariable: Scalars['Boolean']['output'];
updateServerAdminAccess: ServerAdmin;
@@ -433,6 +435,11 @@ export type MutationAddModelToProviderArgs = {
};
export type MutationBackfillApplicationInstallationArgs = {
applicationRegistrationId: Scalars['String']['input'];
};
export type MutationCreateDatabaseConfigVariableArgs = {
key: Scalars['String']['input'];
value: Scalars['JSON']['input'];
@@ -509,6 +516,11 @@ export type MutationSetMaintenanceModeArgs = {
};
export type MutationUpdateAdminApplicationRegistrationArgs = {
input: UpdateApplicationRegistrationInput;
};
export type MutationUpdateAdminApplicationRegistrationVariableArgs = {
input: UpdateApplicationRegistrationVariableInput;
};
@@ -773,6 +785,19 @@ export type SystemHealthService = {
status: AdminPanelHealthServiceStatus;
};
export type UpdateApplicationRegistrationInput = {
id: Scalars['String']['input'];
update: UpdateApplicationRegistrationPayload;
};
export type UpdateApplicationRegistrationPayload = {
isListed?: InputMaybe<Scalars['Boolean']['input']>;
isPreInstalled?: InputMaybe<Scalars['Boolean']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
oAuthRedirectUris?: InputMaybe<Array<Scalars['String']['input']>>;
oAuthScopes?: InputMaybe<Array<Scalars['String']['input']>>;
};
export type UpdateApplicationRegistrationVariableInput = {
id: Scalars['String']['input'];
update: UpdateApplicationRegistrationVariablePayload;
@@ -975,6 +1000,20 @@ export type GetModelsDevSuggestionsQueryVariables = Exact<{
export type GetModelsDevSuggestionsQuery = { __typename?: 'Query', getModelsDevSuggestions: Array<{ __typename?: 'ModelsDevModelSuggestion', modelId: string, name: string, inputCostPerMillionTokens: number, outputCostPerMillionTokens: number, cachedInputCostPerMillionTokens?: number | null, cacheCreationCostPerMillionTokens?: number | null, contextWindowTokens: number, maxOutputTokens: number, modalities: Array<string>, supportsReasoning: boolean }> };
export type BackfillApplicationInstallationMutationVariables = Exact<{
applicationRegistrationId: Scalars['String']['input'];
}>;
export type BackfillApplicationInstallationMutation = { __typename?: 'Mutation', backfillApplicationInstallation: boolean };
export type UpdateAdminApplicationRegistrationMutationVariables = Exact<{
input: UpdateApplicationRegistrationInput;
}>;
export type UpdateAdminApplicationRegistrationMutation = { __typename?: 'Mutation', updateAdminApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string } };
export type UpdateAdminApplicationRegistrationVariableMutationVariables = Exact<{
input: UpdateApplicationRegistrationVariableInput;
}>;
@@ -1228,6 +1267,8 @@ export const GetAdminAiUsageByWorkspaceDocument = {"kind":"Document","definition
export const GetAiProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAiProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAiProviders"}}]}}]} as unknown as DocumentNode<GetAiProvidersQuery, GetAiProvidersQueryVariables>;
export const GetModelsDevProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"modelCount"}},{"kind":"Field","name":{"kind":"Name","value":"npm"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevProvidersQuery, GetModelsDevProvidersQueryVariables>;
export const GetModelsDevSuggestionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevSuggestions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevSuggestions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"inputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"outputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cachedInputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cacheCreationCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"maxOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"modalities"}},{"kind":"Field","name":{"kind":"Name","value":"supportsReasoning"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevSuggestionsQuery, GetModelsDevSuggestionsQueryVariables>;
export const BackfillApplicationInstallationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BackfillApplicationInstallation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backfillApplicationInstallation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}]}]}}]} as unknown as DocumentNode<BackfillApplicationInstallationMutation, BackfillApplicationInstallationMutationVariables>;
export const UpdateAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateAdminApplicationRegistrationMutation, UpdateAdminApplicationRegistrationMutationVariables>;
export const UpdateAdminApplicationRegistrationVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistrationVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationVariableInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistrationVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<UpdateAdminApplicationRegistrationVariableMutation, UpdateAdminApplicationRegistrationVariableMutationVariables>;
export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
@@ -5243,6 +5243,7 @@ export type UpdateApplicationRegistrationInput = {
export type UpdateApplicationRegistrationPayload = {
isListed?: InputMaybe<Scalars['Boolean']['input']>;
isPreInstalled?: InputMaybe<Scalars['Boolean']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
oAuthRedirectUris?: InputMaybe<Array<Scalars['String']['input']>>;
oAuthScopes?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const BACKFILL_APPLICATION_INSTALLATION = gql`
mutation BackfillApplicationInstallation(
$applicationRegistrationId: String!
) {
backfillApplicationInstallation(
applicationRegistrationId: $applicationRegistrationId
)
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
import { APPLICATION_REGISTRATION_FRAGMENT } from '@/settings/application-registrations/graphql/fragments/applicationRegistrationFragment';
export const UPDATE_ADMIN_APPLICATION_REGISTRATION = gql`
mutation UpdateAdminApplicationRegistration(
$input: UpdateApplicationRegistrationInput!
) {
updateAdminApplicationRegistration(input: $input) {
...ApplicationRegistrationFragment
}
}
${APPLICATION_REGISTRATION_FRAGMENT}
`;
@@ -1,34 +1,90 @@
import { Section } from 'twenty-ui/layout';
import { Card } from 'twenty-ui/surfaces';
import { AppTooltip, Card, TooltipDelay } from 'twenty-ui/surfaces';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { IconArrowBarToDown } from 'twenty-ui/icon';
import { IconArrowBarToDown, IconPinned, IconReload } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import { H2Title } from 'twenty-ui/typography';
import { type ApplicationRegistration } from '~/generated-metadata/graphql';
import {
type ApplicationRegistration,
UpdateApplicationRegistrationDocument,
} from '~/generated-metadata/graphql';
BackfillApplicationInstallationDocument,
UpdateAdminApplicationRegistrationDocument,
} from '~/generated-admin/graphql';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
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';
const StyledToggleContainer = styled.div`
display: flex;
margin-top: ${themeCssVariables.spacing[4]};
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
`;
const StyledBackfillContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-top: ${themeCssVariables.spacing[6]};
`;
const StyledButtonWrapper = styled.span`
display: inline-flex;
`;
const BACKFILL_INSTALLATION_MODAL_ID =
'backfill-application-installation-modal';
const BACKFILL_BUTTON_ID = 'backfill-application-installation-button';
export const SettingsAdminApplicationRegistrationGeneralToggles = ({
registration,
}: {
registration: ApplicationRegistration;
}) => {
const { t } = useLingui();
const apolloAdminClient = useApolloAdminClient();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal, closeModal } = useModal();
const [isBackfilling, setIsBackfilling] = useState(false);
const [updateRegistration] = useMutation(
UpdateApplicationRegistrationDocument,
UpdateAdminApplicationRegistrationDocument,
{ client: apolloAdminClient },
);
const [backfillInstallation] = useMutation(
BackfillApplicationInstallationDocument,
{ client: apolloAdminClient },
);
const handleBackfill = async () => {
setIsBackfilling(true);
try {
await backfillInstallation({
variables: { applicationRegistrationId: registration.id },
});
enqueueSuccessSnackBar({
message: t`Backfill started. This app will be installed on all existing workspaces in the background.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to start backfill`,
});
} finally {
setIsBackfilling(false);
closeModal(BACKFILL_INSTALLATION_MODAL_ID);
}
};
return (
<Section>
<H2Title title={t`Installation`} />
<StyledToggleContainer>
<Card rounded fullWidth>
<SettingsOptionCardContentToggle
@@ -48,7 +104,55 @@ export const SettingsAdminApplicationRegistrationGeneralToggles = ({
}
/>
</Card>
<Card rounded fullWidth>
<SettingsOptionCardContentToggle
Icon={IconPinned}
title={t`Pre-install on new workspaces`}
description={t`Automatically install this app on every newly created workspace`}
checked={registration.isPreInstalled}
onChange={(checked) =>
updateRegistration({
variables: {
input: {
id: registration.id,
update: { isPreInstalled: checked },
},
},
})
}
/>
</Card>
</StyledToggleContainer>
<StyledBackfillContainer>
<StyledButtonWrapper id={BACKFILL_BUTTON_ID}>
<Button
Icon={IconReload}
title={t`Install latest version on all workspaces`}
variant="secondary"
onClick={() => openModal(BACKFILL_INSTALLATION_MODAL_ID)}
disabled={isBackfilling || !registration.isPreInstalled}
/>
</StyledButtonWrapper>
{!registration.isPreInstalled && (
<AppTooltip
anchorSelect={`#${BACKFILL_BUTTON_ID}`}
content={t`Pre-install must be enabled.`}
noArrow
place="bottom"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
)}
</StyledBackfillContainer>
<ConfirmationModal
modalInstanceId={BACKFILL_INSTALLATION_MODAL_ID}
title={t`Backfill installation`}
subtitle={t`This will install the latest version of "${registration.name}" on all existing active and suspended workspaces, upgrading any workspace that already has an older version. It runs as a background job and may take a while. Continue?`}
onConfirmClick={handleBackfill}
confirmButtonText={t`Backfill`}
confirmButtonAccent="blue"
loading={isBackfilling}
/>
</Section>
);
};
@@ -52,6 +52,11 @@ import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import {
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
type BackfillApplicationInstallationJobData,
} from 'src/engine/core-modules/application/jobs/backfill-application-installation.job-constants';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { AdminAiModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
@@ -60,7 +65,9 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
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 ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -132,6 +139,8 @@ export class AdminPanelResolver {
private readonly upgradeStatusService: UpgradeStatusService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@UseGuards(AdminPanelOrImpersonateGuard)
@@ -470,6 +479,41 @@ export class AdminPanelResolver {
return this.applicationRegistrationService.findAll();
}
@UseGuards(AdminPanelGuard)
@Mutation(() => ApplicationRegistrationEntity)
async updateAdminApplicationRegistration(
@Args('input') input: UpdateApplicationRegistrationInput,
): Promise<ApplicationRegistrationEntity> {
return this.applicationRegistrationService.updateGlobal(input);
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async backfillApplicationInstallation(
@Args('applicationRegistrationId') applicationRegistrationId: string,
): Promise<boolean> {
const registration =
await this.applicationRegistrationService.findOneByIdGlobal(
applicationRegistrationId,
);
if (!registration.isPreInstalled) {
throw new UserInputError(
'Only pre-installed apps can be backfilled. Enable pre-install first.',
);
}
await this.messageQueueService.add<BackfillApplicationInstallationJobData>(
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
{ applicationRegistrationId },
{
id: `${BACKFILL_APPLICATION_INSTALLATION_JOB_NAME}-${applicationRegistrationId}`,
}, // Avoids triggering multiple pending jobs for the same app
);
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => GraphQLJSON)
async getAiProviders(): Promise<Record<string, unknown>> {
@@ -19,7 +19,10 @@ import {
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import {
type UpdateApplicationRegistrationInput,
type UpdateApplicationRegistrationPayload,
} from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
@@ -186,7 +189,26 @@ export class ApplicationRegistrationService {
const { id, update } = input;
await this.findOneById(id, ownerWorkspaceId);
await this.applyUpdate(id, update);
return this.findOneById(id, ownerWorkspaceId);
}
async updateGlobal(
input: UpdateApplicationRegistrationInput,
): Promise<ApplicationRegistrationEntity> {
const { id, update } = input;
await this.findOneByIdGlobal(id);
await this.applyUpdate(id, update);
return this.findOneByIdGlobal(id);
}
private async applyUpdate(
id: string,
update: UpdateApplicationRegistrationPayload,
): Promise<void> {
if (isDefined(update.oAuthRedirectUris)) {
this.validateRedirectUris(update.oAuthRedirectUris);
}
@@ -203,12 +225,12 @@ export class ApplicationRegistrationService {
if (isDefined(update.oAuthScopes))
updateData.oAuthScopes = update.oAuthScopes;
if (isDefined(update.isListed)) updateData.isListed = update.isListed;
if (isDefined(update.isPreInstalled))
updateData.isPreInstalled = update.isPreInstalled;
if (Object.keys(updateData).length > 0) {
await this.applicationRegistrationRepository.update(id, updateData);
}
return this.findOneById(id, ownerWorkspaceId);
}
async updateFromManifest({
@@ -41,6 +41,11 @@ export class UpdateApplicationRegistrationPayload {
@IsBoolean()
@IsOptional()
isListed?: boolean;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
isPreInstalled?: boolean;
}
@InputType()
@@ -0,0 +1,6 @@
export const BACKFILL_APPLICATION_INSTALLATION_JOB_NAME =
'BackfillApplicationInstallationJob';
export type BackfillApplicationInstallationJobData = {
applicationRegistrationId: string;
};
@@ -0,0 +1,22 @@
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
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 {
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
type BackfillApplicationInstallationJobData,
} from 'src/engine/core-modules/application/jobs/backfill-application-installation.job-constants';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
@Processor(MessageQueue.workspaceQueue)
export class BackfillApplicationInstallationJob {
constructor(
private readonly preInstalledAppsService: PreInstalledAppsService,
) {}
@Process(BACKFILL_APPLICATION_INSTALLATION_JOB_NAME)
async handle(data: BackfillApplicationInstallationJobData): Promise<void> {
await this.preInstalledAppsService.backfillApplicationOnAllWorkspaces(
data.applicationRegistrationId,
);
}
}
@@ -0,0 +1,155 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
describe('PreInstalledAppsService', () => {
let service: PreInstalledAppsService;
let applicationInstallService: { installApplication: jest.Mock };
let applicationRegistrationRepository: {
find: jest.Mock;
findOne: jest.Mock;
};
let workspaceIteratorService: { iterate: jest.Mock };
beforeEach(async () => {
applicationInstallService = { installApplication: jest.fn() };
applicationRegistrationRepository = { find: jest.fn(), findOne: jest.fn() };
workspaceIteratorService = { iterate: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
PreInstalledAppsService,
{
provide: ApplicationInstallService,
useValue: applicationInstallService,
},
{
provide: getRepositoryToken(ApplicationRegistrationEntity),
useValue: applicationRegistrationRepository,
},
{
provide: WorkspaceIteratorService,
useValue: workspaceIteratorService,
},
],
}).compile();
service = module.get<PreInstalledAppsService>(PreInstalledAppsService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('backfillApplicationOnAllWorkspaces', () => {
it('should throw when the registration does not exist', async () => {
applicationRegistrationRepository.findOne.mockResolvedValue(null);
await expect(
service.backfillApplicationOnAllWorkspaces('missing-id'),
).rejects.toThrow(ApplicationException);
expect(workspaceIteratorService.iterate).not.toHaveBeenCalled();
});
it('should install the app on every iterated workspace', async () => {
applicationRegistrationRepository.findOne.mockResolvedValue({
id: 'app-1',
name: 'My App',
} as ApplicationRegistrationEntity);
workspaceIteratorService.iterate.mockImplementation(
async ({ callback }) => {
await callback({ workspaceId: 'workspace-1', index: 0, total: 2 });
await callback({ workspaceId: 'workspace-2', index: 1, total: 2 });
return { success: [{ workspaceId: 'workspace-1' }], fail: [] };
},
);
await service.backfillApplicationOnAllWorkspaces('app-1');
expect(
applicationInstallService.installApplication,
).toHaveBeenCalledTimes(2);
expect(applicationInstallService.installApplication).toHaveBeenCalledWith(
{
appRegistrationId: 'app-1',
workspaceId: 'workspace-1',
},
);
expect(applicationInstallService.installApplication).toHaveBeenCalledWith(
{
appRegistrationId: 'app-1',
workspaceId: 'workspace-2',
},
);
});
it('should swallow already-installed errors so the backfill stays idempotent', async () => {
applicationRegistrationRepository.findOne.mockResolvedValue({
id: 'app-1',
name: 'My App',
} as ApplicationRegistrationEntity);
applicationInstallService.installApplication.mockRejectedValue(
new ApplicationException(
'already installed',
ApplicationExceptionCode.APP_ALREADY_INSTALLED,
),
);
workspaceIteratorService.iterate.mockImplementation(
async ({ callback }) => {
await expect(
callback({ workspaceId: 'workspace-1', index: 0, total: 1 }),
).resolves.toBeUndefined();
return { success: [{ workspaceId: 'workspace-1' }], fail: [] };
},
);
await expect(
service.backfillApplicationOnAllWorkspaces('app-1'),
).resolves.toBeUndefined();
});
it('should rethrow unexpected install errors to the iterator', async () => {
applicationRegistrationRepository.findOne.mockResolvedValue({
id: 'app-1',
name: 'My App',
} as ApplicationRegistrationEntity);
const unexpectedError = new Error('boom');
applicationInstallService.installApplication.mockRejectedValue(
unexpectedError,
);
workspaceIteratorService.iterate.mockImplementation(
async ({ callback }) => {
await expect(
callback({ workspaceId: 'workspace-1', index: 0, total: 1 }),
).rejects.toThrow(unexpectedError);
return {
success: [],
fail: [{ workspaceId: 'workspace-1', error: unexpectedError }],
};
},
);
await expect(
service.backfillApplicationOnAllWorkspaces('app-1'),
).resolves.toBeUndefined();
});
});
});
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
@@ -9,6 +10,7 @@ import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre
imports: [
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
ApplicationInstallModule,
WorkspaceIteratorModule,
],
providers: [PreInstalledAppsService],
exports: [PreInstalledAppsService],
@@ -3,8 +3,13 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
@Injectable()
export class PreInstalledAppsService {
@@ -14,6 +19,7 @@ export class PreInstalledAppsService {
private readonly applicationInstallService: ApplicationInstallService,
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly workspaceIteratorService: WorkspaceIteratorService,
) {}
// Per-app failures are logged but never block the other installs —
@@ -45,4 +51,43 @@ export class PreInstalledAppsService {
}),
);
}
async backfillApplicationOnAllWorkspaces(
applicationRegistrationId: string,
): Promise<void> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id: applicationRegistrationId, isPreInstalled: true },
});
if (!registration) {
throw new ApplicationException(
`Pre-installed application registration with id ${applicationRegistrationId} not found`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
const report = await this.workspaceIteratorService.iterate({
callback: async ({ workspaceId }) => {
try {
await this.applicationInstallService.installApplication({
appRegistrationId: registration.id,
workspaceId,
});
} catch (error) {
if (
error instanceof ApplicationException &&
error.code === ApplicationExceptionCode.APP_ALREADY_INSTALLED
) {
return;
}
throw error;
}
},
});
this.logger.log(
`Backfilled app "${registration.name}" (${registration.id}): ${report.success.length} succeeded, ${report.fail.length} failed`,
);
}
}
@@ -3,7 +3,6 @@
import { Logger, Scope } from '@nestjs/common';
import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -12,6 +12,8 @@ import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/j
import { BillingReminderModule } from 'src/engine/core-modules/billing/reminders/billing-reminder.module';
import { BillingReminderCronJob } from 'src/engine/core-modules/billing/reminders/crons/billing-reminder.cron.job';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { BackfillApplicationInstallationJob } from 'src/engine/core-modules/application/jobs/backfill-application-installation.job';
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
@@ -85,6 +87,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
LogicFunctionModule,
EnterpriseModule,
EmailingModule,
PreInstalledAppsModule,
BillingReminderModule,
],
providers: [
@@ -99,6 +102,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
CleanWorkspaceDeletionWarningUserVarsJob,
UpdateWorkspaceMemberEmailJob,
GenerateSdkClientJob,
BackfillApplicationInstallationJob,
],
})
export class JobsModule {