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
@@ -19,7 +19,10 @@ import {
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import {
type UpdateApplicationRegistrationInput,
type UpdateApplicationRegistrationPayload,
} from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
@@ -186,7 +189,26 @@ export class ApplicationRegistrationService {
const { id, update } = input;
await this.findOneById(id, ownerWorkspaceId);
await this.applyUpdate(id, update);
return this.findOneById(id, ownerWorkspaceId);
}
async updateGlobal(
input: UpdateApplicationRegistrationInput,
): Promise<ApplicationRegistrationEntity> {
const { id, update } = input;
await this.findOneByIdGlobal(id);
await this.applyUpdate(id, update);
return this.findOneByIdGlobal(id);
}
private async applyUpdate(
id: string,
update: UpdateApplicationRegistrationPayload,
): Promise<void> {
if (isDefined(update.oAuthRedirectUris)) {
this.validateRedirectUris(update.oAuthRedirectUris);
}
@@ -203,12 +225,12 @@ export class ApplicationRegistrationService {
if (isDefined(update.oAuthScopes))
updateData.oAuthScopes = update.oAuthScopes;
if (isDefined(update.isListed)) updateData.isListed = update.isListed;
if (isDefined(update.isPreInstalled))
updateData.isPreInstalled = update.isPreInstalled;
if (Object.keys(updateData).length > 0) {
await this.applicationRegistrationRepository.update(id, updateData);
}
return this.findOneById(id, ownerWorkspaceId);
}
async updateFromManifest({
@@ -41,6 +41,11 @@ export class UpdateApplicationRegistrationPayload {
@IsBoolean()
@IsOptional()
isListed?: boolean;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
isPreInstalled?: boolean;
}
@InputType()
@@ -0,0 +1,6 @@
export const BACKFILL_APPLICATION_INSTALLATION_JOB_NAME =
'BackfillApplicationInstallationJob';
export type BackfillApplicationInstallationJobData = {
applicationRegistrationId: string;
};
@@ -0,0 +1,22 @@
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import {
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
type BackfillApplicationInstallationJobData,
} from 'src/engine/core-modules/application/jobs/backfill-application-installation.job-constants';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
@Processor(MessageQueue.workspaceQueue)
export class BackfillApplicationInstallationJob {
constructor(
private readonly preInstalledAppsService: PreInstalledAppsService,
) {}
@Process(BACKFILL_APPLICATION_INSTALLATION_JOB_NAME)
async handle(data: BackfillApplicationInstallationJobData): Promise<void> {
await this.preInstalledAppsService.backfillApplicationOnAllWorkspaces(
data.applicationRegistrationId,
);
}
}
@@ -0,0 +1,155 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
describe('PreInstalledAppsService', () => {
let service: PreInstalledAppsService;
let applicationInstallService: { installApplication: jest.Mock };
let applicationRegistrationRepository: {
find: jest.Mock;
findOne: jest.Mock;
};
let workspaceIteratorService: { iterate: jest.Mock };
beforeEach(async () => {
applicationInstallService = { installApplication: jest.fn() };
applicationRegistrationRepository = { find: jest.fn(), findOne: jest.fn() };
workspaceIteratorService = { iterate: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
PreInstalledAppsService,
{
provide: ApplicationInstallService,
useValue: applicationInstallService,
},
{
provide: getRepositoryToken(ApplicationRegistrationEntity),
useValue: applicationRegistrationRepository,
},
{
provide: WorkspaceIteratorService,
useValue: workspaceIteratorService,
},
],
}).compile();
service = module.get<PreInstalledAppsService>(PreInstalledAppsService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('backfillApplicationOnAllWorkspaces', () => {
it('should throw when the registration does not exist', async () => {
applicationRegistrationRepository.findOne.mockResolvedValue(null);
await expect(
service.backfillApplicationOnAllWorkspaces('missing-id'),
).rejects.toThrow(ApplicationException);
expect(workspaceIteratorService.iterate).not.toHaveBeenCalled();
});
it('should install the app on every iterated workspace', async () => {
applicationRegistrationRepository.findOne.mockResolvedValue({
id: 'app-1',
name: 'My App',
} as ApplicationRegistrationEntity);
workspaceIteratorService.iterate.mockImplementation(
async ({ callback }) => {
await callback({ workspaceId: 'workspace-1', index: 0, total: 2 });
await callback({ workspaceId: 'workspace-2', index: 1, total: 2 });
return { success: [{ workspaceId: 'workspace-1' }], fail: [] };
},
);
await service.backfillApplicationOnAllWorkspaces('app-1');
expect(
applicationInstallService.installApplication,
).toHaveBeenCalledTimes(2);
expect(applicationInstallService.installApplication).toHaveBeenCalledWith(
{
appRegistrationId: 'app-1',
workspaceId: 'workspace-1',
},
);
expect(applicationInstallService.installApplication).toHaveBeenCalledWith(
{
appRegistrationId: 'app-1',
workspaceId: 'workspace-2',
},
);
});
it('should swallow already-installed errors so the backfill stays idempotent', async () => {
applicationRegistrationRepository.findOne.mockResolvedValue({
id: 'app-1',
name: 'My App',
} as ApplicationRegistrationEntity);
applicationInstallService.installApplication.mockRejectedValue(
new ApplicationException(
'already installed',
ApplicationExceptionCode.APP_ALREADY_INSTALLED,
),
);
workspaceIteratorService.iterate.mockImplementation(
async ({ callback }) => {
await expect(
callback({ workspaceId: 'workspace-1', index: 0, total: 1 }),
).resolves.toBeUndefined();
return { success: [{ workspaceId: 'workspace-1' }], fail: [] };
},
);
await expect(
service.backfillApplicationOnAllWorkspaces('app-1'),
).resolves.toBeUndefined();
});
it('should rethrow unexpected install errors to the iterator', async () => {
applicationRegistrationRepository.findOne.mockResolvedValue({
id: 'app-1',
name: 'My App',
} as ApplicationRegistrationEntity);
const unexpectedError = new Error('boom');
applicationInstallService.installApplication.mockRejectedValue(
unexpectedError,
);
workspaceIteratorService.iterate.mockImplementation(
async ({ callback }) => {
await expect(
callback({ workspaceId: 'workspace-1', index: 0, total: 1 }),
).rejects.toThrow(unexpectedError);
return {
success: [],
fail: [{ workspaceId: 'workspace-1', error: unexpectedError }],
};
},
);
await expect(
service.backfillApplicationOnAllWorkspaces('app-1'),
).resolves.toBeUndefined();
});
});
});
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
@@ -9,6 +10,7 @@ import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre
imports: [
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
ApplicationInstallModule,
WorkspaceIteratorModule,
],
providers: [PreInstalledAppsService],
exports: [PreInstalledAppsService],
@@ -3,8 +3,13 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
@Injectable()
export class PreInstalledAppsService {
@@ -14,6 +19,7 @@ export class PreInstalledAppsService {
private readonly applicationInstallService: ApplicationInstallService,
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly workspaceIteratorService: WorkspaceIteratorService,
) {}
// Per-app failures are logged but never block the other installs —
@@ -45,4 +51,43 @@ export class PreInstalledAppsService {
}),
);
}
async backfillApplicationOnAllWorkspaces(
applicationRegistrationId: string,
): Promise<void> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id: applicationRegistrationId, isPreInstalled: true },
});
if (!registration) {
throw new ApplicationException(
`Pre-installed application registration with id ${applicationRegistrationId} not found`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
const report = await this.workspaceIteratorService.iterate({
callback: async ({ workspaceId }) => {
try {
await this.applicationInstallService.installApplication({
appRegistrationId: registration.id,
workspaceId,
});
} catch (error) {
if (
error instanceof ApplicationException &&
error.code === ApplicationExceptionCode.APP_ALREADY_INSTALLED
) {
return;
}
throw error;
}
},
});
this.logger.log(
`Backfilled app "${registration.name}" (${registration.id}): ${report.success.length} succeeded, ${report.fail.length} failed`,
);
}
}