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

## After

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


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

## Key Changes

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

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

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

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

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

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

## Implementation Details

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


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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
martmull
2026-06-26 18:21:27 +02:00
committed by GitHub
parent fea2b8736f
commit e747bc3e42
18 changed files with 492 additions and 13 deletions
@@ -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>> {