feat: initial commit

This commit is contained in:
Mohamed Marrouchi
2024-09-10 10:50:11 +01:00
commit 30e5766487
879 changed files with 122820 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import {
installSettingFixtures,
settingFixtures,
} from '@/utils/test/fixtures/setting';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { SettingController } from './setting.controller';
import { SettingRepository } from '../repositories/setting.repository';
import { SettingModel } from '../schemas/setting.schema';
import { SettingSeeder } from '../seeds/setting.seed';
import { SettingService } from '../services/setting.service';
describe('SettingController', () => {
let settingController: SettingController;
let settingService: SettingService;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [SettingController],
imports: [
rootMongooseTestModule(installSettingFixtures),
MongooseModule.forFeature([SettingModel]),
],
providers: [
SettingService,
SettingRepository,
SettingSeeder,
LoggerService,
EventEmitter2,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
settingController = module.get<SettingController>(SettingController);
settingService = module.get<SettingService>(SettingService);
});
afterAll(closeInMongodConnection);
afterEach(jest.clearAllMocks);
describe('find', () => {
it('Should return an array of ordered by group Settings', async () => {
jest.spyOn(settingService, 'find');
const result = await settingController.find(
{},
{
sort: ['weight', 'asc'],
},
);
expect(settingService.find).toHaveBeenCalled();
expect(result).toEqualPayload(settingFixtures);
});
});
describe('updateOne', () => {
it('Should update and return a specific Setting by id', async () => {
jest.spyOn(settingService, 'updateOne');
const payload = {
value: 'updated setting value',
};
const id = (await settingService.findOne({ value: 'admin@example.com' }))
.id;
const result = await settingController.updateOne(id, payload);
expect(settingService.updateOne).toHaveBeenCalledWith(id, payload);
expect(result).toEqualPayload({
...settingFixtures.find(
(settingFixture) => settingFixture.value === 'admin@example.com',
),
value: payload.value,
});
});
});
});

View File

@@ -0,0 +1,92 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Patch,
Query,
UseInterceptors,
} from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { QuerySortDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { Setting } from '../schemas/setting.schema';
import { SettingService } from '../services/setting.service';
@UseInterceptors(CsrfInterceptor)
@Controller('setting')
export class SettingController {
constructor(
private readonly settingService: SettingService,
private readonly logger: LoggerService,
) {}
/**
* Finds settings that match the provided filters and sorting options.
*
* @param filters - Filters to apply when querying settings, limited to the `group` field.
* @param sort - Sorting options for the query.
*
* @returns A list of settings that match the criteria.
*/
@Get()
async find(
@Query(
new SearchFilterPipe<Setting>({
allowedFields: ['group'],
}),
)
filters: TFilterQuery<Setting>,
@Query(PageQueryPipe) { sort }: { sort: QuerySortDto<Setting> },
) {
return await this.settingService.find(filters, sort);
}
/**
* Loads all settings available in the system.
*
* @returns A list of all settings.
*/
@Get('load')
async load() {
return await this.settingService.load();
}
/**
* Updates a setting by its ID. If the setting does not exist, throws a `NotFoundException`.
*
* @param id - The ID of the setting to update.
* @param settingUpdateDto - The new value of the setting.
*
* @returns The updated setting.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() settingUpdateDto: { value: any },
): Promise<Setting> {
const result = await this.settingService.updateOne(id, settingUpdateDto);
if (!result) {
this.logger.warn(`Unable to update setting by id ${id}`);
throw new NotFoundException(`Setting with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsIn,
IsNotEmpty,
IsString,
IsOptional,
} from 'class-validator';
import { SettingType } from '../schemas/types';
export class SettingCreateDto {
@ApiProperty({ description: 'Setting group of setting', type: String })
@IsNotEmpty()
@IsString()
group: string;
@ApiProperty({ description: 'Setting label of setting', type: String })
@IsNotEmpty()
@IsString()
label: string;
@ApiProperty({
description: 'Setting type of the setting',
enum: [
'text',
'multiple_text',
'checkbox',
'select',
'number',
'attachment',
'multiple_attachment',
],
})
@IsNotEmpty()
@IsIn(Object.values(SettingType))
type: SettingType;
@ApiProperty({ description: 'Setting value of the setting' })
@IsNotEmpty()
value: any;
@ApiPropertyOptional({
description: 'Setting options',
isArray: true,
type: Array,
})
@IsArray()
@IsOptional()
options?: string[];
//TODO: adding swagger decorators
config?: Record<string, any>;
//TODO: adding swagger decorators
weight: number;
}
export class SettingUpdateDto {
@ApiProperty({ description: 'value of the setting' })
value: null | string | number | boolean | string[] | Record<string, any>;
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query, Types } from 'mongoose';
import { config } from '@/config';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { BaseRepository } from '@/utils/generics/base-repository';
import { Setting } from '../schemas/setting.schema';
@Injectable()
export class SettingRepository extends BaseRepository<Setting> {
constructor(
@InjectModel(Setting.name) readonly model: Model<Setting>,
private readonly eventEmitter: EventEmitter2,
private readonly i18n: ExtendedI18nService,
) {
super(model, Setting);
}
/**
* Validates the `Setting` document after it has been retrieved.
*
* Checks the `type` of the setting and validates the `value` field according to the type:
* - `text` expects a string.
* - `multiple_text` expects an array of strings.
* - `checkbox` expects a boolean.
*
* @param setting The `Setting` document to be validated.
*/
async postValidate(
setting: Document<unknown, unknown, Setting> &
Setting & { _id: Types.ObjectId },
) {
if (setting.type === 'text' && typeof setting.value !== 'string') {
throw new Error('Setting Model : Value must be a string!');
} else if (setting.type === 'multiple_text') {
const isStringArray =
Array.isArray(setting.value) &&
setting.value.every((v) => {
return typeof v === 'string';
});
if (!isStringArray) {
throw new Error('Setting Model : Value must be a string array!');
}
} else if (
setting.type === 'checkbox' &&
typeof setting.value !== 'boolean'
) {
throw new Error('Setting Model : Value must be a boolean!');
}
}
/**
* Emits an event after a `Setting` has been updated.
*
* This method is used to synchronize global settings by emitting an event
* based on the `group` and `label` of the `Setting`. It also updates the i18n
* default language setting when the `default_lang` label is updated.
*
* @param _query The Mongoose query object used to find and update the document.
* @param setting The updated `Setting` object.
*/
async postUpdate(
_query: Query<
Document<Setting, any, any>,
Document<Setting, any, any>,
unknown,
Setting,
'findOneAndUpdate'
>,
setting: Setting,
) {
// Sync global settings var
this.eventEmitter.emit(
'hook:settings:' + setting.group + ':' + setting.label,
setting,
);
if (setting.label === 'default_lang') {
// @todo : check if this actually updates the default lang
this.i18n.resolveLanguage(setting.value as string);
}
}
/**
* Sets default values before creating a `Setting` document.
*
* If the setting is part of the `nlp_settings` group, it sets specific values
* for `languages` and `default_lang` labels, using configuration values from the
* chatbot settings.
*
* @param setting The `Setting` document to be created.
*/
async preCreate(
setting: Document<unknown, unknown, Setting> &
Setting & { _id: Types.ObjectId },
) {
if (setting.group === 'nlp_settings') {
if (setting.label === 'languages') {
setting.value = config.chatbot.lang.available;
} else if (setting.label === 'default_lang') {
setting.value = config.chatbot.lang.default;
setting.options = config.chatbot.lang.available;
}
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
import { IsArray, IsIn } from 'class-validator';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { SettingType } from './types';
@Schema({ timestamps: true })
export class Setting extends BaseSchema {
@Prop({
type: String,
required: true,
})
group: string;
@Prop({
type: String,
required: true,
})
label: string;
@Prop({
type: String,
required: true,
})
@IsIn(Object.values(SettingType))
type: SettingType;
@Prop({ type: JSON })
value: any;
@IsArray()
@Prop({ type: JSON })
options?: string[];
@Prop({ type: JSON, default: {} })
config?: Record<string, any>;
@Prop({
type: Number,
default: 0,
})
weight?: number;
}
export const SettingModel: ModelDefinition = LifecycleHookManager.attach({
name: Setting.name,
schema: SchemaFactory.createForClass(Setting),
});
export default SettingModel.schema;

View File

@@ -0,0 +1,120 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Setting } from './setting.schema';
export enum SettingType {
text = 'text',
textarea = 'textarea',
secret = 'secret',
multiple_text = 'multiple_text',
checkbox = 'checkbox',
select = 'select',
number = 'number',
attachment = 'attachment',
multiple_attachment = 'multiple_attachment',
}
/**
* The following interfaces are declared, and currently not used
* TextSetting
* MultiTextSetting
* CheckboxSetting
* SelectSetting
* NumberSetting
* AttachmentSetting
* MultipleAttachmentSetting
* AnySetting
**/
export interface TextSetting extends Setting {
type: SettingType.text;
value: string;
options: never;
config: never;
}
export interface MultiTextSetting extends Setting {
type: SettingType.multiple_text;
value: string[];
options: never;
config: never;
}
export interface CheckboxSetting extends Setting {
type: SettingType.checkbox;
value: boolean;
options: never;
config: never;
}
export interface SelectSetting extends Setting {
type: SettingType.select;
value: string;
options: string[];
config: never;
}
export interface NumberSetting extends Setting {
type: SettingType.number;
value: number;
options: never;
config?: {
min: number;
max: number;
step: number;
};
}
export interface AttachmentSetting extends Setting {
type: SettingType.attachment;
value: string | null; // attachment id
options: never;
config: never;
}
export interface MultipleAttachmentSetting extends Setting {
type: SettingType.multiple_attachment;
value: string[]; // attachment ids
options: never;
config: never;
}
export type AnySetting =
| TextSetting
| MultiTextSetting
| CheckboxSetting
| SelectSetting
| NumberSetting
| AttachmentSetting
| MultipleAttachmentSetting;
export type SettingDict = { [group: string]: Setting[] };
export type Settings = {
nlp_settings: {
default_lang: string;
languages: string[];
threshold: string;
provider: string;
endpoint: string;
token: string;
};
contact: { [key: string]: string };
chatbot_settings: {
global_fallback: boolean;
fallback_message: string[];
fallback_block: string;
};
email_settings: {
mailer: string;
auth_user: string;
auth_pass: string;
from: string;
};
} & Record<string, any>;

View File

@@ -0,0 +1,220 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { SettingCreateDto } from '../dto/setting.dto';
import { SettingType } from '../schemas/types';
export const settingModels: SettingCreateDto[] = [
{
group: 'chatbot_settings',
label: 'global_fallback',
value: true,
type: SettingType.checkbox,
weight: 1,
},
{
group: 'chatbot_settings',
label: 'fallback_block',
value: '',
options: [],
type: SettingType.select,
config: {
multiple: false,
allowCreate: false,
source: '/Block/',
valueKey: 'id',
labelKey: 'name',
},
weight: 2,
},
{
group: 'chatbot_settings',
label: 'fallback_message',
value: [
"Sorry but i didn't understand your request. Maybe you can check the menu",
"I'm really sorry but i don't quite understand what you are saying :(",
],
type: SettingType.multiple_text,
weight: 3,
},
{
group: 'nlp_settings',
label: 'provider',
value: 'default',
options: ['default'],
type: SettingType.select,
weight: 1,
},
{
group: 'nlp_settings',
label: 'endpoint',
value: 'http://nlu-api:5000/',
type: SettingType.text,
weight: 2,
},
{
group: 'nlp_settings',
label: 'token',
value: 'token123',
type: SettingType.text,
weight: 3,
},
{
group: 'nlp_settings',
label: 'languages',
value: [],
options: [],
type: SettingType.select,
config: {
multiple: true,
allowCreate: true,
},
weight: 4,
},
{
group: 'nlp_settings',
label: 'default_lang',
value: '',
options: [], // NOTE : will be set onBeforeCreate from config
type: SettingType.select,
weight: 5,
},
{
group: 'nlp_settings',
label: 'threshold',
value: 0.9,
type: SettingType.number,
config: {
min: 0,
max: 1,
step: 0.01,
},
weight: 6,
},
{
group: 'email_settings',
label: 'from',
value: 'no-reply@domain.com',
type: SettingType.text,
weight: 1,
},
{
group: 'email_settings',
label: 'mailer',
value: 'sendmail',
options: ['sendmail', 'smtp'],
type: SettingType.select,
weight: 2,
},
{
group: 'email_settings',
label: 'host',
value: 'localhost',
type: SettingType.text,
weight: 3,
},
{
group: 'email_settings',
label: 'port',
value: '25',
type: SettingType.text,
weight: 4,
},
{
group: 'email_settings',
label: 'secure',
value: true,
type: SettingType.checkbox,
weight: 5,
},
{
group: 'email_settings',
label: 'auth_user',
value: '',
type: SettingType.text,
weight: 6,
},
{
group: 'email_settings',
label: 'auth_pass',
value: '',
type: SettingType.text,
weight: 7,
},
{
group: 'contact',
label: 'contact_email_recipient',
value: 'admin@example.com',
type: SettingType.text,
weight: 1,
},
{
group: 'contact',
label: 'company_name',
value: 'Your company name',
type: SettingType.text,
weight: 2,
},
{
group: 'contact',
label: 'company_phone',
value: '(+999) 9999 9999 999',
type: SettingType.text,
weight: 3,
},
{
group: 'contact',
label: 'company_email',
value: 'contact[at]mycompany.com',
type: SettingType.text,
weight: 4,
},
{
group: 'contact',
label: 'company_address1',
value: '71 Pilgrim Avenue',
type: SettingType.text,
weight: 5,
},
{
group: 'contact',
label: 'company_address2',
value: '',
type: SettingType.text,
weight: 6,
},
{
group: 'contact',
label: 'company_city',
value: 'Chevy Chase',
type: SettingType.text,
weight: 7,
},
{
group: 'contact',
label: 'company_zipcode',
value: '85705',
type: SettingType.text,
weight: 8,
},
{
group: 'contact',
label: 'company_state',
value: 'Orlando',
type: SettingType.text,
weight: 9,
},
{
group: 'contact',
label: 'company_country',
value: 'US',
type: SettingType.text,
weight: 10,
},
];

View File

@@ -0,0 +1,41 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { BaseSchema } from '@/utils/generics/base-schema';
import { BaseSeeder } from '@/utils/generics/base-seeder';
import { SettingRepository } from '../repositories/setting.repository';
import { Setting } from '../schemas/setting.schema';
@Injectable()
export class SettingSeeder extends BaseSeeder<Setting> {
constructor(private readonly settingRepository: SettingRepository) {
super(settingRepository);
}
async seed(models: Omit<Setting, keyof BaseSchema>[]): Promise<boolean> {
const grouppedModels = models.reduce(
(acc, model) => {
if (!acc[model.group]) acc[model.group] = [model];
else acc[model.group].push(model);
return acc;
},
{} as Record<string, Omit<Setting, keyof BaseSchema>[]>,
);
Object.entries(grouppedModels).forEach(async ([group, models]) => {
if ((await this.repository.count({ group })) === 0)
await this.repository.createMany(models);
});
return true;
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import {
installSettingFixtures,
settingFixtures,
} from '@/utils/test/fixtures/setting';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { SettingService } from './setting.service';
import { SettingRepository } from '../repositories/setting.repository';
import { Setting, SettingModel } from '../schemas/setting.schema';
import { SettingType } from '../schemas/types';
import { SettingSeeder } from '../seeds/setting.seed';
describe('SettingService', () => {
let settingService: SettingService;
let settingRepository: SettingRepository;
const commonAttributes = {
type: SettingType.text,
id: '',
createdAt: undefined,
updatedAt: undefined,
};
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installSettingFixtures),
MongooseModule.forFeature([SettingModel]),
],
providers: [
SettingService,
SettingRepository,
SettingSeeder,
EventEmitter2,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
],
}).compile();
settingService = module.get<SettingService>(SettingService);
settingRepository = module.get<SettingRepository>(SettingRepository);
});
afterAll(closeInMongodConnection);
afterEach(jest.clearAllMocks);
describe('load', () => {
it('Should return loaded settings', async () => {
jest.spyOn(settingRepository, 'findAll');
const result = await settingService.load();
expect(settingRepository.findAll).toHaveBeenCalled();
expect(result).toEqualPayload(
settingService.group(settingFixtures as Setting[]),
);
});
});
describe('buildTree', () => {
it('should return an empty object when settings are empty', () => {
expect(settingService.buildTree([])).toEqual({});
});
it('should categorize settings by group and map labels to values', () => {
const settings: Setting[] = [
{ group: 'group1', label: 'setting1', value: 'value1' },
{ group: 'group1', label: 'setting2', value: 'value2' },
{ group: 'group2', label: 'setting1', value: 'value3' },
].map((s) => ({ ...commonAttributes, ...s }));
expect(settingService.buildTree(settings)).toEqualPayload({
group1: { setting1: 'value1', setting2: 'value2' },
group2: { setting1: 'value3' },
});
});
it('should handle undefined group as "undefinedGroup"', () => {
const settings: Setting[] = [
{
...commonAttributes,
label: 'setting1',
value: 'value1',
group: '',
},
];
expect(settingService.buildTree(settings)).toEqualPayload({
undefinedGroup: { setting1: 'value1' },
});
});
});
describe('group', () => {
it('should return an empty object when settings are empty', () => {
expect(settingService.group([])).toEqual({});
});
it('should group settings by their group property', () => {
const settings = [
{ group: 'group1', label: 'setting1', value: 'value1' },
{ group: 'group1', label: 'setting2', value: 'value2' },
{ group: 'group2', label: 'setting1', value: 'value3' },
].map((s) => ({ ...commonAttributes, ...s }));
expect(settingService.group(settings)).toEqualPayload({
group1: [
{ group: 'group1', label: 'setting1', type: 'text', value: 'value1' },
{ group: 'group1', label: 'setting2', type: 'text', value: 'value2' },
],
group2: [
{ group: 'group2', label: 'setting1', type: 'text', value: 'value3' },
],
});
});
});
});

View File

@@ -0,0 +1,130 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cache } from 'cache-manager';
import { config } from '@/config';
import { Config } from '@/config/types';
import { LoggerService } from '@/logger/logger.service';
import { SETTING_CACHE_KEY } from '@/utils/constants/cache';
import { Cacheable } from '@/utils/decorators/cacheable.decorator';
import { BaseService } from '@/utils/generics/base-service';
import { SettingCreateDto } from '../dto/setting.dto';
import { SettingRepository } from '../repositories/setting.repository';
import { Setting } from '../schemas/setting.schema';
import { Settings } from '../schemas/types';
import { SettingSeeder } from '../seeds/setting.seed';
@Injectable()
export class SettingService extends BaseService<Setting> {
constructor(
readonly repository: SettingRepository,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly logger: LoggerService,
private readonly seeder: SettingSeeder,
) {
super(repository);
}
/**
* Seeds the settings if they don't already exist for the provided group.
*
* @param group - The group of settings to check.
* @param data - The array of settings to seed if none exist.
*/
async seedIfNotExist(group: string, data: SettingCreateDto[]) {
const count = await this.count({ group });
if (count === 0) {
await this.seeder.seed(data);
}
}
/**
* Loads all settings and returns them grouped in ascending order by weight.
*
* @returns A grouped object of settings.
*/
async load() {
const settings = await this.findAll(['weight', 'asc']);
return this.group(settings);
}
/**
* Builds a tree structure from the settings array.
*
* Each setting is grouped by its `group` and returned as a structured object.
*
* @param settings - An array of settings to build into a tree structure.
*
* @returns A `Settings` object organized by group.
*/
public buildTree(settings: Setting[]): Settings {
return settings.reduce((acc: Settings, s: Setting) => {
const groupKey = s.group || 'undefinedGroup';
acc[groupKey] = acc[groupKey] || {};
acc[groupKey][s.label] = s.value;
return acc;
}, {} as Settings);
}
/**
* Groups the settings into a record where the key is the setting group and
* the value is an array of settings in that group.
*
* @param settings - An array of settings to group.
*
* @returns A record where each key is a group and each value is an array of settings.
*/
public group(settings: Setting[]): Record<string, Setting[]> {
return (
settings?.reduce((acc, curr) => {
const group = acc[curr.group] || [];
group.push(curr);
acc[curr.group] = group;
return acc;
}, {}) || {}
);
}
/**
* Retrieves the application configuration object.
*
* @returns The global configuration object.
*/
getConfig(): Config {
return config;
}
/**
* Event handler for setting updates. Listens to 'hook:settings:*:*' events
* and invalidates the cache for settings when triggered.
*/
@OnEvent('hook:settings:*:*')
async handleSettingUpdateEvent() {
this.cacheManager.del(SETTING_CACHE_KEY);
}
/**
* Retrieves settings from the cache if available, or loads them from the
* repository and caches the result.
*
* @returns A promise that resolves to a `Settings` object.
*/
@Cacheable(SETTING_CACHE_KEY)
async getSettings(): Promise<Settings> {
const settings = await this.findAll();
return this.buildTree(settings);
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright © 2024 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).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Global, Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport';
import { SettingController } from './controllers/setting.controller';
import { SettingRepository } from './repositories/setting.repository';
import { SettingModel } from './schemas/setting.schema';
import { SettingSeeder } from './seeds/setting.seed';
import { SettingService } from './services/setting.service';
@Global()
@Module({
imports: [
MongooseModule.forFeature([SettingModel]),
PassportModule.register({
session: true,
}),
],
providers: [SettingRepository, SettingSeeder, SettingService],
controllers: [SettingController],
exports: [SettingService],
})
export class SettingModule {}