diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 94d3fa30a1..04afa80ec4 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -4461,6 +4461,7 @@ input UpdateApplicationRegistrationPayload { oAuthRedirectUris: [String!] oAuthScopes: [String!] isListed: Boolean + isPreInstalled: Boolean } input CreateApplicationRegistrationVariableInput { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 8846e485d8..e44e02177f 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -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)} diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 99dbcb946f..007f133751 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -11359,6 +11359,9 @@ export default { "isListed": [ 6 ], + "isPreInstalled": [ + 6 + ], "__typename": [ 1 ] diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index 508d70fa56..5d747ee6b6 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -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; + isPreInstalled?: InputMaybe; + name?: InputMaybe; + oAuthRedirectUris?: InputMaybe>; + oAuthScopes?: InputMaybe>; +}; + 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, 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, oAuthScopes: Array, 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; 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; 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; +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; +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; 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; 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; 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; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index e12484419d..a27fa18f30 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5243,6 +5243,7 @@ export type UpdateApplicationRegistrationInput = { export type UpdateApplicationRegistrationPayload = { isListed?: InputMaybe; + isPreInstalled?: InputMaybe; name?: InputMaybe; oAuthRedirectUris?: InputMaybe>; oAuthScopes?: InputMaybe>; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/mutations/backfillApplicationInstallation.ts b/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/mutations/backfillApplicationInstallation.ts new file mode 100644 index 0000000000..58ff8c419f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/mutations/backfillApplicationInstallation.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const BACKFILL_APPLICATION_INSTALLATION = gql` + mutation BackfillApplicationInstallation( + $applicationRegistrationId: String! + ) { + backfillApplicationInstallation( + applicationRegistrationId: $applicationRegistrationId + ) + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/mutations/updateAdminApplicationRegistration.ts b/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/mutations/updateAdminApplicationRegistration.ts new file mode 100644 index 0000000000..0bbf260a05 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/mutations/updateAdminApplicationRegistration.ts @@ -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} +`; diff --git a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminApplicationRegistrationGeneralToggles.tsx b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminApplicationRegistrationGeneralToggles.tsx index 63fd8c7754..f0417687c0 100644 --- a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminApplicationRegistrationGeneralToggles.tsx +++ b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminApplicationRegistrationGeneralToggles.tsx @@ -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 (
+ + + + updateRegistration({ + variables: { + input: { + id: registration.id, + update: { isPreInstalled: checked }, + }, + }, + }) + } + /> + + + +
); }; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index cd7a958e00..4edd52812e 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -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, + @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 { + return this.applicationRegistrationService.updateGlobal(input); + } + + @UseGuards(AdminPanelGuard) + @Mutation(() => Boolean) + async backfillApplicationInstallation( + @Args('applicationRegistrationId') applicationRegistrationId: string, + ): Promise { + 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( + 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> { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts index 42640b1666..2b8eb30fbd 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts @@ -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 { + 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 { 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({ diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/dtos/update-application-registration.input.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/dtos/update-application-registration.input.ts index f60cd17a3d..82c018d648 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/dtos/update-application-registration.input.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/dtos/update-application-registration.input.ts @@ -41,6 +41,11 @@ export class UpdateApplicationRegistrationPayload { @IsBoolean() @IsOptional() isListed?: boolean; + + @Field(() => Boolean, { nullable: true }) + @IsBoolean() + @IsOptional() + isPreInstalled?: boolean; } @InputType() diff --git a/packages/twenty-server/src/engine/core-modules/application/jobs/backfill-application-installation.job-constants.ts b/packages/twenty-server/src/engine/core-modules/application/jobs/backfill-application-installation.job-constants.ts new file mode 100644 index 0000000000..9ae1341c66 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/jobs/backfill-application-installation.job-constants.ts @@ -0,0 +1,6 @@ +export const BACKFILL_APPLICATION_INSTALLATION_JOB_NAME = + 'BackfillApplicationInstallationJob'; + +export type BackfillApplicationInstallationJobData = { + applicationRegistrationId: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/jobs/backfill-application-installation.job.ts b/packages/twenty-server/src/engine/core-modules/application/jobs/backfill-application-installation.job.ts new file mode 100644 index 0000000000..eea8520109 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/jobs/backfill-application-installation.job.ts @@ -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 { + await this.preInstalledAppsService.backfillApplicationOnAllWorkspaces( + data.applicationRegistrationId, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/__tests__/pre-installed-apps.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/__tests__/pre-installed-apps.service.spec.ts new file mode 100644 index 0000000000..9f554af324 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/__tests__/pre-installed-apps.service.spec.ts @@ -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); + }); + + 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(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module.ts b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module.ts index 7501e8fc8d..fab5185d77 100644 --- a/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module.ts @@ -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], diff --git a/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service.ts b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service.ts index 523340fe8d..fdd7b5d14e 100644 --- a/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service.ts @@ -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, + 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 { + 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`, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts index cba98c2068..dac42318b1 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index fdf07931ad..36667c9686 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -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 {