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:
@@ -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']>>;
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const BACKFILL_APPLICATION_INSTALLATION = gql`
|
||||
mutation BackfillApplicationInstallation(
|
||||
$applicationRegistrationId: String!
|
||||
) {
|
||||
backfillApplicationInstallation(
|
||||
applicationRegistrationId: $applicationRegistrationId
|
||||
)
|
||||
}
|
||||
`;
|
||||
+14
@@ -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}
|
||||
`;
|
||||
+111
-7
@@ -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>> {
|
||||
|
||||
+25
-3
@@ -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({
|
||||
|
||||
+5
@@ -41,6 +41,11 @@ export class UpdateApplicationRegistrationPayload {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isListed?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPreInstalled?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export const BACKFILL_APPLICATION_INSTALLATION_JOB_NAME =
|
||||
'BackfillApplicationInstallationJob';
|
||||
|
||||
export type BackfillApplicationInstallationJobData = {
|
||||
applicationRegistrationId: string;
|
||||
};
|
||||
+22
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+155
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
@@ -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],
|
||||
|
||||
+45
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user