mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: initial commit
This commit is contained in:
68
api/src/cms/cms.module.ts
Normal file
68
api/src/cms/cms.module.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
217
api/src/cms/controllers/content-type.controller.spec.ts
Normal file
217
api/src/cms/controllers/content-type.controller.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
165
api/src/cms/controllers/content-type.controller.ts
Normal file
165
api/src/cms/controllers/content-type.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
360
api/src/cms/controllers/content.controller.spec.ts
Normal file
360
api/src/cms/controllers/content.controller.spec.ts
Normal 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'));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
331
api/src/cms/controllers/content.controller.ts
Normal file
331
api/src/cms/controllers/content.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
117
api/src/cms/controllers/menu.controller.spec.ts
Normal file
117
api/src/cms/controllers/menu.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
191
api/src/cms/controllers/menu.controller.ts
Normal file
191
api/src/cms/controllers/menu.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
35
api/src/cms/dto/content.dto.ts
Normal file
35
api/src/cms/dto/content.dto.ts
Normal 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>;
|
||||
}
|
||||
58
api/src/cms/dto/contentType.dto.ts
Normal file
58
api/src/cms/dto/contentType.dto.ts
Normal 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) {}
|
||||
56
api/src/cms/dto/menu.dto.ts
Normal file
56
api/src/cms/dto/menu.dto.ts
Normal 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) {}
|
||||
55
api/src/cms/interceptors/content.interceptor.ts
Normal file
55
api/src/cms/interceptors/content.interceptor.ts
Normal 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);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
21
api/src/cms/middlewares/content.middleware.ts
Normal file
21
api/src/cms/middlewares/content.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
87
api/src/cms/repositories/content-type.repository.spec.ts
Normal file
87
api/src/cms/repositories/content-type.repository.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
68
api/src/cms/repositories/content-type.repository.ts
Normal file
68
api/src/cms/repositories/content-type.repository.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
90
api/src/cms/repositories/content.repository.spec.ts
Normal file
90
api/src/cms/repositories/content.repository.spec.ts
Normal 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],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
api/src/cms/repositories/content.repository.ts
Normal file
129
api/src/cms/repositories/content.repository.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
61
api/src/cms/repositories/menu.reporsitory.spec.ts
Normal file
61
api/src/cms/repositories/menu.reporsitory.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
122
api/src/cms/repositories/menu.repository.ts
Normal file
122
api/src/cms/repositories/menu.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
55
api/src/cms/schemas/content-type.schema.ts
Normal file
55
api/src/cms/schemas/content-type.schema.ts
Normal 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;
|
||||
106
api/src/cms/schemas/content.schema.ts
Normal file
106
api/src/cms/schemas/content.schema.ts
Normal 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;
|
||||
80
api/src/cms/schemas/menu.schema.ts
Normal file
80
api/src/cms/schemas/menu.schema.ts
Normal 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;
|
||||
48
api/src/cms/schemas/types/menu.ts
Normal file
48
api/src/cms/schemas/types/menu.ts
Normal 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;
|
||||
})[];
|
||||
93
api/src/cms/services/content-type.service.spec.ts
Normal file
93
api/src/cms/services/content-type.service.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
37
api/src/cms/services/content-type.service.ts
Normal file
37
api/src/cms/services/content-type.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
297
api/src/cms/services/content.service.spec.ts
Normal file
297
api/src/cms/services/content.service.spec.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
238
api/src/cms/services/content.service.ts
Normal file
238
api/src/cms/services/content.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
api/src/cms/services/menu.service.spec.ts
Normal file
96
api/src/cms/services/menu.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
161
api/src/cms/services/menu.service.ts
Normal file
161
api/src/cms/services/menu.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
api/src/cms/utilities/index.ts
Normal file
25
api/src/cms/utilities/index.ts
Normal 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;
|
||||
};
|
||||
46
api/src/cms/utilities/verifyTree.ts
Normal file
46
api/src/cms/utilities/verifyTree.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
63
api/src/cms/validators/validate-required-fields.validator.ts
Normal file
63
api/src/cms/validators/validate-required-fields.validator.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user