diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 86c35c44..67e296d7 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -11,7 +11,7 @@ import path from 'path'; import { CacheModule } from '@nestjs/cache-manager'; // eslint-disable-next-line import/order import { MailerModule } from '@nestjs-modules/mailer'; -import { Module } from '@nestjs/common'; +import { Module, OnApplicationBootstrap } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { MongooseModule } from '@nestjs/mongoose'; @@ -33,12 +33,15 @@ import { AppService } from './app.service'; import { AttachmentModule } from './attachment/attachment.module'; import { ChannelModule } from './channel/channel.module'; import { ChatModule } from './chat/chat.module'; +import { CleanupModule } from './cleanup/cleanup.module'; +import { CleanupService } from './cleanup/cleanup.service'; import { CmsModule } from './cms/cms.module'; import { config } from './config'; import extraModules from './extra'; import { HelperModule } from './helper/helper.module'; import { I18nModule } from './i18n/i18n.module'; import { LoggerModule } from './logger/logger.module'; +import { LoggerService } from './logger/logger.service'; import { MigrationModule } from './migration/migration.module'; import { NlpModule } from './nlp/nlp.module'; import { PluginsModule } from './plugins/plugins.module'; @@ -152,6 +155,7 @@ const i18nOptions: I18nOptions = { max: config.cache.max, }), MigrationModule, + CleanupModule, ...extraModules, ], controllers: [AppController], @@ -161,4 +165,17 @@ const i18nOptions: I18nOptions = { AppService, ], }) -export class HexabotModule {} +export class HexabotModule implements OnApplicationBootstrap { + constructor( + private readonly loggerService: LoggerService, + private readonly cleanupService: CleanupService, + ) {} + + async onApplicationBootstrap() { + try { + await this.cleanupService.deleteUnusedSettings(); + } catch (error) { + this.loggerService.error('Unable to delete unused settings', error); + } + } +} diff --git a/api/src/cleanup/cleanup.module.ts b/api/src/cleanup/cleanup.module.ts new file mode 100644 index 00000000..e758fc26 --- /dev/null +++ b/api/src/cleanup/cleanup.module.ts @@ -0,0 +1,18 @@ +/* + * 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 } from '@nestjs/common'; + +import { CleanupService } from './cleanup.service'; + +@Global() +@Module({ + providers: [CleanupService], + exports: [CleanupService], +}) +export class CleanupModule {} diff --git a/api/src/cleanup/cleanup.service.ts b/api/src/cleanup/cleanup.service.ts new file mode 100644 index 00000000..c3a241a2 --- /dev/null +++ b/api/src/cleanup/cleanup.service.ts @@ -0,0 +1,62 @@ +/* + * 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, + ) {} + + private async deleteMany(criteria: TCriteria[]): Promise { + return await this.settingService.deleteMany({ + $or: criteria.map(({ suffix, namespaces }) => ({ + group: { $regex: suffix, $nin: namespaces }, + })), + }); + } + + public getChannelNamespaces(): TExtractNamespace<'channel'>[] { + return this.channelService + .getAll() + .map((channel) => channel.getNamespace>()); + } + + public getHelperNamespaces(): TExtractNamespace<'helper'>[] { + return this.helperService + .getAll() + .map((helper) => helper.getNamespace>()); + } + + public async deleteUnusedSettings() { + const channels = this.getChannelNamespaces(); + const helpers = this.getHelperNamespaces(); + const { deletedCount } = await this.deleteMany([ + { suffix: '_channel', namespaces: channels }, + { suffix: '_helper', namespaces: helpers }, + ]); + + if (deletedCount > 0) { + this.loggerService.log( + `${deletedCount} unused setting${deletedCount === 1 ? '' : 's'} are successfully deleted!`, + ); + } + } +} diff --git a/api/src/cleanup/types.ts b/api/src/cleanup/types.ts new file mode 100644 index 00000000..2514c1c8 --- /dev/null +++ b/api/src/cleanup/types.ts @@ -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; + +export type TExtension = + Extract extends `${string}-${infer S}` + ? `${S}` + : never; + +export type TNamespace = HyphenToUnderscore; + +export type TExtractNamespace< + T extends TExtension = TExtension, + M extends TExtensionName = TExtensionName, +> = M extends `${string}${T}` ? HyphenToUnderscore : 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[]; +}; diff --git a/api/src/utils/test/fixtures/setting.ts b/api/src/utils/test/fixtures/setting.ts index 39ccfadb..7e848aee 100644 --- a/api/src/utils/test/fixtures/setting.ts +++ b/api/src/utils/test/fixtures/setting.ts @@ -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 () => {