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