mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
Merge pull request #883 from Hexastack/637-blank-settings-when-a-helper-extension-gets-removed
feat: add extension module
This commit is contained in:
commit
9b752e74e5
@ -35,6 +35,7 @@ import { ChannelModule } from './channel/channel.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { CmsModule } from './cms/cms.module';
|
||||
import { config } from './config';
|
||||
import { ExtensionModule } from './extension/extension.module';
|
||||
import extraModules from './extra';
|
||||
import { HelperModule } from './helper/helper.module';
|
||||
import { I18nModule } from './i18n/i18n.module';
|
||||
@ -152,6 +153,7 @@ const i18nOptions: I18nOptions = {
|
||||
max: config.cache.max,
|
||||
}),
|
||||
MigrationModule,
|
||||
ExtensionModule,
|
||||
...extraModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
109
api/src/extension/cleanup.service.spec.ts
Normal file
109
api/src/extension/cleanup.service.spec.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright © 2025 Hexastack. All rights reserved.
|
||||
*
|
||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
||||
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
|
||||
*/
|
||||
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { ChannelService } from '@/channel/channel.service';
|
||||
import { SubscriberRepository } from '@/chat/repositories/subscriber.repository';
|
||||
import { SubscriberModel } from '@/chat/schemas/subscriber.schema';
|
||||
import { SubscriberService } from '@/chat/services/subscriber.service';
|
||||
import LocalStorageHelper from '@/extensions/helpers/local-storage/index.helper';
|
||||
import { HelperService } from '@/helper/helper.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { SettingRepository } from '@/setting/repositories/setting.repository';
|
||||
import { Setting, SettingModel } from '@/setting/schemas/setting.schema';
|
||||
import { SettingSeeder } from '@/setting/seeds/setting.seed';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { installSettingFixtures } from '@/utils/test/fixtures/setting';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
import { buildTestingMocks } from '@/utils/test/utils';
|
||||
|
||||
import { CleanupService } from './cleanup.service';
|
||||
import { TNamespace } from './types';
|
||||
|
||||
describe('CleanupService', () => {
|
||||
let initialSettings: Setting[];
|
||||
let helperService: HelperService;
|
||||
let cleanupService: CleanupService;
|
||||
let settingService: SettingService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { getMocks, resolveMocks } = await buildTestingMocks({
|
||||
imports: [
|
||||
rootMongooseTestModule(installSettingFixtures),
|
||||
MongooseModule.forFeature([
|
||||
SettingModel,
|
||||
SubscriberModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
CleanupService,
|
||||
HelperService,
|
||||
SettingService,
|
||||
SettingRepository,
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: {
|
||||
del: jest.fn(),
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
},
|
||||
SettingSeeder,
|
||||
SubscriberService,
|
||||
SubscriberRepository,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
ChannelService,
|
||||
],
|
||||
});
|
||||
[cleanupService, settingService, helperService] = await getMocks([
|
||||
CleanupService,
|
||||
SettingService,
|
||||
HelperService,
|
||||
]);
|
||||
|
||||
const [loggerService] = await resolveMocks([LoggerService]);
|
||||
initialSettings = await settingService.findAll();
|
||||
|
||||
helperService.register(
|
||||
new LocalStorageHelper(settingService, helperService, loggerService),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete all the unregistered settings with a group suffix `_channel` or/and `_helper`', async () => {
|
||||
const registeredNamespaces = [
|
||||
...cleanupService.getChannelNamespaces(),
|
||||
...cleanupService.getHelperNamespaces(),
|
||||
];
|
||||
|
||||
await cleanupService.pruneExtensionSettings();
|
||||
const cleanSettings = await settingService.findAll();
|
||||
const filteredSettings = initialSettings.filter(
|
||||
({ group }) =>
|
||||
!/_(channel|helper)$/.test(group) !==
|
||||
registeredNamespaces.includes(group as TNamespace),
|
||||
);
|
||||
|
||||
expect(cleanSettings).toEqualPayload(filteredSettings);
|
||||
});
|
||||
});
|
||||
});
|
86
api/src/extension/cleanup.service.ts
Normal file
86
api/src/extension/cleanup.service.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright © 2025 Hexastack. All rights reserved.
|
||||
*
|
||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
||||
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ChannelService } from '@/channel/channel.service';
|
||||
import { HelperService } from '@/helper/helper.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { DeleteResult } from '@/utils/generics/base-repository';
|
||||
|
||||
import { TCriteria, TExtractExtension, TExtractNamespace } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class CleanupService {
|
||||
constructor(
|
||||
private readonly helperService: HelperService,
|
||||
private readonly loggerService: LoggerService,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly channelService: ChannelService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Deletes unused settings with the specified criteria.
|
||||
*
|
||||
* @param criteria - An array of criteria objects containing:
|
||||
* - suffix: Regex pattern to match setting groups
|
||||
* - namespaces: Array of namespaces to exclude from deletion
|
||||
* @returns A promise that resolves to the result of the deletion operation.
|
||||
*/
|
||||
private async deleteManyBySuffixAndNamespaces(
|
||||
criteria: TCriteria[],
|
||||
): Promise<DeleteResult> {
|
||||
return await this.settingService.deleteMany({
|
||||
$or: criteria.map(({ suffix, namespaces }) => ({
|
||||
group: { $regex: new RegExp(`${suffix}$`), $nin: namespaces },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of channel Namespaces.
|
||||
*
|
||||
* @returns An array of channel Namespaces.
|
||||
*/
|
||||
public getChannelNamespaces(): TExtractNamespace<'channel'>[] {
|
||||
return this.channelService
|
||||
.getAll()
|
||||
.map((channel) => channel.getNamespace<TExtractExtension<'channel'>>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of helper Namespaces.
|
||||
*
|
||||
* @returns An array of helper Namespaces.
|
||||
*/
|
||||
public getHelperNamespaces(): TExtractNamespace<'helper'>[] {
|
||||
return this.helperService
|
||||
.getAll()
|
||||
.map((helper) => helper.getNamespace<TExtractExtension<'helper'>>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune extensions unused settings.
|
||||
*
|
||||
*/
|
||||
public async pruneExtensionSettings(): Promise<void> {
|
||||
const channels = this.getChannelNamespaces();
|
||||
const helpers = this.getHelperNamespaces();
|
||||
const { deletedCount } = await this.deleteManyBySuffixAndNamespaces([
|
||||
{ suffix: '_channel', namespaces: channels },
|
||||
{ suffix: '_helper', namespaces: helpers },
|
||||
]);
|
||||
|
||||
if (deletedCount > 0) {
|
||||
this.loggerService.log(
|
||||
`${deletedCount} unused setting${deletedCount === 1 ? '' : 's'} ${deletedCount === 1 ? 'is' : 'are'} successfully deleted!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
33
api/src/extension/extension.module.ts
Normal file
33
api/src/extension/extension.module.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright © 2025 Hexastack. All rights reserved.
|
||||
*
|
||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
||||
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
|
||||
*/
|
||||
|
||||
import { Global, Module, OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
|
||||
import { CleanupService } from './cleanup.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CleanupService],
|
||||
exports: [CleanupService],
|
||||
})
|
||||
export class ExtensionModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly loggerService: LoggerService,
|
||||
private readonly cleanupService: CleanupService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
try {
|
||||
await this.cleanupService.pruneExtensionSettings();
|
||||
} catch (error) {
|
||||
this.loggerService.error('Unable to delete unused settings', error);
|
||||
}
|
||||
}
|
||||
}
|
41
api/src/extension/types.ts
Normal file
41
api/src/extension/types.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright © 2025 Hexastack. All rights reserved.
|
||||
*
|
||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
||||
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
|
||||
*/
|
||||
|
||||
import { ExtensionName } from '@/utils/types/extension';
|
||||
|
||||
type TExcludedExtension = 'plugin';
|
||||
|
||||
type TExcludeSuffix<
|
||||
T,
|
||||
S extends string = '_',
|
||||
Suffix extends string = `${S}${TExcludedExtension}`,
|
||||
> = T extends `${infer _Base}${Suffix}` ? never : T;
|
||||
|
||||
export type TExtensionName = TExcludeSuffix<ExtensionName, '-'>;
|
||||
|
||||
export type TExtension =
|
||||
Extract<TExtensionName, `${string}-${string}`> extends `${string}-${infer S}`
|
||||
? `${S}`
|
||||
: never;
|
||||
|
||||
export type TNamespace = HyphenToUnderscore<TExtensionName>;
|
||||
|
||||
export type TExtractNamespace<
|
||||
T extends TExtension = TExtension,
|
||||
M extends TExtensionName = TExtensionName,
|
||||
> = M extends `${string}${T}` ? HyphenToUnderscore<M> : never;
|
||||
|
||||
export type TExtractExtension<
|
||||
T extends TExtension = TExtension,
|
||||
M extends TExtensionName = TExtensionName,
|
||||
> = M extends `${string}${T}` ? M : never;
|
||||
|
||||
export type TCriteria = {
|
||||
suffix: `_${TExtension}`;
|
||||
namespaces: TNamespace[];
|
||||
};
|
36
api/src/utils/test/fixtures/setting.ts
vendored
36
api/src/utils/test/fixtures/setting.ts
vendored
@ -11,6 +11,7 @@ import mongoose from 'mongoose';
|
||||
import { SettingCreateDto } from '@/setting/dto/setting.dto';
|
||||
import { SettingModel } from '@/setting/schemas/setting.schema';
|
||||
import { SettingType } from '@/setting/schemas/types';
|
||||
import { getRandom } from '@/utils/helpers/safeRandom';
|
||||
|
||||
export const settingFixtures: SettingCreateDto[] = [
|
||||
{
|
||||
@ -90,6 +91,41 @@ export const settingFixtures: SettingCreateDto[] = [
|
||||
type: SettingType.text,
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
group: `${getRandom()}_channel`,
|
||||
label: `${getRandom()}`,
|
||||
value: '',
|
||||
type: SettingType.text,
|
||||
weight: 11,
|
||||
},
|
||||
{
|
||||
group: `${getRandom()}_helper`,
|
||||
label: `${getRandom()}`,
|
||||
value: '',
|
||||
type: SettingType.text,
|
||||
weight: 12,
|
||||
},
|
||||
{
|
||||
group: `${getRandom()}_channel`,
|
||||
label: `${getRandom()}`,
|
||||
value: '',
|
||||
type: SettingType.text,
|
||||
weight: 13,
|
||||
},
|
||||
{
|
||||
group: `${getRandom()}_helper`,
|
||||
label: `${getRandom()}`,
|
||||
value: '',
|
||||
type: SettingType.text,
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
group: 'local_storage_helper',
|
||||
label: 'default storage helper label',
|
||||
value: 'local-storage-helper',
|
||||
type: SettingType.text,
|
||||
weight: 15,
|
||||
},
|
||||
];
|
||||
|
||||
export const installSettingFixtures = async () => {
|
||||
|
Loading…
Reference in New Issue
Block a user