mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: initial commit
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user