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:
+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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user