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

111
api/src/chat/chat.module.ts Normal file
View File

@@ -0,0 +1,111 @@
/*
* 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 { forwardRef, Module } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { AttachmentModule } from '@/attachment/attachment.module';
import { ChannelModule } from '@/channel/channel.module';
import { CmsModule } from '@/cms/cms.module';
import { NlpModule } from '@/nlp/nlp.module';
import { UserModule } from '@/user/user.module';
import { BlockController } from './controllers/block.controller';
import { CategoryController } from './controllers/category.controller';
import { ContextVarController } from './controllers/context-var.controller';
import { LabelController } from './controllers/label.controller';
import { MessageController } from './controllers/message.controller';
import { SubscriberController } from './controllers/subscriber.controller';
import { TranslationController } from './controllers/translation.controller';
import { BlockRepository } from './repositories/block.repository';
import { CategoryRepository } from './repositories/category.repository';
import { ContextVarRepository } from './repositories/context-var.repository';
import { ConversationRepository } from './repositories/conversation.repository';
import { LabelRepository } from './repositories/label.repository';
import { MessageRepository } from './repositories/message.repository';
import { SubscriberRepository } from './repositories/subscriber.repository';
import { TranslationRepository } from './repositories/translation.repository';
import { BlockModel } from './schemas/block.schema';
import { CategoryModel } from './schemas/category.schema';
import { ContextVarModel } from './schemas/context-var.schema';
import { ConversationModel } from './schemas/conversation.schema';
import { LabelModel } from './schemas/label.schema';
import { MessageModel } from './schemas/message.schema';
import { SubscriberModel } from './schemas/subscriber.schema';
import { TranslationModel } from './schemas/translation.schema';
import { CategorySeeder } from './seeds/category.seed';
import { ContextVarSeeder } from './seeds/context-var.seed';
import { TranslationSeeder } from './seeds/translation.seed';
import { BlockService } from './services/block.service';
import { BotService } from './services/bot.service';
import { CategoryService } from './services/category.service';
import { ChatService } from './services/chat.service';
import { ContextVarService } from './services/context-var.service';
import { ConversationService } from './services/conversation.service';
import { LabelService } from './services/label.service';
import { MessageService } from './services/message.service';
import { SubscriberService } from './services/subscriber.service';
import { TranslationService } from './services/translation.service';
@Module({
imports: [
MongooseModule.forFeature([
CategoryModel,
ContextVarModel,
LabelModel,
BlockModel,
MessageModel,
SubscriberModel,
TranslationModel,
ConversationModel,
SubscriberModel,
]),
forwardRef(() => ChannelModule),
CmsModule,
AttachmentModule,
NlpModule,
EventEmitter2,
UserModule,
],
controllers: [
CategoryController,
ContextVarController,
LabelController,
BlockController,
MessageController,
SubscriberController,
TranslationController,
],
providers: [
CategoryRepository,
ContextVarRepository,
LabelRepository,
BlockRepository,
MessageRepository,
SubscriberRepository,
TranslationRepository,
ConversationRepository,
CategoryService,
ContextVarService,
LabelService,
BlockService,
MessageService,
SubscriberService,
TranslationService,
CategorySeeder,
ContextVarSeeder,
ConversationService,
ChatService,
BotService,
TranslationSeeder,
],
exports: [SubscriberService, MessageService, LabelService, BlockService],
})
export class ChatModule {}

View File

@@ -0,0 +1,325 @@
/*
* 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 { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { ContentModel } from '@/cms/schemas/content.schema';
import { ContentService } from '@/cms/services/content.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
import { PermissionRepository } from '@/user/repositories/permission.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { UserModel } from '@/user/schemas/user.schema';
import { PermissionService } from '@/user/services/permission.service';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
blockFixtures,
installBlockFixtures,
} from '@/utils/test/fixtures/block';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { CategoryModel, Category } from './../schemas/category.schema';
import { BlockController } from './block.controller';
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
import { BlockRepository } from '../repositories/block.repository';
import { CategoryRepository } from '../repositories/category.repository';
import { LabelRepository } from '../repositories/label.repository';
import { BlockModel, Block } from '../schemas/block.schema';
import { LabelModel } from '../schemas/label.schema';
import { BlockService } from '../services/block.service';
import { CategoryService } from '../services/category.service';
import { LabelService } from '../services/label.service';
describe('BlockController', () => {
let blockController: BlockController;
let blockService: BlockService;
let categoryService: CategoryService;
let category: Category;
let block: Block;
let blockToDelete: Block;
let hasNextBlocks: Block;
let hasPreviousBlocks: Block;
const FIELDS_TO_POPULATE = [
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
];
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [BlockController],
imports: [
rootMongooseTestModule(installBlockFixtures),
MongooseModule.forFeature([
BlockModel,
LabelModel,
CategoryModel,
ContentModel,
AttachmentModel,
UserModel,
RoleModel,
PermissionModel,
]),
],
providers: [
BlockRepository,
LabelRepository,
CategoryRepository,
ContentRepository,
AttachmentRepository,
UserRepository,
RoleRepository,
PermissionRepository,
BlockService,
LabelService,
CategoryService,
ContentService,
AttachmentService,
UserService,
RoleService,
PermissionService,
PluginService,
LoggerService,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({})),
},
},
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
blockController = module.get<BlockController>(BlockController);
blockService = module.get<BlockService>(BlockService);
categoryService = module.get<CategoryService>(CategoryService);
category = await categoryService.findOne({ label: 'default' });
block = await blockService.findOne({ name: 'first' });
blockToDelete = await blockService.findOne({ name: 'buttons' });
hasNextBlocks = await blockService.findOne({
name: 'hasNextBlocks',
});
hasPreviousBlocks = await blockService.findOne({
name: 'hasPreviousBlocks',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('find', () => {
it('should find all blocks', async () => {
jest.spyOn(blockService, 'find');
const result = await blockController.find([], {});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category: category.id,
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks.id] : [],
}));
expect(blockService.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory, [
...IGNORED_TEST_FIELDS,
'attachedToBlock',
]);
});
it('should find all blocks, and foreach block populate the corresponding category and previousBlocks', async () => {
jest.spyOn(blockService, 'findAndPopulate');
const category = await categoryService.findOne({ label: 'default' });
const result = await blockController.find(FIELDS_TO_POPULATE, {});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category,
previousBlocks:
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
}));
expect(blockService.findAndPopulate).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory);
});
});
describe('findOne', () => {
it('should find one block by id', async () => {
jest.spyOn(blockService, 'findOne');
const result = await blockController.findOne(hasNextBlocks.id, []);
expect(blockService.findOne).toHaveBeenCalledWith(hasNextBlocks.id);
expect(result).toEqualPayload(
{
...blockFixtures.find(({ name }) => name === hasNextBlocks.name),
category: category.id,
nextBlocks: [hasPreviousBlocks.id],
},
[...IGNORED_TEST_FIELDS, 'attachedToBlock'],
);
});
it('should find one block by id, and populate its category and previousBlocks', async () => {
jest.spyOn(blockService, 'findOneAndPopulate');
const result = await blockController.findOne(
hasPreviousBlocks.id,
FIELDS_TO_POPULATE,
);
expect(blockService.findOneAndPopulate).toHaveBeenCalledWith(
hasPreviousBlocks.id,
);
expect(result).toEqualPayload({
...blockFixtures.find(({ name }) => name === 'hasPreviousBlocks'),
category,
previousBlocks: [hasNextBlocks],
});
});
it('should find one block by id, and populate its category and an empty previousBlocks', async () => {
jest.spyOn(blockService, 'findOneAndPopulate');
block = await blockService.findOne({ name: 'attachment' });
const result = await blockController.findOne(
block.id,
FIELDS_TO_POPULATE,
);
expect(blockService.findOneAndPopulate).toHaveBeenCalledWith(block.id);
expect(result).toEqualPayload({
...blockFixtures.find(({ name }) => name === 'attachment'),
category,
previousBlocks: [],
});
});
});
describe('create', () => {
it('should return created block', async () => {
jest.spyOn(blockService, 'create');
const mockedBlockCreateDto: BlockCreateDto = {
name: 'block with nextBlocks',
nextBlocks: [hasNextBlocks.id],
patterns: ['Hi'],
trigger_labels: [],
assign_labels: [],
trigger_channels: [],
category: category.id,
options: {
typing: 0,
fallback: {
active: false,
max_attempts: 1,
message: [],
},
},
message: ['Hi back !'],
starts_conversation: false,
capture_vars: [],
position: {
x: 0,
y: 0,
},
};
const result = await blockController.create(mockedBlockCreateDto);
expect(blockService.create).toHaveBeenCalledWith(mockedBlockCreateDto);
expect(result).toEqualPayload(
{
...mockedBlockCreateDto,
},
[...IGNORED_TEST_FIELDS, 'nextBlocks', 'builtin'],
);
});
});
describe('deleteOne', () => {
it('should delete block', async () => {
jest.spyOn(blockService, 'deleteOne');
const result = await blockController.deleteOne(blockToDelete.id);
expect(blockService.deleteOne).toHaveBeenCalledWith(blockToDelete.id);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
it('should throw NotFoundException when attempting to delete a block by id', async () => {
await expect(blockController.deleteOne(blockToDelete.id)).rejects.toThrow(
new NotFoundException(`Block with ID ${blockToDelete.id} not found`),
);
});
});
describe('updateOne', () => {
it('should return updated block', async () => {
jest.spyOn(blockService, 'updateOne');
const updateBlock: BlockUpdateDto = {
name: 'modified block name',
};
const result = await blockController.updateOne(block.id, updateBlock);
expect(blockService.updateOne).toHaveBeenCalledWith(
block.id,
updateBlock,
);
expect(result).toEqualPayload(
{
...blockFixtures.find(({ name }) => name === block.name),
category: category.id,
...updateBlock,
},
[...IGNORED_TEST_FIELDS, 'attachedToBlock'],
);
});
it('should throw NotFoundException when attempting to update a block by id', async () => {
const updateBlock: BlockUpdateDto = {
name: 'attempt to modify block name',
};
await expect(
blockController.updateOne(blockToDelete.id, updateBlock),
).rejects.toThrow(
new NotFoundException(`Block with ID ${blockToDelete.id} not found`),
);
});
});
});

View File

@@ -0,0 +1,300 @@
/*
* 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 {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
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 { BaseBlockPlugin } from '@/plugins/base-block-plugin';
import { PluginService } from '@/plugins/plugins.service';
import { PluginType } from '@/plugins/types';
import { UserService } from '@/user/services/user.service';
import { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
import { Block, BlockFull, BlockStub } from '../schemas/block.schema';
import { BlockService } from '../services/block.service';
import { CategoryService } from '../services/category.service';
import { LabelService } from '../services/label.service';
@UseInterceptors(CsrfInterceptor)
@Controller('Block')
export class BlockController extends BaseController<Block, BlockStub> {
constructor(
private readonly blockService: BlockService,
private readonly logger: LoggerService,
private readonly categoryService: CategoryService,
private readonly labelService: LabelService,
private readonly userService: UserService,
private pluginsService: PluginService<BaseBlockPlugin>,
) {
super(blockService);
}
/**
* Finds blocks based on the provided query parameters.
* @param populate - An array of fields to populate in the returned blocks.
* @param filters - Query filters to apply to the block search.
* @returns A Promise that resolves to an array of found blocks.
*/
@Get()
async find(
@Query(PopulatePipe)
populate: string[],
@Query(new SearchFilterPipe<Block>({ allowedFields: ['category'] }))
filters: TFilterQuery<Block>,
): Promise<Block[] | BlockFull[]> {
return this.canPopulate(populate, [
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
'attachedToBlock',
])
? await this.blockService.findAndPopulate(filters)
: await this.blockService.find(filters);
}
/**
* Retrieves a custom block settings for a specific plugin.
*
* @param pluginId - The name of the plugin for which settings are to be retrieved.
*
* @returns An array containing the settings of the specified plugin.
*/
@Get('customBlocks/settings')
findSettings(@Query('plugin') pluginId: string) {
try {
if (!pluginId) {
throw new BadRequestException(
'Plugin id must be supplied as a query param',
);
}
const plugin = this.pluginsService.getPlugin(PluginType.block, pluginId);
if (!plugin) {
throw new NotFoundException('Plugin Not Found');
}
return plugin.settings;
} catch (e) {
this.logger.error('Unable to fetch plugin settings', e);
throw e;
}
}
/**
* Retrieves all custom blocks (plugins) along with their associated block template.
*
* @returns An array containing available custom blocks.
*/
@Get('customBlocks')
findAll() {
try {
const plugins = this.pluginsService
.getAllByType(PluginType.block)
.map((p) => ({
title: p.title,
name: p.id,
template: {
...p.template,
message: {
plugin: p.id,
args: p.settings.reduce(
(acc, setting) => {
acc[setting.id] = setting.value;
return acc;
},
{} as { [key: string]: any },
),
},
},
effects: typeof p.effects === 'object' ? Object.keys(p.effects) : [],
}));
return plugins;
} catch (e) {
this.logger.error(e);
throw e;
}
}
// @TODO : remove once old frontend is abandoned
/**
* Retrieves the effects of all plugins that have effects defined.
*
* @returns An array containing objects representing the effects of plugins.
*/
@Get('effects')
findEffects(): {
name: string;
title: any;
}[] {
try {
const plugins = this.pluginsService.getAllByType(PluginType.block);
const effects = Object.keys(plugins)
.filter(
(plugin) =>
typeof plugins[plugin].effects === 'object' &&
Object.keys(plugins[plugin].effects).length > 0,
)
.map((plugin) => ({
name: plugin,
title: plugins[plugin].title,
}));
return effects;
} catch (e) {
this.logger.error(e);
throw e;
}
}
/**
* Retrieves a single block by its ID.
*
* @param id - The ID of the block to retrieve.
* @param populate - An array of fields to populate in the retrieved block.
* @returns A Promise that resolves to the retrieved block.
*/
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
): Promise<Block | BlockFull> {
const doc = this.canPopulate(populate, [
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
'attachedToBlock',
])
? await this.blockService.findOneAndPopulate(id)
: await this.blockService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Block by id ${id}`);
throw new NotFoundException(`Block with ID ${id} not found`);
}
return doc;
}
/**
* Creates a new block.
*
* @param block - The data of the block to be created.
* @returns A Promise that resolves to the created block.
*/
@CsrfCheck(true)
@Post()
async create(@Body() block: BlockCreateDto): Promise<Block> {
this.validate({
dto: block,
allowedIds: {
category: (await this.categoryService.findOne(block.category))?.id,
attachedBlock: (await this.blockService.findOne(block.attachedBlock))
?.id,
nextBlocks: (
await this.blockService.find({
_id: {
$in: block.nextBlocks,
},
})
).map(({ id }) => id),
assign_labels: (
await this.labelService.find({
_id: {
$in: block.assign_labels,
},
})
).map(({ id }) => id),
trigger_labels: (
await this.labelService.find({
_id: {
$in: block.trigger_labels,
},
})
).map(({ id }) => id),
},
});
// TODO: the validate function doesn't support nested objects, we need to refactor it to support nested objects
if (block.options?.assignTo) {
const user = await this.userService.findOne(block.options.assignTo);
if (!user) {
throw new BadRequestException(
`options.assignTo with ID ${block.options.assignTo} not found`,
);
}
}
return await this.blockService.create(block);
}
/**
* Updates a specific block by ID.
*
* @param id - The ID of the block to update.
* @param blockUpdate - The data to update the block with.
* @returns A Promise that resolves to the updated block if successful.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() blockUpdate: BlockUpdateDto,
): Promise<Block> {
const result = await this.blockService.updateOne(id, blockUpdate);
if (!result) {
this.logger.warn(`Unable to update Block by id ${id}`);
throw new NotFoundException(`Block with ID ${id} not found`);
}
return result;
}
/**
* Deletes a specific block by ID.
*
* @param id - The ID of the block to delete.
* @returns A Promise that resolves to the deletion result.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
const result = await this.blockService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete Block by id ${id}`);
throw new NotFoundException(`Block with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,210 @@
/*
* 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 { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { ContentModel } from '@/cms/schemas/content.schema';
import { ContentService } from '@/cms/services/content.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
import {
categoryFixtures,
installCategoryFixtures,
} from '@/utils/test/fixtures/category';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { Category, CategoryModel } from './../schemas/category.schema';
import { CategoryController } from './category.controller';
import { CategoryCreateDto, CategoryUpdateDto } from '../dto/category.dto';
import { BlockRepository } from '../repositories/block.repository';
import { CategoryRepository } from '../repositories/category.repository';
import { BlockModel } from '../schemas/block.schema';
import { LabelModel } from '../schemas/label.schema';
import { BlockService } from '../services/block.service';
import { CategoryService } from '../services/category.service';
describe('CategoryController', () => {
let categoryController: CategoryController;
let categoryService: CategoryService;
let category: Category;
let categoryToDelete: Category;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [CategoryController],
imports: [
rootMongooseTestModule(installCategoryFixtures),
MongooseModule.forFeature([
BlockModel,
LabelModel,
CategoryModel,
ContentModel,
AttachmentModel,
]),
],
providers: [
BlockRepository,
CategoryRepository,
ContentRepository,
AttachmentRepository,
BlockService,
CategoryService,
ContentService,
AttachmentService,
{
provide: PluginService,
useValue: {},
},
LoggerService,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({})),
},
},
{
provide: BlockService,
useValue: {
findOne: jest.fn(),
},
},
EventEmitter2,
],
}).compile();
categoryService = module.get<CategoryService>(CategoryService);
categoryController = module.get<CategoryController>(CategoryController);
category = await categoryService.findOne({ label: 'test category 1' });
categoryToDelete = await categoryService.findOne({
label: 'test category 2',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findPage', () => {
it('should return an array of categories', async () => {
const pageQuery = getPageQuery<Category>();
const result = await categoryController.findPage(pageQuery, {});
expect(result).toEqualPayload(categoryFixtures.sort(sortRowsBy));
});
});
describe('count', () => {
it('should count categories', async () => {
jest.spyOn(categoryService, 'count');
const result = await categoryController.filterCount();
expect(categoryService.count).toHaveBeenCalled();
expect(result).toEqual({ count: categoryFixtures.length });
});
});
describe('findOne', () => {
it('should return the existing category', async () => {
jest.spyOn(categoryService, 'findOne');
const category = await categoryService.findOne({
label: 'test category 1',
});
const result = await categoryController.findOne(category.id);
expect(categoryService.findOne).toHaveBeenCalledWith(category.id);
expect(result).toEqualPayload({
...categoryFixtures.find(({ label }) => label === 'test category 1'),
});
});
});
describe('create', () => {
it('should return created category', async () => {
jest.spyOn(categoryService, 'create');
const categoryCreateDto: CategoryCreateDto = {
label: 'categoryLabel2',
builtin: true,
zoom: 100,
offset: [0, 0],
};
const result = await categoryController.create(categoryCreateDto);
expect(categoryService.create).toHaveBeenCalledWith(categoryCreateDto);
expect(result).toEqualPayload(categoryCreateDto);
});
});
describe('deleteOne', () => {
it('should delete a category by id', async () => {
jest.spyOn(categoryService, 'deleteOne');
const result = await categoryController.deleteOne(categoryToDelete.id);
expect(categoryService.deleteOne).toHaveBeenCalledWith(
categoryToDelete.id,
);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
it('should throw a NotFoundException when attempting to delete a category by id', async () => {
jest.spyOn(categoryService, 'deleteOne');
const result = categoryController.deleteOne(categoryToDelete.id);
expect(categoryService.deleteOne).toHaveBeenCalledWith(
categoryToDelete.id,
);
await expect(result).rejects.toThrow(
new NotFoundException(
`Category with ID ${categoryToDelete.id} not found`,
),
);
});
});
describe('updateOne', () => {
const categoryUpdateDto: CategoryUpdateDto = {
builtin: false,
};
it('should return updated category', async () => {
jest.spyOn(categoryService, 'updateOne');
const result = await categoryController.updateOne(
category.id,
categoryUpdateDto,
);
expect(categoryService.updateOne).toHaveBeenCalledWith(
category.id,
categoryUpdateDto,
);
expect(result).toEqualPayload({
...categoryFixtures.find(({ label }) => label === 'test category 1'),
...categoryUpdateDto,
});
});
});
});

View File

@@ -0,0 +1,143 @@
/*
* 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,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
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 { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { CategoryCreateDto, CategoryUpdateDto } from '../dto/category.dto';
import { Category } from '../schemas/category.schema';
import { BlockService } from '../services/block.service';
import { CategoryService } from '../services/category.service';
@UseInterceptors(CsrfInterceptor)
@Controller('category')
export class CategoryController extends BaseController<Category> {
constructor(
private readonly categoryService: CategoryService,
private readonly blockService: BlockService,
private readonly logger: LoggerService,
) {
super(categoryService);
}
/**
* Retrieves a paginated list of categories based on provided filters and pagination settings.
* @param pageQuery - The pagination settings.
* @param filters - The filters to apply to the category search.
* @returns A Promise that resolves to a paginated list of categories.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Category>,
@Query(new SearchFilterPipe<Category>({ allowedFields: ['label'] }))
filters: TFilterQuery<Category>,
) {
return await this.categoryService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of categories.
* @returns A promise that resolves to an object representing the filtered number of categories.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Category>({
allowedFields: ['label'],
}),
)
filters?: TFilterQuery<Category>,
) {
return await this.count(filters);
}
/**
* Finds a category by its ID.
* @param id - The ID of the category to find.
* @returns A Promise that resolves to the found category.
*/
@Get(':id')
async findOne(@Param('id') id: string): Promise<Category> {
const doc = await this.categoryService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Category by id ${id}`);
throw new NotFoundException(`Category with ID ${id} not found`);
}
return doc;
}
/**
* Creates a new category.
* @param category - The data of the category to be created.
* @returns A Promise that resolves to the created category.
*/
@CsrfCheck(true)
@Post()
async create(@Body() category: CategoryCreateDto): Promise<Category> {
return await this.categoryService.create(category);
}
/**
* Updates an existing category.
* @param id - The ID of the category to be updated.
* @param categoryUpdate - The updated data for the category.
* @returns A Promise that resolves to the updated category.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() categoryUpdate: CategoryUpdateDto,
): Promise<Category> {
const result = await this.categoryService.updateOne(id, categoryUpdate);
if (!result) {
this.logger.warn(`Unable to update Category by id ${id}`);
throw new NotFoundException(`Category with ID ${id} not found`);
}
return result;
}
/**
* Deletes a category by its ID.
* @param id - The ID of the category to be deleted.
* @returns A Promise that resolves to the deletion result.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
const result = await this.categoryService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete Category by id ${id}`);
throw new NotFoundException(`Category with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,174 @@
/*
* 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 { NotFoundException } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import {
contextVarFixtures,
installContextVarFixtures,
} from '@/utils/test/fixtures/contextvar';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { ContextVarController } from './context-var.controller';
import {
ContextVarCreateDto,
ContextVarUpdateDto,
} from '../dto/context-var.dto';
import { ContextVarRepository } from '../repositories/context-var.repository';
import { ContextVarModel, ContextVar } from '../schemas/context-var.schema';
import { ContextVarService } from '../services/context-var.service';
describe('ContextVarController', () => {
let contextVarController: ContextVarController;
let contextVarService: ContextVarService;
let contextVar: ContextVar;
let contextVarToDelete: ContextVar;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [ContextVarController],
imports: [
rootMongooseTestModule(installContextVarFixtures),
MongooseModule.forFeature([ContextVarModel]),
],
providers: [LoggerService, ContextVarService, ContextVarRepository],
}).compile();
contextVarController =
module.get<ContextVarController>(ContextVarController);
contextVarService = module.get<ContextVarService>(ContextVarService);
contextVar = await contextVarService.findOne({
label: 'test context var 1',
});
contextVarToDelete = await contextVarService.findOne({
label: 'test context var 2',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count the contextVars', async () => {
jest.spyOn(contextVarService, 'count');
const result = await contextVarController.filterCount();
expect(contextVarService.count).toHaveBeenCalled();
expect(result).toEqual({ count: contextVarFixtures.length });
});
});
describe('findPage', () => {
it('should return an array of contextVars', async () => {
const pageQuery = getPageQuery<ContextVar>();
jest.spyOn(contextVarService, 'findPage');
const result = await contextVarController.findPage(pageQuery, {});
expect(contextVarService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(contextVarFixtures.sort(sortRowsBy));
});
});
describe('findOne', () => {
it('should return the existing contextVar', async () => {
jest.spyOn(contextVarService, 'findOne');
const result = await contextVarController.findOne(contextVar.id);
expect(contextVarService.findOne).toHaveBeenCalledWith(contextVar.id);
expect(result).toEqualPayload(
contextVarFixtures.find(({ label }) => label === contextVar.label),
);
});
});
describe('create', () => {
it('should return created contextVar', async () => {
jest.spyOn(contextVarService, 'create');
const contextVarCreateDto: ContextVarCreateDto = {
label: 'contextVarLabel2',
name: 'test_add',
};
const result = await contextVarController.create(contextVarCreateDto);
expect(contextVarService.create).toHaveBeenCalledWith(
contextVarCreateDto,
);
expect(result).toEqualPayload(contextVarCreateDto);
});
});
describe('deleteOne', () => {
it('should delete a contextVar by id', async () => {
jest.spyOn(contextVarService, 'deleteOne');
const result = await contextVarController.deleteOne(
contextVarToDelete.id,
);
expect(contextVarService.deleteOne).toHaveBeenCalledWith(
contextVarToDelete.id,
);
expect(result).toEqual({
acknowledged: true,
deletedCount: 1,
});
});
it('should throw a NotFoundException when attempting to delete a contextVar by id', async () => {
await expect(
contextVarController.deleteOne(contextVarToDelete.id),
).rejects.toThrow(
new NotFoundException(
`ContextVar with ID ${contextVarToDelete.id} not found`,
),
);
});
});
describe('updateOne', () => {
const contextVarUpdatedDto: ContextVarUpdateDto = {
name: 'updated_context_var_name',
};
it('should return updated contextVar', async () => {
jest.spyOn(contextVarService, 'updateOne');
const result = await contextVarController.updateOne(
contextVar.id,
contextVarUpdatedDto,
);
expect(contextVarService.updateOne).toHaveBeenCalledWith(
contextVar.id,
contextVarUpdatedDto,
);
expect(result).toEqualPayload({
...contextVarFixtures.find(({ label }) => label === contextVar.label),
...contextVarUpdatedDto,
});
});
it('should throw a NotFoundException when attempting to update an non existing contextVar by id', async () => {
await expect(
contextVarController.updateOne(
contextVarToDelete.id,
contextVarUpdatedDto,
),
).rejects.toThrow(
new NotFoundException(
`ContextVar with ID ${contextVarToDelete.id} not found`,
),
);
});
});
});

View File

@@ -0,0 +1,148 @@
/*
* 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,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
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 { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import {
ContextVarCreateDto,
ContextVarUpdateDto,
} from '../dto/context-var.dto';
import { ContextVar } from '../schemas/context-var.schema';
import { ContextVarService } from '../services/context-var.service';
@UseInterceptors(CsrfInterceptor)
@Controller('contextvar')
export class ContextVarController extends BaseController<ContextVar> {
constructor(
private readonly contextVarService: ContextVarService,
private readonly logger: LoggerService,
) {
super(contextVarService);
}
/**
* Finds a page of contextVars based on specified filters and pagination parameters.
* @param pageQuery - The pagination parameters.
* @param filters - The filters to apply.
* @returns A Promise that resolves to an array of contextVars.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<ContextVar>,
@Query(new SearchFilterPipe<ContextVar>({ allowedFields: ['label'] }))
filters: TFilterQuery<ContextVar>,
): Promise<ContextVar[]> {
return await this.contextVarService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of contextVars.
* @returns A promise that resolves to an object representing the filtered number of contextVars.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<ContextVar>({
allowedFields: ['label'],
}),
)
filters?: TFilterQuery<ContextVar>,
) {
return await this.count(filters);
}
/**
* Retrieves a contextVar by its ID.
* @param id - The ID of the contextVar to retrieve.
* @returns A Promise that resolves to the retrieved contextVar.
*/
@Get(':id')
async findOne(@Param('id') id: string): Promise<ContextVar> {
const doc = await this.contextVarService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find ContextVar by id ${id}`);
throw new NotFoundException(`ContextVar with ID ${id} not found`);
}
return doc;
}
/**
* Creates a new contextVar.
* @param contextVar - The data of the contextVar to create.
* @returns A Promise that resolves to the created contextVar.
*/
@CsrfCheck(true)
@Post()
async create(@Body() contextVar: ContextVarCreateDto): Promise<ContextVar> {
return await this.contextVarService.create(contextVar);
}
/**
* Updates an existing contextVar.
* @param id - The ID of the contextVar to update.
* @param contextVarUpdate - The updated data for the contextVar.
* @returns A Promise that resolves to the updated contextVar.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() contextVarUpdate: ContextVarUpdateDto,
): Promise<ContextVar> {
const result = await this.contextVarService.updateOne(id, contextVarUpdate);
if (!result) {
this.logger.warn(`Unable to update ContextVar by id ${id}`);
throw new NotFoundException(`ContextVar with ID ${id} not found`);
}
return result;
}
/**
* Deletes a contextVar.
* @param id - The ID of the contextVar to delete.
* @returns A Promise that resolves to a DeleteResult.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
const result = await this.contextVarService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete ContextVar by id ${id}`);
throw new NotFoundException(`ContextVar with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,237 @@
/*
* 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 { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { UserModel } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import { labelFixtures } from '@/utils/test/fixtures/label';
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { LabelController } from './label.controller';
import { LabelCreateDto, LabelUpdateDto } from '../dto/label.dto';
import { LabelRepository } from '../repositories/label.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Label, LabelModel } from '../schemas/label.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
import { LabelService } from '../services/label.service';
import { SubscriberService } from '../services/subscriber.service';
describe('LabelController', () => {
let labelController: LabelController;
let labelService: LabelService;
let label: Label;
let labelToDelete: Label;
let subscriberService: SubscriberService;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [LabelController],
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([
LabelModel,
UserModel,
RoleModel,
PermissionModel,
SubscriberModel,
AttachmentModel,
]),
],
providers: [
LoggerService,
LabelController,
LabelService,
LabelRepository,
UserService,
UserRepository,
RoleService,
RoleRepository,
SubscriberService,
SubscriberRepository,
EventEmitter2,
AttachmentService,
AttachmentRepository,
],
}).compile();
labelService = module.get<LabelService>(LabelService);
subscriberService = module.get<SubscriberService>(SubscriberService);
labelController = module.get<LabelController>(LabelController);
label = await labelService.findOne({ name: 'TEST_TITLE_1' });
labelToDelete = await labelService.findOne({
name: 'TEST_TITLE_2',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count labels', async () => {
jest.spyOn(labelService, 'count');
const result = await labelController.filterCount();
expect(labelService.count).toHaveBeenCalled();
expect(result).toEqual({ count: labelFixtures.length });
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Label>();
it('should find labels', async () => {
jest.spyOn(labelService, 'findPage');
const result = await labelController.findPage(pageQuery, [], {});
const labelsWithBuiltin = labelFixtures.map((labelFixture) => ({
...labelFixture,
}));
expect(labelService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(labelsWithBuiltin.sort(sortRowsBy), [
...IGNORED_TEST_FIELDS,
'nextBlocks',
]);
});
it('should find labels, and foreach label populate its corresponding users', async () => {
jest.spyOn(labelService, 'findPageAndPopulate');
const result = await labelController.findPage(pageQuery, ['users'], {});
const allLabels = await labelService.findAll();
const allSubscribers = await subscriberService.findAll();
const labelsWithUsers = allLabels.map((label) => ({
...label,
users: allSubscribers,
}));
expect(labelService.findPageAndPopulate).toHaveBeenCalledWith(
{},
pageQuery,
);
expect(result).toEqualPayload(labelsWithUsers.sort(sortRowsBy));
});
});
describe('findOne', () => {
it('should find one label by id', async () => {
jest.spyOn(labelService, 'findOne');
const result = await labelController.findOne(label.id, []);
expect(labelService.findOne).toHaveBeenCalledWith(label.id);
expect(result).toEqualPayload(
{
...labelFixtures.find(({ name }) => name === label.name),
},
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
);
});
it('should find one label by id, and populate its corresponding users', async () => {
jest.spyOn(labelService, 'findOneAndPopulate');
const result = await labelController.findOne(label.id, ['users']);
const users = await subscriberService.findAll();
expect(labelService.findOneAndPopulate).toHaveBeenCalledWith(label.id);
expect(result).toEqualPayload(
{
...labelFixtures.find(({ name }) => name === label.name),
users,
},
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
);
});
});
describe('create', () => {
it('should create a label', async () => {
jest.spyOn(labelService, 'create');
const labelCreate: LabelCreateDto = {
title: 'Label2',
name: 'LABEL_2',
label_id: {
messenger: 'messenger',
offline: 'offline',
twitter: 'twitter',
dimelo: 'dimelo',
},
description: 'LabelDescription2',
};
const result = await labelController.create(labelCreate);
expect(labelService.create).toHaveBeenCalledWith(labelCreate);
expect(result).toEqualPayload({ ...labelCreate, builtin: false });
});
});
describe('deleteOne', () => {
it('should delete one label by id', async () => {
jest.spyOn(labelService, 'deleteOne');
const result = await labelController.deleteOne(labelToDelete.id);
expect(labelService.deleteOne).toHaveBeenCalledWith(labelToDelete.id);
expect(result).toEqual({
acknowledged: true,
deletedCount: 1,
});
});
it('should throw a NotFoundException when attempting to delete a non existing label by id', async () => {
await expect(labelController.deleteOne(labelToDelete.id)).rejects.toThrow(
new NotFoundException(`Label with ID ${labelToDelete.id} not found`),
);
});
});
describe('updateOne', () => {
const labelUpdateDto: LabelUpdateDto = {
description: 'test description 1',
};
it('should update a label by id', async () => {
jest.spyOn(labelService, 'updateOne');
const result = await labelController.updateOne(label.id, labelUpdateDto);
expect(labelService.updateOne).toHaveBeenCalledWith(
label.id,
labelUpdateDto,
);
expect(result).toEqualPayload(
{
...labelFixtures.find(({ name }) => name === label.name),
...labelUpdateDto,
},
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
);
});
it('should throw a NotFoundException when attempting to update a non existing label by id', async () => {
await expect(
labelController.updateOne(labelToDelete.id, labelUpdateDto),
).rejects.toThrow(
new NotFoundException(`Label with ID ${labelToDelete.id} not found`),
);
});
});
});

View File

@@ -0,0 +1,124 @@
/*
* 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,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Post,
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 { BaseController } from '@/utils/generics/base-controller';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { LabelCreateDto, LabelUpdateDto } from '../dto/label.dto';
import { Label, LabelStub } from '../schemas/label.schema';
import { LabelService } from '../services/label.service';
@UseInterceptors(CsrfInterceptor)
@Controller('label')
export class LabelController extends BaseController<Label, LabelStub> {
constructor(
private readonly labelService: LabelService,
private readonly logger: LoggerService,
) {
super(labelService);
}
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Label>,
@Query(PopulatePipe)
populate: string[],
@Query(new SearchFilterPipe<Label>({ allowedFields: ['name', 'title'] }))
filters: TFilterQuery<Label>,
) {
return this.canPopulate(populate, ['users'])
? await this.labelService.findPageAndPopulate(filters, pageQuery)
: await this.labelService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of labels.
* @returns A promise that resolves to an object representing the filtered number of labels.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Label>({
allowedFields: ['name', 'title'],
}),
)
filters?: TFilterQuery<Label>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
) {
const doc = this.canPopulate(populate, ['users'])
? await this.labelService.findOneAndPopulate(id)
: await this.labelService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Label by id ${id}`);
throw new NotFoundException(`Label with ID ${id} not found`);
}
return doc;
}
@CsrfCheck(true)
@Post()
async create(@Body() label: LabelCreateDto) {
return await this.labelService.create(label);
}
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() labelUpdate: LabelUpdateDto,
) {
const result = await this.labelService.updateOne(id, labelUpdate);
if (!result) {
this.logger.warn(`Unable to update Label by id ${id}`);
throw new NotFoundException(`Label with ID ${id} not found`);
}
return result;
}
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string) {
const result = await this.labelService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete Label by id ${id}`);
throw new NotFoundException(`Label with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,223 @@
/*
* 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 { 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 { MenuRepository } from '@/cms/repositories/menu.repository';
import { MenuModel } from '@/cms/schemas/menu.schema';
import { MenuService } from '@/cms/services/menu.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingService } from '@/setting/services/setting.service';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { User, UserModel } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import {
installMessageFixtures,
messageFixtures,
} from '@/utils/test/fixtures/message';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MessageController } from './message.controller';
import { MessageRepository } from '../repositories/message.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Message, MessageModel } from '../schemas/message.schema';
import { Subscriber, SubscriberModel } from '../schemas/subscriber.schema';
import { MessageService } from '../services/message.service';
import { SubscriberService } from '../services/subscriber.service';
describe('MessageController', () => {
let messageController: MessageController;
let messageService: MessageService;
let subscriberService: SubscriberService;
let userService: UserService;
let sender: Subscriber;
let recipient: Subscriber;
let user: User;
let message: Message;
let allMessages: Message[];
let allUsers: User[];
let allSubscribers: Subscriber[];
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [MessageController],
imports: [
rootMongooseTestModule(installMessageFixtures),
MongooseModule.forFeature([
SubscriberModel,
MessageModel,
UserModel,
RoleModel,
PermissionModel,
AttachmentModel,
MenuModel,
]),
],
providers: [
MessageController,
MessageRepository,
MessageService,
SubscriberService,
UserService,
UserRepository,
RoleService,
RoleRepository,
SubscriberRepository,
ChannelService,
AttachmentService,
AttachmentRepository,
MenuService,
MenuRepository,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: NlpService,
useValue: {
getNLP: jest.fn(() => undefined),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({})),
},
},
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
],
}).compile();
messageService = module.get<MessageService>(MessageService);
userService = module.get<UserService>(UserService);
subscriberService = module.get<SubscriberService>(SubscriberService);
messageController = module.get<MessageController>(MessageController);
message = await messageService.findOne({ mid: 'mid-1' });
sender = await subscriberService.findOne(message.sender);
recipient = await subscriberService.findOne(message.recipient);
user = await userService.findOne(message.sentBy);
allSubscribers = await subscriberService.findAll();
allUsers = await userService.findAll();
allMessages = await messageService.findAll();
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count messages', async () => {
jest.spyOn(messageService, 'count');
const result = await messageController.filterCount();
expect(messageService.count).toHaveBeenCalled();
expect(result).toEqual({ count: messageFixtures.length });
});
});
describe('findOne', () => {
it('should find message by id, and populate its corresponding sender and recipient', async () => {
jest.spyOn(messageService, 'findOneAndPopulate');
const result = await messageController.findOne(message.id, [
'sender',
'recipient',
]);
expect(messageService.findOneAndPopulate).toHaveBeenCalledWith(
message.id,
);
expect(result).toEqualPayload({
...messageFixtures.find(({ mid }) => mid === message.mid),
sender,
recipient,
sentBy: user.id,
});
});
it('should find message by id', async () => {
jest.spyOn(messageService, 'findOne');
const result = await messageController.findOne(message.id, []);
expect(messageService.findOne).toHaveBeenCalledWith(message.id);
expect(result).toEqualPayload({
...messageFixtures.find(({ mid }) => mid === message.mid),
sender: sender.id,
recipient: recipient.id,
sentBy: user.id,
});
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Message>();
it('should find messages', async () => {
jest.spyOn(messageService, 'findPage');
const result = await messageController.findPage(pageQuery, [], {});
const messagesWithSenderAndRecipient = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']).id,
recipient: allSubscribers.find(({ id }) => id === message['recipient'])
.id,
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
expect(messageService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(messagesWithSenderAndRecipient);
});
it('should find messages, and foreach message populate the corresponding sender and recipient', async () => {
jest.spyOn(messageService, 'findPageAndPopulate');
const result = await messageController.findPage(
pageQuery,
['sender', 'recipient'],
{},
);
const messages = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']),
recipient: allSubscribers.find(({ id }) => id === message['recipient']),
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
expect(messageService.findPageAndPopulate).toHaveBeenCalledWith(
{},
pageQuery,
);
expect(result).toEqualPayload(messages);
});
});
});

View File

@@ -0,0 +1,169 @@
/*
* 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 {
BadRequestException,
Body,
Controller,
Get,
NotFoundException,
Param,
Post,
Query,
Req,
UseInterceptors,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { Request } from 'express'; // Import the Express request and response types
import { TFilterQuery } from 'mongoose';
import { ChannelService } from '@/channel/channel.service';
import { GenericEventWrapper } from '@/channel/lib/EventWrapper';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { BaseController } from '@/utils/generics/base-controller';
import { BaseSchema } from '@/utils/generics/base-schema';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { MessageCreateDto } from '../dto/message.dto';
import { Message, MessageStub } from '../schemas/message.schema';
import {
OutgoingMessage,
OutgoingMessageFormat,
StdOutgoingEnvelope,
StdOutgoingMessage,
StdOutgoingTextMessage,
} from '../schemas/types/message';
import { MessageService } from '../services/message.service';
import { SubscriberService } from '../services/subscriber.service';
@UseInterceptors(CsrfInterceptor)
@Controller('message')
export class MessageController extends BaseController<Message, MessageStub> {
constructor(
private readonly messageService: MessageService,
private readonly subscriberService: SubscriberService,
private readonly channelService: ChannelService,
private readonly logger: LoggerService,
private readonly eventEmitter: EventEmitter2,
) {
super(messageService);
}
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Message>,
@Query(PopulatePipe)
populate: string[],
@Query(
new SearchFilterPipe<Message>({ allowedFields: ['recipient', 'sender'] }),
)
filters: TFilterQuery<Message>,
) {
return this.canPopulate(populate, ['recipient', 'sender', 'sentBy'])
? await this.messageService.findPageAndPopulate(filters, pageQuery)
: await this.messageService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of messages.
* @returns A promise that resolves to an object representing the filtered number of messages.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Message>({
allowedFields: ['recipient', 'sender'],
}),
)
filters?: TFilterQuery<Message>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
) {
const doc = this.canPopulate(populate, ['recipient', 'sender', 'sentBy'])
? await this.messageService.findOneAndPopulate(id)
: await this.messageService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Message by id ${id}`);
throw new NotFoundException(`Message with ID ${id} not found`);
}
return doc;
}
@CsrfCheck(true)
@Post()
async create(@Body() messageDto: MessageCreateDto, @Req() req: Request) {
//TODO : Investigate if recipient and inReplyTo should be updated to required in dto
if (!messageDto.recipient || !messageDto.inReplyTo) {
throw new BadRequestException('MessageController send : invalid params');
}
const subscriber = await this.subscriberService.findOne(
messageDto.recipient,
);
if (!subscriber) {
this.logger.warn(
`Unable to find subscriber by id ${messageDto.recipient}`,
);
throw new NotFoundException(
`Subscriber with ID ${messageDto.recipient} not found`,
);
}
if (!this.channelService.findChannel(subscriber?.channel.name)) {
throw new BadRequestException(`Subscriber channel not found`);
}
const envelope: StdOutgoingEnvelope = {
format: OutgoingMessageFormat.text,
message: messageDto.message as StdOutgoingTextMessage,
};
const channelHandler = this.channelService.getChannelHandler(
subscriber.channel.name,
);
const event = new GenericEventWrapper(channelHandler, {
senderId: subscriber.foreign_id,
messageId: messageDto.inReplyTo,
});
event.setSender(subscriber);
try {
const { mid } = await channelHandler.sendMessage(event, envelope, {}, {});
// Trigger sent message event
const sentMessage: Omit<OutgoingMessage, keyof BaseSchema> = {
mid,
recipient: subscriber.id,
message: messageDto.message as StdOutgoingMessage,
sentBy: req.session?.passport?.user.id,
read: false,
delivery: false,
};
this.eventEmitter.emit('hook:chatbot:sent', sentMessage);
return {
success: true,
};
} catch (err) {
this.logger.debug('MessageController send : Unable to send message', err);
throw new BadRequestException(
'MessageController send : unable to send message',
);
}
}
}

View File

@@ -0,0 +1,191 @@
/*
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { UserModel, User } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import {
installSubscriberFixtures,
subscriberFixtures,
} from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { SocketEventDispatcherService } from '@/websocket/services/socket-event-dispatcher.service';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { UserService } from './../../user/services/user.service';
import { LabelService } from './../services/label.service';
import { SubscriberController } from './subscriber.controller';
import { LabelRepository } from '../repositories/label.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { LabelModel, Label } from '../schemas/label.schema';
import { SubscriberModel, Subscriber } from '../schemas/subscriber.schema';
import { SubscriberService } from '../services/subscriber.service';
describe('SubscriberController', () => {
let subscriberController: SubscriberController;
let subscriberService: SubscriberService;
let labelService: LabelService;
let userService: UserService;
let subscriber: Subscriber;
let allLabels: Label[];
let allSubscribers: Subscriber[];
let allUsers: User[];
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [SubscriberController],
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([
SubscriberModel,
LabelModel,
UserModel,
RoleModel,
PermissionModel,
AttachmentModel,
]),
],
providers: [
LoggerService,
SubscriberRepository,
SubscriberService,
LabelService,
LabelRepository,
UserService,
WebsocketGateway,
SocketEventDispatcherService,
UserRepository,
RoleService,
RoleRepository,
EventEmitter2,
AttachmentService,
AttachmentRepository,
],
}).compile();
subscriberService = module.get<SubscriberService>(SubscriberService);
labelService = module.get<LabelService>(LabelService);
userService = module.get<UserService>(UserService);
subscriberController =
module.get<SubscriberController>(SubscriberController);
subscriber = await subscriberService.findOne({
first_name: 'Jhon',
});
allLabels = await labelService.findAll();
allSubscribers = await subscriberService.findAll();
allUsers = await userService.findAll();
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count subscribers', async () => {
jest.spyOn(subscriberService, 'count');
const result = await subscriberController.filterCount();
expect(subscriberService.count).toHaveBeenCalled();
expect(result).toEqual({ count: subscriberFixtures.length });
});
});
describe('findOne', () => {
it('should find one subscriber by id', async () => {
jest.spyOn(subscriberService, 'findOne');
const result = await subscriberService.findOne(subscriber.id);
const labelIDs = allLabels
.filter((label) => subscriber.labels.includes(label.id))
.map(({ id }) => id);
expect(subscriberService.findOne).toHaveBeenCalledWith(subscriber.id);
expect(result).toEqualPayload({
...subscriberFixtures.find(
({ first_name }) => first_name === subscriber.first_name,
),
labels: labelIDs,
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id).id,
});
});
it('should find one subscriber by id, and populate its corresponding labels', async () => {
jest.spyOn(subscriberService, 'findOneAndPopulate');
const result = await subscriberController.findOne(subscriber.id, [
'labels',
]);
expect(subscriberService.findOneAndPopulate).toHaveBeenCalledWith(
subscriber.id,
);
expect(result).toEqualPayload({
...subscriberFixtures.find(
({ first_name }) => first_name === subscriber.first_name,
),
labels: allLabels.filter((label) =>
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
});
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Subscriber>();
it('should find subscribers', async () => {
jest.spyOn(subscriberService, 'findPage');
const result = await subscriberController.findPage(pageQuery, [], {});
const subscribersWithIds = allSubscribers.map(({ labels, ...rest }) => ({
...rest,
labels: allLabels
.filter((label) => labels.includes(label.id))
.map(({ id }) => id),
}));
expect(subscriberService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(subscribersWithIds.sort(sortRowsBy));
});
it('should find subscribers, and foreach subscriber populate the corresponding labels', async () => {
jest.spyOn(subscriberService, 'findPageAndPopulate');
const result = await subscriberController.findPage(
pageQuery,
['labels'],
{},
);
const subscribersWithLabels = allSubscribers.map(
({ labels, ...rest }) => ({
...rest,
labels: allLabels.filter((label) => labels.includes(label.id)),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
}),
);
expect(subscriberService.findPageAndPopulate).toHaveBeenCalledWith(
{},
pageQuery,
);
expect(result).toEqualPayload(subscribersWithLabels.sort(sortRowsBy));
});
});
});

View File

@@ -0,0 +1,146 @@
/*
* 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,
StreamableFile,
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 { Roles } from '@/utils/decorators/roles.decorator';
import { BaseController } from '@/utils/generics/base-controller';
import { generateInitialsAvatar } from '@/utils/helpers/avatar';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { SubscriberUpdateDto } from '../dto/subscriber.dto';
import { Subscriber, SubscriberStub } from '../schemas/subscriber.schema';
import { SubscriberService } from '../services/subscriber.service';
@UseInterceptors(CsrfInterceptor)
@Controller('subscriber')
export class SubscriberController extends BaseController<
Subscriber,
SubscriberStub
> {
constructor(
private readonly subscriberService: SubscriberService,
private readonly logger: LoggerService,
) {
super(subscriberService);
}
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Subscriber>,
@Query(PopulatePipe)
populate: string[],
@Query(
new SearchFilterPipe<Subscriber>({
// TODO : Check if the field email should be added to Subscriber schema
allowedFields: [
'first_name',
'last_name',
'assignedTo',
'labels',
'channel.name',
],
}),
)
filters: TFilterQuery<Subscriber>,
) {
return this.canPopulate(populate, ['labels', 'assignedTo', 'avatar'])
? await this.subscriberService.findPageAndPopulate(filters, pageQuery)
: await this.subscriberService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of subscribers.
* @returns A promise that resolves to an object representing the filtered number of subscribers.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Subscriber>({
allowedFields: [
'first_name',
'last_name',
'assignedTo',
'labels',
'channel.name',
],
}),
)
filters?: TFilterQuery<Subscriber>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
) {
const doc = this.canPopulate(populate, ['labels', 'assignedTo', 'avatar'])
? await this.subscriberService.findOneAndPopulate(id)
: await this.subscriberService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Subscriber by id ${id}`);
throw new NotFoundException(`Subscriber with ID ${id} not found`);
}
return doc;
}
@Roles('public')
@Get(':foreign_id/profile_pic')
async findProfilePic(
@Param('foreign_id') foreign_id: string,
): Promise<StreamableFile> {
try {
const pic = await this.subscriberService.findProfilePic(foreign_id);
return pic;
} catch (e) {
const [subscriber] = await this.subscriberService.find({ foreign_id });
if (subscriber) {
return generateInitialsAvatar(subscriber);
} else {
throw new NotFoundException(
`Subscriber with ID ${foreign_id} not found`,
);
}
}
}
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() subscriberUpdate: SubscriberUpdateDto,
) {
const result = await this.subscriberService.updateOne(id, subscriberUpdate);
if (!result) {
this.logger.warn(`Unable to update Subscriber by id ${id}`);
throw new NotFoundException(`Subscriber with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,205 @@
/*
* 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 { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
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 { ContentRepository } from '@/cms/repositories/content.repository';
import { MenuRepository } from '@/cms/repositories/menu.repository';
import { ContentModel } from '@/cms/schemas/content.schema';
import { MenuModel } from '@/cms/schemas/menu.schema';
import { ContentService } from '@/cms/services/content.service';
import { MenuService } from '@/cms/services/menu.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import {
installTranslationFixtures,
translationFixtures,
} from '@/utils/test/fixtures/translation';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MessageController } from './message.controller';
import { TranslationController } from './translation.controller';
import { TranslationUpdateDto } from '../dto/translation.dto';
import { BlockRepository } from '../repositories/block.repository';
import { MessageRepository } from '../repositories/message.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { TranslationRepository } from '../repositories/translation.repository';
import { BlockModel } from '../schemas/block.schema';
import { MessageModel } from '../schemas/message.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
import { Translation, TranslationModel } from '../schemas/translation.schema';
import { BlockService } from '../services/block.service';
import { MessageService } from '../services/message.service';
import { SubscriberService } from '../services/subscriber.service';
import { TranslationService } from '../services/translation.service';
describe('TranslationController', () => {
let translationController: TranslationController;
let translationService: TranslationService;
let translation: Translation;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [MessageController],
imports: [
rootMongooseTestModule(installTranslationFixtures),
MongooseModule.forFeature([
SubscriberModel,
TranslationModel,
MessageModel,
AttachmentModel,
MenuModel,
BlockModel,
ContentModel,
]),
],
providers: [
TranslationController,
TranslationService,
TranslationRepository,
MessageService,
MessageRepository,
SubscriberService,
SubscriberRepository,
ChannelService,
AttachmentService,
AttachmentRepository,
MenuService,
MenuRepository,
{
provide: NlpService,
useValue: {
getNLP: jest.fn(() => undefined),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({})),
},
},
BlockService,
BlockRepository,
ContentService,
ContentRepository,
{
provide: PluginService,
useValue: {},
},
EventEmitter2,
LoggerService,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
initDynamicTranslations: jest.fn(),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
],
}).compile();
translationService = module.get<TranslationService>(TranslationService);
translationController = module.get<TranslationController>(
TranslationController,
);
translation = await translationService.findOne({ str: 'Welcome' });
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count translations', async () => {
jest.spyOn(translationService, 'count');
const result = await translationController.filterCount();
expect(translationService.count).toHaveBeenCalled();
expect(result).toEqual({ count: translationFixtures.length });
});
});
describe('findOne', () => {
it('should find one translation by id', async () => {
jest.spyOn(translationService, 'findOne');
const result = await translationController.findOne(translation.id);
expect(translationService.findOne).toHaveBeenCalledWith(translation.id);
expect(result).toEqualPayload(
translationFixtures.find(({ str }) => str === translation.str),
);
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Translation>();
it('should find translations', async () => {
jest.spyOn(translationService, 'findPage');
const result = await translationController.findPage(pageQuery, {});
expect(translationService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(translationFixtures);
});
});
describe('updateOne', () => {
const translationUpdateDto: TranslationUpdateDto = {
str: 'Welcome !',
};
it('should update one translation by id', async () => {
jest.spyOn(translationService, 'updateOne');
const result = await translationController.updateOne(
translation.id,
translationUpdateDto,
);
expect(translationService.updateOne).toHaveBeenCalledWith(
translation.id,
translationUpdateDto,
);
expect(result).toEqualPayload({
...translationFixtures.find(({ str }) => str === translation.str),
...translationUpdateDto,
});
});
it('should throw a NotFoundException when attempting to update a translation by id', async () => {
jest.spyOn(translationService, 'updateOne');
await expect(
translationController.updateOne(NOT_FOUND_ID, translationUpdateDto),
).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -0,0 +1,142 @@
/*
* 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,
Post,
} 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 { SettingService } from '@/setting/services/setting.service';
import { BaseController } from '@/utils/generics/base-controller';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { TranslationUpdateDto } from '../dto/translation.dto';
import { Translation } from '../schemas/translation.schema';
import { TranslationService } from '../services/translation.service';
@UseInterceptors(CsrfInterceptor)
@Controller('translation')
export class TranslationController extends BaseController<Translation> {
constructor(
private readonly translationService: TranslationService,
private readonly settingService: SettingService,
private readonly logger: LoggerService,
) {
super(translationService);
}
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Translation>,
@Query(new SearchFilterPipe<Translation>({ allowedFields: ['str'] }))
filters: TFilterQuery<Translation>,
) {
return await this.translationService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of translations.
* @returns A promise that resolves to an object representing the filtered number of translations.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Translation>({
allowedFields: ['str'],
}),
)
filters?: TFilterQuery<Translation>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(@Param('id') id: string) {
const doc = await this.translationService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Translation by id ${id}`);
throw new NotFoundException(`Translation with ID ${id} not found`);
}
return doc;
}
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() translationUpdate: TranslationUpdateDto,
) {
const result = await this.translationService.updateOne(
id,
translationUpdate,
);
if (!result) {
this.logger.warn(`Unable to update Translation by id ${id}`);
throw new NotFoundException(`Translation with ID ${id} not found`);
}
return result;
}
/**
* Refresh translations : Add new strings and remove old ones
* @returns {Promise<any>}
*/
@CsrfCheck(true)
@Post('refresh')
async refresh(): Promise<any> {
const settings = await this.settingService.getSettings();
const languages = settings.nlp_settings.languages;
const defaultTrans: Translation['translations'] = languages.reduce(
(acc, curr) => {
acc[curr] = '';
return acc;
},
{} as { [key: string]: string },
);
// Scan Blocks
return this.translationService
.getAllBlockStrings()
.then(async (strings: string[]) => {
const settingStrings =
await this.translationService.getSettingStrings();
// Scan global settings
strings = strings.concat(settingStrings);
// Filter unique and not empty messages
strings = strings.filter((str, pos) => {
return str && strings.indexOf(str) == pos;
});
// Perform refresh
const queue = strings.map((str) =>
this.translationService.findOneOrCreate(
{ str },
{ str, translations: defaultTrans as any, translated: 100 },
),
);
return Promise.all(queue).then(() => {
// Purge non existing translations
return this.translationService.deleteMany({
str: { $nin: strings },
});
});
});
}
}

View File

@@ -0,0 +1,149 @@
/*
* 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,
OmitType,
PartialType,
} from '@nestjs/swagger';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import { CaptureVar } from '../schemas/types/capture-var';
import { BlockMessage } from '../schemas/types/message';
import { BlockOptions } from '../schemas/types/options';
import { Pattern } from '../schemas/types/pattern';
import { Position } from '../schemas/types/position';
import { IsMessage } from '../validation-rules/is-message';
import { IsPatternList } from '../validation-rules/is-pattern-list';
import { IsPosition } from '../validation-rules/is-position';
import { IsVarCapture } from '../validation-rules/is-valid-capture';
export class BlockCreateDto {
@ApiProperty({ description: 'Block name', type: String })
@IsNotEmpty()
@IsString()
name: string;
@ApiPropertyOptional({ description: 'Block patterns', type: Array })
@IsOptional()
@IsPatternList({ message: 'Patterns list is invalid' })
patterns?: Pattern[] = [];
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Trigger label must be a valid objectId' })
trigger_labels?: string[] = [];
@ApiPropertyOptional({ description: 'Block assign labels', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Assign label must be a valid objectId' })
assign_labels?: string[] = [];
@ApiPropertyOptional({ description: 'Block trigger channels', type: Array })
@IsOptional()
@IsArray()
trigger_channels?: string[] = [];
@ApiPropertyOptional({ description: 'Block options', type: Object })
@IsOptional()
@IsObject()
options?: BlockOptions;
@ApiProperty({ description: 'Block message', type: Object })
@IsNotEmpty()
@IsMessage({ message: 'Message is invalid' })
message: BlockMessage;
@ApiPropertyOptional({ description: 'next blocks', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Next block must be a valid objectId' })
nextBlocks?: string[];
@ApiPropertyOptional({ description: 'attached blocks', type: String })
@IsOptional()
@IsString()
@IsObjectId({
message: 'Attached block must be a valid objectId',
})
attachedBlock?: string;
@ApiProperty({ description: 'Block category', type: String })
@IsNotEmpty()
@IsString()
@IsObjectId({ message: 'Category must be a valid objectId' })
category: string;
@ApiPropertyOptional({
description: 'Block has started conversation',
type: Boolean,
})
@IsBoolean()
@IsOptional()
starts_conversation?: boolean;
@ApiPropertyOptional({
description: 'Block capture vars',
type: Array,
})
@IsOptional()
@IsVarCapture({ message: 'Capture vars are invalid' })
capture_vars?: CaptureVar[];
@ApiProperty({
description: 'Block position',
type: Object,
})
@IsNotEmpty()
@IsPosition({ message: 'Position is invalid' })
position: Position;
}
export class BlockUpdateDto extends PartialType(
OmitType(BlockCreateDto, [
'patterns',
'trigger_labels',
'assign_labels',
'trigger_channels',
]),
) {
@ApiPropertyOptional({ description: 'Block patterns', type: Array })
@IsOptional()
@IsPatternList({ message: 'Patterns list is invalid' })
patterns?: Pattern[];
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Trigger label must be a valid objectId' })
trigger_labels?: string[];
@ApiPropertyOptional({ description: 'Block assign labels', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Assign label must be a valid objectId' })
assign_labels?: string[];
@ApiPropertyOptional({ description: 'Block trigger channels', type: Array })
@IsArray()
@IsOptional()
trigger_channels?: string[];
}

View File

@@ -0,0 +1,42 @@
/*
* 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, PartialType } from '@nestjs/swagger';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsNumber,
IsArray,
} from 'class-validator';
export class CategoryCreateDto {
@ApiProperty({ description: 'Category label', type: String })
@IsNotEmpty()
@IsString()
label: string;
@ApiPropertyOptional({ description: 'Category is builtin', type: Boolean })
@IsOptional()
@IsBoolean()
builtin?: boolean;
@ApiPropertyOptional({ description: 'Zoom', type: Number })
@IsOptional()
@IsNumber()
zoom?: number;
@ApiPropertyOptional({ description: 'Offset', type: Array })
@IsOptional()
@IsArray()
offset?: [number, number];
}
export class CategoryUpdateDto extends PartialType(CategoryCreateDto) {}

View File

@@ -0,0 +1,25 @@
/*
* 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, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class ContextVarCreateDto {
@ApiProperty({ description: 'Context var label', type: String })
@IsNotEmpty()
@IsString()
label: string;
@ApiProperty({ description: 'Context var name', type: String })
@IsNotEmpty()
@IsString()
name: string;
}
export class ContextVarUpdateDto extends PartialType(ContextVarCreateDto) {}

View File

@@ -0,0 +1,59 @@
/*
* 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,
IsBoolean,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import { Context } from './../schemas/types/context';
export class ConversationCreateDto {
@ApiProperty({ description: 'Conversation sender', type: String })
@IsNotEmpty()
@IsString()
@IsObjectId({
message: 'Sender must be a valid objectId',
})
sender: string;
@ApiPropertyOptional({ description: 'Conversation is active', type: Boolean })
@IsBoolean()
@IsOptional()
active?: boolean;
@ApiPropertyOptional({ description: 'Conversation context', type: Object })
@IsOptional()
@IsObject()
context?: Context;
@ApiProperty({ description: 'Current conversation', type: String })
@IsOptional()
@IsString()
@IsObjectId({
message: 'Current must be a valid objectId',
})
current: string;
@ApiProperty({ description: 'next conversation', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({
each: true,
message: 'next must be a valid objectId',
})
next: string[];
}

View File

@@ -0,0 +1,42 @@
/*
* 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, PartialType } from '@nestjs/swagger';
import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
IsObject,
} from 'class-validator';
export class LabelCreateDto {
@ApiProperty({ description: 'Label title', type: String })
@IsNotEmpty()
@IsString()
title: string;
@ApiProperty({ description: 'Label name', type: String })
@IsNotEmpty()
@IsString()
@Matches(/^[A-Z_0-9]+$/)
name: string;
@ApiPropertyOptional({ description: 'Label description', type: String })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Label id', type: Object })
@IsOptional()
@IsObject()
label_id?: Record<string, any>;
}
export class LabelUpdateDto extends PartialType(LabelCreateDto) {}

View File

@@ -0,0 +1,80 @@
/*
* 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, PartialType } from '@nestjs/swagger';
import {
IsBoolean,
IsNotEmpty,
IsObject,
IsString,
IsOptional,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import {
StdIncomingMessage,
StdOutgoingMessage,
} from '../schemas/types/message';
import { IsValidMessageText } from '../validation-rules/is-valid-message-text';
export class MessageCreateDto {
@ApiProperty({ description: 'Message id', type: String })
@IsOptional()
@IsString()
mid?: string;
@ApiProperty({ description: 'Reply to Message id', type: String })
@IsOptional()
@IsString()
inReplyTo?: string;
@ApiPropertyOptional({ description: 'Message sender', type: String })
@IsString()
@IsOptional()
@IsObjectId({ message: 'Sender must be a valid ObjectId' })
sender?: string;
@ApiPropertyOptional({ description: 'Message recipient', type: String })
@IsString()
@IsOptional()
@IsObjectId({ message: 'Recipient must be a valid ObjectId' })
recipient?: string;
@ApiPropertyOptional({ description: 'Message sent by', type: String })
@IsString()
@IsOptional()
@IsObjectId({ message: 'SentBy must be a valid ObjectId' })
sentBy?: string;
@ApiProperty({ description: 'Message', type: Object })
@IsObject()
@IsNotEmpty()
@IsValidMessageText({ message: 'Message should have text property' })
message: StdOutgoingMessage | StdIncomingMessage;
@ApiPropertyOptional({ description: 'Message is read', type: Boolean })
@IsBoolean()
@IsNotEmpty()
@IsOptional()
read?: boolean;
@ApiPropertyOptional({ description: 'Message is delivered', type: Boolean })
@IsBoolean()
@IsNotEmpty()
@IsOptional()
delivery?: boolean;
@ApiPropertyOptional({ description: 'Message is handed over', type: Boolean })
@IsBoolean()
@IsOptional()
handover?: boolean;
}
export class MessageUpdateDto extends PartialType(MessageCreateDto) {}

View File

@@ -0,0 +1,116 @@
/*
* 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, PartialType } from '@nestjs/swagger';
import {
IsArray,
IsNotEmpty,
IsNumber,
IsString,
IsOptional,
IsDate,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import { ChannelData } from '../schemas/types/channel';
import { IsChannelData } from '../validation-rules/is-channel-data';
export class SubscriberCreateDto {
@ApiProperty({ description: 'Subscriber first name', type: String })
@IsNotEmpty()
@IsString()
first_name: string;
@ApiProperty({ description: 'Subscriber last name', type: String })
@IsNotEmpty()
@IsString()
last_name: string;
@ApiPropertyOptional({ description: 'Subscriber locale', type: String })
@IsOptional()
@IsString()
locale: string;
@ApiPropertyOptional({ description: 'Subscriber timezone', type: Number })
@IsOptional()
@IsNumber()
timezone?: number;
@ApiPropertyOptional({ description: 'Subscriber language', type: String })
@IsNotEmpty()
@IsString()
language: string;
@ApiPropertyOptional({ description: 'Subscriber gender', type: String })
@IsOptional()
@IsString()
gender: string;
@ApiPropertyOptional({ description: 'Subscriber country', type: String })
@IsOptional()
@IsString()
country: string;
@ApiPropertyOptional({ description: 'Subscriber foreign id', type: String })
@IsOptional()
@IsString()
foreign_id: string;
@ApiProperty({ description: 'Subscriber labels', type: Array })
@IsNotEmpty()
@IsArray()
@IsObjectId({ each: true, message: 'Label must be a valid ObjectId' })
labels: string[];
@ApiPropertyOptional({
description: 'Subscriber assigned to',
type: String,
default: null,
})
@IsOptional()
@IsString()
@IsObjectId({ message: 'AssignedTo must be a valid ObjectId' })
assignedTo?: string | null;
@ApiPropertyOptional({
description: 'Subscriber assigned at',
type: Date,
default: null,
})
@IsOptional()
@IsDate()
assignedAt: Date | null;
@ApiPropertyOptional({
description: 'Subscriber last visit',
type: Date,
})
@IsOptional()
@IsDate()
lastvisit: Date;
@ApiPropertyOptional({
description: 'Subscriber retained from',
type: Date,
})
@IsOptional()
@IsDate()
retainedFrom: Date;
@ApiProperty({
description: 'Subscriber channel',
type: Object,
})
@IsNotEmpty()
@IsChannelData()
channel: ChannelData;
}
export class SubscriberUpdateDto extends PartialType(SubscriberCreateDto) {}

View File

@@ -0,0 +1,48 @@
/*
* 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 {
IsNotEmpty,
IsObject,
IsString,
IsOptional,
IsNumber,
} from 'class-validator';
export class TranslationCreateDto {
@ApiProperty({ description: 'Translation str', type: String })
@IsNotEmpty()
@IsString()
str: string;
@ApiProperty({ description: 'Translations', type: Object })
@IsNotEmpty()
@IsObject()
translations: Record<string, string>;
@ApiProperty({ description: 'Translated', type: Number })
@IsNotEmpty()
@IsNumber()
translated: number;
}
export class TranslationUpdateDto {
@ApiPropertyOptional({ description: 'Translation str', type: String })
@IsNotEmpty()
@IsString()
@IsOptional()
str?: string;
@ApiPropertyOptional({ description: 'Translations', type: Object })
@IsNotEmpty()
@IsObject()
@IsOptional()
translations?: Record<string, string>;
}

View File

@@ -0,0 +1,13 @@
/*
* 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.
*/
/**
* VIEW_MORE_PAYLOAD is declared, but never used.
*/
export const VIEW_MORE_PAYLOAD = 'VIEW_MORE';

View File

@@ -0,0 +1,109 @@
/*
* 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 { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { Model } from 'mongoose';
import {
blockFixtures,
installBlockFixtures,
} from '@/utils/test/fixtures/block';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { BlockRepository } from './block.repository';
import { CategoryRepository } from './category.repository';
import { BlockModel, Block } from '../schemas/block.schema';
import { CategoryModel, Category } from '../schemas/category.schema';
import { LabelModel } from '../schemas/label.schema';
describe('BlockRepository', () => {
let blockRepository: BlockRepository;
let categoryRepository: CategoryRepository;
let blockModel: Model<Block>;
let category: Category;
let hasPreviousBlocks: Block;
let hasNextBlocks: Block;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installBlockFixtures),
MongooseModule.forFeature([BlockModel, CategoryModel, LabelModel]),
],
providers: [BlockRepository, CategoryRepository],
}).compile();
blockRepository = module.get<BlockRepository>(BlockRepository);
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
blockModel = module.get<Model<Block>>(getModelToken('Block'));
category = await categoryRepository.findOne({ label: 'default' });
hasPreviousBlocks = await blockRepository.findOne({
name: 'hasPreviousBlocks',
});
hasNextBlocks = await blockRepository.findOne({
name: 'hasNextBlocks',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one block by id, and populate its trigger_labels, assign_labels, nextBlocks, attachedBlock, category,previousBlocks', async () => {
jest.spyOn(blockModel, 'findById');
const result = await blockRepository.findOneAndPopulate(hasNextBlocks.id);
expect(blockModel.findById).toHaveBeenCalledWith(hasNextBlocks.id);
expect(result).toEqualPayload({
...blockFixtures.find(({ name }) => name === hasNextBlocks.name),
category,
nextBlocks: [hasPreviousBlocks],
previousBlocks: [],
});
});
});
describe('findAndPopulate', () => {
it('should find blocks, and foreach block populate its trigger_labels, assign_labels, attachedBlock, category, previousBlocks', async () => {
jest.spyOn(blockModel, 'find');
const category = await categoryRepository.findOne({ label: 'default' });
const result = await blockRepository.findAndPopulate({});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category,
previousBlocks:
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
}));
expect(blockModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory);
});
it('should find blocks, and foreach block populate its trigger_labels, assign_labels, nextBlocks, attachedBlock, category', async () => {
jest.spyOn(blockModel, 'find');
const category = await categoryRepository.findOne({ label: 'default' });
const result = await blockRepository.findAndPopulate({});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category,
previousBlocks:
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
}));
expect(blockModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory);
});
});
});

View File

@@ -0,0 +1,203 @@
/*
* 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, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
TFilterQuery,
Model,
Document,
Types,
Query,
UpdateQuery,
UpdateWithAggregationPipeline,
} from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
import { Block, BlockFull } from '../schemas/block.schema';
@Injectable()
export class BlockRepository extends BaseRepository<
Block,
| 'trigger_labels'
| 'assign_labels'
| 'nextBlocks'
| 'attachedBlock'
| 'category'
| 'previousBlocks'
| 'attachedToBlock'
> {
private readonly logger: LoggerService;
constructor(
@InjectModel(Block.name) readonly model: Model<Block>,
@Optional() logger?: LoggerService,
) {
super(model, Block);
this.logger = logger;
}
/**
* Checks if the `url` field in the attachment payload is deprecated, and logs an error if found.
*
* @param block - The block DTO (create or update) to check.
*/
checkDeprecatedAttachmentUrl(block: BlockCreateDto | BlockUpdateDto) {
if (
block.message &&
'attachment' in block.message &&
'url' in block.message.attachment.payload
) {
this.logger.error(
'NOTE: `url` payload has been deprecated in favor of `attachment_id`',
block.name,
);
}
}
/**
* Pre-processing logic for creating a new block.
*
* @param doc - The document that is being created.
*/
async preCreate(
_doc: Document<unknown, object, Block> & Block & { _id: Types.ObjectId },
): Promise<void> {
if (_doc) this.checkDeprecatedAttachmentUrl(_doc);
}
/**
* Pre-processing logic for updating a block.
*
* @param query - The query to update a block.
* @param criteria - The filter criteria for the update query.
* @param updates - The update data.
*/
async preUpdate(
_query: Query<
Document<Block, any, any>,
Document<Block, any, any>,
unknown,
Block,
'findOneAndUpdate'
>,
_criteria: TFilterQuery<Block>,
_updates:
| UpdateWithAggregationPipeline
| UpdateQuery<Document<Block, any, any>>,
): Promise<void> {
const updates: BlockUpdateDto = _updates?.['$set'];
this.checkDeprecatedAttachmentUrl(updates);
}
/**
* Post-processing logic after deleting a block.
*
* @param query - The delete query.
* @param result - The result of the delete operation.
*/
async postDelete(
_query: Query<
DeleteResult,
Document<Block, any, any>,
unknown,
Block,
'deleteOne' | 'deleteMany'
>,
result: DeleteResult,
) {
if (result.deletedCount > 0) {
}
}
/**
* Pre-processing logic before deleting a block.
* It handles removing references to the block from other related blocks.
*
* @param query - The delete query.
* @param criteria - The filter criteria for finding blocks to delete.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<Block, any, any>,
unknown,
Block,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<Block>,
) {
const docsToDelete = await this.model.find(criteria);
const idsToDelete = docsToDelete.map(({ id }) => id);
if (idsToDelete.length > 0) {
// Remove from all other blocks
await this.model.updateMany(
{ attachedBlock: { $in: idsToDelete } },
{
$set: {
attachedBlock: null,
},
},
);
// Remove all other previous blocks
await this.model.updateMany(
{ nextBlocks: { $in: idsToDelete } },
{
$pull: {
nextBlocks: { $in: idsToDelete },
},
},
);
}
}
/**
* Finds blocks and populates related fields (e.g., labels, attached blocks).
*
* @param filters - The filter criteria for finding blocks.
*
* @returns The populated block results.
*/
async findAndPopulate(filters: TFilterQuery<Block>) {
const query = this.findQuery(filters).populate([
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
'attachedToBlock',
]);
return await this.execute(query, BlockFull);
}
/**
* Finds a single block by ID and populates related fields (e.g., labels, attached blocks).
*
* @param id - The ID of the block to find.
*
* @returns The populated block result or null if not found.
*/
async findOneAndPopulate(id: string) {
const query = this.findOneQuery(id).populate([
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
'attachedToBlock',
]);
return await this.executeOne(query, BlockFull);
}
}

View File

@@ -0,0 +1,60 @@
/*
* 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 { ForbiddenException, Injectable, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query, TFilterQuery } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { Category } from '../schemas/category.schema';
import { BlockService } from '../services/block.service';
@Injectable()
export class CategoryRepository extends BaseRepository<Category> {
private readonly logger: LoggerService;
private readonly blockService: BlockService;
constructor(
@InjectModel(Category.name) readonly model: Model<Category>,
@Optional() blockService?: BlockService,
@Optional() logger?: LoggerService,
) {
super(model, Category);
this.logger = logger;
this.blockService = blockService;
}
/**
* Pre-processing logic before deleting a category.
* It avoids delete a category that contains blocks.
*
* @param query - The delete query.
* @param criteria - The filter criteria for finding blocks to delete.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<Category, any, any>,
unknown,
Category,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<Category>,
) {
const associatedBlocks = await this.blockService.findOne({
category: criteria._id,
});
if (associatedBlocks) {
throw new ForbiddenException(`Category have blocks associated to it`);
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* 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 { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { ContextVar } from '../schemas/context-var.schema';
@Injectable()
export class ContextVarRepository extends BaseRepository<ContextVar> {
constructor(@InjectModel(ContextVar.name) readonly model: Model<ContextVar>) {
super(model, ContextVar);
}
}

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 { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import {
Conversation,
ConversationDocument,
ConversationFull,
} from '../schemas/conversation.schema';
@Injectable()
export class ConversationRepository extends BaseRepository<
Conversation,
'sender' | 'current' | 'next'
> {
constructor(
@InjectModel(Conversation.name) readonly model: Model<Conversation>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Conversation);
}
/**
* Called after a new conversation is created. This method emits the event
* with the newly created conversation document.
*
* @param created - The newly created conversation document.
*/
async postCreate(created: ConversationDocument): Promise<void> {
this.eventEmitter.emit('hook:chatbot:conversation:start', created);
}
/**
* Marks a conversation as ended by setting its `active` status to `false`.
*
* @param convo The conversation or full conversation object to be ended.
*
* @returns A promise resolving to the result of the update operation.
*/
async end(convo: Conversation | ConversationFull) {
return await this.updateOne(convo.id, { active: false });
}
/**
* Finds a single conversation by a given criteria and populates the related fields: `sender`, `current`, and `next`.
*
* @param criteria The search criteria, either a string or a filter query.
*
* @returns A promise resolving to the populated conversation full object.
*/
async findOneAndPopulate(criteria: string | FilterQuery<Conversation>) {
const query = this.findOneQuery(criteria).populate([
'sender',
'current',
'next',
]);
return await this.executeOne(query, ConversationFull);
}
}

View File

@@ -0,0 +1,101 @@
/*
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { Model } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { labelFixtures } from '@/utils/test/fixtures/label';
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { LabelRepository } from './label.repository';
import { SubscriberRepository } from './subscriber.repository';
import { LabelModel, Label } from '../schemas/label.schema';
import { SubscriberModel, Subscriber } from '../schemas/subscriber.schema';
describe('LabelRepository', () => {
let labelRepository: LabelRepository;
let labelModel: Model<Label>;
let subscriberRepository: SubscriberRepository;
let users: Subscriber[];
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([LabelModel, SubscriberModel]),
],
providers: [
LabelRepository,
SubscriberRepository,
EventEmitter2,
LoggerService,
],
}).compile();
labelRepository = module.get<LabelRepository>(LabelRepository);
subscriberRepository =
module.get<SubscriberRepository>(SubscriberRepository);
labelModel = module.get<Model<Label>>(getModelToken('Label'));
users = await subscriberRepository.findAll();
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one label by id, and populate its users', async () => {
jest.spyOn(labelModel, 'findById');
const label = await labelRepository.findOne({ name: 'TEST_TITLE_2' });
const result = await labelRepository.findOneAndPopulate(label.id);
expect(labelModel.findById).toHaveBeenCalledWith(label.id);
expect(result).toEqualPayload({
...labelFixtures.find(({ name }) => name === label.name),
users,
});
});
});
describe('findAllAndPopulate', () => {
it('should find all labels, and foreach label populate its corresponding users', async () => {
jest.spyOn(labelModel, 'find');
const result = await labelRepository.findAllAndPopulate();
const labelsWithUsers = labelFixtures.map((label) => ({
...label,
users,
}));
expect(labelModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(labelsWithUsers);
});
});
describe('findPageAndPopulate', () => {
it('should find labels, and foreach label populate its corresponding users', async () => {
const pageQuery = getPageQuery<Label>();
jest.spyOn(labelModel, 'find');
const result = await labelRepository.findPageAndPopulate({}, pageQuery);
const labelsWithUsers = labelFixtures.map((label) => ({
...label,
users,
}));
expect(labelModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(labelsWithUsers.sort(sortRowsBy));
});
});
});

View File

@@ -0,0 +1,119 @@
/*
* 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 { TFilterQuery, Model, Document, Query } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { Label, LabelDocument, LabelFull } from '../schemas/label.schema';
@Injectable()
export class LabelRepository extends BaseRepository<Label, 'users'> {
constructor(
@InjectModel(Label.name) readonly model: Model<Label>,
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService,
) {
super(model, Label);
}
/**
* After creating a `Label`, this method emits an event and updates the `label_id` field.
*
* @param created - The created label document instance.
*
* @returns A promise that resolves when the update operation is complete.
*/
async postCreate(created: LabelDocument): Promise<void> {
this.eventEmitter.emit(
'hook:chatbot:label:create',
created,
async (result: Record<string, any>) => {
await this.model.updateOne(
{ _id: created._id },
{
$set: {
label_id: {
...(created.label_id || {}),
...result,
},
},
},
);
},
);
}
/**
* Before deleting a label, this method fetches the label(s) based on the given criteria and emits a delete event.
*
* @param query - The Mongoose query object used for deletion.
* @param criteria - The filter criteria for finding the labels to be deleted.
*
* @returns {Promise<void>} A promise that resolves once the event is emitted.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<Label, any, any>,
unknown,
Label,
'deleteOne' | 'deleteMany'
>,
_criteria: TFilterQuery<Label>,
): Promise<void> {
const labels = await this.find(
typeof _criteria === 'string' ? { _id: _criteria } : _criteria,
);
this.eventEmitter.emit('hook:chatbot:label:delete', labels);
}
/**
* Fetches all label documents and populates the `users` field which references the subscribers.
*
* @returns A promise that resolves with an array of fully populated `LabelFull` documents.
*/
async findAllAndPopulate() {
const query = this.findAllQuery().populate(['users']);
return await this.execute(query, LabelFull);
}
/**
* Fetches a paginated list of label documents based on filters and populates the `users` (subscribers) field.
*
* @param filters - The filter criteria for querying the labels.
* @param pageQuery - The pagination query options.
*
* @returns A promise that resolves with a paginated array of fully populated `LabelFull` documents.
*/
async findPageAndPopulate(
filters: TFilterQuery<Label>,
pageQuery: PageQueryDto<Label>,
) {
const query = this.findPageQuery(filters, pageQuery).populate(['users']);
return await this.execute(query, LabelFull);
}
/**
* Fetches a single label document by its ID and populates the `users` (subscribers) field.
*
* @param id - The ID of the label to be fetched.
*
* @returns A promise that resolves with a fully populated label.
*/
async findOneAndPopulate(id: string) {
const query = this.findOneQuery(id).populate(['users']);
return await this.executeOne(query, LabelFull);
}
}

View File

@@ -0,0 +1,102 @@
/*
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { Model } from 'mongoose';
import { UserRepository } from '@/user/repositories/user.repository';
import { UserModel } from '@/user/schemas/user.schema';
import {
installMessageFixtures,
messageFixtures,
} from '@/utils/test/fixtures/message';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MessageRepository } from './message.repository';
import { SubscriberRepository } from './subscriber.repository';
import { MessageModel, Message } from '../schemas/message.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
import { AnyMessage } from '../schemas/types/message';
describe('MessageRepository', () => {
let messageRepository: MessageRepository;
let userRepository: UserRepository;
let subscriberRepository: SubscriberRepository;
let messageModel: Model<Message>;
beforeAll(async () => {
const testModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installMessageFixtures),
MongooseModule.forFeature([MessageModel, SubscriberModel, UserModel]),
],
providers: [
MessageRepository,
SubscriberRepository,
UserRepository,
EventEmitter2,
],
}).compile();
messageRepository = testModule.get<MessageRepository>(MessageRepository);
userRepository = testModule.get<UserRepository>(UserRepository);
subscriberRepository =
testModule.get<SubscriberRepository>(SubscriberRepository);
messageModel = testModule.get<Model<Message>>(getModelToken('Message'));
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one message by id, and populate its sender and recipient', async () => {
jest.spyOn(messageModel, 'findById');
const message = await messageRepository.findOne({ mid: 'mid-1' });
const sender = await subscriberRepository.findOne(message['sender']);
const recipient = await subscriberRepository.findOne(
message['recipient'],
);
const user = await userRepository.findOne(message['sentBy']);
const result = await messageRepository.findOneAndPopulate(message.id);
expect(messageModel.findById).toHaveBeenCalledWith(message.id);
expect(result).toEqualPayload({
...messageFixtures.find(({ mid }) => mid === message.mid),
sender,
recipient,
sentBy: user.id,
});
});
});
describe('findPageAndPopulate', () => {
it('should find one messages, and foreach message populate its sender and recipient', async () => {
jest.spyOn(messageModel, 'find');
const pageQuery = getPageQuery<AnyMessage>();
const result = await messageRepository.findPageAndPopulate({}, pageQuery);
const allSubscribers = await subscriberRepository.findAll();
const allUsers = await userRepository.findAll();
const allMessages = await messageRepository.findAll();
const messages = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']),
recipient: allSubscribers.find(({ id }) => id === message['recipient']),
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
expect(messageModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(messages);
});
});
});

View File

@@ -0,0 +1,167 @@
/*
* 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, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { TFilterQuery, Model, Query } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { NlpSampleCreateDto } from '@/nlp/dto/nlp-sample.dto';
import { NlpSampleState } from '@/nlp/schemas/types';
import { NlpSampleService } from '@/nlp/services/nlp-sample.service';
import { BaseRepository } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { Message, MessageFull } from '../schemas/message.schema';
import { Subscriber } from '../schemas/subscriber.schema';
import { AnyMessage } from '../schemas/types/message';
@Injectable()
export class MessageRepository extends BaseRepository<
AnyMessage,
'sender' | 'recipient'
> {
private readonly nlpSampleService: NlpSampleService;
private readonly logger: LoggerService;
constructor(
@InjectModel(Message.name) readonly model: Model<AnyMessage>,
@Optional() nlpSampleService?: NlpSampleService,
@Optional() logger?: LoggerService,
) {
super(model, Message as new () => AnyMessage);
this.logger = logger;
this.nlpSampleService = nlpSampleService;
}
/**
* Pre-create hook to validate message data before saving.
* If the message is from a end-user (i.e., has a sender), it is saved
* as an inbox NLP sample. Throws an error if neither sender nor recipient
* is provided.
*
* @param _doc - The message document to be created.
*/
async preCreate(_doc: AnyMessage): Promise<void> {
if (_doc) {
if (!('sender' in _doc) && !('recipient' in _doc)) {
this.logger.error('Either sender or recipient must be provided!', _doc);
throw new Error('Either sender or recipient must be provided!');
}
// If message is sent by the user then add it as an inbox sample
if (
'sender' in _doc &&
_doc.sender &&
'message' in _doc &&
'text' in _doc.message
) {
const record: NlpSampleCreateDto = {
text: _doc.message.text,
type: NlpSampleState.inbox,
trained: false,
};
try {
await this.nlpSampleService.findOneOrCreate(record, record);
this.logger.debug('User message saved as a inbox sample !');
} catch (err) {
this.logger.error(
'Unable to add message as a new inbox sample!',
err,
);
throw err;
}
}
}
}
/**
* Retrieves a paginated list of messages with sender and recipient populated.
* Uses filter criteria and pagination settings for the query.
*
* @param filters - Filter criteria for querying messages.
* @param pageQuery - Pagination settings, including skip, limit, and sort order.
*
* @returns A paginated list of messages with sender and recipient details populated.
*/
async findPageAndPopulate(
filters: TFilterQuery<AnyMessage>,
pageQuery: PageQueryDto<AnyMessage>,
) {
const query = this.findPageQuery(filters, pageQuery).populate([
'sender',
'recipient',
]);
return await this.execute(
query as Query<AnyMessage[], AnyMessage, object, AnyMessage, 'find'>,
MessageFull,
);
}
/**
* Retrieves a single message by its ID, populating the sender and recipient fields.
*
* @param id - The ID of the message to retrieve.
*
* @returns The message with sender and recipient details populated.
*/
async findOneAndPopulate(id: string) {
const query = this.findOneQuery(id).populate(['sender', 'recipient']);
return await this.executeOne(query, MessageFull);
}
/**
* Retrieves the message history for a given subscriber, with messages sent or received
* before the specified date. Results are limited and sorted by creation date.
*
* @param subscriber - The subscriber whose message history is being retrieved.
* @param until - Optional date to retrieve messages sent before (default: current date).
* @param limit - Optional limit on the number of messages to retrieve (default: 30).
*
* @returns The message history until the specified date.
*/
async findHistoryUntilDate(
subscriber: Subscriber,
until = new Date(),
limit: number = 30,
) {
return await this.findPage(
{
$or: [{ recipient: subscriber.id }, { sender: subscriber.id }],
createdAt: { $lt: until },
},
{ skip: 0, limit, sort: ['createdAt', 'desc'] },
);
}
/**
* Retrieves the message history for a given subscriber, with messages sent or received
* after the specified date. Results are limited and sorted by creation date.
*
* @param subscriber The subscriber whose message history is being retrieved.
* @param since Optional date to retrieve messages sent after (default: current date).
* @param limit Optional limit on the number of messages to retrieve (default: 30).
*
* @returns The message history since the specified date.
*/
async findHistorySinceDate(
subscriber: Subscriber,
since = new Date(),
limit: number = 30,
) {
return await this.findPage(
{
$or: [{ recipient: subscriber.id }, { sender: subscriber.id }],
createdAt: { $gt: since },
},
{ skip: 0, limit, sort: ['createdAt', 'asc'] },
);
}
}

View File

@@ -0,0 +1,154 @@
/*
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { Model } from 'mongoose';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import {
Attachment,
AttachmentModel,
} from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { UserRepository } from '@/user/repositories/user.repository';
import { UserModel, User } from '@/user/schemas/user.schema';
import {
installSubscriberFixtures,
subscriberFixtures,
} from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { LabelRepository } from './label.repository';
import { SubscriberRepository } from './subscriber.repository';
import { LabelModel, Label } from '../schemas/label.schema';
import {
SubscriberModel,
Subscriber,
SubscriberFull,
} from '../schemas/subscriber.schema';
describe('SubscriberRepository', () => {
let subscriberRepository: SubscriberRepository;
let subscriberModel: Model<Subscriber>;
let labelRepository: LabelRepository;
let userRepository: UserRepository;
let attachmentRepository: AttachmentRepository;
let allLabels: Label[];
let allUsers: User[];
let allSubscribers: Subscriber[];
let allAttachments: Attachment[];
let subscribersWithPopulatedFields: SubscriberFull[];
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([
SubscriberModel,
LabelModel,
UserModel,
AttachmentModel,
]),
],
providers: [
SubscriberRepository,
LabelRepository,
UserRepository,
EventEmitter2,
LoggerService,
AttachmentService,
AttachmentRepository,
],
}).compile();
subscriberRepository =
module.get<SubscriberRepository>(SubscriberRepository);
labelRepository = module.get<LabelRepository>(LabelRepository);
userRepository = module.get<UserRepository>(UserRepository);
attachmentRepository =
module.get<AttachmentRepository>(AttachmentRepository);
subscriberModel = module.get<Model<Subscriber>>(
getModelToken('Subscriber'),
);
allLabels = await labelRepository.findAll();
allSubscribers = await subscriberRepository.findAll();
allUsers = await userRepository.findAll();
allAttachments = await attachmentRepository.findAll();
subscribersWithPopulatedFields = allSubscribers.map((subscriber) => ({
...subscriber,
labels: allLabels.filter((label) => subscriber.labels.includes(label.id)),
assignedTo:
allUsers.find(({ id }) => subscriber.assignedTo === id) || null,
avatar: allAttachments.find(({ id }) => subscriber.avatar === id) || null,
}));
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one subscriber by id,and populate its labels', async () => {
jest.spyOn(subscriberModel, 'findById');
const subscriber = await subscriberRepository.findOne({
first_name: 'Jhon',
});
const allLabels = await labelRepository.findAll();
const result = await subscriberRepository.findOneAndPopulate(
subscriber.id,
);
const subscriberWithLabels = {
...subscriberFixtures.find(
({ first_name }) => first_name === subscriber.first_name,
),
labels: allLabels.filter((label) =>
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
};
expect(subscriberModel.findById).toHaveBeenCalledWith(subscriber.id);
expect(result).toEqualPayload(subscriberWithLabels);
});
});
describe('findPageAndPopulate', () => {
const pageQuery = getPageQuery<Subscriber>();
it('should find subscribers, and foreach subscriber populate the corresponding labels', async () => {
jest.spyOn(subscriberModel, 'find');
const result = await subscriberRepository.findPageAndPopulate(
{},
pageQuery,
);
expect(subscriberModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(
subscribersWithPopulatedFields.sort(sortRowsBy),
);
});
});
describe('findAllAndPopulate', () => {
it('should return all subscribers, and foreach subscriber populate the corresponding labels', async () => {
jest.spyOn(subscriberModel, 'find');
const result = await subscriberRepository.findAllAndPopulate();
expect(subscriberModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(
subscribersWithPopulatedFields.sort(sortRowsBy),
);
});
});
});

View File

@@ -0,0 +1,278 @@
/*
* 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,
TFilterQuery,
UpdateQuery,
UpdateWithAggregationPipeline,
} from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { SubscriberUpdateDto } from '../dto/subscriber.dto';
import {
Subscriber,
SubscriberDocument,
SubscriberFull,
} from '../schemas/subscriber.schema';
@Injectable()
export class SubscriberRepository extends BaseRepository<
Subscriber,
'labels' | 'assignedTo' | 'avatar'
> {
constructor(
@InjectModel(Subscriber.name) readonly model: Model<Subscriber>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Subscriber);
}
/**
* Emits events related to the creation of a new subscriber.
*
* @param _created - The newly created subscriber document.
*/
async postCreate(_created: SubscriberDocument): Promise<void> {
this.eventEmitter.emit(
'hook:stats:entry',
'new_users',
'New users',
_created,
);
this.eventEmitter.emit('hook:chatbot:subscriber:create', _created);
}
/**
* Emits events before updating a subscriber. Specifically handles the
* assignment of the subscriber and triggers appropriate events.
*
* @param _query - The Mongoose query object for finding and updating a subscriber.
* @param criteria - The filter criteria used to find the subscriber.
* @param updates - The update data, which may include fields like `assignedTo`.
*/
async preUpdate(
_query: Query<
Document<Subscriber, any, any>,
Document<Subscriber, any, any>,
unknown,
Subscriber,
'findOneAndUpdate'
>,
criteria: TFilterQuery<Subscriber>,
updates:
| UpdateWithAggregationPipeline
| UpdateQuery<Document<Subscriber, any, any>>,
): Promise<void> {
const subscriberUpdates: SubscriberUpdateDto = updates?.['$set'];
this.eventEmitter.emit(
'hook:chatbot:subscriber:update:before',
criteria,
subscriberUpdates,
);
const oldSubscriber = await this.findOne(criteria);
if (subscriberUpdates.assignedTo !== oldSubscriber?.assignedTo) {
this.eventEmitter.emit(
'hook:subscriber:assign',
subscriberUpdates,
oldSubscriber,
);
if (!(subscriberUpdates.assignedTo && oldSubscriber?.assignedTo)) {
this.eventEmitter.emit(
'hook:analytics:passation',
oldSubscriber,
!!subscriberUpdates?.assignedTo,
);
}
}
}
/**
* Emits an event after successfully updating a subscriber.
* Triggers the event with the updated subscriber data.
*
* @param _query - The Mongoose query object for finding and updating a subscriber.
* @param updated - The updated subscriber entity.
*/
async postUpdate(
_query: Query<
Document<Subscriber, any, any>,
Document<Subscriber, any, any>,
unknown,
Subscriber,
'findOneAndUpdate'
>,
updated: Subscriber,
) {
this.eventEmitter.emit('hook:chatbot:subscriber:update:after', updated);
}
/**
* Constructs a query to find a subscriber by their foreign ID.
*
* @param id - The foreign ID of the subscriber.
*
* @returns The constructed query object.
*/
findByForeignIdQuery(id: string) {
return this.findPageQuery(
{ foreign_id: id },
{ skip: 0, limit: 1, sort: ['lastvisit', 'desc'] },
);
}
/**
* Finds a single subscriber by his foreign ID (channel's id).
*
* @param id - The foreign ID of the subscriber.
*
* @returns The found subscriber entity.
*/
async findOneByForeignId(id: string): Promise<Subscriber> {
const query = this.findByForeignIdQuery(id);
const [result] = await this.execute(query, Subscriber);
return result;
}
/**
* Finds a subscriber by their foreign ID and populates related fields such as `labels` and `assignedTo`.
*
* @param id - The foreign ID of the subscriber.
*
* @returns The found subscriber entity with populated fields.
*/
async findOneByForeignIdAndPopulate(id: string): Promise<SubscriberFull> {
const query = this.findByForeignIdQuery(id).populate([
'labels',
'assignedTo',
]);
const [result] = await this.execute(query, SubscriberFull);
return result;
}
/**
* Updates a subscriber's information based on their foreign ID.
*
* @param id - The foreign ID of the subscriber.
* @param updates - The update data to apply to the subscriber.
*
* @returns The updated subscriber entity.
*/
async updateOneByForeignIdQuery(
id: string,
updates: SubscriberUpdateDto,
): Promise<Subscriber> {
return await this.updateOne({ foreign_id: id }, updates);
}
/**
* Unassigns a subscriber by their foreign ID by setting the `assignedTo` field to `null`.
*
* @param foreignId - The foreign ID of the subscriber.
*
* @returns The updated subscriber entity.
*/
async handBackByForeignIdQuery(foreignId: string): Promise<Subscriber> {
return await this.updateOne(
{
foreign_id: foreignId,
assignedTo: { $ne: null },
},
{
assignedTo: null,
},
);
}
/**
* Assigns a subscriber to a new user by their foreign ID.
*
* @param foreignId The foreign ID of the subscriber.
* @param userId The ID of the user to assign the subscriber to.
*
* @returns The updated subscriber entity.
*/
async handOverByForeignIdQuery(
foreignId: string,
userId: string,
): Promise<Subscriber> {
return await this.updateOne(
{
foreign_id: foreignId,
assignedTo: { $ne: userId },
},
{
assignedTo: userId,
},
);
}
/**
* Finds all subscribers and populates related fields such as `labels`, `assignedTo`, and `avatar`.
*
* @returns A list of all subscribers with populated fields.
*/
async findAllAndPopulate(): Promise<SubscriberFull[]> {
const query = this.findAllQuery().populate([
'labels',
'assignedTo',
'avatar',
]);
return await this.execute(query, SubscriberFull);
}
/**
* Finds subscribers using pagination and populates related fields such as `labels`, `assignedTo`, and `avatar`.
*
* @param filters - The filter criteria to apply when finding subscribers.
* @param pageQuery - The pagination query.
*
* @returns A paginated list of subscribers with populated fields.
*/
async findPageAndPopulate(
filters: TFilterQuery<Subscriber>,
pageQuery: PageQueryDto<Subscriber>,
): Promise<SubscriberFull[]> {
const query = this.findPageQuery(filters, pageQuery).populate([
'labels',
'assignedTo',
'avatar',
]);
return await this.execute(query, SubscriberFull);
}
/**
* Finds a single subscriber by criteria and populates related fields such as `labels`, `assignedTo`, and `avatar`.
*
* @param criteria - The filter criteria to apply when finding a subscriber.
*
* @returns The found subscriber entity with populated fields.
*/
async findOneAndPopulate(
criteria: string | TFilterQuery<Subscriber>,
): Promise<SubscriberFull> {
const query = this.findOneQuery(criteria).populate([
'labels',
'assignedTo',
'avatar',
]);
return await this.executeOne(query, SubscriberFull);
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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 { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { Translation } from '../schemas/translation.schema';
@Injectable()
export class TranslationRepository extends BaseRepository<Translation> {
constructor(
@InjectModel(Translation.name) readonly model: Model<Translation>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Translation);
}
/**
* Emits an event after a translation document is updated.
*
* @param query - The query object representing the update operation.
* @param updated - The updated translation document.
*/
async postUpdate(
_query: Query<
Document<Translation, any, any>,
Document<Translation, any, any>,
unknown,
Translation,
'findOneAndUpdate'
>,
_updated: Translation,
) {
this.eventEmitter.emit('hook:translation:update');
}
/**
* Emits an event after a new translation document is created.
*
* @param created - The newly created translation document.
*/
async postCreate(
_created: Document<unknown, unknown, Translation> &
Translation & { _id: Types.ObjectId },
) {
this.eventEmitter.emit('hook:translation:create');
}
/**
* Emits an event after a translation document is deleted.
*
* @param query - The query object representing the delete operation.
* @param result - The result of the delete operation.
*/
async postDelete(
_query: Query<
DeleteResult,
Document<Translation, any, any>,
unknown,
Translation,
'deleteOne' | 'deleteMany'
>,
_result: DeleteResult,
) {
this.eventEmitter.emit('hook:translation:delete');
}
}

View File

@@ -0,0 +1,196 @@
/*
* 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 { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Exclude, Transform, Type } from 'class-transformer';
import { Schema as MongooseSchema, THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { Category } from './category.schema';
import { Label } from './label.schema';
import { CaptureVar } from './types/capture-var';
import { BlockMessage } from './types/message';
import { BlockOptions } from './types/options';
import { Pattern } from './types/pattern';
import { Position } from './types/position';
import { isValidMessage } from '../validation-rules/is-message';
import { isPatternList } from '../validation-rules/is-pattern-list';
import { isPosition } from '../validation-rules/is-position';
import { isValidVarCapture } from '../validation-rules/is-valid-capture';
@Schema({ timestamps: true })
export class BlockStub extends BaseSchema {
@Prop({
type: String,
required: true,
})
name: string;
@Prop({
type: Object,
validate: isPatternList,
default: [],
})
patterns?: Pattern[];
@Prop([
{
type: MongooseSchema.Types.ObjectId,
ref: 'Label',
default: [],
},
])
trigger_labels?: unknown;
@Prop([
{
type: MongooseSchema.Types.ObjectId,
ref: 'Label',
default: [],
},
])
assign_labels?: unknown;
@Prop({
type: Object,
default: [],
})
trigger_channels?: string[];
@Prop({
type: Object,
default: {},
})
options?: BlockOptions;
@Prop({
type: Object,
validate: isValidMessage,
})
message: BlockMessage;
@Prop([
{
type: MongooseSchema.Types.ObjectId,
ref: 'Block',
default: [],
},
])
nextBlocks?: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Block',
})
attachedBlock?: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Category',
})
category: unknown;
@Prop({
type: Boolean,
default: false,
})
starts_conversation?: boolean;
@Prop({
type: Object,
validate: isValidVarCapture,
default: [],
})
capture_vars?: CaptureVar[];
@Prop({
type: Object,
validate: isPosition,
})
position: Position;
@Prop({
type: Boolean,
default: false,
})
builtin?: boolean;
}
@Schema({ timestamps: true })
export class Block extends BlockStub {
@Transform(({ obj }) => obj.trigger_labels?.map((elem) => elem.toString()))
trigger_labels?: string[];
@Transform(({ obj }) => obj.assign_labels?.map((elem) => elem.toString()))
assign_labels?: string[];
@Transform(({ obj }) => obj.nextBlocks?.map((elem) => elem.toString()))
nextBlocks?: string[];
@Transform(({ obj }) => obj.attachedBlock?.toString() || null)
attachedBlock?: string;
@Transform(({ obj }) => obj.category.toString())
category: string;
@Exclude()
previousBlocks?: never;
@Exclude()
attachedToBlock?: never | null;
}
@Schema({ timestamps: true })
export class BlockFull extends BlockStub {
@Type(() => Label)
trigger_labels: Label[];
@Type(() => Label)
assign_labels: Label[];
@Type(() => Block)
nextBlocks?: Block[];
@Type(() => Block)
attachedBlock?: Block;
@Type(() => Category)
category: Category;
@Type(() => Block)
previousBlocks: Block[];
@Type(() => Block)
attachedToBlock?: Block;
}
export type BlockDocument = THydratedDocument<Block>;
export const BlockModel: ModelDefinition = LifecycleHookManager.attach({
name: Block.name,
schema: SchemaFactory.createForClass(BlockStub),
});
BlockModel.schema.virtual('previousBlocks', {
ref: 'Block',
localField: '_id',
foreignField: 'nextBlocks',
justOne: false,
});
BlockModel.schema.virtual('attachedToBlock', {
ref: 'Block',
localField: '_id',
foreignField: 'attachedBlock',
justOne: true,
});
export default BlockModel.schema;

View File

@@ -0,0 +1,51 @@
/*
* 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 { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
@Schema({ timestamps: true })
export class Category extends BaseSchema {
@Prop({
type: String,
unique: true,
required: true,
})
label: string;
@Prop({
type: Boolean,
default: false,
})
builtin?: boolean;
@Prop({
type: Number,
default: 100,
})
zoom?: number;
@Prop({
type: [Number, Number],
default: [0, 0],
})
offset?: [number, number];
}
export const CategoryModel: ModelDefinition = LifecycleHookManager.attach({
name: Category.name,
schema: SchemaFactory.createForClass(Category),
});
export type CategoryDocument = THydratedDocument<Category>;
export default CategoryModel.schema;

View File

@@ -0,0 +1,40 @@
/*
* 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 { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
@Schema({ timestamps: true })
export class ContextVar extends BaseSchema {
@Prop({
type: String,
unique: true,
required: true,
})
label: string;
@Prop({
type: String,
unique: true,
required: true,
match: /^[a-z_0-9]+$/,
})
name: string;
}
export const ContextVarModel: ModelDefinition = {
name: ContextVar.name,
schema: SchemaFactory.createForClass(ContextVar),
};
export type ContextVarDocument = THydratedDocument<ContextVar>;
export default ContextVarModel.schema;

View File

@@ -0,0 +1,105 @@
/*
* 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 { Transform, Type } from 'class-transformer';
import { THydratedDocument, Schema as MongooseSchema } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { Block } from './block.schema';
import { Subscriber } from './subscriber.schema';
import { Context } from './types/context';
export function getDefaultConversationContext(): Context {
return {
vars: {}, // Used for capturing vars from user entries
user: {
first_name: '',
last_name: '',
} as Subscriber,
user_location: {
// Used for capturing geolocation from QR
lat: 0.0,
lon: 0.0,
},
skip: {}, // Used for list pagination
attempt: 0, // Used to track fallback max attempts
};
}
@Schema({ timestamps: true })
class ConversationStub extends BaseSchema {
@Prop({
type: MongooseSchema.Types.ObjectId,
required: true,
ref: 'Subscriber',
})
sender: unknown;
@Prop({
type: Boolean,
default: true,
})
active?: boolean;
@Prop({
type: Object,
default: getDefaultConversationContext(),
})
context?: Context;
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Block',
})
current?: unknown;
@Prop([
{
type: MongooseSchema.Types.ObjectId,
ref: 'Block',
default: [],
},
])
next?: unknown;
}
@Schema({ timestamps: true })
export class Conversation extends ConversationStub {
@Transform(({ obj }) => obj.sender.toString())
sender: string;
@Transform(({ obj }) => obj.current.toString())
current?: string;
@Transform(({ obj }) => obj.next.map((elem) => elem.toString()))
next?: string[];
}
@Schema({ timestamps: true })
export class ConversationFull extends ConversationStub {
@Type(() => Subscriber)
sender: Subscriber;
@Type(() => Block)
current: Block;
@Type(() => Block)
next: Block[];
}
export type ConversationDocument = THydratedDocument<Conversation>;
export const ConversationModel: ModelDefinition = {
name: Conversation.name,
schema: SchemaFactory.createForClass(ConversationStub),
};
export default ConversationModel.schema;

View File

@@ -0,0 +1,79 @@
/*
* 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 { Exclude, Type } from 'class-transformer';
import { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { Subscriber } from './subscriber.schema';
@Schema({ timestamps: true })
export class LabelStub extends BaseSchema {
@Prop({
type: String,
unique: true,
required: true,
})
title: string;
@Prop({
type: String,
unique: true,
required: true,
match: /^[A-Z_0-9]+$/,
})
name: string;
@Prop({
type: Object,
})
label_id?: Record<string, any>; // Indexed by channel name
@Prop({
type: String,
})
description?: string;
@Prop({
type: Boolean,
default: false,
})
builtin?: boolean;
}
@Schema({ timestamps: true })
export class Label extends LabelStub {
@Exclude()
users?: never;
}
@Schema({ timestamps: true })
export class LabelFull extends LabelStub {
@Type(() => Subscriber)
users?: Subscriber[];
}
export type LabelDocument = THydratedDocument<Label>;
export const LabelModel: ModelDefinition = LifecycleHookManager.attach({
name: Label.name,
schema: SchemaFactory.createForClass(LabelStub),
});
LabelModel.schema.virtual('users', {
ref: 'Subscriber',
localField: '_id',
foreignField: 'labels',
justOne: false,
});
export default LabelModel.schema;

View File

@@ -0,0 +1,104 @@
/*
* 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 { Transform, Type } from 'class-transformer';
import { Schema as MongooseSchema } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { Subscriber } from './subscriber.schema';
import { StdIncomingMessage, StdOutgoingMessage } from './types/message';
@Schema({ timestamps: true })
export class MessageStub extends BaseSchema {
@Prop({
type: String,
required: false,
//TODO : add default value for mid
})
mid?: string;
@Prop({
type: MongooseSchema.Types.ObjectId,
required: false,
ref: 'Subscriber',
})
sender?: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
required: false,
ref: 'Subscriber',
})
recipient?: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
required: false,
ref: 'User',
})
sentBy?: unknown;
@Prop({
type: Object,
required: true,
})
message: StdOutgoingMessage | StdIncomingMessage;
@Prop({
type: Boolean,
default: false,
})
read?: boolean;
@Prop({
type: Boolean,
default: false,
})
delivery?: boolean;
@Prop({
type: Boolean,
default: false,
})
handover?: boolean;
}
@Schema({ timestamps: true })
export class Message extends MessageStub {
@Transform(({ obj }) => obj.sender?.toString())
sender?: string;
@Transform(({ obj }) => obj.recipient?.toString())
recipient?: string;
@Transform(({ obj }) => obj.sentBy?.toString())
sentBy?: string;
}
@Schema({ timestamps: true })
export class MessageFull extends MessageStub {
@Type(() => Subscriber)
sender?: Subscriber;
@Type(() => Subscriber)
recipient?: Subscriber;
@Transform(({ obj }) => obj.sentBy?.toString())
sentBy?: string; // sendBy is never populate
}
export const MessageModel: ModelDefinition = LifecycleHookManager.attach({
name: Message.name,
schema: SchemaFactory.createForClass(MessageStub),
});
export default MessageModel.schema;

View File

@@ -0,0 +1,142 @@
/*
* 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 { Transform, Type } from 'class-transformer';
import { THydratedDocument, Schema as MongooseSchema } from 'mongoose';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { User } from '@/user/schemas/user.schema';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { Label } from './label.schema';
import { ChannelData } from './types/channel';
@Schema({ timestamps: true })
export class SubscriberStub extends BaseSchema {
@Prop({
type: String,
required: true,
})
first_name: string;
@Prop({
type: String,
required: true,
})
last_name: string;
@Prop({
type: String,
})
locale: string;
@Prop({
type: Number,
default: 0,
})
timezone?: number;
@Prop({
type: String,
})
language: string;
@Prop({
type: String,
})
gender: string;
@Prop({
type: String,
})
country: string;
@Prop({
type: String,
})
foreign_id: string;
@Prop([
{ type: MongooseSchema.Types.ObjectId, required: false, ref: 'Label' },
])
labels: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
required: false,
ref: 'User',
default: null,
})
assignedTo?: unknown;
@Prop({
type: Date,
default: null,
})
assignedAt?: Date;
@Prop({
type: Date,
default: () => Date.now() + 7 * 24 * 60 * 60 * 1000,
})
lastvisit?: Date;
@Prop({
type: Date,
default: () => Date.now() + 7 * 24 * 60 * 60 * 1000,
})
retainedFrom?: Date;
@Prop({
type: Object,
})
channel: ChannelData;
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Attachment',
default: null,
})
avatar?: unknown;
}
@Schema({ timestamps: true })
export class Subscriber extends SubscriberStub {
@Transform(({ obj }) => obj.labels.map((label) => label.toString()))
labels: string[];
@Transform(({ obj }) => (obj.assignedTo ? obj.assignedTo.toString() : null))
assignedTo?: string;
@Transform(({ obj }) => obj.avatar?.toString() || null)
avatar?: string;
}
@Schema({ timestamps: true })
export class SubscriberFull extends SubscriberStub {
@Type(() => Label)
labels: Label[];
@Type(() => User)
assignedTo?: User | null;
@Type(() => Attachment)
avatar: Attachment | null;
}
export type SubscriberDocument = THydratedDocument<Subscriber>;
export const SubscriberModel: ModelDefinition = LifecycleHookManager.attach({
name: Subscriber.name,
schema: SchemaFactory.createForClass(SubscriberStub),
});
export default SubscriberModel.schema;

View File

@@ -0,0 +1,43 @@
/*
* 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 { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
@Schema({ timestamps: true })
export class Translation extends BaseSchema {
@Prop({
type: String,
required: true,
unique: true,
})
str: string;
@Prop({
type: Object,
required: true,
})
translations: Record<string, string>;
@Prop({
type: Number,
})
translated: number;
}
export const TranslationModel: ModelDefinition = {
name: Translation.name,
schema: SchemaFactory.createForClass(Translation),
};
export type TranslationDocument = THydratedDocument<Translation>;
export default TranslationModel.schema;

View File

@@ -0,0 +1,39 @@
/*
* 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 { Attachment } from '@/attachment/schemas/attachment.schema';
export enum FileType {
image = 'image',
video = 'video',
audio = 'audio',
file = 'file',
unknown = 'unknown',
}
export type AttachmentForeignKey = {
url?: string;
attachment_id: string;
};
export type WithUrl<A> = A & { url?: string };
export interface AttachmentPayload<
A extends WithUrl<Attachment> | AttachmentForeignKey,
> {
type: FileType;
payload: A;
}
export interface IncomingAttachmentPayload {
type: FileType;
payload: {
url: string;
};
}

View File

@@ -0,0 +1,29 @@
/*
* 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.
*/
export enum ButtonType {
postback = 'postback',
web_url = 'web_url',
}
export type PostBackButton = {
type: ButtonType.postback;
title: string;
payload: string;
};
export type WebUrlButton = {
type: ButtonType.web_url;
title: string;
url: string;
messenger_extensions?: boolean;
webview_height_ratio?: 'compact' | 'tall' | 'full';
};
export type Button = PostBackButton | WebUrlButton;

View File

@@ -0,0 +1,16 @@
/*
* 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.
*/
export interface CaptureVar {
// entity=`-1` to match text message
// entity=`-2` for postback payload
// entity is `String` for NLP entities
entity: number | string;
context_var: string;
}

View File

@@ -0,0 +1,16 @@
/*
* 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.
*/
interface BaseChannelData {
name: string; // channel name
isSocket?: boolean;
type?: any; //TODO: type has to be checked
}
export type ChannelData = BaseChannelData;

View File

@@ -0,0 +1,29 @@
/*
* 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 { Nlp } from '@/nlp/lib/types';
import { Payload } from './quick-reply';
import { Subscriber } from '../subscriber.schema';
export interface Context {
channel?: string;
text?: string;
payload?: Payload | string;
nlp?: Nlp.ParseEntities | null;
vars: { [key: string]: any };
user_location: {
address?: Record<string, string>;
lat: number;
lon: number;
};
user: Subscriber;
skip: Record<string, number>;
attempt: number;
}

View File

@@ -0,0 +1,196 @@
/*
* 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 { Attachment } from '@/attachment/schemas/attachment.schema';
import { Content } from '@/cms/schemas/content.schema';
import {
AttachmentForeignKey,
AttachmentPayload,
IncomingAttachmentPayload,
WithUrl,
} from './attachment';
import { Button } from './button';
import { ContentOptions } from './options';
import { StdQuickReply } from './quick-reply';
import { Message } from '../message.schema';
/**
* StdEventType enum is declared, and currently not used
**/
export enum StdEventType {
message = 'message',
delivery = 'delivery',
read = 'read',
typing = 'typing',
follow = 'follow',
echo = 'echo',
unknown = '',
}
/**
* IncomingMessageType enum is declared, and currently not used
**/
export enum IncomingMessageType {
message = 'message',
postback = 'postback',
quick_reply = 'quick_reply',
location = 'location',
attachments = 'attachments',
unknown = '',
}
export enum OutgoingMessageFormat {
text = 'text',
quickReplies = 'quickReplies',
buttons = 'buttons',
attachment = 'attachment',
list = 'list',
carousel = 'carousel',
}
/**
* FileType enum is declared, and currently not used
**/
export enum FileType {
image = 'image',
video = 'video',
audio = 'audio',
file = 'file',
unknown = 'unknown',
}
export enum PayloadType {
location = 'location',
attachments = 'attachments',
}
export type StdOutgoingTextMessage = { text: string };
export type StdOutgoingQuickRepliesMessage = {
text: string;
quickReplies: StdQuickReply[];
};
export type StdOutgoingButtonsMessage = {
text: string;
buttons: Button[];
};
export type StdOutgoingListMessage = {
options: ContentOptions;
elements: Content[];
pagination: {
total: number;
skip: number;
limit: number;
};
};
export type StdOutgoingAttachmentMessage<
A extends WithUrl<Attachment> | AttachmentForeignKey,
> = {
// Stored in DB as `AttachmentPayload`, `Attachment` when populated for channels relaying
attachment: AttachmentPayload<A>;
quickReplies?: StdQuickReply[];
};
export type StdPluginMessage = {
plugin: string;
args: { [key: string]: any };
};
export type BlockMessage =
| string[]
| StdOutgoingTextMessage
| StdOutgoingQuickRepliesMessage
| StdOutgoingButtonsMessage
| StdOutgoingListMessage
| StdOutgoingAttachmentMessage<AttachmentForeignKey>
| StdPluginMessage;
export type StdOutgoingMessage =
| StdOutgoingTextMessage
| StdOutgoingQuickRepliesMessage
| StdOutgoingButtonsMessage
| StdOutgoingListMessage
| StdOutgoingAttachmentMessage<WithUrl<Attachment>>;
type StdIncomingTextMessage = { text: string };
export type StdIncomingPostBackMessage = StdIncomingTextMessage & {
postback: string;
};
export type StdIncomingLocationMessage = {
type: PayloadType.location;
coordinates: {
lat: number;
lon: number;
};
};
export type StdIncomingAttachmentMessage = {
type: PayloadType.attachments;
serialized_text: string;
attachment: IncomingAttachmentPayload | IncomingAttachmentPayload[];
};
export type StdIncomingMessage =
| StdIncomingTextMessage
| StdIncomingPostBackMessage
| StdIncomingLocationMessage
| StdIncomingAttachmentMessage;
export interface IncomingMessage extends Omit<Message, 'recipient' | 'sentBy'> {
message: StdIncomingMessage;
sender: string;
}
export interface OutgoingMessage extends Omit<Message, 'sender'> {
message: StdOutgoingMessage;
recipient: string;
sentBy?: string;
handover?: boolean;
}
export type AnyMessage = IncomingMessage | OutgoingMessage;
export interface StdOutgoingTextEnvelope {
format: OutgoingMessageFormat.text;
message: StdOutgoingTextMessage;
}
export interface StdOutgoingQuickRepliesEnvelope {
format: OutgoingMessageFormat.quickReplies;
message: StdOutgoingQuickRepliesMessage;
}
export interface StdOutgoingButtonsEnvelope {
format: OutgoingMessageFormat.buttons;
message: StdOutgoingButtonsMessage;
}
export interface StdOutgoingListEnvelope {
format: OutgoingMessageFormat.list | OutgoingMessageFormat.carousel;
message: StdOutgoingListMessage;
}
export interface StdOutgoingAttachmentEnvelope {
format: OutgoingMessageFormat.attachment;
message: StdOutgoingAttachmentMessage<WithUrl<Attachment>>;
}
export type StdOutgoingEnvelope =
| StdOutgoingTextEnvelope
| StdOutgoingQuickRepliesEnvelope
| StdOutgoingButtonsEnvelope
| StdOutgoingListEnvelope
| StdOutgoingAttachmentEnvelope;

View File

@@ -0,0 +1,43 @@
/*
* 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 { Button } from './button';
import { OutgoingMessageFormat } from './message';
export interface ContentOptions {
display: OutgoingMessageFormat.list | OutgoingMessageFormat.carousel;
fields: {
title: string;
subtitle: string | null;
image_url: string | null;
url?: string;
action_title?: string;
action_payload?: string;
};
buttons: Button[];
limit: number;
query?: any; // Waterline model criteria
entity?: string | number; // ContentTypeID
top_element_style?: 'large' | 'compact';
}
export interface BlockOptions {
typing?: number;
// In case of carousel/list message
content?: ContentOptions;
// Only if the block has next blocks
fallback?: {
active: boolean;
message: string[];
max_attempts: number;
};
assignTo?: string;
// plugins effects
effects?: string[];
}

View File

@@ -0,0 +1,30 @@
/*
* 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 { PayloadType } from './message';
export interface PayloadPattern {
label: string;
value: string;
// @todo : rename 'attachment' to 'attachments'
type?: PayloadType;
}
export type NlpPattern =
| {
entity: string;
match: 'entity';
}
| {
entity: string;
match: 'value';
value: string;
};
export type Pattern = string | RegExp | PayloadPattern | NlpPattern[];

View File

@@ -0,0 +1,13 @@
/*
* 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.
*/
export type Position = {
x: number;
y: number;
};

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 { IncomingAttachmentPayload } from './attachment';
export enum PayloadType {
location = 'location',
attachments = 'attachments',
}
export type Payload =
| {
type: PayloadType.location;
coordinates: {
lat: number;
lon: number;
};
}
| {
type: PayloadType.attachments;
attachments: IncomingAttachmentPayload;
};
export enum QuickReplyType {
text = 'text',
location = 'location',
user_phone_number = 'user_phone_number',
user_email = 'user_email',
}
export interface StdQuickReply {
content_type: QuickReplyType;
title: string;
payload: string;
}

View File

@@ -0,0 +1,19 @@
/*
* 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 { CategoryCreateDto } from '../dto/category.dto';
export const categoryModels: CategoryCreateDto[] = [
{
label: 'category1',
},
{
label: 'category2',
},
];

View File

@@ -0,0 +1,22 @@
/*
* 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 { BaseSeeder } from '@/utils/generics/base-seeder';
import { CategoryRepository } from '../repositories/category.repository';
import { Category } from '../schemas/category.schema';
@Injectable()
export class CategorySeeder extends BaseSeeder<Category> {
constructor(private readonly categoryRepository: CategoryRepository) {
super(categoryRepository);
}
}

View File

@@ -0,0 +1,21 @@
/*
* 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 { ContextVarCreateDto } from '../dto/context-var.dto';
export const contextVarModels: ContextVarCreateDto[] = [
{
name: 'phone_number',
label: 'Phone Number',
},
{
name: 'email',
label: 'Email Address',
},
];

View File

@@ -0,0 +1,22 @@
/*
* 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 { BaseSeeder } from '@/utils/generics/base-seeder';
import { ContextVarRepository } from '../repositories/context-var.repository';
import { ContextVar } from '../schemas/context-var.schema';
@Injectable()
export class ContextVarSeeder extends BaseSeeder<ContextVar> {
constructor(private readonly contextVarRepository: ContextVarRepository) {
super(contextVarRepository);
}
}

View File

@@ -0,0 +1,21 @@
/*
* 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 { TranslationCreateDto } from '../dto/translation.dto';
export const translationModels: TranslationCreateDto[] = [
{
str: 'Welcome',
translations: {
en: 'Welcome',
fr: 'Bienvenue',
},
translated: 100,
},
];

View File

@@ -0,0 +1,22 @@
/*
* 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 { BaseSeeder } from '@/utils/generics/base-seeder';
import { TranslationRepository } from '../repositories/translation.repository';
import { Translation } from '../schemas/translation.schema';
@Injectable()
export class TranslationSeeder extends BaseSeeder<Translation> {
constructor(private readonly translationRepository: TranslationRepository) {
super(translationRepository);
}
}

View File

@@ -0,0 +1,543 @@
/*
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import {
subscriberWithLabels,
subscriberWithoutLabels,
} from '@/channel/lib/__test__/subscriber.mock';
import { ContentTypeRepository } from '@/cms/repositories/content-type.repository';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
import { Content, ContentModel } from '@/cms/schemas/content.schema';
import { ContentTypeService } from '@/cms/services/content-type.service';
import { ContentService } from '@/cms/services/content.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import OfflineHandler from '@/extensions/channels/offline/index.channel';
import { OFFLINE_CHANNEL_NAME } from '@/extensions/channels/offline/settings';
import { Offline } from '@/extensions/channels/offline/types';
import OfflineEventWrapper from '@/extensions/channels/offline/wrapper';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { Settings } from '@/setting/schemas/types';
import { SettingService } from '@/setting/services/setting.service';
import {
blockFixtures,
installBlockFixtures,
} from '@/utils/test/fixtures/block';
import { installContentFixtures } from '@/utils/test/fixtures/content';
import {
blockEmpty,
blockGetStarted,
blockProductListMock,
blocks,
} from '@/utils/test/mocks/block';
import {
contextBlankInstance,
contextEmailVarInstance,
contextGetStartedInstance,
} from '@/utils/test/mocks/conversation';
import { nlpEntitiesGreeting } from '@/utils/test/mocks/nlp';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { CategoryRepository } from './../repositories/category.repository';
import { BlockService } from './block.service';
import { CategoryService } from './category.service';
import { BlockRepository } from '../repositories/block.repository';
import { Block, BlockModel } from '../schemas/block.schema';
import { Category, CategoryModel } from '../schemas/category.schema';
import { LabelModel } from '../schemas/label.schema';
import { FileType } from '../schemas/types/attachment';
import { Context } from '../schemas/types/context';
import { PayloadType, StdOutgoingListMessage } from '../schemas/types/message';
describe('BlockService', () => {
let blockRepository: BlockRepository;
let categoryRepository: CategoryRepository;
let category: Category;
let block: Block;
let blockService: BlockService;
let hasPreviousBlocks: Block;
let contentService: ContentService;
let contentTypeService: ContentTypeService;
let settingService: SettingService;
let settings: Settings;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(async () => {
await installContentFixtures();
await installBlockFixtures();
}),
MongooseModule.forFeature([
BlockModel,
CategoryModel,
ContentTypeModel,
ContentModel,
AttachmentModel,
LabelModel,
]),
],
providers: [
BlockRepository,
CategoryRepository,
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
BlockService,
CategoryService,
ContentTypeService,
ContentService,
AttachmentService,
{
provide: PluginService,
useValue: {},
},
LoggerService,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => {
return t === 'Welcome' ? 'Bienvenue' : t;
}),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({
contact: { company_name: 'Your company name' },
})),
},
},
EventEmitter2,
],
}).compile();
blockService = module.get<BlockService>(BlockService);
contentService = module.get<ContentService>(ContentService);
settingService = module.get<SettingService>(SettingService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
blockRepository = module.get<BlockRepository>(BlockRepository);
category = await categoryRepository.findOne({ label: 'default' });
hasPreviousBlocks = await blockRepository.findOne({
name: 'hasPreviousBlocks',
});
block = await blockRepository.findOne({ name: 'hasNextBlocks' });
settings = await settingService.getSettings();
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one block by id, and populate its trigger_labels, assign_labels,attachedBlock,category,nextBlocks', async () => {
jest.spyOn(blockRepository, 'findOneAndPopulate');
const result = await blockService.findOneAndPopulate(block.id);
expect(blockRepository.findOneAndPopulate).toHaveBeenCalledWith(block.id);
expect(result).toEqualPayload({
...blockFixtures.find(({ name }) => name === 'hasNextBlocks'),
category,
nextBlocks: [hasPreviousBlocks],
previousBlocks: [],
});
});
});
describe('findAndPopulate', () => {
it('should find blocks and populate them', async () => {
jest.spyOn(blockRepository, 'findAndPopulate');
const result = await blockService.findAndPopulate({});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category,
previousBlocks:
blockFixture.name === 'hasPreviousBlocks' ? [block] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
}));
expect(blockRepository.findAndPopulate).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory);
});
});
describe('getRandom', () => {
it('should get a random message', () => {
const messages = [
'Hello, this is Nour',
'Oh ! How are you ?',
"Hmmm that's cool !",
'Corona virus',
'God bless you',
];
const result = blockService.getRandom(messages);
expect(messages).toContain(result);
});
it('should return undefined when trying to get a random message from an empty array', () => {
const result = blockService.getRandom([]);
expect(result).toBe(undefined);
});
});
describe('match', () => {
const handlerMock = {
getChannel: jest.fn(() => OFFLINE_CHANNEL_NAME),
} as any as OfflineHandler;
const offlineEventGreeting = new OfflineEventWrapper(
handlerMock,
{
type: Offline.IncomingMessageType.text,
data: {
text: 'Hello',
},
},
{},
);
const offlineEventGetStarted = new OfflineEventWrapper(
handlerMock,
{
type: Offline.IncomingMessageType.postback,
data: {
text: 'Get Started',
payload: 'GET_STARTED',
},
},
{},
);
it('should return undefined when no blocks are provided', async () => {
const result = await blockService.match([], offlineEventGreeting);
expect(result).toBe(undefined);
});
it('should return undefined for empty blocks', async () => {
const result = await blockService.match(
[blockEmpty],
offlineEventGreeting,
);
expect(result).toEqual(undefined);
});
it('should return undefined for no matching labels', async () => {
offlineEventGreeting.setSender(subscriberWithoutLabels);
const result = await blockService.match(blocks, offlineEventGreeting);
expect(result).toEqual(undefined);
});
it('should match block text and labels', async () => {
offlineEventGreeting.setSender(subscriberWithLabels);
const result = await blockService.match(blocks, offlineEventGreeting);
expect(result).toEqual(blockGetStarted);
});
it('should match block with payload', async () => {
offlineEventGetStarted.setSender(subscriberWithLabels);
const result = await blockService.match(blocks, offlineEventGetStarted);
expect(result).toEqual(blockGetStarted);
});
it('should match block with nlp', async () => {
offlineEventGreeting.setSender(subscriberWithLabels);
offlineEventGreeting.setNLP(nlpEntitiesGreeting);
const result = await blockService.match(blocks, offlineEventGreeting);
expect(result).toEqual(blockGetStarted);
});
});
describe('matchNLP', () => {
it('should return undefined for match nlp against a block with no patterns', () => {
const result = blockService.matchNLP(nlpEntitiesGreeting, blockEmpty);
expect(result).toEqual(undefined);
});
it('should return undefined for match nlp when no nlp entities are provided', () => {
const result = blockService.matchNLP({ entities: [] }, blockGetStarted);
expect(result).toEqual(undefined);
});
it('should return match nlp patterns', () => {
const result = blockService.matchNLP(
nlpEntitiesGreeting,
blockGetStarted,
);
expect(result).toEqual([
{
entity: 'intent',
match: 'value',
value: 'greeting',
},
{
entity: 'firstname',
match: 'entity',
},
]);
});
it('should return undefined when it does not match nlp patterns', () => {
const result = blockService.matchNLP(nlpEntitiesGreeting, {
...blockGetStarted,
patterns: [[{ entity: 'lastname', match: 'value', value: 'Belakhel' }]],
});
expect(result).toEqual(undefined);
});
it('should return undefined when unknown nlp patterns', () => {
const result = blockService.matchNLP(nlpEntitiesGreeting, {
...blockGetStarted,
patterns: [[{ entity: 'product', match: 'value', value: 'pizza' }]],
});
expect(result).toEqual(undefined);
});
});
describe('matchPayload', () => {
it('should return undefined for empty payload', () => {
const result = blockService.matchPayload('', blockGetStarted);
expect(result).toEqual(undefined);
});
it('should return undefined for empty block', () => {
const result = blockService.matchPayload('test', blockEmpty);
expect(result).toEqual(undefined);
});
it('should match payload and return object for label string', () => {
const location = {
label: 'Tounes',
value: 'Tounes',
type: 'location',
};
const result = blockService.matchPayload('Tounes', blockGetStarted);
expect(result).toEqual(location);
});
it('should match payload and return object for value string', () => {
const result = blockService.matchPayload('GET_STARTED', blockGetStarted);
expect(result).toEqual({
label: 'Get Started',
value: 'GET_STARTED',
});
});
it("should match payload when it's an attachment location", () => {
const result = blockService.matchPayload(
{
type: PayloadType.location,
coordinates: {
lat: 15,
lon: 23,
},
},
blockGetStarted,
);
expect(result).toEqual(blockGetStarted.patterns[3]);
});
it("should match payload when it's an attachment file", () => {
const result = blockService.matchPayload(
{
type: PayloadType.attachments,
attachments: {
type: FileType.file,
payload: {
url: 'http://link.to/the/file',
},
},
},
blockGetStarted,
);
expect(result).toEqual(blockGetStarted.patterns[4]);
});
});
describe('matchText', () => {
it('should return false for matching an empty text', () => {
const result = blockService.matchText('', blockGetStarted);
expect(result).toEqual(false);
});
it('should match text message', () => {
const result = blockService.matchText('Hello', blockGetStarted);
expect(result).toEqual(['Hello']);
});
it('should match regex text message', () => {
const result = blockService.matchText(
'weeeelcome to our house',
blockGetStarted,
);
expect(result).toEqualPayload(
['weeeelcome'],
['index', 'index', 'input', 'groups'],
);
});
it("should return false when there's no match", () => {
const result = blockService.matchText(
'Goodbye Mr black',
blockGetStarted,
);
expect(result).toEqual(false);
});
it('should return false when matching message against a block with no patterns', () => {
const result = blockService.matchText('Hello', blockEmpty);
expect(result).toEqual(false);
});
});
describe('processMessage', () => {
it('should process list message (with limit = 2 and skip = 0)', async () => {
const contentType = await contentTypeService.findOne({ name: 'Product' });
blockProductListMock.options.content.entity = contentType.id;
const result = await blockService.processMessage(
blockProductListMock,
{
...contextBlankInstance,
skip: { [blockProductListMock.id]: 0 },
},
false,
'conv_id',
);
const elements = await contentService.findPage(
{ status: true, entity: contentType.id },
{ skip: 0, limit: 2, sort: ['createdAt', 'desc'] },
);
const flattenedElements = elements.map((element) =>
Content.flatDynamicFields(element),
);
expect(result.format).toEqualPayload(
blockProductListMock.options.content?.display,
);
expect(
(result.message as StdOutgoingListMessage).elements,
).toEqualPayload(flattenedElements);
expect((result.message as StdOutgoingListMessage).options).toEqualPayload(
blockProductListMock.options.content,
);
expect(
(result.message as StdOutgoingListMessage).pagination,
).toEqualPayload({ total: 4, skip: 0, limit: 2 });
});
it('should process list message (with limit = 2 and skip = 2)', async () => {
const contentType = await contentTypeService.findOne({ name: 'Product' });
blockProductListMock.options.content.entity = contentType.id;
const result = await blockService.processMessage(
blockProductListMock,
{
...contextBlankInstance,
skip: { [blockProductListMock.id]: 2 },
},
false,
'conv_id',
);
const elements = await contentService.findPage(
{ status: true, entity: contentType.id },
{ skip: 2, limit: 2, sort: ['createdAt', 'desc'] },
);
const flattenedElements = elements.map((element) =>
Content.flatDynamicFields(element),
);
expect(result.format).toEqual(
blockProductListMock.options.content?.display,
);
expect((result.message as StdOutgoingListMessage).elements).toEqual(
flattenedElements,
);
expect((result.message as StdOutgoingListMessage).options).toEqual(
blockProductListMock.options.content,
);
expect((result.message as StdOutgoingListMessage).pagination).toEqual({
total: 4,
skip: 2,
limit: 2,
});
});
});
describe('processText', () => {
const context: Context = {
...contextGetStartedInstance,
channel: 'offline',
text: '',
payload: undefined,
nlp: { entities: [] },
vars: { age: 21, email: 'email@example.com' },
user_location: {
address: { address: 'sangafora' },
lat: 23,
lon: 16,
},
user: subscriberWithoutLabels,
skip: { '1': 0 },
attempt: 0,
};
it('should process empty text', () => {
const result = blockService.processText('', context, settings);
expect(result).toEqual('');
});
it('should process text translation', () => {
const translation = { en: 'Welcome', fr: 'Bienvenue' };
const result = blockService.processText(
translation.en,
context,
settings,
);
expect(result).toEqual(translation.fr);
});
it('should process text replacements with ontext vars', () => {
const result = blockService.processText(
'{context.user.first_name} {context.user.last_name}, email : {context.vars.email}',
contextEmailVarInstance,
settings,
);
expect(result).toEqual('John Doe, email : email@example.com');
});
it('should process text replacements with context vars', () => {
const result = blockService.processText(
'{context.user.first_name} {context.user.last_name}, email : {context.vars.email}',
contextEmailVarInstance,
settings,
);
expect(result).toEqual('John Doe, email : email@example.com');
});
it('should process text replacements with settings contact infos', () => {
const result = blockService.processText(
'Trying the settings : the name of company is <<{contact.company_name}>>',
contextBlankInstance,
settings,
);
expect(result).toEqual(
'Trying the settings : the name of company is <<Your company name>>',
);
});
});
});

View File

@@ -0,0 +1,601 @@
/*
* 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 { TFilterQuery } from 'mongoose';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import EventWrapper from '@/channel/lib/EventWrapper';
import { ContentService } from '@/cms/services/content.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { Nlp } from '@/nlp/lib/types';
import { PluginService } from '@/plugins/plugins.service';
import { PluginType } from '@/plugins/types';
import { Settings } from '@/setting/schemas/types';
import { SettingService } from '@/setting/services/setting.service';
import { BaseService } from '@/utils/generics/base-service';
import { BlockRepository } from '../repositories/block.repository';
import { Block, BlockFull } from '../schemas/block.schema';
import { WithUrl } from '../schemas/types/attachment';
import { Context } from '../schemas/types/context';
import {
BlockMessage,
OutgoingMessageFormat,
StdOutgoingEnvelope,
} from '../schemas/types/message';
import { NlpPattern, Pattern, PayloadPattern } from '../schemas/types/pattern';
import { Payload, StdQuickReply } from '../schemas/types/quick-reply';
@Injectable()
export class BlockService extends BaseService<Block> {
constructor(
readonly repository: BlockRepository,
private readonly contentService: ContentService,
private readonly attachmentService: AttachmentService,
private readonly settingService: SettingService,
private readonly pluginService: PluginService,
private readonly logger: LoggerService,
protected readonly i18n: ExtendedI18nService,
) {
super(repository);
}
/**
* Finds and populates blocks based on the specified filters.
*
* @param filters - Query filters used to specify search criteria for finding blocks.
*
* @returns A promise that resolves to the populated blocks matching the filters.
*/
async findAndPopulate(filters: TFilterQuery<Block>) {
return await this.repository.findAndPopulate(filters);
}
/**
* Finds and populates a block by ID.
*
* @param id - The block ID.
*
* @returns A promise that resolves to the populated block.
*/
async findOneAndPopulate(id: string) {
return await this.repository.findOneAndPopulate(id);
}
/**
* Find a block whose patterns matches the received event
*
* @param blocks blocks Starting/Next blocks in the conversation flow
* @param event Received channel's message
*
* @returns The block that matches
*/
async match(
blocks: BlockFull[],
event: EventWrapper<any, any>,
): Promise<BlockFull | undefined> {
// Search for block matching a given event
let block: BlockFull | undefined = undefined;
const payload = event.getPayload();
// Perform a filter on the specific channels
const channel = event.getHandler().getChannel();
blocks = blocks.filter((b) => {
return (
!b.trigger_channels ||
b.trigger_channels.length === 0 ||
b.trigger_channels.includes(channel)
);
});
// Perform a filter on trigger labels
let userLabels: string[] = [];
const profile = event.getSender();
if (profile && Array.isArray(profile.labels)) {
userLabels = profile.labels.map((l) => l);
}
blocks = blocks
.filter((b) => {
const trigger_labels = b.trigger_labels.map(({ id }) => id);
return (
trigger_labels.length === 0 ||
trigger_labels.some((l) => userLabels.includes(l))
);
})
// Priority goes to block who target users with labels
.sort((a, b) => b.trigger_labels.length - a.trigger_labels.length);
// Perform a payload match & pick last createdAt
if (payload) {
block = blocks
.filter((b) => {
return this.matchPayload(payload, b);
})
.shift();
}
if (!block) {
// Perform a text match (Text or Quick reply)
const text = event.getText().trim();
// Check & catch user language through NLP
const nlp = event.getNLP();
if (nlp) {
const settings = await this.settingService.getSettings();
const lang = nlp.entities.find((e) => e.entity === 'language');
if (
lang &&
settings.nlp_settings.languages.indexOf(lang.value) !== -1
) {
const profile = event.getSender();
profile.language = lang.value;
event.setSender(profile);
}
}
// Perform a text pattern match
block = blocks
.filter((b) => {
return this.matchText(text, b);
})
.shift();
// Perform an NLP Match
if (!block && nlp) {
// Find block pattern having the best match of nlp entities
let nlpBest = 0;
blocks.forEach((b, index, self) => {
const nlpPattern = this.matchNLP(nlp, b);
if (nlpPattern && nlpPattern.length > nlpBest) {
nlpBest = nlpPattern.length;
block = self[index];
}
});
}
}
// Uknown event type => return false;
// this.logger.error('Unable to recognize event type while matching', event);
return block;
}
/**
* Performs a payload pattern match for the provided block
*
* @param payload - The payload
* @param block - The block
*
* @returns The payload pattern if there's a match
*/
matchPayload(
payload: string | Payload,
block: BlockFull | Block,
): PayloadPattern | undefined {
const payloadPatterns = block.patterns.filter(
(p) => typeof p === 'object' && 'label' in p,
) as PayloadPattern[];
return payloadPatterns.find((pt: PayloadPattern) => {
// Either button postback payload Or content payload (ex. BTN_TITLE:CONTENT_PAYLOAD)
return (
(typeof payload === 'string' &&
pt.value &&
(pt.value === payload || payload.startsWith(pt.value + ':'))) ||
// Or attachment postback (ex. Like location quick reply for example)
(typeof payload === 'object' && pt.type && pt.type === payload.type)
);
});
}
/**
* Checks if the block has matching text/regex patterns
*
* @param text - The received text message
* @param block - The block to check against
*
* @returns False if no match, string/regex capture else
*/
matchText(
text: string,
block: Block | BlockFull,
): (RegExpMatchArray | string)[] | false {
// Filter text patterns & Instanciate Regex patterns
const patterns: (string | RegExp | Pattern)[] = block.patterns.map(
(pattern) => {
if (
typeof pattern === 'string' &&
pattern.endsWith('/') &&
pattern.startsWith('/')
) {
return new RegExp(pattern.slice(1, -1), 'i');
}
return pattern;
},
);
// Return first match
for (let i = 0; i < patterns.length; i++) {
const pattern = patterns[i];
if (pattern instanceof RegExp) {
if (pattern.test(text)) {
const matches = text.match(pattern);
if (matches) {
if (matches.length >= 2) {
// Remove global match if needed
matches.shift();
}
return matches;
}
}
continue;
} else if (
typeof pattern === 'object' &&
'label' in pattern &&
text.trim().toLowerCase() === pattern.label.toLowerCase()
) {
// Payload (quick reply)
return [text];
} else if (
typeof pattern === 'string' &&
text.trim().toLowerCase() === pattern.toLowerCase()
) {
// Equals
return [text];
}
// @deprecated
// else if (
// typeof pattern === 'string' &&
// Soundex(text) === Soundex(pattern)
// ) {
// // Sound like
// return [text];
// }
}
// No match
return false;
}
/**
* Performs an NLP pattern match based on the best guessed entities and/or values
*
* @param nlp - Parsed NLP entities
* @param block - The block to test
*
* @returns The NLP patterns that matches
*/
matchNLP(
nlp: Nlp.ParseEntities,
block: Block | BlockFull,
): NlpPattern[] | undefined {
// No nlp entities to check against
if (nlp.entities.length === 0) {
return undefined;
}
const nlpPatterns = block.patterns.filter((p) => {
return Array.isArray(p);
}) as NlpPattern[][];
// No nlp patterns found
if (nlpPatterns.length === 0) {
return undefined;
}
// Find NLP pattern match based on best guessed entities
return nlpPatterns.find((entities: NlpPattern[]) => {
return entities.every((ev: NlpPattern) => {
if (ev.match === 'value') {
return nlp.entities.find((e) => {
return e.entity === ev.entity && e.value === ev.value;
});
} else if (ev.match === 'entity') {
return nlp.entities.find((e) => {
return e.entity === ev.entity;
});
} else {
this.logger.warn('Block Service : Unknown NLP match type', ev);
return false;
}
});
});
}
/**
* Replaces tokens with their context variables values in the provided text message
*
* `You phone number is {context.vars.phone}`
* Becomes
* `You phone number is 6354-543-534`
*
* @param text - Text message
* @param context - Variable holding context values relative to the subscriber
*
* @returns Text message with the tokens being replaced
*/
processTokenReplacements(
text: string,
context: Context,
settings: Settings,
): string {
// Replace context tokens with their values
Object.keys(context.vars || {}).forEach((key) => {
if (
typeof context.vars[key] === 'string' &&
context.vars[key].indexOf(':') !== -1
) {
const tmp = context.vars[key].split(':');
context.vars[key] = tmp[1];
}
text = text.replace(
'{context.vars.' + key + '}',
typeof context.vars[key] === 'string'
? context.vars[key]
: JSON.stringify(context.vars[key]),
);
});
// Replace context tokens about user location
if (context.user_location) {
if (context.user_location.address) {
const userAddress = context.user_location.address;
Object.keys(userAddress).forEach((key) => {
text = text.replace(
'{context.user_location.address.' + key + '}',
typeof userAddress[key] === 'string'
? userAddress[key]
: JSON.stringify(userAddress[key]),
);
});
}
text = text.replace(
'{context.user_location.lat}',
context.user_location.lat.toString(),
);
text = text.replace(
'{context.user_location.lon}',
context.user_location.lon.toString(),
);
}
// Replace tokens for user infos
Object.keys(context.user).forEach((key) => {
const userAttr = (context.user as any)[key];
text = text.replace(
'{context.user.' + key + '}',
typeof userAttr === 'string' ? userAttr : JSON.stringify(userAttr),
);
});
// Replace contact infos tokens with their values
Object.keys(settings.contact).forEach((key) => {
text = text.replace('{contact.' + key + '}', settings.contact[key]);
});
return text;
}
/**
* Translates and replaces tokens with context variables values
*
* @param text - Text to process
* @param context - The context object
*
* @returns The text message translated and tokens being replaces with values
*/
processText(text: string, context: Context, settings: Settings): string {
const lang =
context && context.user && context.user.language
? context.user.language
: settings.nlp_settings.default_lang;
// Translate
text = this.i18n.t(text, { lang, defaultValue: text });
// Replace context tokens
text = this.processTokenReplacements(text, context, settings);
return text;
}
/**
* Return a randomly picked item of the array
*
* @param array - Array of any type
*
* @returns A random item from the array
*/
getRandom<T>(array: T[]): T {
return Array.isArray(array)
? array[Math.floor(Math.random() * array.length)]
: array;
}
/**
* Logs a warning message
*/
checkDeprecatedAttachmentUrl(block: Block | BlockFull) {
if (
block.message &&
'attachment' in block.message &&
'url' in block.message.attachment.payload
) {
this.logger.error(
'Attachment Model : `url` payload has been deprecated in favor of `attachment_id`',
block.id,
block.message,
);
}
}
/**
* Processes a block message based on the format.
*
* @param block - The block holding the message to process
* @param context - Context object
* @param fallback - Whenever to process main message or local fallback message
* @param conversationId - The conversation ID
*
* @returns - Envelope containing message format and content following {format, message} object structure
*/
async processMessage(
block: Block | BlockFull,
context: Context,
fallback = false,
conversationId?: string,
): Promise<StdOutgoingEnvelope> {
const settings = await this.settingService.getSettings();
const blockMessage: BlockMessage =
fallback && block.options.fallback
? [...block.options.fallback.message]
: Array.isArray(block.message)
? [...block.message]
: { ...block.message };
if (Array.isArray(blockMessage)) {
// Text Message
// Get random message from array
const text = this.processText(
this.getRandom(blockMessage),
context,
settings,
);
const envelope: StdOutgoingEnvelope = {
format: OutgoingMessageFormat.text,
message: { text },
};
return envelope;
} else if (blockMessage && 'text' in blockMessage) {
if (
'quickReplies' in blockMessage &&
Array.isArray(blockMessage.quickReplies) &&
blockMessage.quickReplies.length > 0
) {
const envelope: StdOutgoingEnvelope = {
format: OutgoingMessageFormat.quickReplies,
message: {
text: this.processText(blockMessage.text, context, settings),
quickReplies: blockMessage.quickReplies.map((qr: StdQuickReply) => {
return qr.title
? {
...qr,
title: this.processText(qr.title, context, settings),
}
: qr;
}),
},
};
return envelope;
} else if (
'buttons' in blockMessage &&
Array.isArray(blockMessage.buttons) &&
blockMessage.buttons.length > 0
) {
const envelope: StdOutgoingEnvelope = {
format: OutgoingMessageFormat.buttons,
message: {
text: this.processText(blockMessage.text, context, settings),
buttons: blockMessage.buttons.map((btn) => {
return btn.title
? {
...btn,
title: this.processText(btn.title, context, settings),
}
: btn;
}),
},
};
return envelope;
}
} else if (blockMessage && 'attachment' in blockMessage) {
const attachmentPayload = blockMessage.attachment.payload;
if (!attachmentPayload.attachment_id) {
this.checkDeprecatedAttachmentUrl(block);
throw new Error('Remote attachments are no longer supported!');
}
const attachment = await this.attachmentService.findOne(
attachmentPayload.attachment_id,
);
if (!attachment) {
this.logger.debug(
'Unable to locate the attachment for the given block',
block,
);
throw new Error('Unable to find attachment.');
}
const envelope: StdOutgoingEnvelope = {
format: OutgoingMessageFormat.attachment,
message: {
attachment: {
type: blockMessage.attachment.type,
payload: attachment as WithUrl<Attachment>,
},
quickReplies: blockMessage.quickReplies
? [...blockMessage.quickReplies]
: undefined,
},
};
return envelope;
} else if (
blockMessage &&
'elements' in blockMessage &&
block.options.content
) {
const contentBlockOptions = block.options.content;
// Hadnle pagination for list/carousel
let skip = 0;
if (
contentBlockOptions.display === OutgoingMessageFormat.list ||
contentBlockOptions.display === OutgoingMessageFormat.carousel
) {
skip =
context.skip && context.skip[block.id] ? context.skip[block.id] : 0;
}
// Populate list with content
try {
const results = await this.contentService.getContent(
contentBlockOptions,
skip,
);
const envelope: StdOutgoingEnvelope = {
format: contentBlockOptions.display,
message: {
...results,
options: contentBlockOptions,
},
};
return envelope;
} catch (err) {
this.logger.error(
'Unable to retrieve content for list template process',
err,
);
throw err;
}
} else if (blockMessage && 'plugin' in blockMessage) {
const plugin = this.pluginService.findPlugin(
PluginType.block,
blockMessage.plugin,
);
// Process custom plugin block
try {
return await plugin.process(block, context, conversationId);
} catch (e) {
this.logger.error('Plugin was unable to load/process ', e);
throw new Error(`Unknown plugin - ${JSON.stringify(blockMessage)}`);
}
} else {
throw new Error('Invalid message format.');
}
}
}

View File

@@ -0,0 +1,329 @@
/*
* 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 { 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 { ContentTypeRepository } from '@/cms/repositories/content-type.repository';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { MenuRepository } from '@/cms/repositories/menu.repository';
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
import { ContentModel } from '@/cms/schemas/content.schema';
import { MenuModel } from '@/cms/schemas/menu.schema';
import { ContentTypeService } from '@/cms/services/content-type.service';
import { ContentService } from '@/cms/services/content.service';
import { MenuService } from '@/cms/services/menu.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { offlineEventText } from '@/extensions/channels/offline/__test__/events.mock';
import OfflineHandler from '@/extensions/channels/offline/index.channel';
import OfflineEventWrapper from '@/extensions/channels/offline/wrapper';
import { LoggerService } from '@/logger/logger.service';
import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository';
import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository';
import { NlpSampleRepository } from '@/nlp/repositories/nlp-sample.repository';
import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository';
import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema';
import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema';
import { NlpSampleModel } from '@/nlp/schemas/nlp-sample.schema';
import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema';
import { NlpEntityService } from '@/nlp/services/nlp-entity.service';
import { NlpSampleEntityService } from '@/nlp/services/nlp-sample-entity.service';
import { NlpSampleService } from '@/nlp/services/nlp-sample.service';
import { NlpValueService } from '@/nlp/services/nlp-value.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
import { installBlockFixtures } from '@/utils/test/fixtures/block';
import { installContentFixtures } from '@/utils/test/fixtures/content';
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { SocketEventDispatcherService } from '@/websocket/services/socket-event-dispatcher.service';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { CategoryRepository } from './../repositories/category.repository';
import { BlockService } from './block.service';
import { BotService } from './bot.service';
import { CategoryService } from './category.service';
import { ConversationService } from './conversation.service';
import { MessageService } from './message.service';
import { SubscriberService } from './subscriber.service';
import { BlockRepository } from '../repositories/block.repository';
import { ConversationRepository } from '../repositories/conversation.repository';
import { MessageRepository } from '../repositories/message.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { BlockFull, BlockModel } from '../schemas/block.schema';
import { CategoryModel } from '../schemas/category.schema';
import {
Conversation,
ConversationFull,
ConversationModel,
} from '../schemas/conversation.schema';
import { LabelModel } from '../schemas/label.schema';
import { MessageModel } from '../schemas/message.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
describe('BlockService', () => {
let blockService: BlockService;
let subscriberService: SubscriberService;
let botService: BotService;
let handler: OfflineHandler;
let eventEmitter: EventEmitter2;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(async () => {
await installSubscriberFixtures();
await installContentFixtures();
await installBlockFixtures();
}),
MongooseModule.forFeature([
BlockModel,
CategoryModel,
ContentTypeModel,
ContentModel,
AttachmentModel,
LabelModel,
ConversationModel,
SubscriberModel,
MessageModel,
MenuModel,
NlpValueModel,
NlpEntityModel,
NlpSampleEntityModel,
NlpSampleModel,
]),
],
providers: [
EventEmitter2,
BlockRepository,
CategoryRepository,
WebsocketGateway,
SocketEventDispatcherService,
ConversationRepository,
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
SubscriberRepository,
MessageRepository,
MenuRepository,
NlpValueRepository,
NlpEntityRepository,
NlpSampleEntityRepository,
NlpSampleRepository,
BlockService,
CategoryService,
ContentTypeService,
ContentService,
AttachmentService,
SubscriberService,
ConversationService,
BotService,
ChannelService,
MessageService,
MenuService,
OfflineHandler,
NlpValueService,
NlpEntityService,
NlpSampleEntityService,
NlpSampleService,
NlpService,
{
provide: PluginService,
useValue: {},
},
LoggerService,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({
contact: { company_name: 'Your company name' },
})),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
subscriberService = module.get<SubscriberService>(SubscriberService);
botService = module.get<BotService>(BotService);
blockService = module.get<BlockService>(BlockService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
handler = module.get<OfflineHandler>(OfflineHandler);
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
it('should start a conversation', async () => {
const triggeredEvents: any[] = [];
eventEmitter.on('hook:stats:entry', (...args) => {
triggeredEvents.push(args);
});
const event = new OfflineEventWrapper(handler, offlineEventText, {
isSocket: false,
ipAddress: '1.1.1.1',
});
const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] });
const offlineSubscriber = await subscriberService.findOne({
foreign_id: 'foreign-id-offline-1',
});
event.setSender(offlineSubscriber);
let hasBotSpoken = false;
const clearMock = jest
.spyOn(botService, 'findBlockAndSendReply')
.mockImplementation(
(
actualEvent: OfflineEventWrapper,
actualConversation: Conversation,
actualBlock: BlockFull,
isFallback: boolean,
) => {
expect(actualConversation).toEqualPayload({
sender: offlineSubscriber.id,
active: true,
next: [],
context: {
user: {
first_name: offlineSubscriber.first_name,
last_name: offlineSubscriber.last_name,
language: 'en',
id: offlineSubscriber.id,
},
user_location: {
lat: 0,
lon: 0,
},
vars: {},
nlp: null,
payload: null,
attempt: 0,
channel: 'offline',
text: offlineEventText.data.text,
},
});
expect(actualEvent).toEqual(event);
expect(actualBlock).toEqual(block);
expect(isFallback).toEqual(false);
hasBotSpoken = true;
},
);
await botService.startConversation(event, block);
expect(hasBotSpoken).toEqual(true);
expect(triggeredEvents).toEqual([
['popular', 'hasNextBlocks'],
['new_conversations', 'New conversations'],
]);
clearMock.mockClear();
});
it('should capture a conversation', async () => {
const triggeredEvents: any[] = [];
eventEmitter.on('hook:stats:entry', (...args) => {
triggeredEvents.push(args);
});
const event = new OfflineEventWrapper(handler, offlineEventText, {
isSocket: false,
ipAddress: '1.1.1.1',
});
const offlineSubscriber = await subscriberService.findOne({
foreign_id: 'foreign-id-offline-1',
});
event.setSender(offlineSubscriber);
const clearMock = jest
.spyOn(botService, 'handleIncomingMessage')
.mockImplementation(
async (
actualConversation: ConversationFull,
event: OfflineEventWrapper,
) => {
expect(actualConversation).toEqualPayload({
next: [],
sender: offlineSubscriber,
active: true,
context: {
user: {
first_name: offlineSubscriber.first_name,
last_name: offlineSubscriber.last_name,
language: 'en',
id: offlineSubscriber.id,
},
user_location: { lat: 0, lon: 0 },
vars: {},
nlp: null,
payload: null,
attempt: 0,
channel: 'offline',
text: offlineEventText.data.text,
},
});
expect(event).toEqual(event);
return true;
},
);
const captured = await botService.processConversationMessage(event);
expect(captured).toBe(true);
expect(triggeredEvents).toEqual([
['existing_conversations', 'Existing conversations'],
]);
clearMock.mockClear();
});
it('has no active conversation', async () => {
const triggeredEvents: any[] = [];
eventEmitter.on('hook:stats:entry', (...args) => {
triggeredEvents.push(args);
});
const event = new OfflineEventWrapper(handler, offlineEventText, {
isSocket: false,
ipAddress: '1.1.1.1',
});
const offlineSubscriber = await subscriberService.findOne({
foreign_id: 'foreign-id-offline-2',
});
event.setSender(offlineSubscriber);
const captured = await botService.processConversationMessage(event);
expect(captured).toBe(false);
expect(triggeredEvents).toEqual([]);
});
});

View File

@@ -0,0 +1,506 @@
/*
* 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 EventWrapper from '@/channel/lib/EventWrapper';
import { LoggerService } from '@/logger/logger.service';
import { Settings } from '@/setting/schemas/types';
import { SettingService } from '@/setting/services/setting.service';
import { BlockService } from './block.service';
import { ConversationService } from './conversation.service';
import { SubscriberService } from './subscriber.service';
import { MessageCreateDto } from '../dto/message.dto';
import { BlockFull } from '../schemas/block.schema';
import {
Conversation,
ConversationFull,
getDefaultConversationContext,
} from '../schemas/conversation.schema';
import { Context } from '../schemas/types/context';
import {
IncomingMessageType,
StdOutgoingEnvelope,
} from '../schemas/types/message';
@Injectable()
export class BotService {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService,
private readonly blockService: BlockService,
private readonly conversationService: ConversationService,
private readonly subscriberService: SubscriberService,
private readonly settingService: SettingService,
) {}
/**
* Sends a processed message to the user based on a specified content block.
* Replaces tokens within the block with context data, handles fallback scenarios,
* and assigns relevant labels to the user.
*
* @param event - The incoming message or action that triggered the bot's response.
* @param block - The content block containing the message and options to be sent.
* @param context - Optional. The conversation context object, containing relevant data for personalization.
* @param fallback - Optional. Boolean flag indicating if this is a fallback message when no appropriate response was found.
* @param conversationId - Optional. The conversation ID to link the message to a specific conversation thread.
*
* @returns A promise that resolves with the message response, including the message ID.
*/
async sendMessageToSubscriber(
event: EventWrapper<any, any>,
block: BlockFull,
context?: Context,
fallback?: boolean,
conservationId?: string,
) {
context = context || getDefaultConversationContext();
fallback = typeof fallback !== 'undefined' ? fallback : false;
const options = block.options;
this.logger.debug(
'Bot service : Sending message ... ',
event.getSenderForeignId(),
);
// Process message : Replace tokens with context data and then send the message
const envelope: StdOutgoingEnvelope =
await this.blockService.processMessage(
block,
context,
fallback,
conservationId,
);
// Send message through the right channel
const response = await event
.getHandler()
.sendMessage(event, envelope, options, context);
this.eventEmitter.emit('hook:stats:entry', 'outgoing', 'Outgoing');
this.eventEmitter.emit('hook:stats:entry', 'all_messages', 'All Messages');
// Trigger sent message event
const recipient = event.getSender();
const sentMessage: MessageCreateDto = {
mid: response && 'mid' in response ? response.mid : '',
message: envelope.message,
recipient: recipient.id,
handover: !!(options && options.assignTo),
read: false,
delivery: false,
};
this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event);
// analytics log block or local fallback
if (fallback) {
this.eventEmitter.emit(
'hook:analytics:fallback-local',
block,
event,
context,
);
} else {
this.eventEmitter.emit('hook:analytics:block', block, event, context);
}
// Apply updates : Assign block labels to user
const blockLabels = (block.assign_labels || []).map(({ id }) => id);
const assignTo = block.options.assignTo || null;
await this.subscriberService.applyUpdates(
event.getSender(),
blockLabels,
assignTo,
);
this.logger.debug('Bot service : Assigned labels ', blockLabels);
return response;
}
/**
* Finds an appropriate reply block and sends it to the user.
* If there are additional blocks or attached blocks, it continues the conversation flow.
* Ends the conversation if no further blocks are available.
*
* @param event - The incoming message or action that initiated this response.
* @param convo - The current conversation context and flow.
* @param block - The content block to be processed and sent.
* @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found.
*
* @returns A promise that continues or ends the conversation based on available blocks.
*/
async findBlockAndSendReply(
event: EventWrapper<any, any>,
convo: Conversation,
block: BlockFull,
fallback: boolean,
) {
try {
await this.sendMessageToSubscriber(
event,
block,
convo.context,
fallback,
convo.id,
);
if (block.attachedBlock) {
// Sequential messaging ?
try {
const attachedBlock = await this.blockService.findOneAndPopulate(
block.attachedBlock.id,
);
if (!attachedBlock) {
throw new Error(
'No attached block to be found with id ' + block.attachedBlock,
);
}
return await this.findBlockAndSendReply(
event,
convo,
attachedBlock,
fallback,
);
} catch (err) {
this.logger.error('Unable to retrieve attached block', err);
this.eventEmitter.emit('hook:conversation:end', convo, true);
}
} else if (
Array.isArray(block.nextBlocks) &&
block.nextBlocks.length > 0
) {
// Conversation continues : Go forward to next blocks
this.logger.debug('Conversation continues ...', convo.id);
const nextIds = block.nextBlocks.map(({ id }) => id);
try {
await this.conversationService.updateOne(convo.id, {
current: block.id,
next: nextIds,
});
} catch (err) {
this.logger.error(
'Unable to update conversation when going next',
convo,
err,
);
return;
}
} else {
// We need to end the conversation in this case
this.logger.debug('No attached/next blocks to execute ...');
this.eventEmitter.emit('hook:conversation:end', convo, false);
}
} catch (err) {
this.logger.error('Unable to process/send message.', err);
this.eventEmitter.emit('hook:conversation:end', convo, true);
}
}
/**
* Processes and responds to an incoming message within an ongoing conversation flow.
* Determines the next block in the conversation, attempts to match the message with available blocks,
* and handles fallback scenarios if no match is found.
*
* @param convo - The current conversation object, representing the flow and context of the dialogue.
* @param event - The incoming message or action that triggered this response.
*
* @returns A promise that resolves with a boolean indicating whether the conversation is active and a matching block was found.
*/
async handleIncomingMessage(
convo: ConversationFull,
event: EventWrapper<any, any>,
) {
const nextIds = convo.next.map(({ id }) => id);
// Reload blocks in order to populate his nextBlocks
// nextBlocks & trigger/assign _labels
try {
const nextBlocks = await this.blockService.findAndPopulate({
_id: { $in: nextIds },
});
let fallback = false;
const fallbackOptions =
convo.current && convo.current.options.fallback
? convo.current.options.fallback
: {
active: false,
max_attempts: 0,
};
// Find the next block that matches
const matchedBlock = await this.blockService.match(nextBlocks, event);
// If there is no match in next block then loopback (current fallback)
// This applies only to text messages + there's a max attempt to be specified
let fallbackBlock: BlockFull | undefined;
if (
!matchedBlock &&
event.getMessageType() === IncomingMessageType.message &&
fallbackOptions.active &&
convo.context.attempt < fallbackOptions.max_attempts
) {
// Trigger block fallback
// NOTE : current is not populated, this may cause some anomaly
const currentBlock = convo.current;
fallbackBlock = {
...currentBlock,
nextBlocks: convo.next,
// If there's labels, they should be already have been assigned
assign_labels: [],
trigger_labels: [],
attachedBlock: undefined,
category: undefined,
previousBlocks: [],
};
convo.context.attempt++;
fallback = true;
} else {
convo.context.attempt = 0;
fallbackBlock = undefined;
}
const next = matchedBlock || fallbackBlock;
this.logger.debug('Responding ...', convo.id);
if (next) {
// Increment stats about popular blocks
this.eventEmitter.emit('hook:stats:entry', 'popular', next.name);
// Go next!
this.logger.debug('Respond to nested conversion! Go next ', next.id);
try {
const updatedConversation =
await this.conversationService.storeContextData(
convo,
next,
event,
// If this is a local fallback then we don't capture vars
// Otherwise, old captured const value may be replaced by another const value
!fallback,
);
await this.findBlockAndSendReply(
event,
updatedConversation,
next,
fallback,
);
} catch (err) {
this.logger.error('Unable to store context data!', err);
return this.eventEmitter.emit('hook:conversation:end', convo, true);
}
return true;
} else {
// Conversation is still active, but there's no matching block to call next
// We'll end the conversation but this message is probably lost in time and space.
this.logger.debug('No matching block found to call next ', convo.id);
this.eventEmitter.emit('hook:conversation:end', convo, false);
return false;
}
} catch (err) {
this.logger.error('Unable to populate the next blocks!', err);
this.eventEmitter.emit('hook:conversation:end', convo, true);
throw err;
}
}
/**
* Determines if the incoming message belongs to an active conversation and processes it accordingly.
* If an active conversation is found, the message is handled as part of that conversation.
*
* @param event - The incoming message or action from the subscriber.
*
* @returns A promise that resolves with the conversation's response or false if no active conversation is found.
*/
async processConversationMessage(event: EventWrapper<any, any>) {
this.logger.debug(
'Is this message apart of an active conversation ? Searching ... ',
);
const subscriber = event.getSender();
try {
const conversation = await this.conversationService.findOneAndPopulate({
sender: subscriber.id,
active: true,
});
// No active conversation found
if (!conversation) {
this.logger.debug('No active conversation found ', subscriber.id);
return false;
}
this.eventEmitter.emit(
'hook:stats:entry',
'existing_conversations',
'Existing conversations',
);
this.logger.debug('Conversation has been captured! Responding ...');
return await this.handleIncomingMessage(conversation, event);
} catch (err) {
this.logger.error(
'An error occured when searching for a conversation ',
err,
);
return null;
}
}
/**
* Create a new conversation starting from a given block (entrypoint)
*
* @param event - Incoming message/action
* @param block - Starting block
*/
async startConversation(event: EventWrapper<any, any>, block: BlockFull) {
// Increment popular stats
this.eventEmitter.emit('hook:stats:entry', 'popular', block.name);
// Launching a new conversation
const subscriber = event.getSender();
try {
const convo = await this.conversationService.create({
sender: subscriber.id,
});
this.eventEmitter.emit(
'hook:stats:entry',
'new_conversations',
'New conversations',
);
try {
const updatedConversation =
await this.conversationService.storeContextData(
convo,
block,
event,
true,
);
this.logger.debug(
'Bot service : Started a new conversation with ',
subscriber.id,
block.name,
);
return this.findBlockAndSendReply(
event,
updatedConversation,
block,
false,
);
} catch (err) {
this.logger.error('Bot service : Unable to store context data!', err);
this.eventEmitter.emit('hook:conversation:end', convo, true);
}
} catch (err) {
this.logger.error(
'Botservice : Unable to start a new conversation with ',
err,
);
}
}
/**
* Return global fallback block
*
* @param settings - The app settings
*
* @returns The global fallback block
*/
async getGlobalFallbackBlock(settings: Settings) {
const chatbot_settings = settings.chatbot_settings;
if (chatbot_settings.fallback_block) {
const block = await this.blockService.findOneAndPopulate(
chatbot_settings.fallback_block,
);
if (!block) {
throw new Error('Unable to retrieve global fallback block.');
}
return block;
}
throw new Error('No global fallback block is defined.');
}
/**
* Processes incoming message event from a given channel
*
* @param event - Incoming message/action
*/
async handleMessageEvent(event: EventWrapper<any, any>) {
const settings = await this.settingService.getSettings();
try {
const captured = await this.processConversationMessage(event);
if (captured) {
return;
}
// Search for entry blocks
try {
const blocks = await this.blockService.findAndPopulate({
starts_conversation: true,
});
if (!blocks.length) {
return this.logger.debug('No starting message blocks was found');
}
// Search for a block match
const block = await this.blockService.match(blocks, event);
// No block match
if (!block) {
this.logger.debug('No message blocks available!');
if (
settings.chatbot_settings &&
settings.chatbot_settings.global_fallback
) {
this.eventEmitter.emit('hook:analytics:fallback-global', event);
this.logger.debug('Sending global fallback message ...');
// If global fallback is defined in a block then launch a new conversation
// Otherwise, send a simple text message as defined in global settings
try {
const fallbackBlock = await this.getGlobalFallbackBlock(settings);
return this.startConversation(event, fallbackBlock);
} catch (err) {
this.logger.warn(
'No global fallback block defined, sending a message ...',
err,
);
this.sendMessageToSubscriber(event, {
id: 'global-fallback',
name: 'Global Fallback',
message: settings.chatbot_settings.fallback_message,
options: {},
patterns: [],
assign_labels: [],
starts_conversation: false,
position: { x: 0, y: 0 },
capture_vars: [],
builtin: true,
createdAt: new Date(),
updatedAt: new Date(),
attachedBlock: null,
} as any as BlockFull);
}
}
// Do nothing ...
return;
}
this.startConversation(event, block);
} catch (err) {
this.logger.error(
'An error occured while retrieving starting message blocks ',
err,
);
}
} catch (err) {
this.logger.debug(
'Either something went wrong, no active conservation was found or user changed subject',
err,
);
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* 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 { BaseService } from '@/utils/generics/base-service';
import { CategoryRepository } from '../repositories/category.repository';
import { Category } from '../schemas/category.schema';
@Injectable()
export class CategoryService extends BaseService<Category> {
constructor(readonly repository: CategoryRepository) {
super(repository);
}
}

View File

@@ -0,0 +1,305 @@
/*
* 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, OnEvent } from '@nestjs/event-emitter';
import EventWrapper from '@/channel/lib/EventWrapper';
import { config } from '@/config';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { BotService } from './bot.service';
import { ConversationService } from './conversation.service';
import { MessageService } from './message.service';
import { SubscriberService } from './subscriber.service';
import { MessageCreateDto } from '../dto/message.dto';
import { Conversation } from '../schemas/conversation.schema';
import { Subscriber } from '../schemas/subscriber.schema';
import { OutgoingMessage } from '../schemas/types/message';
@Injectable()
export class ChatService {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService,
private readonly conversationService: ConversationService,
private readonly messageService: MessageService,
private readonly subscriberService: SubscriberService,
private readonly botService: BotService,
private readonly websocketGateway: WebsocketGateway,
private readonly nlpService: NlpService,
) {}
/**
* Ends a given conversation (sets active to false)
*
* @param convo - The conversation to end
*/
@OnEvent('hook:conversation:end')
async handleEndConversation(convo: Conversation) {
try {
await this.conversationService.end(convo);
this.logger.debug('Conversation has ended successfully.', convo.id);
} catch (err) {
this.logger.error('Unable to end conversation !', convo.id);
}
}
/**
* Ends a given conversation (sets active to false)
*
* @param convoId - The conversation ID
*/
@OnEvent('hook:conversation:close')
async handleCloseConversation(convoId: string) {
try {
await this.conversationService.deleteOne(convoId);
this.logger.debug('Conversation is closed successfully.', convoId);
} catch (err) {
this.logger.error('Unable to close conversation.', err);
}
}
/**
* Finds or creates a message and broadcast it to the websocket "Message" room
*
* @param sentMessage - The message that has been sent
*/
@OnEvent('hook:chatbot:sent')
async handleSentMessage(sentMessage: MessageCreateDto) {
if (sentMessage.mid) {
try {
const message = await this.messageService.findOneOrCreate(
{
mid: sentMessage.mid,
},
sentMessage,
);
this.websocketGateway.broadcastMessageSent(message as OutgoingMessage);
this.logger.debug('Message has been logged.', sentMessage.mid);
} catch (err) {
this.logger.error('Unable to log sent message.', err);
}
}
}
/**
* Creates the received message and broadcast it to the websocket "Message" room
*
* @param event - The received event
*/
@OnEvent('hook:chatbot:received')
async handleReceivedMessage(event: EventWrapper<any, any>) {
let messageId = '';
this.logger.debug('Received message', event);
try {
messageId = event.getId();
} catch (err) {
this.logger.warn('Failed to get the event id', messageId);
}
const subscriber = event.getSender();
const received: MessageCreateDto = {
mid: messageId,
sender: subscriber.id,
message: event.getMessage(),
delivery: true,
read: true,
};
this.logger.debug('Logging message', received);
try {
const msg = await this.messageService.create(received);
const populatedMsg = await this.messageService.findOneAndPopulate(msg.id);
this.websocketGateway.broadcastMessageReceived(populatedMsg, subscriber);
this.eventEmitter.emit('hook:stats:entry', 'incoming', 'Incoming');
this.eventEmitter.emit(
'hook:stats:entry',
'all_messages',
'All Messages',
);
} catch (err) {
this.logger.error('Unable to log received message.', err, event);
}
}
/**
* Marks messages as delivered and broadcast it to the websocket "Message" room
*
* @param event - The received event
*/
@OnEvent('hook:chatbot:delivery')
async handleMessageDelivery(event: EventWrapper<any, any>) {
if (config.chatbot.messages.track_delivery) {
const subscriber = event.getSender();
const deliveredMessages = event.getDeliveredMessages();
try {
await this.messageService.updateMany(
{ mid: { $in: deliveredMessages } },
{ delivery: true },
);
this.websocketGateway.broadcastMessageDelivered(
deliveredMessages,
subscriber,
);
} catch (err) {
this.logger.error('Unable to mark message as delivered.', err);
}
}
}
/**
* Mark messages as read and broadcast to websocket "Message" room
*
* @param event - The received event
*/
@OnEvent('hook:chatbot:read')
async handleMessageRead(event: EventWrapper<any, any>) {
if (config.chatbot.messages.track_read) {
const subscriber = event.getSender();
const watermark = new Date(event.getWatermark());
const start = new Date(watermark.getTime() - 24 * 3600 * 1000);
try {
await this.messageService.updateMany(
{
// @ts-expect-error Invesstigate why this is causing a ts error
recipient: subscriber.id,
createdAt: { $lte: watermark, $gte: start },
},
{
delivery: true,
read: true,
},
);
this.websocketGateway.broadcastMessageRead(
watermark.getTime(),
subscriber,
);
} catch (err) {
this.logger.error('Unable to mark message as read.', err);
}
}
}
/**
* Handle echoing messages
*
* @param event - The received event
*/
@OnEvent('hook:chatbot:echo')
async handleEchoMessage(event: EventWrapper<any, any>) {
this.logger.verbose('Message echo received', event._adapter._raw);
const foreignId = event.getRecipientForeignId();
if (foreignId) {
try {
const recipient = await this.subscriberService.findOne({
foreign_id: foreignId,
});
if (!recipient) {
throw new Error(`Subscriber with foreign ID ${foreignId} not found`);
}
const sentMessage: MessageCreateDto = {
mid: event.getId(),
recipient: recipient.id,
message: event.getMessage(),
delivery: false,
read: false,
};
this.eventEmitter.emit('hook:chatbot:sent', sentMessage);
this.eventEmitter.emit('hook:stats:entry', 'echo', 'Echo');
} catch (err) {
this.logger.error('Unable to log echo message', err, event);
}
}
}
/**
* Handle new incoming messages
*
* @param event - The received event
*/
@OnEvent('hook:chatbot:message')
async handleNewMessage(event: EventWrapper<any, any>) {
this.logger.debug('New message received', event);
const foreignId = event.getSenderForeignId();
const handler = event.getHandler();
try {
let subscriber = await this.subscriberService.findOne({
foreign_id: foreignId,
});
if (!subscriber) {
const subscriberData = await handler.getUserData(event);
this.eventEmitter.emit('hook:stats:entry', 'new_users', 'New users');
subscriberData.channel = {
...event.getChannelData(),
name: handler.getChannel(),
};
subscriber = await this.subscriberService.create(subscriberData);
} else {
// Already existing user profile
// Exec lastvisit hook
this.eventEmitter.emit('hook:user:lastvisit', subscriber);
}
this.websocketGateway.broadcastSubscriberUpdate(subscriber);
event.setSender(subscriber);
// Trigger message received event
this.eventEmitter.emit('hook:chatbot:received', event);
if (subscriber.assignedTo) {
this.logger.debug('Conversation taken over', subscriber.assignedTo);
return;
}
if (event.getText() && !event.getNLP()) {
const nlpAdapter = this.nlpService.getNLP();
try {
const nlp = await nlpAdapter.parse(event.getText());
event.setNLP(nlp);
} catch (err) {
this.logger.error('Unable to perform NLP parse', err);
}
}
this.botService.handleMessageEvent(event);
} catch (err) {
this.logger.error('Error handling new message', err);
}
}
/**
* Handle new subscriber and send notification the websocket
*
* @param subscriber - The end user (subscriber)
*/
@OnEvent('hook:chatbot:subscriber:create')
onSubscriberCreate(subscriber: Subscriber) {
this.websocketGateway.broadcastSubscriberNew(subscriber);
}
/**
* Handle updated subscriber and send notification the websocket
*
* @param subscriber - The end user (subscriber)
*/
@OnEvent('hook:chatbot:subscriber:update:after')
onSubscriberUpdate(subscriber: Subscriber) {
this.websocketGateway.broadcastSubscriberUpdate(subscriber);
}
}

View File

@@ -0,0 +1,22 @@
/*
* 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 { BaseService } from '@/utils/generics/base-service';
import { ContextVarRepository } from '../repositories/context-var.repository';
import { ContextVar } from '../schemas/context-var.schema';
@Injectable()
export class ContextVarService extends BaseService<ContextVar> {
constructor(readonly repository: ContextVarRepository) {
super(repository);
}
}

View File

@@ -0,0 +1,183 @@
/*
* 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 { FilterQuery } from 'mongoose';
import EventWrapper from '@/channel/lib/EventWrapper';
import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service';
import { VIEW_MORE_PAYLOAD } from '../helpers/constants';
import { ConversationRepository } from '../repositories/conversation.repository';
import { Block, BlockFull } from '../schemas/block.schema';
import { Conversation, ConversationFull } from '../schemas/conversation.schema';
import { OutgoingMessageFormat } from '../schemas/types/message';
import { Payload } from '../schemas/types/quick-reply';
@Injectable()
export class ConversationService extends BaseService<Conversation> {
constructor(
readonly repository: ConversationRepository,
private readonly logger: LoggerService,
) {
super(repository);
}
/**
* Finds and retrieves a single conversation entity based on the provided criteria,
* while populating the necessary related data.
*
* @param criteria - The search criteria to find a conversation, which can be a string
* representing a unique identifier or a filter query object that specifies the conditions
* for the search.
*
* @returns A Promise that resolves with the found conversation, populated with any related data,
* or `null` if no conversation matches the criteria.
*/
async findOneAndPopulate(criteria: string | FilterQuery<Conversation>) {
return await this.repository.findOneAndPopulate(criteria);
}
/**
* Marks the conversation as inactive.
*
* @param convo - The conversation
*/
async end(convo: Conversation | ConversationFull) {
return await this.repository.end(convo);
}
/**
* Saves the actual conversation context and returns the updated conversation
*
* @param convo - The Current Conversation
* @param next - The next block to be triggered
* @param event - The event received
* @param captureVars - If we should capture vars or not
*
* @returns The updated conversation
*/
async storeContextData(
convo: Conversation | ConversationFull,
next: Block | BlockFull,
event: EventWrapper<any, any>,
captureVars: boolean = false,
) {
const msgType = event.getMessageType();
// Capture channel specific context data
convo.context.channel = event.getHandler().getChannel();
convo.context.text = event.getText();
convo.context.payload = event.getPayload();
convo.context.nlp = event.getNLP();
convo.context.vars = convo.context.vars || {};
// Capture user entry in context vars
if (captureVars && next.capture_vars && next.capture_vars.length > 0) {
next.capture_vars.forEach((capture) => {
let contextValue: string | Payload;
const nlp = event.getNLP();
if (nlp && nlp.entities && nlp.entities.length) {
const nlpIndex = nlp.entities
.map((n) => {
return n.entity;
})
.indexOf(capture.entity.toString());
if (capture.entity && nlpIndex !== -1) {
// Get the most confident value
contextValue = nlp.entities[nlpIndex].value;
// .reduce((prev, current) => {
// return (prev.confidence > current.confidence) ? prev : current;
// }, {value: '', confidence: 0}).value;
}
}
if (capture.entity === -1) {
// Capture the whole message
contextValue =
['message', 'quick_reply'].indexOf(msgType) !== -1
? event.getText()
: event.getPayload();
} else if (capture.entity === -2) {
// Capture the postback payload (button click)
contextValue = event.getPayload();
}
contextValue =
typeof contextValue === 'string' ? contextValue.trim() : contextValue;
convo.context.vars[capture.context_var] = contextValue;
});
}
// Store user infos
const profile = event.getSender();
if (profile) {
// @ts-expect-error : id needs to remain readonly
convo.context.user.id = profile.id;
convo.context.user.first_name = profile.first_name || '';
convo.context.user.last_name = profile.last_name || '';
if (profile.language) {
convo.context.user.language = profile.language;
}
}
// Handle attachments (location, ...)
if (msgType === 'location') {
const coordinates = event.getMessage().coordinates;
convo.context.user_location = { lat: 0, lon: 0 };
convo.context.user_location.lat = parseFloat(coordinates.lat);
convo.context.user_location.lon = parseFloat(coordinates.lon);
} else if (msgType === 'attachments') {
// @TODO : deprecated in favor of geolocation msgType
const attachments = event.getAttachments();
// @ts-expect-error deprecated
if (attachments.length === 1 && attachments[0].type === 'location') {
// @ts-expect-error deprecated
const coord = attachments[0].payload.coordinates;
convo.context.user_location = { lat: 0, lon: 0 };
convo.context.user_location.lat = parseFloat(coord.lat);
convo.context.user_location.lon = parseFloat(coord.long);
}
}
// Deal with load more in the case of a list display
if (
next.options.content &&
(next.options.content.display === OutgoingMessageFormat.list ||
next.options.content.display === OutgoingMessageFormat.carousel)
) {
if (event.getPayload() === VIEW_MORE_PAYLOAD) {
convo.context.skip[next.id] += next.options.content.limit;
} else {
convo.context.skip = convo.context.skip ? convo.context.skip : {};
convo.context.skip[next.id] = 0;
}
}
// Execute additional logic provided by plugins on the context
// @todo : uncomment once plugin module is tested
// sails.plugins.applyEffect('onStoreContextData', next.options.effects || [], [convo, next, event, captureVars]);
// Store new context data
try {
const updatedConversation = await this.updateOne(convo.id, {
context: convo.context,
});
if (!updatedConversation) {
throw new Error(
'Conversation Model : No conversation has been updated',
);
}
return updatedConversation;
} catch (err) {
this.logger.error('Conversation Model : Unable to store context', err);
throw err;
}
}
}

View File

@@ -0,0 +1,113 @@
/*
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import {
installLabelFixtures,
labelFixtures,
} from '@/utils/test/fixtures/label';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { SubscriberRepository } from './../repositories/subscriber.repository';
import { LabelService } from './label.service';
import { SubscriberService } from './subscriber.service';
import { LabelRepository } from '../repositories/label.repository';
import { LabelModel, Label, LabelFull } from '../schemas/label.schema';
import { SubscriberModel, Subscriber } from '../schemas/subscriber.schema';
describe('LabelService', () => {
let labelRepository: LabelRepository;
let labelService: LabelService;
let subscriberRepository: SubscriberRepository;
let allSubscribers: Subscriber[];
let allLabels: Label[];
let labelsWithUsers: LabelFull[];
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installLabelFixtures),
MongooseModule.forFeature([
LabelModel,
SubscriberModel,
AttachmentModel,
]),
],
providers: [
LoggerService,
LabelService,
LabelRepository,
SubscriberService,
AttachmentService,
AttachmentRepository,
SubscriberRepository,
EventEmitter2,
],
}).compile();
labelService = module.get<LabelService>(LabelService);
labelRepository = module.get<LabelRepository>(LabelRepository);
subscriberRepository =
module.get<SubscriberRepository>(SubscriberRepository);
allSubscribers = await subscriberRepository.findAll();
allLabels = await labelRepository.findAll();
labelsWithUsers = allLabels.map((label) => ({
...label,
users: allSubscribers,
}));
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findAllAndPopulate', () => {
it('should find all labels, and foreach label populate its corresponding users', async () => {
jest.spyOn(labelRepository, 'findAllAndPopulate');
const result = await labelService.findAllAndPopulate();
expect(labelRepository.findAllAndPopulate).toHaveBeenCalled();
expect(result).toEqualPayload(labelsWithUsers);
});
});
describe('findPageAndPopulate', () => {
const pageQuery = getPageQuery<Label>();
it('should find labels, and foreach label populate its corresponding users', async () => {
jest.spyOn(labelRepository, 'findPageAndPopulate');
const result = await labelService.findPageAndPopulate({}, pageQuery);
expect(labelRepository.findPageAndPopulate).toHaveBeenCalled();
expect(result).toEqualPayload(labelsWithUsers.sort(sortRowsBy));
});
});
describe('findOneAndPopulate', () => {
it('should find one label by id, and populate its corresponding users', async () => {
jest.spyOn(labelRepository, 'findOneAndPopulate');
const label = await labelRepository.findOne({ name: 'TEST_TITLE_1' });
const result = await labelService.findOneAndPopulate(label.id);
expect(result).toEqualPayload({
...labelFixtures.find(({ name }) => name === label.name),
users: allSubscribers,
});
});
});
});

View File

@@ -0,0 +1,59 @@
/*
* 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 { TFilterQuery } from 'mongoose';
import { BaseService } from '@/utils/generics/base-service';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { LabelRepository } from '../repositories/label.repository';
import { Label } from '../schemas/label.schema';
@Injectable()
export class LabelService extends BaseService<Label> {
constructor(readonly repository: LabelRepository) {
super(repository);
}
/**
* Finds all labels and populates related data.
*
* @returns A promise that resolves with an array of labels with populated related data.
*/
async findAllAndPopulate() {
return await this.repository.findAllAndPopulate();
}
/**
* Finds a page of labels based on filters and page query, and populates related data.
*
* @param filters The filters to apply when querying the labels.
* @param pageQuery The pagination and sorting options.
*
* @returns A promise that resolves with a paginated list of labels with populated related data.
*/
async findPageAndPopulate(
filters: TFilterQuery<Label>,
pageQuery: PageQueryDto<Label>,
) {
return await this.repository.findPageAndPopulate(filters, pageQuery);
}
/**
* Finds a label by ID and populates related data.
*
* @param id The ID of the label to find.
*
* @returns A promise that resolves with the found label with populated related data or null if not found.
*/
async findOneAndPopulate(id: string) {
return await this.repository.findOneAndPopulate(id);
}
}

View File

@@ -0,0 +1,190 @@
/*
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { User, UserModel } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import {
installMessageFixtures,
messageFixtures,
} from '@/utils/test/fixtures/message';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { SubscriberRepository } from './../repositories/subscriber.repository';
import { MessageService } from './message.service';
import { SubscriberService } from './subscriber.service';
import { MessageRepository } from '../repositories/message.repository';
import { Message, MessageModel } from '../schemas/message.schema';
import { Subscriber, SubscriberModel } from '../schemas/subscriber.schema';
describe('MessageService', () => {
let messageRepository: MessageRepository;
let messageService: MessageService;
let subscriberRepository: SubscriberRepository;
let userRepository: UserRepository;
let allMessages: Message[];
let allSubscribers: Subscriber[];
let allUsers: User[];
let message: Message;
let sender: Subscriber;
let recipient: Subscriber;
let messagesWithSenderAndRecipient: Message[];
let user: User;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installMessageFixtures),
MongooseModule.forFeature([
UserModel,
RoleModel,
PermissionModel,
SubscriberModel,
MessageModel,
AttachmentModel,
]),
],
providers: [
LoggerService,
AttachmentService,
AttachmentRepository,
UserService,
UserRepository,
RoleService,
RoleRepository,
SubscriberService,
SubscriberRepository,
MessageService,
MessageRepository,
EventEmitter2,
],
}).compile();
messageService = module.get<MessageService>(MessageService);
messageRepository = module.get<MessageRepository>(MessageRepository);
subscriberRepository =
module.get<SubscriberRepository>(SubscriberRepository);
userRepository = module.get<UserRepository>(UserRepository);
allSubscribers = await subscriberRepository.findAll();
allUsers = await userRepository.findAll();
allMessages = await messageRepository.findAll();
message = await messageRepository.findOne({ mid: 'mid-1' });
sender = await subscriberRepository.findOne(message['sender']);
recipient = await subscriberRepository.findOne(message['recipient']);
user = await userRepository.findOne(message['sentBy']);
messagesWithSenderAndRecipient = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']).id,
recipient: allSubscribers.find(({ id }) => id === message['recipient'])
.id,
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find message by id, and populate its corresponding sender and recipient', async () => {
jest.spyOn(messageRepository, 'findOneAndPopulate');
const result = await messageService.findOneAndPopulate(message.id);
expect(messageRepository.findOneAndPopulate).toHaveBeenCalledWith(
message.id,
);
expect(result).toEqualPayload({
...messageFixtures.find(({ mid }) => mid === message.mid),
sender,
recipient,
sentBy: user.id,
});
});
});
describe('findPageAndPopulate', () => {
const pageQuery = getPageQuery<Message>();
it('should find messages, and foreach message populate the corresponding sender and recipient', async () => {
jest.spyOn(messageRepository, 'findPageAndPopulate');
const result = await messageService.findPageAndPopulate({}, pageQuery);
const messagesWithSenderAndRecipient = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']),
recipient: allSubscribers.find(({ id }) => id === message['recipient']),
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
expect(messageRepository.findPageAndPopulate).toHaveBeenCalledWith(
{},
pageQuery,
);
expect(result).toEqualPayload(messagesWithSenderAndRecipient);
});
});
describe('findHistoryUntilDate', () => {
it('should return history until given date', async () => {
const until: Date = new Date(
new Date().setMonth(new Date().getMonth() + 1),
);
const result = await messageService.findHistoryUntilDate(
sender,
until,
30,
);
const historyMessages = messagesWithSenderAndRecipient.filter(
(message) => message.createdAt <= until,
);
expect(result).toEqualPayload(historyMessages);
});
});
describe('findHistorySinceDate', () => {
it('should return history since given date', async () => {
const since: Date = new Date();
const result = await messageService.findHistorySinceDate(
sender,
since,
30,
);
const messagesWithSenderAndRecipient = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']).id,
recipient: allSubscribers.find(({ id }) => id === message['recipient'])
.id,
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
const historyMessages = messagesWithSenderAndRecipient.filter(
(message) => message.createdAt > since,
);
expect(result).toEqual(
historyMessages.sort((message1, message2) =>
sortRowsBy(message1, message2, 'createdAt', 'asc'),
),
);
});
});
});

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 {
Injectable,
InternalServerErrorException,
Optional,
} from '@nestjs/common';
import { TFilterQuery } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import {
SocketGet,
SocketPost,
} from '@/websocket/decorators/socket-method.decorator';
import { SocketReq } from '@/websocket/decorators/socket-req.decorator';
import { SocketRes } from '@/websocket/decorators/socket-res.decorator';
import { Room } from '@/websocket/types';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { MessageRepository } from '../repositories/message.repository';
import { Subscriber } from '../schemas/subscriber.schema';
import { AnyMessage } from '../schemas/types/message';
@Injectable()
export class MessageService extends BaseService<AnyMessage> {
private readonly logger: LoggerService;
private readonly gateway: WebsocketGateway;
constructor(
private readonly messageRepository: MessageRepository,
@Optional() logger?: LoggerService,
@Optional() gateway?: WebsocketGateway,
) {
super(messageRepository);
this.logger = logger;
this.gateway = gateway;
}
/**
* Subscribes the socket to the message room
*
* @param req - The socket request object
* @param res - The socket response object
*/
@SocketGet('/message/subscribe/')
@SocketPost('/message/subscribe/')
subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) {
try {
this.gateway.io.socketsJoin(Room.MESSAGE);
return res.status(200).json({
success: true,
});
} catch (e) {
this.logger.error(
'MessageController subscribe : Websocket subscription',
e,
);
throw new InternalServerErrorException(e);
}
}
/**
* Retrieves a paginated list of messages based on the provided filters
* and page query, and populates the results with additional data.
*
* @param filters - The query filters to apply for message retrieval.
* @param pageQuery - The page query containing pagination information.
*
* @returns A paginated list of populated messages.
*/
async findPageAndPopulate(
filters: TFilterQuery<AnyMessage>,
pageQuery: PageQueryDto<AnyMessage>,
) {
return await this.messageRepository.findPageAndPopulate(filters, pageQuery);
}
/**
* Retrieves a single message by its identifier and populates it with
* additional data.
*
* @param id - The identifier of the message to retrieve.
*
* @returns A populated message object.
*/
async findOneAndPopulate(id: string) {
return await this.messageRepository.findOneAndPopulate(id);
}
/**
* Retrieves the message history for a given subscriber up until a specific
* date, with an optional limit on the number of messages to return.
*
* @param subscriber - The subscriber whose message history is being retrieved.
* @param until - The date until which to retrieve messages (defaults to the current date).
* @param limit - The maximum number of messages to return (defaults to 30).
*
* @returns The message history until the specified date.
*/
async findHistoryUntilDate(
subscriber: Subscriber,
until = new Date(),
limit: number = 30,
) {
return await this.messageRepository.findHistoryUntilDate(
subscriber,
until,
limit,
);
}
/**
* Retrieves the message history for a given subscriber since a specific
* date, with an optional limit on the number of messages to return.
*
* @param subscriber - The subscriber whose message history is being retrieved.
* @param since - The date since which to retrieve messages (defaults to the current date).
* @param limit - The maximum number of messages to return (defaults to 30).
*
* @returns The message history since the specified date.
*/
async findHistorySinceDate(
subscriber: Subscriber,
since = new Date(),
limit: number = 30,
) {
return await this.messageRepository.findHistorySinceDate(
subscriber,
since,
limit,
);
}
}

View File

@@ -0,0 +1,147 @@
/*
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { User, UserModel } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { LabelService } from './label.service';
import { SubscriberService } from './subscriber.service';
import { LabelRepository } from '../repositories/label.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Label, LabelModel } from '../schemas/label.schema';
import { Subscriber, SubscriberModel } from '../schemas/subscriber.schema';
describe('SubscriberService', () => {
let subscriberRepository: SubscriberRepository;
let labelRepository: LabelRepository;
let userRepository: UserRepository;
let subscriberService: SubscriberService;
let allSubscribers: Subscriber[];
let allLabels: Label[];
let allUsers: User[];
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([
SubscriberModel,
LabelModel,
UserModel,
RoleModel,
PermissionModel,
AttachmentModel,
]),
],
providers: [
SubscriberService,
SubscriberRepository,
LabelService,
LabelRepository,
UserService,
UserRepository,
RoleService,
RoleRepository,
LoggerService,
EventEmitter2,
AttachmentService,
AttachmentRepository,
],
}).compile();
labelRepository = module.get<LabelRepository>(LabelRepository);
userRepository = module.get<UserRepository>(UserRepository);
subscriberService = module.get<SubscriberService>(SubscriberService);
subscriberRepository =
module.get<SubscriberRepository>(SubscriberRepository);
allSubscribers = await subscriberRepository.findAll();
allLabels = await labelRepository.findAll();
allUsers = await userRepository.findAll();
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find subscribers, and foreach subscriber populate its corresponding labels', async () => {
jest.spyOn(subscriberService, 'findOneAndPopulate');
const subscriber = await subscriberRepository.findOne({
first_name: 'Jhon',
});
const result = await subscriberService.findOneAndPopulate(subscriber.id);
expect(subscriberService.findOneAndPopulate).toHaveBeenCalledWith(
subscriber.id,
);
expect(result).toEqualPayload({
...subscriber,
labels: allLabels.filter((label) =>
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
});
});
});
describe('findPageAndPopulate', () => {
const pageQuery = getPageQuery<Subscriber>();
it('should find subscribers, and foreach subscriber populate its corresponding labels', async () => {
jest.spyOn(subscriberRepository, 'findPageAndPopulate');
const result = await subscriberService.findPageAndPopulate({}, pageQuery);
const subscribersWithLabels = allSubscribers.map((subscriber) => ({
...subscriber,
labels: allLabels.filter((label) =>
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
}));
expect(subscriberRepository.findPageAndPopulate).toHaveBeenCalled();
expect(result).toEqualPayload(subscribersWithLabels.sort(sortRowsBy));
});
});
describe('findOneByForeignId', () => {
it('should find one subscriber by foreign id', async () => {
jest.spyOn(subscriberRepository, 'findOneByForeignId');
const result =
await subscriberService.findOneByForeignId('foreign-id-dimelo');
const subscriber = allSubscribers.find(
({ foreign_id }) => foreign_id === 'foreign-id-dimelo',
);
expect(subscriberRepository.findOneByForeignId).toHaveBeenCalled();
expect(result).toEqualPayload({
...subscriber,
labels: allLabels
.filter((label) => subscriber.labels.includes(label.id))
.map((label) => label.id),
});
});
});
});

View File

@@ -0,0 +1,253 @@
/*
* 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,
InternalServerErrorException,
NotFoundException,
Optional,
StreamableFile,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { TFilterQuery } from 'mongoose';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { config } from '@/config';
import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import {
SocketGet,
SocketPost,
} from '@/websocket/decorators/socket-method.decorator';
import { SocketReq } from '@/websocket/decorators/socket-req.decorator';
import { SocketRes } from '@/websocket/decorators/socket-res.decorator';
import { Room } from '@/websocket/types';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { SubscriberUpdateDto } from '../dto/subscriber.dto';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Subscriber } from '../schemas/subscriber.schema';
@Injectable()
export class SubscriberService extends BaseService<Subscriber> {
private readonly gateway: WebsocketGateway;
constructor(
readonly repository: SubscriberRepository,
private readonly logger: LoggerService,
protected attachmentService: AttachmentService,
@Optional() gateway?: WebsocketGateway,
) {
super(repository);
this.gateway = gateway;
}
/**
* Internally subscribe web-sockets to user's event
* For example : Notify chat if new user interacted with the chatbot
*
* @param req - The socket request object
* @param res - The socket response object
*/
@SocketGet('/subscriber/subscribe/')
@SocketPost('/subscriber/subscribe/')
subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) {
try {
this.gateway.io.socketsJoin(Room.SUBSCRIBER);
return res.json({
success: true,
subscribe: Room.SUBSCRIBER,
});
} catch (e) {
this.logger.error(
'SubscriberController subscribe : Websocket subscription',
);
throw new InternalServerErrorException(e);
}
}
/**
* Retrieves a paginated list of subscribers based on the provided filters
* and populates the result with related data.
*
* @param filters - The criteria used to filter the subscribers.
* @param pageQuery - The pagination and sorting details.
*
* @returns A paginated list of subscribers with populated related data.
*/
async findPageAndPopulate(
filters: TFilterQuery<Subscriber>,
pageQuery: PageQueryDto<Subscriber>,
) {
return await this.repository.findPageAndPopulate(filters, pageQuery);
}
/**
* Finds and returns a single subscriber based on its ID or filters,
* and populates the result with related data.
*
* @param id - The ID of the subscriber or a set of query filters.
*
* @returns The subscriber with populated related data.
*/
async findOneAndPopulate(id: string | TFilterQuery<Subscriber>) {
return await this.repository.findOneAndPopulate(id);
}
/**
* Finds and returns a single subscriber based on a foreign ID.
*
* @param id - The foreign ID used to find the subscriber.
*
* @returns The subscriber matching the foreign ID.
*/
async findOneByForeignId(id: string) {
return await this.repository.findOneByForeignId(id);
}
/**
* Finds and returns a single subscriber based on a foreign ID,
* and populates the result with related data.
*
* @param id - The foreign ID used to find the subscriber.
*
* @returns The subscriber with populated related data.
*/
async findOneByForeignIdAndPopulate(id: string) {
return await this.repository.findOneByForeignIdAndPopulate(id);
}
/**
* Updates a subscriber's details based on a foreign ID.
*
* @param id - The foreign ID of the subscriber to update.
* @param updates - The updates to apply to the subscriber.
*
* @returns The updated subscriber data.
*/
async updateOneByForeignId(id: string, updates: SubscriberUpdateDto) {
return await this.repository.updateOneByForeignIdQuery(id, updates);
}
/**
* Hands back control or association of a subscriber based on a foreign ID.
*
* @param foreignId - The foreign ID of the subscriber.
*
* @returns The result of the hand-back operation.
*/
async handBackByForeignId(foreignId: string) {
return await this.repository.handBackByForeignIdQuery(foreignId);
}
/**
* Hands over control or association of a subscriber to another user
* based on the foreign ID and the new user's ID.
*
* @param foreignId - The foreign ID of the subscriber.
* @param userId - The ID of the user to whom control is handed over.
*
* @returns The result of the hand-over operation.
*/
async handOverByForeignId(foreignId: string, userId: string) {
return await this.repository.handOverByForeignIdQuery(foreignId, userId);
}
/**
* Retrieves the profile picture of a subscriber based on the foreign ID.
*
* @param foreign_id - The foreign ID of the subscriber.
*
* @returns A streamable file representing the profile picture.
*/
async findProfilePic(foreign_id: string): Promise<StreamableFile> {
try {
return await this.attachmentService.downloadProfilePic(foreign_id);
} catch (err) {
this.logger.error('Error downloading profile picture', err);
throw new NotFoundException('Profile picture not found');
}
}
/**
* Apply updates on end-user such as :
* - Assign labels to specific end-user
* - Handover discussion to human
*
* @param profile - The end-user (subscriber) profile
* @param labels - Array of label ids that represent new labels to assign to end-user
* @param assignTo - User ID to handover the discussion to
*
* @returns The updated profile
*/
async applyUpdates(
profile: Subscriber,
labels: string[],
assignTo: string | null,
) {
try {
const updates: SubscriberUpdateDto = {};
if (labels.length > 0) {
let userLabels = profile.labels ? profile.labels : [];
// Filter unique
userLabels = [...new Set(userLabels.concat(labels))];
updates.labels = userLabels;
}
if (assignTo) {
updates.assignedTo = assignTo;
}
const updated = await this.updateOne(profile.id, updates);
this.logger.debug('Block updates has been applied!', updates);
return updated;
} catch (err) {
this.logger.error('Unable to perform block updates!', err);
throw err;
}
}
/**
* Updates the `lastvisit` and `retainedFrom` fields of a subscriber when a new message is received.
*
* This method checks if the subscriber has a `lastvisit` field, and if so, it attempts to update the subscriber's
* information. Specifically, it resets the `retainedFrom` field if the subscriber has not been active for a
* configured period of time (`retentionReset` threshold). The `lastvisit` field is always updated to the current time.
*
* If the update is successful, it logs the updated user's information.
*
* @param subscriber The subscriber whose is being handled.
*/
@OnEvent('hook:user:lastvisit')
private async handleLastVisit(subscriber: Subscriber) {
if (subscriber.lastvisit) {
try {
const user = await this.updateOne(subscriber.id, {
// Retentioned user is lost if not chating for a giving duration
// at a first time if a user passes a day without chating we reset
retainedFrom:
+new Date() - +subscriber.lastvisit >
config.analytics.thresholds.retentionReset
? new Date()
: subscriber.retainedFrom,
lastvisit: new Date(),
});
this.logger.debug(
'lastVisit Hook : user retainedFrom/lastvisit updated !',
JSON.stringify(user),
);
} catch (err) {
this.logger.error(err);
}
}
}
}

View File

@@ -0,0 +1,140 @@
/*
* 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 { OnEvent } from '@nestjs/event-emitter';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { SettingService } from '@/setting/services/setting.service';
import { BaseService } from '@/utils/generics/base-service';
import { BlockService } from './block.service';
import { TranslationRepository } from '../repositories/translation.repository';
import { Block } from '../schemas/block.schema';
import { Translation } from '../schemas/translation.schema';
@Injectable()
export class TranslationService extends BaseService<Translation> {
constructor(
readonly repository: TranslationRepository,
private readonly blockService: BlockService,
private readonly settingService: SettingService,
private readonly i18n: ExtendedI18nService,
) {
super(repository);
this.resetI18nTranslations();
}
public async resetI18nTranslations() {
const translations = await this.findAll();
this.i18n.initDynamicTranslations(translations);
}
/**
* Return any available string inside a given block (message, button titles, fallback messages, ...)
*
* @param block - The block to parse
*
* @returns An array of strings
*/
getBlockStrings(block: Block): string[] {
let strings: string[] = [];
if (Array.isArray(block.message)) {
// Text Messages
strings = strings.concat(block.message);
} else if (typeof block.message === 'object') {
if ('plugin' in block.message) {
// plugin
Object.values(block.message.args).forEach((arg) => {
if (Array.isArray(arg)) {
// array of text
strings = strings.concat(arg);
} else if (typeof arg === 'string') {
// text
strings.push(arg);
}
});
} else if ('text' in block.message && Array.isArray(block.message.text)) {
// array of text
strings = strings.concat(block.message.text);
} else if (
'text' in block.message &&
typeof block.message.text === 'string'
) {
// text
strings.push(block.message.text);
}
if (
'quickReplies' in block.message &&
Array.isArray(block.message.quickReplies) &&
block.message.quickReplies.length > 0
) {
// Quick replies
strings = strings.concat(
block.message.quickReplies.map((qr) => qr.title),
);
} else if (
'buttons' in block.message &&
Array.isArray(block.message.buttons) &&
block.message.buttons.length > 0
) {
// Buttons
strings = strings.concat(block.message.buttons.map((btn) => btn.title));
}
}
// Add fallback messages
if (
'fallback' in block.options &&
block.options.fallback &&
'message' in block.options.fallback &&
Array.isArray(block.options.fallback.message)
) {
strings = strings.concat(block.options.fallback.message);
}
return strings;
}
/**
* Return any available string inside a block (message, button titles, fallback messages, ...)
*
* @returns A promise of all strings available in a array
*/
async getAllBlockStrings(): Promise<string[]> {
const blocks = await this.blockService.find({});
if (blocks.length === 0) {
return [];
}
return blocks.reduce((acc, block) => {
const strings = this.getBlockStrings(block);
return acc.concat(strings);
}, [] as string[]);
}
/**
* Return any available strings in settings
*
* @returns A promise of all strings available in a array
*/
async getSettingStrings(): Promise<string[]> {
let strings: string[] = [];
const settings = await this.settingService.getSettings();
if (settings.chatbot_settings.global_fallback) {
strings = strings.concat(settings.chatbot_settings.fallback_message);
}
return strings;
}
/**
* Updates the in-memory translations
*/
@OnEvent('hook:translation:*')
handleTranslationsUpdate() {
this.resetI18nTranslations();
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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 {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
export function isChannelData(channel: any) {
return (
typeof channel === 'object' &&
channel.name &&
typeof channel.name === 'string'
);
}
@ValidatorConstraint({ async: false })
export class ChannelDataValidator implements ValidatorConstraintInterface {
validate(channel: any) {
return isChannelData(channel);
}
}
export function IsChannelData(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: ChannelDataValidator,
});
};
}

View File

@@ -0,0 +1,131 @@
/*
* 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 {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import Joi from 'joi';
import { BlockMessage } from '../schemas/types/message';
export function isValidMessage(msg: any) {
if (typeof msg === 'string' && msg !== '') {
// Custom code
const MESSAGE_REGEX = /^function \(context\) \{[^]+\}/;
if (!MESSAGE_REGEX.test(msg)) {
// eslint-disable-next-line
console.error('Block Model : Invalid custom code.', msg);
return false;
} else {
return true;
}
} else if (Array.isArray(msg)) {
// Simple text message
const textSchema = Joi.array().items(Joi.string().max(1000).required());
const textCheck = textSchema.validate(msg);
return !textCheck.error;
} else if (typeof msg === 'object') {
if ('plugin' in msg) {
return true;
} else {
const buttonsSchema = Joi.array().items(
Joi.object().keys({
type: Joi.string().valid('postback', 'web_url').required(),
title: Joi.string().max(20),
payload: Joi.alternatives().conditional('type', {
is: 'postback',
then: Joi.string().max(1000).required(),
otherwise: Joi.forbidden(),
}),
url: Joi.alternatives().conditional('type', {
is: 'web_url',
then: Joi.string().uri(),
otherwise: Joi.forbidden(),
}),
messenger_extensions: Joi.alternatives().conditional('type', {
is: 'web_url',
then: Joi.boolean(),
otherwise: Joi.forbidden(),
}),
webview_height_ratio: Joi.alternatives().conditional('type', {
is: 'web_url',
then: Joi.string().valid('compact', 'tall', 'full'),
otherwise: Joi.forbidden(),
}),
}),
);
// Attachment message
const objectSchema = Joi.object().keys({
text: Joi.string().max(1000),
attachment: Joi.object().keys({
type: Joi.string()
.valid('image', 'audio', 'video', 'file', 'unknown')
.required(),
payload: Joi.object().keys({
url: Joi.string().uri(),
attachment_id: Joi.string(),
}),
}),
elements: Joi.boolean(),
cards: Joi.object().keys({
default_action: buttonsSchema.max(1),
buttons: buttonsSchema.max(3),
}),
buttons: buttonsSchema.max(3),
quickReplies: Joi.array()
.items(
Joi.object().keys({
content_type: Joi.string()
.valid('text', 'location', 'user_phone_number', 'user_email')
.required(),
title: Joi.alternatives().conditional('content_type', {
is: 'text',
then: Joi.string().max(20).required(),
}),
payload: Joi.alternatives().conditional('content_type', {
is: 'text',
then: Joi.string().max(1000).required(),
}),
}),
)
.max(11),
});
const objectCheck = objectSchema.validate(msg);
if (objectCheck.error) {
// eslint-disable-next-line
console.log('Message validation failed! ', objectCheck);
}
return !objectCheck.error;
}
} else {
return false;
}
}
@ValidatorConstraint({ async: false })
export class MessageValidator implements ValidatorConstraintInterface {
validate(msg: BlockMessage) {
return isValidMessage(msg);
}
}
export function IsMessage(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: MessageValidator,
});
};
}

View File

@@ -0,0 +1,91 @@
/*
* 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 {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import Joi from 'joi';
import { Pattern } from '../schemas/types/pattern';
export function isPatternList(patterns: Pattern[]) {
return (
Array.isArray(patterns) &&
patterns.every((pattern) => {
if (typeof pattern === 'string') {
// Check if valid regex
if (pattern.endsWith('/') && pattern.startsWith('/')) {
try {
new RegExp(pattern.slice(1, -1), 'gi');
} catch (err) {
return false;
}
return true;
}
// Check if valid string (Equals/Like)
return pattern !== '';
} else if (Array.isArray(pattern)) {
// Check if valid NLP pattern
const nlpSchema = Joi.array()
.items(
Joi.object().keys({
entity: Joi.string().required(),
match: Joi.string().valid('entity', 'value').required(),
value: Joi.string().required(),
}),
)
.min(1);
const nlpCheck = nlpSchema.validate(pattern);
if (nlpCheck.error) {
// console.log('Message validation failed! ', nlpCheck);
}
return !nlpCheck.error;
} else if (typeof pattern === 'object') {
// Invalid structure?
const payloadSchema = Joi.object().keys({
label: Joi.string().required(),
value: Joi.any().required(),
type: Joi.string(),
});
const payloadCheck = payloadSchema.validate(pattern);
if (payloadCheck.error) {
// console.log(
// 'Message validation failed! ',
// payloadCheck,
// );
}
return !payloadCheck.error;
} else {
return false;
}
})
);
}
@ValidatorConstraint({ async: false })
export class PatternListValidator implements ValidatorConstraintInterface {
validate(patterns: Pattern[]) {
return isPatternList(patterns);
}
}
export function IsPatternList(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: PatternListValidator,
});
};
}

View File

@@ -0,0 +1,48 @@
/*
* 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 {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { Position } from '../schemas/types/position';
export function isPosition(position: Position) {
return (
typeof position === 'object' &&
!isNaN(position.x) &&
!isNaN(position.y) &&
position.x !== Infinity &&
position.x !== -Infinity &&
position.y !== Infinity &&
position.y !== -Infinity
);
}
@ValidatorConstraint({ async: false })
export class PositionValidator implements ValidatorConstraintInterface {
validate(position: Position) {
return isPosition(position);
}
}
export function IsPosition(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: PositionValidator,
});
};
}

View File

@@ -0,0 +1,72 @@
/*
* 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 {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import Joi from 'joi';
type Tentity = -1 | -2;
export interface CaptureVar {
// entity=`-1` to match text message
// entity=`-2` for postback payload
// entity is `String` for NLP entities
entity: Tentity | string;
context_var: string;
}
const allowedEntityValues: Tentity[] = [-1, -2];
export function isValidVarCapture(vars: CaptureVar[]) {
const captureSchema = Joi.array().items(
Joi.object().keys({
entity: Joi.alternatives().try(
// `-1` to match text message & `-2` for postback payload
Joi.number()
.valid(...allowedEntityValues)
.required(),
// String for NLP entities
Joi.string().required(),
),
context_var: Joi.string()
.regex(/^[a-z][a-z_0-9]*$/)
.required(),
}),
);
const captureCheck = captureSchema.validate(vars);
if (captureCheck.error) {
// eslint-disable-next-line
console.log('Capture vars validation failed!', captureCheck.error);
}
return !captureCheck.error;
}
@ValidatorConstraint({ async: false })
export class CaptureVarValidator implements ValidatorConstraintInterface {
validate(vars: CaptureVar[]) {
return isValidVarCapture(vars);
}
}
export function IsVarCapture(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: CaptureVarValidator,
});
};
}

View File

@@ -0,0 +1,30 @@
/*
* 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 { registerDecorator, ValidationOptions } from 'class-validator';
import {
StdIncomingMessage,
StdOutgoingTextMessage,
} from '../schemas/types/message';
export function IsValidMessageText(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(message: StdOutgoingTextMessage | StdIncomingMessage) {
return !!(message as StdOutgoingTextMessage).text;
},
},
});
};
}