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

68
api/src/cms/cms.module.ts Normal file
View File

@@ -0,0 +1,68 @@
/*
* 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,
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AttachmentModule } from '@/attachment/attachment.module';
import { ChatModule } from '@/chat/chat.module';
import { ContentTypeController } from './controllers/content-type.controller';
import { ContentController } from './controllers/content.controller';
import { MenuController } from './controllers/menu.controller';
import { ContentMiddleWare } from './middlewares/content.middleware';
import { ContentTypeRepository } from './repositories/content-type.repository';
import { ContentRepository } from './repositories/content.repository';
import { MenuRepository } from './repositories/menu.repository';
import { ContentTypeModel } from './schemas/content-type.schema';
import { ContentModel } from './schemas/content.schema';
import { MenuModel } from './schemas/menu.schema';
import { ContentTypeService } from './services/content-type.service';
import { ContentService } from './services/content.service';
import { MenuService } from './services/menu.service';
import { AttachmentModel } from '../attachment/schemas/attachment.schema';
@Module({
imports: [
MongooseModule.forFeature([
ContentModel,
ContentTypeModel,
AttachmentModel,
MenuModel,
]),
AttachmentModule,
forwardRef(() => ChatModule),
],
controllers: [ContentController, ContentTypeController, MenuController],
providers: [
ContentTypeService,
ContentService,
ContentTypeRepository,
ContentRepository,
MenuRepository,
MenuService,
],
exports: [MenuService, ContentService, ContentTypeService],
})
export class CmsModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ContentMiddleWare)
.forRoutes(
{ path: 'content', method: RequestMethod.POST },
{ path: 'content/:id', method: RequestMethod.PATCH },
);
}
}

View File

@@ -0,0 +1,217 @@
/*
* 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/exceptions';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } 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 { BlockService } from '@/chat/services/block.service';
import { LoggerService } from '@/logger/logger.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import { installContentFixtures } from '@/utils/test/fixtures/content';
import { contentTypeFixtures } from '@/utils/test/fixtures/contenttype';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { ContentTypeController } from './content-type.controller';
import { ContentTypeCreateDto } from '../dto/contentType.dto';
import { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentRepository } from '../repositories/content.repository';
import { ContentType, ContentTypeModel } from '../schemas/content-type.schema';
import { ContentModel } from '../schemas/content.schema';
import { ContentTypeService } from '../services/content-type.service';
import { ContentService } from '../services/content.service';
describe('ContentTypeController', () => {
let contentTypeController: ContentTypeController;
let contentTypeService: ContentTypeService;
let contentService: ContentService;
let contentType: ContentType;
let blockService: BlockService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ContentTypeController],
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([
ContentTypeModel,
ContentModel,
AttachmentModel,
]),
],
providers: [
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
ContentTypeService,
ContentService,
AttachmentService,
LoggerService,
EventEmitter2,
{
provide: BlockService,
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
blockService = module.get<BlockService>(BlockService);
contentTypeController = module.get<ContentTypeController>(
ContentTypeController,
);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
contentService = module.get<ContentService>(ContentService);
contentType = await contentTypeService.findOne({ name: 'Product' });
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findPage', () => {
it('should return all contentTypes', async () => {
const pageQuery = getPageQuery<ContentType>({ sort: ['_id', 'asc'] });
jest.spyOn(contentTypeService, 'findPage');
const result = await contentTypeController.findPage(pageQuery, {});
expect(contentTypeService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toHaveLength(contentTypeFixtures.length);
expect(result).toEqualPayload(contentTypeFixtures);
});
});
describe('create', () => {
it('should create a new Content type', async () => {
const newContentType: ContentTypeCreateDto = {
name: 'House',
fields: [
{
name: 'address',
label: 'Address',
type: 'text',
},
{
name: 'image',
label: 'Image',
type: 'file',
},
{
name: 'description',
label: 'Description',
type: 'html',
},
{
name: 'rooms',
label: 'Rooms',
type: 'file',
},
{
name: 'price',
label: 'Price',
type: 'file',
},
],
};
jest.spyOn(contentTypeService, 'create');
const result = await contentTypeController.create(newContentType);
expect(contentTypeService.create).toHaveBeenCalledWith(newContentType);
expect(result).toEqualPayload(newContentType);
});
});
describe('findOne', () => {
it('should find a content type by id', async () => {
jest.spyOn(contentTypeService, 'findOne');
const result = await contentTypeController.findOne(contentType.id);
expect(contentTypeService.findOne).toHaveBeenCalledWith(contentType.id);
expect(result).toEqualPayload(
contentTypeFixtures.find(({ name }) => name === 'Product'),
);
});
it('should throw NotFoundException when finding content type by non-existing ID', async () => {
jest.spyOn(contentTypeService, 'findOne');
await expect(contentTypeController.findOne(NOT_FOUND_ID)).rejects.toThrow(
NotFoundException,
);
expect(contentTypeService.findOne).toHaveBeenCalledWith(NOT_FOUND_ID);
});
});
describe('update', () => {
const updatedContent = { name: 'modified' };
it('should update and return the updated content type', async () => {
jest.spyOn(contentTypeService, 'updateOne');
const result = await contentTypeController.updateOne(
updatedContent,
contentType.id,
);
expect(contentTypeService.updateOne).toHaveBeenCalledWith(
contentType.id,
updatedContent,
);
expect(result).toEqualPayload({
...contentTypeFixtures.find(({ name }) => name === 'Product'),
...updatedContent,
});
});
it('should throw NotFoundException if the content type is not found', async () => {
jest.spyOn(contentTypeService, 'updateOne');
await expect(
contentTypeController.updateOne(updatedContent, NOT_FOUND_ID),
).rejects.toThrow(NotFoundException);
expect(contentTypeService.updateOne).toHaveBeenCalledWith(
NOT_FOUND_ID,
updatedContent,
);
});
});
describe('remove', () => {
it('should delete and return the deletion result', async () => {
jest.spyOn(contentTypeService, 'deleteCascadeOne');
const contentType = await contentTypeService.findOne({
name: 'Restaurant',
});
const result = await contentTypeController.deleteOne(contentType.id);
expect(contentTypeService.deleteCascadeOne).toHaveBeenCalledWith(
contentType.id,
);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
await expect(
contentTypeController.findOne(contentType.id),
).rejects.toThrow(NotFoundException);
expect(await contentService.find({ entity: contentType.id })).toEqual([]);
});
it('should throw NotFoundException if the content type is not found', async () => {
jest.spyOn(blockService, 'findOne').mockResolvedValueOnce(null);
jest.spyOn(contentTypeService, 'deleteCascadeOne');
await expect(
contentTypeController.deleteOne(NOT_FOUND_ID),
).rejects.toThrow(NotFoundException);
expect(contentTypeService.deleteCascadeOne).toHaveBeenCalledWith(
NOT_FOUND_ID,
);
});
});
});

View File

@@ -0,0 +1,165 @@
/*
* 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 { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import {
ContentTypeCreateDto,
ContentTypeUpdateDto,
} from '../dto/contentType.dto';
import { ContentType } from '../schemas/content-type.schema';
import { ContentTypeService } from '../services/content-type.service';
@UseInterceptors(CsrfInterceptor)
@Controller('contenttype')
export class ContentTypeController extends BaseController<ContentType> {
constructor(
private readonly contentTypeService: ContentTypeService,
private readonly logger: LoggerService,
) {
super(contentTypeService);
}
/**
* Creates a new content type.
*
* @param contentTypeDto - The data transfer object containing the content type information.
*
* @returns The created content type.
*/
@CsrfCheck(true)
@Post()
async create(
@Body() contentTypeDto: ContentTypeCreateDto,
): Promise<ContentType> {
return await this.contentTypeService.create(contentTypeDto);
}
/**
* Retrieves a paginated list of content types based on query parameters.
*
* @param pageQuery - The pagination options for the query.
* @param filters - The query filters applied to the content types (e.g., by name).
*
* @returns A paginated list of content types matching the provided filters.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<ContentType>,
@Query(new SearchFilterPipe<ContentType>({ allowedFields: ['name'] }))
filters: TFilterQuery<ContentType>,
) {
return await this.contentTypeService.findPage(filters, pageQuery);
}
/**
* Retrieves the count of content types matching the provided filters.
*
* @param filters - The filters applied to the count query.
*
* @returns The number of content types matching the filters.
*/
@Get('count')
async filterCount(
@Query(new SearchFilterPipe<ContentType>({ allowedFields: ['name'] }))
filters: TFilterQuery<ContentType>,
) {
return await this.count(filters);
}
/**
* Retrieves a single content type by its ID.
*
* @param id - The ID of the content type to retrieve.
*
* @returns The content type matching the provided ID.
*/
@Get(':id')
async findOne(@Param('id') id: string): Promise<ContentType> {
const foundContentType = await this.contentTypeService.findOne(id);
if (!foundContentType) {
this.logger.warn(
`Failed to fetch content type with id ${id}. Content type not found.`,
);
throw new NotFoundException(`Content type with id ${id} not found`);
}
return foundContentType;
}
/**
* Deletes a single content type by its ID.
*
* @param id - The ID of the content type to delete.
*
* @returns The result of the delete operation.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string) {
const removedType = await this.contentTypeService.deleteCascadeOne(id);
if (removedType.deletedCount === 0) {
this.logger.warn(
`Failed to delete content type with id ${id}. Content type not found.`,
);
throw new NotFoundException(`Content type with id ${id} not found`);
}
return removedType;
}
/**
* Updates a content type by its ID.
*
* @param contentTypeDto - The data transfer object containing updated content type information.
* @param id - The ID of the content type to update.
*
* @returns The updated content type.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Body() contentTypeDto: ContentTypeUpdateDto,
@Param('id') id: string,
) {
const updatedContentType = await this.contentTypeService.updateOne(
id,
contentTypeDto,
);
if (!updatedContentType) {
this.logger.warn(
`Failed to update content type with id ${id}. Content type not found.`,
);
throw new NotFoundException(`Content type with id ${id} not found`);
}
return updatedContentType;
}
}

View File

@@ -0,0 +1,360 @@
/*
* 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 fs from 'fs';
import { NotFoundException } from '@nestjs/common/exceptions';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import {
AttachmentModel,
Attachment,
} from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
contentFixtures,
installContentFixtures,
} from '@/utils/test/fixtures/content';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { ContentController } from './content.controller';
import { ContentCreateDto } from '../dto/content.dto';
import { ContentTransformInterceptor } from '../interceptors/content.interceptor';
import { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentRepository } from '../repositories/content.repository';
import { ContentType, ContentTypeModel } from '../schemas/content-type.schema';
import { Content, ContentModel } from '../schemas/content.schema';
import { ContentTypeService } from '../services/content-type.service';
import { ContentService } from '../services/content.service';
describe('ContentController', () => {
let contentController: ContentController;
let contentService: ContentService;
let contentTypeService: ContentTypeService;
let attachmentService: AttachmentService;
let transformInterceptor: ContentTransformInterceptor;
let contentType: ContentType;
let content: Content;
let attachment: Attachment;
let updatedContent;
let pageQuery: PageQueryDto<Content>;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ContentController],
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([
ContentTypeModel,
ContentModel,
AttachmentModel,
]),
],
providers: [
LoggerService,
ContentTypeService,
ContentService,
ContentRepository,
AttachmentService,
ContentTypeRepository,
AttachmentRepository,
ContentTransformInterceptor,
EventEmitter2,
],
}).compile();
contentController = module.get<ContentController>(ContentController);
contentService = module.get<ContentService>(ContentService);
attachmentService = module.get<AttachmentService>(AttachmentService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
transformInterceptor = module.get<ContentTransformInterceptor>(
ContentTransformInterceptor,
);
contentType = await contentTypeService.findOne({ name: 'Product' });
content = await contentService.findOne({
title: 'Jean',
});
attachment = await attachmentService.findOne({
name: 'store1.jpg',
});
pageQuery = getPageQuery<Content>({
limit: 1,
sort: ['_id', 'asc'],
});
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findOne', () => {
it('should find content by ID', async () => {
const contentType = await contentTypeService.findOne(content.entity);
jest.spyOn(contentService, 'findOne');
const result = await contentController.findOne(content.id, []);
expect(contentService.findOne).toHaveBeenCalledWith(content.id);
expect(result).toEqualPayload({
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType.id,
});
});
it('should throw NotFoundException when finding content by non-existing ID', async () => {
jest.spyOn(contentService, 'findOne');
await expect(contentController.findOne(NOT_FOUND_ID, [])).rejects.toThrow(
NotFoundException,
);
expect(contentService.findOne).toHaveBeenCalledWith(NOT_FOUND_ID);
});
it('should find content by ID and populate its corresponding content type', async () => {
const result = await contentController.findOne(content.id, ['entity']);
const contentType = await contentTypeService.findOne(content.entity);
expect(result).toEqualPayload({
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType,
});
});
});
describe('findPage', () => {
it('should find all contents', async () => {
const result = await contentController.findPage(pageQuery, [], {});
expect(result).toEqualPayload([
{
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType.id,
},
]);
});
it('should find all contents and populate the corresponding content types', async () => {
const result = await contentController.findPage(
pageQuery,
['entity'],
{},
);
expect(result).toEqualPayload([
{
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType,
},
]);
});
});
describe('findByType', () => {
it('should find contents by content type', async () => {
const result = await contentController.findByType(
contentType.id,
pageQuery,
);
const contents = contentFixtures.filter(({ entity }) => entity === '0');
contents.reduce((acc, curr) => {
curr['entity'] = contentType.id;
return acc;
}, []);
expect(result).toEqualPayload([contents[0]]);
});
});
describe('update', () => {
it('should update and return the updated content', async () => {
const contentType = await contentTypeService.findOne(content.entity);
updatedContent = {
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType.id,
title: 'modified Jean',
};
const result = await contentController.updateOne(
updatedContent,
content.id,
);
expect(result).toEqualPayload(updatedContent, [
'rag',
...IGNORED_TEST_FIELDS,
]);
});
it('should throw NotFoundException if the content is not found', async () => {
await expect(
contentController.updateOne(updatedContent, NOT_FOUND_ID),
).rejects.toThrow(NotFoundException);
});
});
describe('deleteOne', () => {
it('should delete an existing Content', async () => {
const content = await contentService.findOne({ title: 'Adaptateur' });
const result = await contentService.deleteOne(content.id);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
});
describe('create', () => {
it('should create a new Content', async () => {
const newContent: ContentCreateDto = {
title: 'Bluetooth Headphones',
dynamicFields: {
subtitle:
'Sony WH-1000XM4 Wireless Noise-Cancelling Headphones, Black',
image: {
payload: {
url: 'https://images-eu.ssl-images-amazon.com/images/I/61D4ZlSgmRL._SL1500_.jpg',
},
},
},
entity: contentType.id,
status: true,
};
jest.spyOn(contentService, 'create');
const result = await contentController.create(newContent);
expect(contentService.create).toHaveBeenCalledWith(newContent);
expect(result).toEqualPayload(newContent, [
'rag',
...IGNORED_TEST_FIELDS,
]);
});
});
describe('filterDynamicFields', () => {
it('should flatten dynamic fields', () => {
const result = transformInterceptor.transformDynamicFields(
contentFixtures[0],
);
expect(result).toEqualPayload(
{
title: 'Jean',
status: true,
subtitle: 'Jean Droit Taille Normale',
image: {
payload: {
url: 'https://images-na.ssl-images-amazon.com/images/I/31DY09uzLDL._SX38_SY50_CR,0,0,38,50_.jpg',
},
},
},
['entity', 'rag', ...IGNORED_TEST_FIELDS],
);
});
});
describe('count', () => {
it('should return the number of contents', async () => {
jest.spyOn(contentService, 'count');
const result = await contentController.filterCount();
expect(contentService.count).toHaveBeenCalled();
expect(result).toEqual({ count: contentFixtures.length });
});
});
describe('import', () => {
it('should import content from a CSV file', async () => {
const mockCsvData: string = `other,title,status,image
should not appear,store 3,true,image.jpg`;
const mockCsvContentDto: ContentCreateDto = {
entity: '0',
title: 'store 3',
status: true,
dynamicFields: {
image: 'image.jpg',
},
};
jest.spyOn(contentService, 'createMany');
jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true);
jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(mockCsvData);
const contentType = await contentTypeService.findOne({
name: 'Store',
});
const result = await contentController.import({
idFileToImport: attachment.id,
idTargetContentType: contentType.id,
});
expect(contentService.createMany).toHaveBeenCalledWith([
{ ...mockCsvContentDto, entity: contentType.id },
]);
expect(result).toEqualPayload(
[
{
...mockCsvContentDto,
entity: contentType.id,
},
],
[...IGNORED_TEST_FIELDS, 'rag'],
);
});
it('should throw NotFoundException if content type is not found', async () => {
await expect(
contentController.import({
idFileToImport: attachment.id,
idTargetContentType: NOT_FOUND_ID,
}),
).rejects.toThrow(new NotFoundException('Content type is not found'));
});
it('should throw NotFoundException if file is not found in attachment database', async () => {
const contentType = await contentTypeService.findOne({
name: 'Product',
});
jest.spyOn(contentTypeService, 'findOne');
await expect(
contentController.import({
idFileToImport: NOT_FOUND_ID,
idTargetContentType: contentType.id.toString(),
}),
).rejects.toThrow(new NotFoundException('File does not exist'));
});
it('should throw NotFoundException if file does not exist in the given path ', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
await expect(
contentController.import({
idFileToImport: attachment.id,
idTargetContentType: contentType.id,
}),
).rejects.toThrow(new NotFoundException('File does not exist'));
});
it.each([
['file param and content type params are missing', '', ''],
['content type param is missing', '', NOT_FOUND_ID],
['file param is missing', NOT_FOUND_ID, ''],
])(
'should throw NotFoundException if %s',
async (_message, fileToImport, targetContentType) => {
await expect(
contentController.import({
idFileToImport: fileToImport,
idTargetContentType: targetContentType,
}),
).rejects.toThrow(new NotFoundException('Missing params'));
},
);
});
});

View File

@@ -0,0 +1,331 @@
/*
* 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 fs from 'fs';
import path from 'path';
import {
Body,
Controller,
Param,
Post,
Get,
Delete,
HttpCode,
UseInterceptors,
Patch,
NotFoundException,
Query,
} from '@nestjs/common';
import { BadRequestException } from '@nestjs/common/exceptions';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import Papa from 'papaparse';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { config } from '@/config';
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 { ContentTypeService } from './../services/content-type.service';
import { ContentService } from './../services/content.service';
import { ContentCreateDto } from '../dto/content.dto';
import { ContentTransformInterceptor } from '../interceptors/content.interceptor';
import { ContentType } from '../schemas/content-type.schema';
import { Content, ContentStub } from '../schemas/content.schema';
import { preprocessDynamicFields } from '../utilities';
@UseInterceptors(ContentTransformInterceptor, CsrfInterceptor)
@Controller('content')
export class ContentController extends BaseController<Content, ContentStub> {
constructor(
private readonly contentService: ContentService,
private readonly contentTypeService: ContentTypeService,
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService,
) {
super(contentService);
}
/**
* Filters and processes dynamic fields in the content DTO based on the associated content type.
* It ensures that only fields matching the content type are passed forward.
*
* @param contentDto - The content DTO containing dynamic fields.
* @param contentType - The content type schema defining valid fields.
*
* @returns The content DTO with filtered dynamic fields.
*/
filterDynamicFields(contentDto: ContentCreateDto, contentType: ContentType) {
if (!contentType) {
this.logger.warn(
`Content type of id ${contentDto.entity}. Content type not found.`,
);
throw new NotFoundException(
`Content type of id ${contentDto.entity} not found`,
);
}
// Filter the fields coming from the request body to correspond to the contentType
const dynamicFields = contentType.fields.reduce((acc, { name }) => {
return name in contentDto.dynamicFields && contentDto.dynamicFields[name]
? { ...acc, [name]: contentDto.dynamicFields[name] }
: acc;
}, {});
return { ...contentDto, dynamicFields };
}
/**
* Creates new content based on the provided DTO, filtering dynamic fields to match
* the associated content type before persisting it.
*
* @param contentDto - The DTO containing the content data to be created.
*
* @returns The created content document.
*/
@CsrfCheck(true)
@Post()
async create(@Body() contentDto: ContentCreateDto): Promise<Content> {
// Find the content type that corresponds to the given content
const contentType = await this.contentTypeService.findOne(
contentDto.entity,
);
this.validate({
dto: contentDto,
allowedIds: {
entity: contentType?.id,
},
});
const newContent = this.filterDynamicFields(contentDto, contentType);
return await this.contentService.create(newContent);
}
/**
* Imports content from a CSV file based on the provided content type and file ID.
*
* @param idTargetContentType - The content type to match the CSV data against.
* @param idFileToImport - The ID of the file to be imported.
*
* @returns A promise that resolves to the newly created content documents.
*/
@Get('import/:idTargetContentType/:idFileToImport')
async import(
@Param()
{
idTargetContentType: targetContentType,
idFileToImport: fileToImport,
}: {
idTargetContentType: string;
idFileToImport: string;
},
) {
// Check params
if (!fileToImport || !targetContentType) {
this.logger.warn(`Parameters are missing`);
throw new NotFoundException(`Missing params`);
}
// Find the content type that corresponds to the given content
const contentType =
await this.contentTypeService.findOne(targetContentType);
if (!contentType) {
this.logger.warn(
`Failed to fetch content type with id ${targetContentType}. Content type not found.`,
);
throw new NotFoundException(`Content type is not found`);
}
// Get file location
const file = await this.attachmentService.findOne(fileToImport);
// Check if file is present
const filePath = file
? path.join(config.parameters.uploadDir, file.location)
: undefined;
if (!file || !fs.existsSync(filePath)) {
this.logger.warn(`Failed to find file type with id ${fileToImport}.`);
throw new NotFoundException(`File does not exist`);
}
//read file sync
const data = fs.readFileSync(filePath, 'utf8');
const result = Papa.parse<Record<string, string | boolean | number>>(data, {
header: true,
skipEmptyLines: true,
dynamicTyping: true,
});
if (result.errors.length > 0) {
this.logger.warn(
`Errors parsing the file: ${JSON.stringify(result.errors)}`,
);
throw new BadRequestException(result.errors, {
cause: result.errors,
description: 'Error while parsing CSV',
});
}
const contentsDto = result.data.map((content) => {
content.entity = targetContentType;
const dto = preprocessDynamicFields(content);
// Match headers against entity fields
return this.filterDynamicFields(dto, contentType);
});
// Create content
return await this.contentService.createMany(contentsDto);
}
/**
* Retrieves paginated content based on filters and optional population of related entities.
*
* @param pageQuery - Pagination parameters.
* @param populate - Fields to populate in the query.
* @param filters - Filters for content retrieval.
*
* @returns Paginated content list.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Content>,
@Query(PopulatePipe) populate: string[],
@Query(
new SearchFilterPipe<Content>({ allowedFields: ['entity', 'title'] }),
)
filters: TFilterQuery<Content>,
) {
return this.canPopulate(populate, ['entity'])
? await this.contentService.findPageAndPopulate(filters, pageQuery)
: await this.contentService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of contents based on the provided filters.
*
* @param filters - Optional filters for counting content.
*
* @returns The count of content matching the filters.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Content>({ allowedFields: ['entity', 'title'] }),
)
filters?: TFilterQuery<Content>,
) {
return await this.count(filters);
}
/**
* Retrieves a single content by ID, with optional population of related entities.
*
* @param id - The ID of the content to retrieve.
* @param populate - Fields to populate in the query.
*
* @returns The requested content document.
*/
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe) populate: string[],
) {
const doc = this.canPopulate(populate, ['entity'])
? await this.contentService.findOneAndPopulate(id)
: await this.contentService.findOne(id);
if (!doc) {
this.logger.warn(
`Failed to fetch content with id ${id}. Content not found.`,
);
throw new NotFoundException(`Content of id ${id} not found`);
}
return doc;
}
/**
* Deletes a content document by ID.
*
* @param id - The ID of the content to delete.
*
* @returns The result of the delete operation.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string) {
const removedContent = await this.contentService.deleteOne(id);
if (removedContent.deletedCount === 0) {
this.logger.warn(
`Failed to delete content with id ${id}. Content not found.`,
);
throw new NotFoundException(`Content of id ${id} not found`);
}
return removedContent;
}
/**
* Retrieves content based on content type ID with optional pagination.
*
* @param contentType - The content type ID to filter by.
* @param pageQuery - Pagination parameters.
*
* @returns List of content documents matching the content type.
*/
@Get('/type/:id')
async findByType(
@Param('id') contentType: string,
@Query(PageQueryPipe) pageQuery: PageQueryDto<Content>,
): Promise<Content[]> {
const type = await this.contentTypeService.findOne(contentType);
if (!type) {
this.logger.warn(
`Failed to find content with contentType ${contentType}. ContentType not found.`,
);
throw new NotFoundException(`ContentType of id ${contentType} not found`);
}
return await this.contentService.findPage(
{ entity: contentType },
pageQuery,
);
}
/**
* Updates a content document by ID, after filtering dynamic fields to match the associated content type.
*
* @param contentDto - The DTO containing the updated content data.
* @param id - The ID of the content to update.
*
* @returns The updated content document.
*/
@CsrfCheck(true)
@Patch('/:id')
async updateOne(
@Body() contentDto: ContentCreateDto,
@Param('id') id: string,
): Promise<Content> {
const contentType = await this.contentTypeService.findOne(
contentDto.entity,
);
const newContent = this.filterDynamicFields(contentDto, contentType);
const updatedContent = await this.contentService.updateOne(id, newContent);
if (!updatedContent) {
this.logger.warn(
`Failed to update content with id ${id}. Content not found.`,
);
throw new NotFoundException(`Content of id ${id} not found`);
}
return updatedContent;
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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, TestingModule } from '@nestjs/testing';
import { LoggerModule } from '@/logger/logger.module';
import { LoggerService } from '@/logger/logger.service';
import {
installMenuFixtures,
offerMenuFixture,
offersMenuFixtures,
websiteMenuFixture,
} from '@/utils/test/fixtures/menu';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MenuController } from './menu.controller';
import { MenuRepository } from '../repositories/menu.repository';
import { MenuModel } from '../schemas/menu.schema';
import { MenuType } from '../schemas/types/menu';
import { MenuService } from '../services/menu.service';
import { verifyTree } from '../utilities/verifyTree';
describe('MenuController', () => {
let menuController: MenuController;
let menuService: MenuService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installMenuFixtures),
MongooseModule.forFeature([MenuModel]),
LoggerModule,
],
providers: [
MenuRepository,
MenuService,
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
],
controllers: [MenuController],
}).compile();
menuController = module.get<MenuController>(MenuController);
menuService = module.get<MenuService>(MenuService);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('create', () => {
it('should create the item successfully', async () => {
const initialData = {
title: 'new Item',
type: MenuType.postback,
payload: 'string',
};
const result = await menuController.create(initialData);
expect(result).toEqualPayload(initialData);
});
});
describe('findOne', () => {
it('should find an element by id', async () => {
const websiteMenu = await menuService.findOne({
title: websiteMenuFixture.title,
});
const search = await menuController.findOne(websiteMenu.id);
expect(search).toEqualPayload(websiteMenuFixture);
});
});
describe('getTree', () => {
it('should return a valid tree', async () => {
const tree = await menuController.getTree();
expect(verifyTree(tree)).toBeTruthy();
});
});
describe('delete', () => {
it('should delete the subtree', async () => {
const offersEntry = await menuService.findOne({
title: offerMenuFixture.title,
});
await menuController.delete(offersEntry.id);
const offersChildren = await menuService.find({
title: {
$in: [
offersEntry.title,
...offersMenuFixtures.map((menu) => menu.title),
],
},
});
expect(offersChildren.length).toBe(0);
});
});
});

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 {
Body,
Controller,
Delete,
Get,
InternalServerErrorException,
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 { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { MenuCreateDto, MenuQueryDto } from '../dto/menu.dto';
import { Menu, MenuStub } from '../schemas/menu.schema';
import { MenuService } from '../services/menu.service';
@UseInterceptors(CsrfInterceptor)
@Controller('menu')
export class MenuController extends BaseController<Menu, MenuStub> {
constructor(
private readonly menuService: MenuService,
private readonly logger: LoggerService,
) {
super(menuService);
}
/**
* Counts the filtered number of menu items.
*
* Applies filtering based on the allowed fields and returns the number of matching menus.
*
* @returns A promise that resolves to the count of filtered menu items.
*/
@Get('count')
async filterCount(
@Query(new SearchFilterPipe<Menu>({ allowedFields: ['parent'] }))
filters: TFilterQuery<Menu>,
) {
return await this.count(filters);
}
/**
* Retrieves a paginated list of menu items.
*
* Fetches a paginated set of menus based on query parameters and search filters.
*
* @returns A promise that resolves to the paginated list of menu items.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Menu>,
@Query(new SearchFilterPipe<Menu>({ allowedFields: ['parent'] }))
filters: TFilterQuery<Menu>,
) {
return await this.menuService.findPage(filters, pageQuery);
}
/**
* Creates a new menu item.
*
* Validates the menu creation request and inserts a new menu into the database.
*
* @param body - DTO containing the data needed to create the new menu.
*
* @returns A promise that resolves to the created menu item.
*/
@CsrfCheck(true)
@Post()
async create(@Body() body: MenuCreateDto) {
this.validate({
dto: body,
allowedIds: {
parent: (await this.menuService.findOne(body.parent))?.id,
},
});
return await this.menuService.create(body);
}
/**
* Retrieves all menu items or filters menus based on query parameters.
*
* If query parameters are provided, it applies filters and returns matching menus.
*
* @param query - Optional DTO for filtering menus.
*
* @returns A promise that resolves to an array of menu items.
*/
@Get()
async findAll(@Query() query?: MenuQueryDto) {
if (!query) return await this.menuService.findAll();
return await this.menuService.find(query);
}
/**
* Retrieves a tree-structured list of menu items.
*
* This endpoint returns menus arranged in a hierarchical tree structure.
*
* @returns A promise that resolves to the tree-structured list of menu items.
*/
@Get('tree')
async getTree() {
return await this.menuService.getTree();
}
/**
* Retrieves a single menu item by its ID.
*
* Fetches a menu item based on its ID and handles not found or error scenarios.
*
* @param id - The ID of the menu item to retrieve.
*
* @returns A promise that resolves to the menu item if found, or throws a `NotFoundException`.
*/
@Get(':id')
async findOne(@Param('id') id: string) {
try {
const result = await this.menuService.findOne(id);
if (!result) {
this.logger.warn(`Unable to find menu with id: ${id}`);
throw new NotFoundException(`Menu with id: ${id} not found`);
}
return result;
} catch (e) {
this.logger.error(e);
throw new InternalServerErrorException();
}
}
/**
* Updates an existing menu item or creates a new one if the ID does not exist.
*
* Checks the validity of the request and updates the menu item with the given ID, or creates a new one if the ID is not provided.
*
* @param body - DTO containing the data needed to update the menu.
* @param id - The ID of the menu to update.
*
* @returns A promise that resolves to the updated or newly created menu item.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(@Body() body: MenuCreateDto, @Param('id') id: string) {
if (!id) return this.create(body);
return this.menuService.updateOne(id, body);
}
/**
* Deletes a menu item by its ID.
*
* Deletes the specified menu item and its child menus, handling errors and not found scenarios.
*
* @param id - The ID of the menu to delete.
*
* @returns A promise that resolves to an empty string upon successful deletion.
*/
@CsrfCheck(true)
@Delete(':id')
async delete(@Param('id') id: string) {
try {
const deletedCount = await this.menuService.deepDelete(id);
if (deletedCount == 0) {
this.logger.warn(`Unable to delete menu with id: ${id}`);
throw new NotFoundException();
}
return '';
} catch (e) {
this.logger.error(e);
throw new InternalServerErrorException();
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* 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 { IsString, IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
export class ContentCreateDto {
@ApiProperty({ description: 'Content entity', type: String })
@IsString()
@IsNotEmpty()
@IsObjectId({ message: 'Entity must be a valid ObjectId' })
entity: string;
@ApiProperty({ description: 'Content title', type: String })
@IsNotEmpty()
@IsString()
title: string;
@ApiPropertyOptional({ description: 'Content status', type: Boolean })
@IsBoolean()
@IsOptional()
status?: boolean;
@ApiPropertyOptional({ description: 'Content dynamic fields', type: Object })
@IsOptional()
dynamicFields?: Record<string, any>;
}

View File

@@ -0,0 +1,58 @@
/*
* 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 { Type } from 'class-transformer';
import {
IsString,
IsArray,
ValidateNested,
IsOptional,
IsEnum,
Matches,
IsNotEmpty,
Validate,
} from 'class-validator';
import { ValidateRequiredFields } from '../validators/validate-required-fields.validator';
export class FieldType {
@IsString()
@IsNotEmpty()
@Matches(/^[a-z][a-z_0-9]*$/)
name: string;
@IsString()
@IsNotEmpty()
label: string;
@IsString()
@IsEnum(['text', 'url', 'textarea', 'checkbox', 'file', 'html'], {
message:
"type must be one of the following values: 'text', 'url', 'textarea', 'checkbox', 'file', 'html'",
})
type: string;
}
export class ContentTypeCreateDto {
@ApiProperty({ description: 'Content type name', type: String })
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({ description: 'Content type fields', type: FieldType })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Validate(ValidateRequiredFields)
@Type(() => FieldType)
fields?: FieldType[];
}
export class ContentTypeUpdateDto extends PartialType(ContentTypeCreateDto) {}

View File

@@ -0,0 +1,56 @@
/*
* 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 {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUrl,
ValidateIf,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import { MenuType } from '../schemas/types/menu';
export class MenuCreateDto {
@ApiProperty({ description: 'Menu title', type: String })
@IsString()
@IsNotEmpty()
title: string;
@ApiPropertyOptional({ description: 'Menu parent', type: String })
@IsOptional()
@IsString()
@IsObjectId({
message: 'Parent must be a valid objectId',
})
parent?: string;
@ApiProperty({ description: 'Menu type', enum: MenuType, type: MenuType })
@IsEnum(MenuType)
@IsNotEmpty()
type: MenuType;
@ApiPropertyOptional({ description: 'Menu payload', type: String })
@ValidateIf((o) => o.type == MenuType.postback)
@IsOptional()
@IsString()
payload?: string;
@ApiPropertyOptional({ description: 'Menu url', type: String })
@ValidateIf((o) => o.type == MenuType.web_url)
@IsOptional()
@IsUrl()
url?: string;
}
export class MenuQueryDto extends PartialType(MenuCreateDto) {}

View File

@@ -0,0 +1,55 @@
/*
* 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,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Content } from '../schemas/content.schema';
@Injectable()
export class ContentTransformInterceptor
implements NestInterceptor<Content, Content>
{
/*
-This interceptor is designed to provide a flattened representation of the 'dynamicFields'.
-The incoming data contains a 'dynamicField' object, and the interceptor is expanding it,
extracting its content as separate entries.
-After the expansion, the 'dynamicFields' property is removed.
-The interceptor will be applied on each endpoint of this controller.
*/
transformDynamicFields(data) {
if (data.dynamicFields) {
Object.keys(data.dynamicFields).forEach((key) => {
data[key] = data.dynamicFields[key];
});
delete data.dynamicFields;
}
return data;
}
intercept(context: ExecutionContext, next: CallHandler): Observable<Content> {
return next.handle().pipe(
map((data) => {
// If the data is not an array, the 'transformDynamicFields' method is applied once
if (!Array.isArray(data)) {
return this.transformDynamicFields(data);
}
return data.map((content) => {
return this.transformDynamicFields(content);
});
}),
);
}
}

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 { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { preprocessDynamicFields } from '../utilities';
@Injectable()
export class ContentMiddleWare implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
req.body = preprocessDynamicFields(req.body);
next();
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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, TestingModule } from '@nestjs/testing';
import { Model } from 'mongoose';
import { BlockRepository } from '@/chat/repositories/block.repository';
import { BlockModel } from '@/chat/schemas/block.schema';
import { BlockService } from '@/chat/services/block.service';
import {
ContentType,
ContentTypeModel,
} from '@/cms/schemas/content-type.schema';
import { LoggerService } from '@/logger/logger.service';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { installContentFixtures } from './../../utils/test/fixtures/content';
import { ContentTypeRepository } from './content-type.repository';
import { ContentRepository } from './content.repository';
import { Content, ContentModel } from '../schemas/content.schema';
describe('ContentTypeRepository', () => {
let contentTypeRepository: ContentTypeRepository;
let contentTypeModel: Model<ContentType>;
let contentModel: Model<Content>;
let blockService: BlockService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([ContentTypeModel, ContentModel, BlockModel]),
],
providers: [
LoggerService,
ContentRepository,
ContentTypeRepository,
BlockService,
BlockRepository,
{
provide: BlockService,
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
blockService = module.get<BlockService>(BlockService);
contentTypeRepository = module.get<ContentTypeRepository>(
ContentTypeRepository,
);
contentTypeModel = module.get<Model<ContentType>>(
getModelToken('ContentType'),
);
contentModel = module.get<Model<Content>>(getModelToken('Content'));
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('deleteCascadeOne', () => {
it('should delete a contentType by id if no associated block was found', async () => {
jest.spyOn(blockService, 'findOne').mockResolvedValueOnce(null);
const contentType = await contentTypeModel.findOne({ name: 'Store' });
const result = await contentTypeRepository.deleteOne(contentType.id);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
const contents = await contentModel.find({
entity: contentType.id,
});
expect(contents).toEqual([]);
});
});
});

View File

@@ -0,0 +1,68 @@
/*
* 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 { BlockService } from '@/chat/services/block.service';
import { LoggerService } from '@/logger/logger.service';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { ContentType } from '../schemas/content-type.schema';
import { Content } from '../schemas/content.schema';
@Injectable()
export class ContentTypeRepository extends BaseRepository<ContentType> {
constructor(
@InjectModel(ContentType.name) readonly model: Model<ContentType>,
@InjectModel(Content.name) private readonly contentModel: Model<Content>,
@Optional() private readonly blockService?: BlockService,
@Optional() private readonly logger?: LoggerService,
) {
super(model, ContentType);
this.logger = logger;
this.blockService = blockService;
}
/**
* This method is triggered before deleting a content type entity.
* It checks if there are any associated blocks linked to the content type entity,
* and if found, it throws a `ForbiddenException` to prevent deletion.
* If no blocks are associated, it deletes all related content records linked to the content type entity.
*
* @param query - The query object used for deletion.
* @param criteria - The filter query to identify the content type entity to delete.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<ContentType, any, any>,
unknown,
ContentType,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<ContentType>,
) {
const entityId: string = criteria._id as string;
const associatedBlocks = await this.blockService.findOne({
'options.content.entity': entityId,
});
if (associatedBlocks) {
throw new ForbiddenException(`Content type have blocks associated to it`);
}
if (criteria._id) {
await this.contentModel.deleteMany({ entity: criteria._id });
} else {
throw new Error(
'Attempted to delete content type using unknown criteria',
);
}
}
}

View File

@@ -0,0 +1,90 @@
/*
* 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, TestingModule } from '@nestjs/testing';
import { Model } from 'mongoose';
import {
ContentType,
ContentTypeModel,
} from '@/cms/schemas/content-type.schema';
import { LoggerService } from '@/logger/logger.service';
import { contentTypeFixtures } from '@/utils/test/fixtures/contenttype';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import {
installContentFixtures,
contentFixtures,
} from './../../utils/test/fixtures/content';
import { ContentRepository } from './content.repository';
import { Content, ContentModel } from '../schemas/content.schema';
describe('ContentRepository', () => {
let contentRepository: ContentRepository;
let contentModel: Model<Content>;
let contentTypeModel: Model<ContentType>;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([ContentTypeModel, ContentModel]),
],
providers: [LoggerService, ContentRepository],
}).compile();
contentRepository = module.get<ContentRepository>(ContentRepository);
contentModel = module.get<Model<Content>>(getModelToken('Content'));
contentTypeModel = module.get<Model<ContentType>>(
getModelToken('ContentType'),
);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findOneAndPopulate', () => {
it('should find a content and populate its content type', async () => {
const findSpy = jest.spyOn(contentModel, 'findById');
const content = await contentModel.findOne({ title: 'Jean' });
const contentType = await contentTypeModel.findById(content.entity);
const result = await contentRepository.findOneAndPopulate(content.id);
expect(findSpy).toHaveBeenCalledWith(content.id);
expect(result).toEqualPayload({
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentTypeFixtures.find(
({ name }) => name === contentType.name,
),
});
});
});
describe('findPageAndPopulate', () => {
it('should find contents and populate their content types', async () => {
const pageQuery = getPageQuery<Content>({
limit: 1,
sort: ['_id', 'asc'],
});
const result = await contentRepository.findPageAndPopulate({}, pageQuery);
expect(result).toEqualPayload([
{
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentTypeFixtures[0],
},
]);
});
});
});

View File

@@ -0,0 +1,129 @@
/*
* 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 {
TFilterQuery,
Model,
Document,
Query,
UpdateQuery,
UpdateWithAggregationPipeline,
HydratedDocument,
} from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { Content, ContentFull } from '../schemas/content.schema';
@Injectable()
export class ContentRepository extends BaseRepository<Content, 'entity'> {
constructor(@InjectModel(Content.name) readonly model: Model<Content>) {
super(model, Content);
}
/**
* Retrieves a paginated list of content documents based on the provided filter
* and pagination query, and populates the `entity` field.
*
* @param filter - A filter query for the content documents.
* @param pageQuery - Pagination and sorting options for the query.
*
* @returns A promise that resolves to an array of fully populated `ContentFull` documents.
*/
async findPageAndPopulate(
filter: TFilterQuery<Content>,
pageQuery: PageQueryDto<Content>,
): Promise<ContentFull[]> {
const query = this.findPageQuery(filter, pageQuery).populate('entity');
return await this.execute(query, ContentFull);
}
/**
* Finds a single content document by its ID and populates the `entity` field.
*
* @param id - The ID of the content document to retrieve.
*
* @returns A promise that resolves to the populated `ContentFull` document.
*/
async findOneAndPopulate(id: string): Promise<ContentFull> {
const query = this.findOneQuery(id).populate('entity');
return await this.executeOne(query, ContentFull);
}
/**
* A pre-create hook that processes the document before it is saved to the database.
* It sets the `rag` field by stringifying the `dynamicFields` property of the document.
*
* @param doc - The document that is about to be created.
*/
async preCreate(_doc: HydratedDocument<Content>) {
_doc.set('rag', this.stringify(_doc.dynamicFields));
}
/**
* A pre-update hook that modifies the update query before applying it to the database.
* If the `dynamicFields` property is present in the update query, it sets the `rag` field accordingly.
*
* @param query - The Mongoose query for updating the document.
* @param criteria - The filter criteria used for finding the document to update.
* @param updates - The update operations to be applied to the document.
*/
async preUpdate(
_query: Query<
Document<Content, any, any>,
Document<Content, any, any>,
unknown,
Content,
'findOneAndUpdate'
>,
_criteria: TFilterQuery<Content>,
_updates:
| UpdateWithAggregationPipeline
| UpdateQuery<Document<Content, any, any>>,
): Promise<void> {
if ('dynamicFields' in _updates['$set']) {
_query.set('rag', this.stringify(_updates['$set']['dynamicFields']));
}
}
/**
* Converts the provided object to a string representation, joining each key-value pair
* with a newline character.
*
* @param obj - The object to be stringified.
*
* @returns The string representation of the object.
*/
private stringify(obj: Record<string, any>): string {
return Object.entries(obj).reduce(
(prev, cur) => `${prev}\n${cur[0]} : ${cur[1]}`,
'',
);
}
/**
* Performs a full-text search on the `Content` model based on the provided query string.
* The search is case-insensitive and diacritic-insensitive.
*
* @param query - The text query string to search for.
* @returns A promise that resolves to the matching content documents.
*/
async textSearch(query: string) {
return this.find({
$text: {
$search: query,
$diacriticSensitive: false,
$caseSensitive: false,
},
});
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerModule } from '@/logger/logger.module';
import {
installMenuFixtures,
rootMenuFixtures,
} from '@/utils/test/fixtures/menu';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MenuRepository } from './menu.repository';
import { MenuModel } from '../schemas/menu.schema';
import { MenuType } from '../schemas/types/menu';
describe('MenuRepository', () => {
let menuRepository: MenuRepository;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installMenuFixtures),
MongooseModule.forFeature([MenuModel]),
LoggerModule,
],
providers: [MenuRepository, EventEmitter2],
}).compile();
menuRepository = module.get<MenuRepository>(MenuRepository);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findOneAndPopulate', () => {
it('should return a populated version of the document', async () => {
const parent = await menuRepository.create({
title: 'Test1',
type: MenuType.nested,
});
const child = await menuRepository.create({
...rootMenuFixtures[0],
parent: parent.id,
});
const result = await menuRepository.findOneAndPopulate(child.id);
expect(result).toEqual({ ...child, parent });
});
});
});

View File

@@ -0,0 +1,122 @@
/*
* 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 } from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { Menu, MenuDocument, MenuFull } from '../schemas/menu.schema';
import { MenuType } from '../schemas/types/menu';
@Injectable()
export class MenuRepository extends BaseRepository<Menu, 'parent'> {
constructor(
@InjectModel(Menu.name) readonly model: Model<Menu>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Menu);
}
/**
* Pre-create validation hook that checks the `MenuDocument` before it is created.
* It throws an error if certain fields (like `type` or `payload`) are missing or illegally modified.
*
* @param doc - The document to be validated before creation.
*/
async preCreate(_doc: MenuDocument) {
if (_doc) {
const modifiedPaths = _doc.modifiedPaths();
if (!_doc?.isNew) {
if (modifiedPaths.includes('type'))
throw new Error("Illegal Update: can't update type");
}
switch (_doc.type) {
case MenuType.postback:
if (!modifiedPaths.includes('payload'))
throw new Error(
"Menu Validation Error: doesn't include payload for type postback",
);
break;
case MenuType.web_url:
if (!modifiedPaths.includes('url'))
throw new Error(
"Menu Validation Error: doesn't include url for type web_url",
);
break;
default:
break;
}
}
}
/**
* Post-create hook that triggers the event after a `Menu` document has been successfully created.
*
* @param created - The newly created `MenuDocument`.
*/
async postCreate(created: MenuDocument): Promise<void> {
this.eventEmitter.emit('hook:menu:create', created);
}
/**
* @description
* Post-update hook that triggers the event after a `Menu` document has been successfully updated.
*
* @param query - The query used to update the `Menu` document.
* @param updated - The updated `Menu` document.
*/
async postUpdate(
_query: Query<
Document<Menu, any, any>,
Document<Menu, any, any>,
unknown,
Menu,
'findOneAndUpdate'
>,
updated: Menu,
): Promise<void> {
this.eventEmitter.emit('hook:menu:update', updated);
}
/**
* Post-delete hook that triggers the event after a `Menu` document has been successfully deleted.
*
* @param query - The query used to delete the `Menu` document.
* @param result - The result of the deletion.
*/
async postDelete(
_query: Query<
DeleteResult,
Document<Menu, any, any>,
unknown,
Menu,
'deleteOne' | 'deleteMany'
>,
result: DeleteResult,
): Promise<void> {
this.eventEmitter.emit('hook:menu:delete', result);
}
/**
* Finds a `Menu` document by its ID and populates the `parent` field.
*
* @param id - The ID of the `Menu` document to be found and populated.
*
* @returns A promise that resolves to the populated `MenuFull` document.
*/
async findOneAndPopulate(id: string): Promise<MenuFull> {
const query = this.findOneQuery(id).populate('parent');
return await this.executeOne(query, MenuFull);
}
}

View File

@@ -0,0 +1,55 @@
/*
* 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 mongoose from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
@Schema({ timestamps: true })
export class ContentType extends BaseSchema {
/**
* The name the content type.
*/
@Prop({ type: String, required: true, unique: true })
name: string;
/**
* The way this type is defined and is presented.
*/
@Prop({
type: mongoose.Schema.Types.Mixed,
default: [
{
name: 'title',
label: 'Title',
type: 'text',
},
{
name: 'status',
label: 'Status',
type: 'checkbox',
},
],
})
fields?: {
name: string;
label: string;
type: string;
}[];
}
export const ContentTypeModel: ModelDefinition = LifecycleHookManager.attach({
name: ContentType.name,
schema: SchemaFactory.createForClass(ContentType),
});
export default ContentTypeModel.schema;

View File

@@ -0,0 +1,106 @@
/*
* 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 mongoose, { Document } from 'mongoose';
import { config } from '@/config';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { ContentType } from './content-type.schema';
export type ContentDocument = Document<Content>;
@Schema({ timestamps: true, strict: false })
export class ContentStub extends BaseSchema {
/**
* The content type of this content.
*/
@Prop({
type: mongoose.Schema.Types.ObjectId,
ref: 'ContentType',
})
entity: unknown;
/**
* The title of the content.
*/
@Prop({ type: String, required: true })
title: string;
/**
* Either of not this content is active.
*/
@Prop({ type: Boolean, default: true })
status?: boolean;
@Prop({ type: mongoose.Schema.Types.Mixed })
dynamicFields?: Record<string, any>;
@Prop({ type: String })
rag?: string;
/**
* Helper to return the internal url of this content.
*/
static getUrl(item: Content): string {
return new URL('/content/view/' + item.id, config.apiPath).toString();
}
/**
* Helper that returns the relative chatbot payload for this content.
*/
static getPayload(item: Content): string {
return 'postback' in item ? (item.postback as string) : item.title;
}
}
@Schema({ timestamps: true })
export class Content extends ContentStub {
@Transform(({ obj }) => obj.entity.toString())
entity: string;
static flatDynamicFields(element: Content) {
Object.entries(element.dynamicFields).forEach(([key, value]) => {
element[key] = value;
});
element.dynamicFields = undefined;
return element;
}
}
@Schema({ timestamps: true })
export class ContentFull extends ContentStub {
@Type(() => ContentType)
entity: ContentType;
}
const ContentSchema = SchemaFactory.createForClass(ContentStub);
ContentSchema.index(
{
title: 'text',
rag: 'text',
},
{
weights: {
title: 2,
rag: 1,
},
background: false,
},
);
export const ContentModel: ModelDefinition = LifecycleHookManager.attach({
name: Content.name,
schema: ContentSchema,
});
export default ContentModel.schema;

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 { 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 { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { MenuType } from './types/menu';
@Schema({ timestamps: true })
export class MenuStub extends BaseSchema {
/**
* The displayed title of the menu.
*/
@Prop({ isRequired: true, type: String })
title: string;
/**
* If this menu item is part of an other nested menu (parent), this will indicate that parent.
*/
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Menu',
isRequired: false,
})
parent?: unknown;
/**
* Type of the menu item, one of: web_url, postback, nested.
*/
@Prop({ type: String, enum: Object.values(MenuType), required: true })
type: MenuType;
/**
* The content of the payload, if the menu item type is postback.
*/
@Prop({ type: String })
payload?: string;
/**
* The url if the menu item is web_url.
*/
@Prop({ type: String, validate: (url: string | URL) => !!new URL(url) })
url?: string;
}
@Schema({ timestamps: true })
export class Menu extends MenuStub {
@Transform(({ obj }) => obj.parent?.toString())
parent?: string;
}
@Schema({ timestamps: true })
export class MenuFull extends MenuStub {
@Type(() => Menu)
parent: Menu;
}
export type MenuDocument = THydratedDocument<Menu>;
// This is an optional additional validation step to enforce the data structure
// this function relies on two assumptions,
// 1. if a path is changed during an update or creation, the new value is already validated
// 2. if a document is created, it is already validated: the properties are already set
export const MenuModel: ModelDefinition = LifecycleHookManager.attach({
name: Menu.name,
schema: SchemaFactory.createForClass(MenuStub),
});
export default MenuModel.schema;

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 { MenuStub, Menu } from '../menu.schema';
export enum MenuType {
web_url = 'web_url',
postback = 'postback',
nested = 'nested',
}
interface MenuAttrs {
type: MenuType;
payload?: unknown;
url?: unknown;
}
export interface NestedMenuAttrs {
type: MenuType.nested;
payload?: never;
url?: never;
}
export interface PostbackMenuAttrs {
type: MenuType.postback;
payload: string;
url?: never;
}
export interface WebUrlMenuAttrs {
type: MenuType.web_url;
payload?: never;
url: string;
}
type AnyMenuAttrs = NestedMenuAttrs | PostbackMenuAttrs | WebUrlMenuAttrs;
export type AnyMenu<T extends MenuStub = Menu> = Omit<T, keyof MenuAttrs> &
AnyMenuAttrs;
export type MenuTree = (AnyMenu<Menu> & {
call_to_actions?: MenuTree;
})[];

View File

@@ -0,0 +1,93 @@
/*
* 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, TestingModule } 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 { BlockService } from '@/chat/services/block.service';
import { LoggerService } from '@/logger/logger.service';
import { installContentFixtures } from '@/utils/test/fixtures/content';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { ContentTypeService } from './content-type.service';
import { ContentService } from './content.service';
import { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentRepository } from '../repositories/content.repository';
import { ContentTypeModel } from '../schemas/content-type.schema';
import { ContentModel } from '../schemas/content.schema';
describe('ContentTypeService', () => {
let contentTypeService: ContentTypeService;
let contentService: ContentService;
let contentTypeRepository: ContentTypeRepository;
let blockService: BlockService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([
ContentTypeModel,
ContentModel,
AttachmentModel,
]),
],
providers: [
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
ContentTypeService,
ContentService,
AttachmentService,
LoggerService,
EventEmitter2,
{
provide: BlockService,
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
blockService = module.get<BlockService>(BlockService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
contentService = module.get<ContentService>(ContentService);
contentTypeRepository = module.get<ContentTypeRepository>(
ContentTypeRepository,
);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('deleteOne', () => {
it('should delete a content type and its related contents', async () => {
const deleteContentTypeSpy = jest.spyOn(
contentTypeRepository,
'deleteOne',
);
jest.spyOn(blockService, 'findOne').mockResolvedValueOnce(null);
const contentType = await contentTypeService.findOne({ name: 'Product' });
const result = await contentTypeService.deleteCascadeOne(contentType.id);
expect(deleteContentTypeSpy).toHaveBeenCalledWith(contentType.id);
expect(await contentService.find({ entity: contentType.id })).toEqual([]);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
});
});

View File

@@ -0,0 +1,37 @@
/*
* 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 { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentType } from '../schemas/content-type.schema';
@Injectable()
export class ContentTypeService extends BaseService<ContentType> {
constructor(readonly repository: ContentTypeRepository) {
super(repository);
}
/**
* Deletes a specific content type by its ID, using cascade deletion.
*
* This method triggers the deletion of a single `ContentType` entity
* from the repository. If there are any related content, they will be
* deleted accordingly.
*
* @param id - The ID of the `ContentType` to be deleted.
*
* @returns A promise that resolves when the deletion is complete.
*/
async deleteCascadeOne(id: string) {
return await this.repository.deleteOne(id);
}
}

View File

@@ -0,0 +1,297 @@
/*
* 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, TestingModule } 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 { FileType } from '@/chat/schemas/types/attachment';
import { OutgoingMessageFormat } from '@/chat/schemas/types/message';
import { ContentOptions } from '@/chat/schemas/types/options';
import { LoggerService } from '@/logger/logger.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
contentFixtures,
installContentFixtures,
} from '@/utils/test/fixtures/content';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { ContentTypeService } from './content-type.service';
import { ContentService } from './content.service';
import { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentRepository } from '../repositories/content.repository';
import { ContentTypeModel } from '../schemas/content-type.schema';
import { Content, ContentModel } from '../schemas/content.schema';
describe('ContentService', () => {
let contentService: ContentService;
let contentTypeService: ContentTypeService;
let contentRepository: ContentRepository;
let attachmentService: AttachmentService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([
ContentTypeModel,
ContentModel,
AttachmentModel,
]),
],
providers: [
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
ContentTypeService,
ContentService,
AttachmentService,
LoggerService,
EventEmitter2,
],
}).compile();
contentService = module.get<ContentService>(ContentService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
contentRepository = module.get<ContentRepository>(ContentRepository);
attachmentService = module.get<AttachmentService>(AttachmentService);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findOneAndPopulate', () => {
it('should return a content and populate its corresponding content type', async () => {
const findSpy = jest.spyOn(contentRepository, 'findOneAndPopulate');
const content = await contentService.findOne({ title: 'Jean' });
const contentType = await contentTypeService.findOne(content.entity);
const result = await contentService.findOneAndPopulate(content.id);
expect(findSpy).toHaveBeenCalledWith(content.id);
expect(result).toEqualPayload({
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType,
});
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Content>({ limit: 1, sort: ['_id', 'asc'] });
it('should return contents and populate their corresponding content types', async () => {
const findSpy = jest.spyOn(contentRepository, 'findPageAndPopulate');
const results = await contentService.findPageAndPopulate({}, pageQuery);
const contentType = await contentTypeService.findOne(
results[0].entity.id,
);
expect(findSpy).toHaveBeenCalledWith({}, pageQuery);
expect(results).toEqualPayload([
{
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType,
},
]);
});
});
describe('getAttachmentIds', () => {
const contents: Content[] = [
{
id: '1',
title: 'store 1',
entity: 'stores',
status: true,
dynamicFields: {
image: {
type: FileType.image,
payload: {
attachment_id: '123',
},
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
title: 'store 2',
entity: 'stores',
status: true,
dynamicFields: {
image: {
type: FileType.image,
payload: {
attachment_id: '456',
},
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '3',
title: 'store 3',
entity: 'stores',
status: true,
dynamicFields: {
image: {
type: FileType.image,
payload: {
url: 'https://remote.file/image.jpg',
},
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
];
it('should return all content attachment ids', () => {
const result = contentService.getAttachmentIds(contents, 'image');
expect(result).toEqual(['123', '456']);
});
it('should not return any of the attachment ids', () => {
const result = contentService.getAttachmentIds(contents, 'file');
expect(result).toEqual([]);
});
});
describe('populateAttachments', () => {
it('should return populated content', async () => {
const storeContents = await contentService.find({ title: /^store/ });
const populatedStoreContents: Content[] = await Promise.all(
storeContents.map(async (store) => {
const attachmentId = store.dynamicFields.image.payload.attachment_id;
if (attachmentId) {
const attachment = await attachmentService.findOne(attachmentId);
if (attachment) {
return {
...store,
dynamicFields: {
...store.dynamicFields,
image: {
type: 'image',
payload: {
...attachment,
url: `http://localhost:4000/attachment/download/${attachment.id}/${attachment.name}`,
},
},
},
};
}
}
return store;
}),
);
const result = await contentService.populateAttachments(
storeContents,
'image',
);
expect(result).toEqualPayload(populatedStoreContents);
});
});
describe('getContent', () => {
const contentOptions: ContentOptions = {
display: OutgoingMessageFormat.list,
fields: {
title: 'title',
subtitle: 'description',
image_url: 'image',
},
buttons: [],
limit: 10,
};
it('should get content that is published', async () => {
const actualData = await contentService.findPage(
{ status: true },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
);
const flattenedElements = actualData.map((content) =>
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(contentOptions, 0);
expect(content?.elements).toEqualPayload(flattenedElements, [
...IGNORED_TEST_FIELDS,
'payload',
]);
});
it('should get content for a specific entity', async () => {
const contentType = await contentTypeService.findOne({ name: 'Product' });
const actualData = await contentService.findPage(
{ status: true, entity: contentType.id },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
);
const flattenedElements = actualData.map((content) =>
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(
{
...contentOptions,
entity: contentType.id,
},
0,
);
expect(content?.elements).toEqualPayload(flattenedElements);
});
it('should get content using query', async () => {
contentOptions.entity = 1;
const contentType = await contentTypeService.findOne({ name: 'Product' });
const actualData = await contentService.findPage(
{ status: true, entity: contentType.id, title: /^Jean/ },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
);
const flattenedElements = actualData.map((content) =>
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(
{
...contentOptions,
query: { title: /^Jean/ },
entity: 1,
},
0,
);
expect(content?.elements).toEqualPayload(flattenedElements);
});
it('should get content skiping 2 elements', async () => {
const actualData = await contentService.findPage(
{ status: true },
{ skip: 2, limit: 2, sort: ['createdAt', 'desc'] },
);
const flattenedElements = actualData.map((content) =>
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(
{
...contentOptions,
query: {},
entity: undefined,
limit: 2,
},
2,
);
expect(content?.elements).toEqualPayload(flattenedElements, [
...IGNORED_TEST_FIELDS,
'payload',
]);
});
});
});

View File

@@ -0,0 +1,238 @@
/*
* 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 { WithUrl } from '@/chat/schemas/types/attachment';
import { StdOutgoingListMessage } from '@/chat/schemas/types/message';
import { ContentOptions } from '@/chat/schemas/types/options';
import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { ContentRepository } from '../repositories/content.repository';
import { Content } from '../schemas/content.schema';
@Injectable()
export class ContentService extends BaseService<Content> {
constructor(
readonly repository: ContentRepository,
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService,
) {
super(repository);
}
/**
* Finds a content item by its ID and populates its related fields.
*
* @param id - The ID of the content to retrieve.
*
* @return The populated content entity.
*/
async findOneAndPopulate(id: string) {
return await this.repository.findOneAndPopulate(id);
}
/**
* Finds a page of content items based on filters and pagination options,
* and populates their related fields.
*
* @param filters - The query filters to apply.
* @param pageQuery - The pagination and sorting options.
*
* @return A list of populated content entities.
*/
async findPageAndPopulate(
filters: TFilterQuery<Content>,
pageQuery: PageQueryDto<Content>,
) {
return await this.repository.findPageAndPopulate(filters, pageQuery);
}
/**
* Performs a text search on the content repository.
*
* @param query - The text query to search for.
*
* @return A list of content matching the search query.
*/
async textSearch(query: string) {
return this.repository.textSearch(query);
}
/**
* Extracts attachment IDs from content entities, issuing warnings for any issues.
*
* @param contents - An array of content entities.
* @param attachmentFieldName - The name of the attachment field to check for.
*
* @return A list of attachment IDs.
*/
getAttachmentIds(contents: Content[], attachmentFieldName: string) {
return contents.reduce((acc, content) => {
if (attachmentFieldName in content.dynamicFields) {
const attachment = content.dynamicFields[attachmentFieldName];
if (
typeof attachment === 'object' &&
'attachment_id' in attachment.payload
) {
acc.push(attachment.payload.attachment_id);
} else {
this.logger.error(
`Remote attachments have been deprecated, content "${content.title}" is missing the "attachment_id"`,
);
}
} else {
this.logger.warn(
`Field "${attachmentFieldName}" not found in content "${content.title}"`,
);
}
return acc;
}, [] as string[]);
}
/**
* Populates attachment fields within content entities with detailed attachment information.
*
* @param contents - An array of content entities.
* @param attachmentFieldName - The name of the attachment field to populate.
*
* @return A list of content with populated attachment data.
*/
async populateAttachments(
contents: Content[],
attachmentFieldName: string,
): Promise<Content[]> {
const attachmentIds = this.getAttachmentIds(contents, attachmentFieldName);
if (attachmentIds.length > 0) {
const attachments = await this.attachmentService.find({
_id: { $in: attachmentIds },
});
const attachmentsById = attachments.reduce(
(acc, curr) => {
acc[curr.id] = curr;
return acc;
},
{} as { [key: string]: WithUrl<Attachment> },
);
const populatedContents = contents.map((content) => {
const attachmentField = content.dynamicFields[attachmentFieldName];
if (
typeof attachmentField === 'object' &&
'attachment_id' in attachmentField.payload
) {
const attachmentId = attachmentField?.payload?.attachment_id;
return {
...content,
dynamicFields: {
...content.dynamicFields,
[attachmentFieldName]: {
type: attachmentField.type,
payload: {
...(attachmentsById[attachmentId] || attachmentField.payload),
url: Attachment.getAttachmentUrl(
attachmentId,
attachmentsById[attachmentId].name,
),
},
},
},
};
} else {
return content;
}
});
return populatedContents;
}
return contents;
}
/**
* Retrieves content based on the provided options and pagination settings.
*
* @param options - Options that define how content should be fetched.
* @param skip - Pagination offset, indicating the number of records to skip.
*
* @return The content with pagination info, or undefined if none found.
*/
async getContent(
options: ContentOptions,
skip: number,
): Promise<Omit<StdOutgoingListMessage, 'options'> | undefined> {
let query: TFilterQuery<Content> = { status: true };
const limit = options.limit;
if (options.query) {
query = { ...query, ...options.query };
}
if (typeof options.entity === 'string') {
query = { ...query, entity: options.entity };
}
try {
const total = await this.count(query);
if (total === 0) {
this.logger.warn('No content found', query);
throw new Error('No content found');
}
try {
const contents = await this.findPage(query, {
skip,
limit,
sort: ['createdAt', 'desc'],
});
const attachmentFieldName = options.fields.image_url;
if (attachmentFieldName) {
// Populate attachment when there's an image field
const populatedContents = await this.populateAttachments(
contents,
attachmentFieldName,
);
const flatContent = populatedContents.map((content) => ({
...content,
...content.dynamicFields,
dynamicFields: undefined,
}));
return {
elements: flatContent,
pagination: {
total,
skip,
limit,
},
};
}
return {
elements: contents,
pagination: {
total,
skip,
limit,
},
};
} catch (err) {
this.logger.error('Unable to retrieve content', err, query);
throw err;
}
} catch (err) {
this.logger.error('Unable to count content', err, query);
throw err;
}
}
}

View File

@@ -0,0 +1,96 @@
/*
* 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, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import {
installMenuFixtures,
rootMenuFixtures,
} from '@/utils/test/fixtures/menu';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MenuService } from './menu.service';
import { MenuRepository } from '../repositories/menu.repository';
import { MenuModel } from '../schemas/menu.schema';
import { MenuType } from '../schemas/types/menu';
import { verifyTree } from '../utilities/verifyTree';
describe('MenuService', () => {
let menuService: MenuService;
let menuRepository: MenuRepository;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installMenuFixtures),
MongooseModule.forFeature([MenuModel]),
],
providers: [
MenuRepository,
MenuService,
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
],
}).compile();
menuService = module.get<MenuService>(MenuService);
menuRepository = module.get<MenuRepository>(MenuRepository);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('create', () => {
it('should create the menu successfully', async () => {
const testingItem = rootMenuFixtures[0];
jest.spyOn(menuRepository, 'create');
const result = await menuService.create(testingItem);
expect(result).toEqualPayload(testingItem);
expect(menuRepository.create).toHaveBeenCalled();
});
it('should throw a 404 error', async () => {
const testingItem = rootMenuFixtures[0];
testingItem.parent = '542c2b97bac0595474108b48';
await expect(menuService.create(testingItem)).rejects.toThrow(
NotFoundException,
);
});
it('should throw validation errors', async () => {
const testingItem = rootMenuFixtures[0];
testingItem.type = MenuType.postback;
testingItem.payload = undefined;
await expect(menuService.create(testingItem)).rejects.toThrow();
testingItem.type = MenuType.web_url;
testingItem.url = undefined;
await expect(menuService.create(testingItem)).rejects.toThrow();
});
});
describe('getTree', () => {
it('should create a valid menu tree', async () => {
const result = await menuService.getTree();
expect(verifyTree(result)).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,161 @@
/*
* 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 {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cache } from 'cache-manager';
import { LoggerService } from '@/logger/logger.service';
import { MENU_CACHE_KEY } from '@/utils/constants/cache';
import { Cacheable } from '@/utils/decorators/cacheable.decorator';
import { BaseService } from '@/utils/generics/base-service';
import { MenuCreateDto } from '../dto/menu.dto';
import { MenuRepository } from '../repositories/menu.repository';
import { Menu } from '../schemas/menu.schema';
import { AnyMenu, MenuTree, MenuType } from '../schemas/types/menu';
@Injectable()
export class MenuService extends BaseService<Menu> {
private RootSymbol: symbol = Symbol('RootMenu');
constructor(
readonly repository: MenuRepository,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly logger: LoggerService,
) {
super(repository);
}
/**
* Creates a new menu item. Validates whether the parent exists and if it's a nested menu.
* If the parent menu is not of type 'nested', a conflict exception is thrown.
*
* @param dto - The data transfer object containing the menu details to create.
*
* @returns The newly created menu entity.
*/
public async create(dto: MenuCreateDto): Promise<Menu> {
if (dto.parent) {
// check if parent exists in database
const parent = await this.findOne(dto.parent);
if (!parent)
throw new NotFoundException('The parent of this object does not exist');
// Check if that parent is nested
if (parent.type !== MenuType.nested)
throw new ConflictException("Cant't nest non nested menu");
}
return super.create(dto);
}
/**
* Recursively deletes a menu node and its descendants. This ensures all children of the node
* are deleted before the node itself.
*
* @param id - The ID of the menu node to be deleted.
*
* @returns The count of deleted nodes including the node and its descendants.
*/
public async deepDelete(id: string) {
const node = await this.findOne(id);
if (node) {
const children = await this.find({ parent: node.id });
// count is the number of deleted nodes, at least the current node would be deleted + number of nodes in deleted subtrees
const count = (
await Promise.all(children.map((child) => this.deepDelete(child.id)))
).reduce((prev, curr) => prev + curr, 1);
// finally delete the current node
await this.deleteOne(id);
return count;
} else return 0;
}
/**
* Groups menu items by their parent. It organizes them into a map where the key is the parent ID,
* and the value is an array of its children. If the menu has no parent, it's grouped under the RootSymbol.
*
* @param menuItems - An array of menu items to group.
*
* @returns A map where the key is the parent ID (or RootSymbol), and the value is an array of child menu items.
*/
private groupByParents(
menuItems: AnyMenu[],
): Map<string | symbol, AnyMenu[]> {
const parents: Map<string | symbol, AnyMenu[]> = new Map();
parents.set(this.RootSymbol, []);
menuItems.forEach((m) => {
const menuParent = m.parent?.toString();
if (!m.parent) {
parents.get(this.RootSymbol).push(m);
return;
}
if (parents.has(menuParent)) {
parents.get(menuParent).push(m);
return;
}
parents.set(menuParent, [m]);
});
return parents;
}
/**
* Builds a tree of menus from the grouped menu items. Each node contains its children recursively.
*
* @param parents - A map where keys are parent IDs and values are arrays of child menu items.
* @param parent - The parent ID to start building the tree from. Defaults to RootSymbol.
*
* @returns A hierarchical tree of menus.
*/
private buildTree(
parents: Map<string | symbol, AnyMenu[]>,
parent: string | symbol = this.RootSymbol,
): MenuTree {
if (!parents.has(parent)) return undefined;
const children: MenuTree = parents.get(parent).map((menu) => {
return {
...menu,
call_to_actions:
menu.type === MenuType.nested
? this.buildTree(parents, menu.id) || []
: undefined,
};
});
return children;
}
/**
* Event handler that listens to menu-related events. On receiving such an event, it invalidates the cached menu data.
*/
@OnEvent('hook:menu:*')
async handleMenuUpdateEvent() {
await this.cacheManager.del(MENU_CACHE_KEY);
}
/**
* Retrieves the full hierarchical tree of menu items. It caches the result to improve performance.
*
* @returns The complete menu tree.
*/
@Cacheable(MENU_CACHE_KEY)
public async getTree() {
const menuItems = (await this.findAll()) as AnyMenu[];
const parents = this.groupByParents(menuItems);
return this.buildTree(parents);
}
}

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 { ContentCreateDto } from '../dto/content.dto';
export const preprocessDynamicFields = (
content: Record<string, string | boolean | number>,
) => {
const { _csrf, title, status, entity, ...dynamicFields } = content;
const processed: ContentCreateDto & { _csrf?: string } = {
_csrf: _csrf?.toString(),
entity: entity.toString(),
status: !!status,
title: title.toString(),
dynamicFields,
};
return processed;
};

View File

@@ -0,0 +1,46 @@
/*
* 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 { Menu } from '../schemas/menu.schema';
import { AnyMenu, MenuTree, MenuType } from '../schemas/types/menu';
const verifyMenu = (
menu: AnyMenu<Menu> & {
call_to_actions?: MenuTree;
},
) => {
// first check if menu is an object
if (typeof menu !== 'object') return false;
// check essential menu fields
if (typeof menu.title !== 'string') return false;
if (menu.type === MenuType.postback && typeof menu.payload === 'string')
return true;
if (
menu.type === MenuType.web_url &&
typeof menu.url === 'string' &&
new URL(menu.url)
)
return true;
if (
menu.type === MenuType.nested &&
(menu.call_to_actions === undefined || Array.isArray(menu.call_to_actions))
)
return true;
};
export const verifyTree = (menuTree: MenuTree) => {
if (!Array.isArray(menuTree)) return true;
return !menuTree.some((v) => {
const valid = verifyMenu(v);
if (valid && v.type === MenuType.nested) {
return !verifyTree(v.call_to_actions);
}
return !valid;
});
};

View File

@@ -0,0 +1,63 @@
/*
* 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 } from '@nestjs/common';
import {
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { FieldType } from '../dto/contentType.dto';
@ValidatorConstraint({ name: 'validateRequiredFields', async: false })
export class ValidateRequiredFields implements ValidatorConstraintInterface {
private readonly REQUIRED_FIELDS: FieldType[] = [
{
name: 'title',
label: 'Title',
type: 'text',
},
{
name: 'status',
label: 'Status',
type: 'checkbox',
},
];
validate(fields: FieldType[]): boolean {
const errors: string[] = [];
this.REQUIRED_FIELDS.forEach((requiredField, index) => {
const field = fields[index];
if (!field) {
errors.push(`Field ${requiredField.name} is required.`);
return;
}
Object.entries(requiredField).forEach(([key, value]) => {
if (field[key] !== value) {
errors.push(
`fields.${index}.${key} must be ${value}, but got ${field[key]}`,
);
}
});
});
if (errors.length > 0) {
throw new BadRequestException({ message: errors });
}
return true;
}
defaultMessage(): string {
return 'The fields must match the required structure.';
}
}