feat: initial commit

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

View File

@@ -0,0 +1,93 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { BlockService } from '@/chat/services/block.service';
import { LoggerService } from '@/logger/logger.service';
import { installContentFixtures } from '@/utils/test/fixtures/content';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { ContentTypeService } from './content-type.service';
import { ContentService } from './content.service';
import { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentRepository } from '../repositories/content.repository';
import { ContentTypeModel } from '../schemas/content-type.schema';
import { ContentModel } from '../schemas/content.schema';
describe('ContentTypeService', () => {
let contentTypeService: ContentTypeService;
let contentService: ContentService;
let contentTypeRepository: ContentTypeRepository;
let blockService: BlockService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([
ContentTypeModel,
ContentModel,
AttachmentModel,
]),
],
providers: [
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
ContentTypeService,
ContentService,
AttachmentService,
LoggerService,
EventEmitter2,
{
provide: BlockService,
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
blockService = module.get<BlockService>(BlockService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
contentService = module.get<ContentService>(ContentService);
contentTypeRepository = module.get<ContentTypeRepository>(
ContentTypeRepository,
);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('deleteOne', () => {
it('should delete a content type and its related contents', async () => {
const deleteContentTypeSpy = jest.spyOn(
contentTypeRepository,
'deleteOne',
);
jest.spyOn(blockService, 'findOne').mockResolvedValueOnce(null);
const contentType = await contentTypeService.findOne({ name: 'Product' });
const result = await contentTypeService.deleteCascadeOne(contentType.id);
expect(deleteContentTypeSpy).toHaveBeenCalledWith(contentType.id);
expect(await contentService.find({ entity: contentType.id })).toEqual([]);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
});
});

View File

@@ -0,0 +1,37 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { BaseService } from '@/utils/generics/base-service';
import { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentType } from '../schemas/content-type.schema';
@Injectable()
export class ContentTypeService extends BaseService<ContentType> {
constructor(readonly repository: ContentTypeRepository) {
super(repository);
}
/**
* Deletes a specific content type by its ID, using cascade deletion.
*
* This method triggers the deletion of a single `ContentType` entity
* from the repository. If there are any related content, they will be
* deleted accordingly.
*
* @param id - The ID of the `ContentType` to be deleted.
*
* @returns A promise that resolves when the deletion is complete.
*/
async deleteCascadeOne(id: string) {
return await this.repository.deleteOne(id);
}
}

View File

@@ -0,0 +1,297 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { FileType } from '@/chat/schemas/types/attachment';
import { OutgoingMessageFormat } from '@/chat/schemas/types/message';
import { ContentOptions } from '@/chat/schemas/types/options';
import { LoggerService } from '@/logger/logger.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
contentFixtures,
installContentFixtures,
} from '@/utils/test/fixtures/content';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { ContentTypeService } from './content-type.service';
import { ContentService } from './content.service';
import { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentRepository } from '../repositories/content.repository';
import { ContentTypeModel } from '../schemas/content-type.schema';
import { Content, ContentModel } from '../schemas/content.schema';
describe('ContentService', () => {
let contentService: ContentService;
let contentTypeService: ContentTypeService;
let contentRepository: ContentRepository;
let attachmentService: AttachmentService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([
ContentTypeModel,
ContentModel,
AttachmentModel,
]),
],
providers: [
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
ContentTypeService,
ContentService,
AttachmentService,
LoggerService,
EventEmitter2,
],
}).compile();
contentService = module.get<ContentService>(ContentService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
contentRepository = module.get<ContentRepository>(ContentRepository);
attachmentService = module.get<AttachmentService>(AttachmentService);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findOneAndPopulate', () => {
it('should return a content and populate its corresponding content type', async () => {
const findSpy = jest.spyOn(contentRepository, 'findOneAndPopulate');
const content = await contentService.findOne({ title: 'Jean' });
const contentType = await contentTypeService.findOne(content.entity);
const result = await contentService.findOneAndPopulate(content.id);
expect(findSpy).toHaveBeenCalledWith(content.id);
expect(result).toEqualPayload({
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType,
});
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Content>({ limit: 1, sort: ['_id', 'asc'] });
it('should return contents and populate their corresponding content types', async () => {
const findSpy = jest.spyOn(contentRepository, 'findPageAndPopulate');
const results = await contentService.findPageAndPopulate({}, pageQuery);
const contentType = await contentTypeService.findOne(
results[0].entity.id,
);
expect(findSpy).toHaveBeenCalledWith({}, pageQuery);
expect(results).toEqualPayload([
{
...contentFixtures.find(({ title }) => title === 'Jean'),
entity: contentType,
},
]);
});
});
describe('getAttachmentIds', () => {
const contents: Content[] = [
{
id: '1',
title: 'store 1',
entity: 'stores',
status: true,
dynamicFields: {
image: {
type: FileType.image,
payload: {
attachment_id: '123',
},
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
title: 'store 2',
entity: 'stores',
status: true,
dynamicFields: {
image: {
type: FileType.image,
payload: {
attachment_id: '456',
},
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '3',
title: 'store 3',
entity: 'stores',
status: true,
dynamicFields: {
image: {
type: FileType.image,
payload: {
url: 'https://remote.file/image.jpg',
},
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
];
it('should return all content attachment ids', () => {
const result = contentService.getAttachmentIds(contents, 'image');
expect(result).toEqual(['123', '456']);
});
it('should not return any of the attachment ids', () => {
const result = contentService.getAttachmentIds(contents, 'file');
expect(result).toEqual([]);
});
});
describe('populateAttachments', () => {
it('should return populated content', async () => {
const storeContents = await contentService.find({ title: /^store/ });
const populatedStoreContents: Content[] = await Promise.all(
storeContents.map(async (store) => {
const attachmentId = store.dynamicFields.image.payload.attachment_id;
if (attachmentId) {
const attachment = await attachmentService.findOne(attachmentId);
if (attachment) {
return {
...store,
dynamicFields: {
...store.dynamicFields,
image: {
type: 'image',
payload: {
...attachment,
url: `http://localhost:4000/attachment/download/${attachment.id}/${attachment.name}`,
},
},
},
};
}
}
return store;
}),
);
const result = await contentService.populateAttachments(
storeContents,
'image',
);
expect(result).toEqualPayload(populatedStoreContents);
});
});
describe('getContent', () => {
const contentOptions: ContentOptions = {
display: OutgoingMessageFormat.list,
fields: {
title: 'title',
subtitle: 'description',
image_url: 'image',
},
buttons: [],
limit: 10,
};
it('should get content that is published', async () => {
const actualData = await contentService.findPage(
{ status: true },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
);
const flattenedElements = actualData.map((content) =>
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(contentOptions, 0);
expect(content?.elements).toEqualPayload(flattenedElements, [
...IGNORED_TEST_FIELDS,
'payload',
]);
});
it('should get content for a specific entity', async () => {
const contentType = await contentTypeService.findOne({ name: 'Product' });
const actualData = await contentService.findPage(
{ status: true, entity: contentType.id },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
);
const flattenedElements = actualData.map((content) =>
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(
{
...contentOptions,
entity: contentType.id,
},
0,
);
expect(content?.elements).toEqualPayload(flattenedElements);
});
it('should get content using query', async () => {
contentOptions.entity = 1;
const contentType = await contentTypeService.findOne({ name: 'Product' });
const actualData = await contentService.findPage(
{ status: true, entity: contentType.id, title: /^Jean/ },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
);
const flattenedElements = actualData.map((content) =>
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(
{
...contentOptions,
query: { title: /^Jean/ },
entity: 1,
},
0,
);
expect(content?.elements).toEqualPayload(flattenedElements);
});
it('should get content skiping 2 elements', async () => {
const actualData = await contentService.findPage(
{ status: true },
{ skip: 2, limit: 2, sort: ['createdAt', 'desc'] },
);
const flattenedElements = actualData.map((content) =>
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(
{
...contentOptions,
query: {},
entity: undefined,
limit: 2,
},
2,
);
expect(content?.elements).toEqualPayload(flattenedElements, [
...IGNORED_TEST_FIELDS,
'payload',
]);
});
});
});

View File

@@ -0,0 +1,238 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { TFilterQuery } from 'mongoose';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { WithUrl } from '@/chat/schemas/types/attachment';
import { StdOutgoingListMessage } from '@/chat/schemas/types/message';
import { ContentOptions } from '@/chat/schemas/types/options';
import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { ContentRepository } from '../repositories/content.repository';
import { Content } from '../schemas/content.schema';
@Injectable()
export class ContentService extends BaseService<Content> {
constructor(
readonly repository: ContentRepository,
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService,
) {
super(repository);
}
/**
* Finds a content item by its ID and populates its related fields.
*
* @param id - The ID of the content to retrieve.
*
* @return The populated content entity.
*/
async findOneAndPopulate(id: string) {
return await this.repository.findOneAndPopulate(id);
}
/**
* Finds a page of content items based on filters and pagination options,
* and populates their related fields.
*
* @param filters - The query filters to apply.
* @param pageQuery - The pagination and sorting options.
*
* @return A list of populated content entities.
*/
async findPageAndPopulate(
filters: TFilterQuery<Content>,
pageQuery: PageQueryDto<Content>,
) {
return await this.repository.findPageAndPopulate(filters, pageQuery);
}
/**
* Performs a text search on the content repository.
*
* @param query - The text query to search for.
*
* @return A list of content matching the search query.
*/
async textSearch(query: string) {
return this.repository.textSearch(query);
}
/**
* Extracts attachment IDs from content entities, issuing warnings for any issues.
*
* @param contents - An array of content entities.
* @param attachmentFieldName - The name of the attachment field to check for.
*
* @return A list of attachment IDs.
*/
getAttachmentIds(contents: Content[], attachmentFieldName: string) {
return contents.reduce((acc, content) => {
if (attachmentFieldName in content.dynamicFields) {
const attachment = content.dynamicFields[attachmentFieldName];
if (
typeof attachment === 'object' &&
'attachment_id' in attachment.payload
) {
acc.push(attachment.payload.attachment_id);
} else {
this.logger.error(
`Remote attachments have been deprecated, content "${content.title}" is missing the "attachment_id"`,
);
}
} else {
this.logger.warn(
`Field "${attachmentFieldName}" not found in content "${content.title}"`,
);
}
return acc;
}, [] as string[]);
}
/**
* Populates attachment fields within content entities with detailed attachment information.
*
* @param contents - An array of content entities.
* @param attachmentFieldName - The name of the attachment field to populate.
*
* @return A list of content with populated attachment data.
*/
async populateAttachments(
contents: Content[],
attachmentFieldName: string,
): Promise<Content[]> {
const attachmentIds = this.getAttachmentIds(contents, attachmentFieldName);
if (attachmentIds.length > 0) {
const attachments = await this.attachmentService.find({
_id: { $in: attachmentIds },
});
const attachmentsById = attachments.reduce(
(acc, curr) => {
acc[curr.id] = curr;
return acc;
},
{} as { [key: string]: WithUrl<Attachment> },
);
const populatedContents = contents.map((content) => {
const attachmentField = content.dynamicFields[attachmentFieldName];
if (
typeof attachmentField === 'object' &&
'attachment_id' in attachmentField.payload
) {
const attachmentId = attachmentField?.payload?.attachment_id;
return {
...content,
dynamicFields: {
...content.dynamicFields,
[attachmentFieldName]: {
type: attachmentField.type,
payload: {
...(attachmentsById[attachmentId] || attachmentField.payload),
url: Attachment.getAttachmentUrl(
attachmentId,
attachmentsById[attachmentId].name,
),
},
},
},
};
} else {
return content;
}
});
return populatedContents;
}
return contents;
}
/**
* Retrieves content based on the provided options and pagination settings.
*
* @param options - Options that define how content should be fetched.
* @param skip - Pagination offset, indicating the number of records to skip.
*
* @return The content with pagination info, or undefined if none found.
*/
async getContent(
options: ContentOptions,
skip: number,
): Promise<Omit<StdOutgoingListMessage, 'options'> | undefined> {
let query: TFilterQuery<Content> = { status: true };
const limit = options.limit;
if (options.query) {
query = { ...query, ...options.query };
}
if (typeof options.entity === 'string') {
query = { ...query, entity: options.entity };
}
try {
const total = await this.count(query);
if (total === 0) {
this.logger.warn('No content found', query);
throw new Error('No content found');
}
try {
const contents = await this.findPage(query, {
skip,
limit,
sort: ['createdAt', 'desc'],
});
const attachmentFieldName = options.fields.image_url;
if (attachmentFieldName) {
// Populate attachment when there's an image field
const populatedContents = await this.populateAttachments(
contents,
attachmentFieldName,
);
const flatContent = populatedContents.map((content) => ({
...content,
...content.dynamicFields,
dynamicFields: undefined,
}));
return {
elements: flatContent,
pagination: {
total,
skip,
limit,
},
};
}
return {
elements: contents,
pagination: {
total,
skip,
limit,
},
};
} catch (err) {
this.logger.error('Unable to retrieve content', err, query);
throw err;
}
} catch (err) {
this.logger.error('Unable to count content', err, query);
throw err;
}
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import {
installMenuFixtures,
rootMenuFixtures,
} from '@/utils/test/fixtures/menu';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MenuService } from './menu.service';
import { MenuRepository } from '../repositories/menu.repository';
import { MenuModel } from '../schemas/menu.schema';
import { MenuType } from '../schemas/types/menu';
import { verifyTree } from '../utilities/verifyTree';
describe('MenuService', () => {
let menuService: MenuService;
let menuRepository: MenuRepository;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installMenuFixtures),
MongooseModule.forFeature([MenuModel]),
],
providers: [
MenuRepository,
MenuService,
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
],
}).compile();
menuService = module.get<MenuService>(MenuService);
menuRepository = module.get<MenuRepository>(MenuRepository);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('create', () => {
it('should create the menu successfully', async () => {
const testingItem = rootMenuFixtures[0];
jest.spyOn(menuRepository, 'create');
const result = await menuService.create(testingItem);
expect(result).toEqualPayload(testingItem);
expect(menuRepository.create).toHaveBeenCalled();
});
it('should throw a 404 error', async () => {
const testingItem = rootMenuFixtures[0];
testingItem.parent = '542c2b97bac0595474108b48';
await expect(menuService.create(testingItem)).rejects.toThrow(
NotFoundException,
);
});
it('should throw validation errors', async () => {
const testingItem = rootMenuFixtures[0];
testingItem.type = MenuType.postback;
testingItem.payload = undefined;
await expect(menuService.create(testingItem)).rejects.toThrow();
testingItem.type = MenuType.web_url;
testingItem.url = undefined;
await expect(menuService.create(testingItem)).rejects.toThrow();
});
});
describe('getTree', () => {
it('should create a valid menu tree', async () => {
const result = await menuService.getTree();
expect(verifyTree(result)).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,161 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cache } from 'cache-manager';
import { LoggerService } from '@/logger/logger.service';
import { MENU_CACHE_KEY } from '@/utils/constants/cache';
import { Cacheable } from '@/utils/decorators/cacheable.decorator';
import { BaseService } from '@/utils/generics/base-service';
import { MenuCreateDto } from '../dto/menu.dto';
import { MenuRepository } from '../repositories/menu.repository';
import { Menu } from '../schemas/menu.schema';
import { AnyMenu, MenuTree, MenuType } from '../schemas/types/menu';
@Injectable()
export class MenuService extends BaseService<Menu> {
private RootSymbol: symbol = Symbol('RootMenu');
constructor(
readonly repository: MenuRepository,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly logger: LoggerService,
) {
super(repository);
}
/**
* Creates a new menu item. Validates whether the parent exists and if it's a nested menu.
* If the parent menu is not of type 'nested', a conflict exception is thrown.
*
* @param dto - The data transfer object containing the menu details to create.
*
* @returns The newly created menu entity.
*/
public async create(dto: MenuCreateDto): Promise<Menu> {
if (dto.parent) {
// check if parent exists in database
const parent = await this.findOne(dto.parent);
if (!parent)
throw new NotFoundException('The parent of this object does not exist');
// Check if that parent is nested
if (parent.type !== MenuType.nested)
throw new ConflictException("Cant't nest non nested menu");
}
return super.create(dto);
}
/**
* Recursively deletes a menu node and its descendants. This ensures all children of the node
* are deleted before the node itself.
*
* @param id - The ID of the menu node to be deleted.
*
* @returns The count of deleted nodes including the node and its descendants.
*/
public async deepDelete(id: string) {
const node = await this.findOne(id);
if (node) {
const children = await this.find({ parent: node.id });
// count is the number of deleted nodes, at least the current node would be deleted + number of nodes in deleted subtrees
const count = (
await Promise.all(children.map((child) => this.deepDelete(child.id)))
).reduce((prev, curr) => prev + curr, 1);
// finally delete the current node
await this.deleteOne(id);
return count;
} else return 0;
}
/**
* Groups menu items by their parent. It organizes them into a map where the key is the parent ID,
* and the value is an array of its children. If the menu has no parent, it's grouped under the RootSymbol.
*
* @param menuItems - An array of menu items to group.
*
* @returns A map where the key is the parent ID (or RootSymbol), and the value is an array of child menu items.
*/
private groupByParents(
menuItems: AnyMenu[],
): Map<string | symbol, AnyMenu[]> {
const parents: Map<string | symbol, AnyMenu[]> = new Map();
parents.set(this.RootSymbol, []);
menuItems.forEach((m) => {
const menuParent = m.parent?.toString();
if (!m.parent) {
parents.get(this.RootSymbol).push(m);
return;
}
if (parents.has(menuParent)) {
parents.get(menuParent).push(m);
return;
}
parents.set(menuParent, [m]);
});
return parents;
}
/**
* Builds a tree of menus from the grouped menu items. Each node contains its children recursively.
*
* @param parents - A map where keys are parent IDs and values are arrays of child menu items.
* @param parent - The parent ID to start building the tree from. Defaults to RootSymbol.
*
* @returns A hierarchical tree of menus.
*/
private buildTree(
parents: Map<string | symbol, AnyMenu[]>,
parent: string | symbol = this.RootSymbol,
): MenuTree {
if (!parents.has(parent)) return undefined;
const children: MenuTree = parents.get(parent).map((menu) => {
return {
...menu,
call_to_actions:
menu.type === MenuType.nested
? this.buildTree(parents, menu.id) || []
: undefined,
};
});
return children;
}
/**
* Event handler that listens to menu-related events. On receiving such an event, it invalidates the cached menu data.
*/
@OnEvent('hook:menu:*')
async handleMenuUpdateEvent() {
await this.cacheManager.del(MENU_CACHE_KEY);
}
/**
* Retrieves the full hierarchical tree of menu items. It caches the result to improve performance.
*
* @returns The complete menu tree.
*/
@Cacheable(MENU_CACHE_KEY)
public async getTree() {
const menuItems = (await this.findAll()) as AnyMenu[];
const parents = this.groupByParents(menuItems);
return this.buildTree(parents);
}
}