From 60ca208846c089d0ed6c68d1c7a2301fc2682a40 Mon Sep 17 00:00:00 2001 From: AselPeiris Date: Sun, 12 Jan 2025 03:00:18 +0530 Subject: [PATCH 01/13] fix(frontend): fix menu accessibility visibility issue --- frontend/src/layout/VerticalMenu.tsx | 50 +++++++++++++++++++--------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/frontend/src/layout/VerticalMenu.tsx b/frontend/src/layout/VerticalMenu.tsx index d826a753..8112485a 100644 --- a/frontend/src/layout/VerticalMenu.tsx +++ b/frontend/src/layout/VerticalMenu.tsx @@ -1,11 +1,12 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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). */ + import { faAlignLeft, faAsterisk, @@ -39,7 +40,6 @@ import { Sidebar } from "@/app-components/menus/Sidebar"; import { useAuth } from "@/hooks/useAuth"; import { useConfig } from "@/hooks/useConfig"; import { useHasPermission } from "@/hooks/useHasPermission"; -import { useTranslate } from "@/hooks/useTranslate"; import { EntityType } from "@/services/types"; import { PermissionAction } from "@/types/permission.types"; import { getLayout } from "@/utils/laylout"; @@ -206,7 +206,6 @@ const getMenuItems = (ssoEnabled: boolean): MenuItem[] => [ }, }, - // { // text: 'menu.broadcast', // href: "/subscribers/broadcast", @@ -287,25 +286,46 @@ export const VerticalMenu: FC = ({ onToggleOut, }) => { const { ssoEnabled } = useConfig(); - const { t } = useTranslate(); const { isAuthenticated } = useAuth(); const router = useRouter(); const hasPermission = useHasPermission(); const menuItems = getMenuItems(ssoEnabled); // Filter menu item to which user is allowed access - const availableMenuItems = useMemo(() => { - return menuItems.filter(({ requires: requiredPermissions }) => { - return ( - !requiredPermissions || - Object.entries(requiredPermissions).every((permission) => { - const entityType = permission[0] as EntityType; - const actions = permission[1]; + const generateValidMenuItems = useMemo(() => { + return (menuItems: MenuItem[]): MenuItem[] => { + const validMenuItems = menuItems + .map((menuItem: MenuItem) => { + if (menuItem && !menuItem.submenuItems) { + const requiredPermissions = menuItem.requires!; - return actions.every((action) => hasPermission(entityType, action)); + if ( + requiredPermissions && + Object.entries(requiredPermissions).every((permission) => { + const entityType = permission[0] as EntityType; + const actions = permission[1]; + + return actions.every((action) => + hasPermission(entityType, action), + ); + }) + ) { + return menuItem; + } + } else if (menuItem.submenuItems) { + menuItem.submenuItems = generateValidMenuItems( + menuItem.submenuItems, + ); + + return menuItem; + } }) - ); - }); - }, [t, hasPermission]); + .filter((menuItem) => menuItem !== undefined) + .filter((menuItem) => menuItem?.submenuItems?.length !== 0); + + return validMenuItems; + }; + }, [menuItems, hasPermission]); + const availableMenuItems = generateValidMenuItems(menuItems); const hasTemporaryDrawer = getLayout(router.pathname.slice(1)) === "full_width"; From 6c75e6df4af0f5c733975faa5d4e701bf2713893 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Thu, 16 Jan 2025 18:47:25 +0100 Subject: [PATCH 02/13] refactor(api): Refactor updateOne logic --- .../analytics/services/bot-stats.service.ts | 2 +- .../chat/controllers/block.controller.spec.ts | 5 +- api/src/chat/controllers/block.controller.ts | 7 +-- .../chat/controllers/category.controller.ts | 7 +-- .../context-var.controller.spec.ts | 5 +- .../controllers/context-var.controller.ts | 7 +-- .../chat/controllers/label.controller.spec.ts | 7 ++- api/src/chat/controllers/label.controller.ts | 7 +-- .../chat/controllers/subscriber.controller.ts | 7 +-- .../repositories/block.repository.spec.ts | 48 +++++++++++-------- .../repositories/conversation.repository.ts | 2 +- .../repositories/subscriber.repository.ts | 8 ++-- api/src/chat/services/conversation.service.ts | 5 -- .../content-type.controller.spec.ts | 3 +- .../controllers/content-type.controller.ts | 13 +---- .../controllers/content.controller.spec.ts | 3 +- api/src/cms/controllers/content.controller.ts | 9 +--- api/src/cms/controllers/menu.controller.ts | 5 +- .../controllers/language.controller.spec.ts | 5 +- .../i18n/controllers/language.controller.ts | 7 +-- .../translation.controller.spec.ts | 4 +- .../controllers/translation.controller.ts | 10 +--- api/src/migration/migration.service.ts | 2 +- .../nlp/controllers/nlp-entity.controller.ts | 12 +---- .../controllers/nlp-sample.controller.spec.ts | 3 +- .../nlp/controllers/nlp-sample.controller.ts | 5 -- .../controllers/nlp-value.controller.spec.ts | 3 +- .../nlp/controllers/nlp-value.controller.ts | 7 +-- api/src/nlp/services/nlp-value.service.ts | 2 +- .../setting/controllers/setting.controller.ts | 8 +--- api/src/user/controllers/role.controller.ts | 7 +-- api/src/user/controllers/user.controller.ts | 8 +--- api/src/utils/generics/base-repository.ts | 11 ++++- api/src/utils/generics/base-service.ts | 2 +- api/src/utils/test/errors/messages.ts | 10 ++++ 35 files changed, 92 insertions(+), 164 deletions(-) create mode 100644 api/src/utils/test/errors/messages.ts diff --git a/api/src/analytics/services/bot-stats.service.ts b/api/src/analytics/services/bot-stats.service.ts index ad12c982..b2a5894a 100644 --- a/api/src/analytics/services/bot-stats.service.ts +++ b/api/src/analytics/services/bot-stats.service.ts @@ -110,7 +110,7 @@ export class BotStatsService extends BaseService { * @param name - The name or identifier of the statistics entry (e.g., a specific feature or component being tracked). */ @OnEvent('hook:stats:entry') - async handleStatEntry(type: BotStatsType, name: string) { + async handleStatEntry(type: BotStatsType, name: string): Promise { const day = new Date(); day.setMilliseconds(0); day.setSeconds(0); diff --git a/api/src/chat/controllers/block.controller.spec.ts b/api/src/chat/controllers/block.controller.spec.ts index 767e69b4..6680c9fb 100644 --- a/api/src/chat/controllers/block.controller.spec.ts +++ b/api/src/chat/controllers/block.controller.spec.ts @@ -35,6 +35,7 @@ import { PermissionService } from '@/user/services/permission.service'; import { RoleService } from '@/user/services/role.service'; import { UserService } from '@/user/services/user.service'; import { IGNORED_TEST_FIELDS } from '@/utils/test/constants'; +import { getUpdateOneError } from '@/utils/test/errors/messages'; import { blockFixtures, installBlockFixtures, @@ -327,9 +328,7 @@ describe('BlockController', () => { await expect( blockController.updateOne(blockToDelete.id, updateBlock), - ).rejects.toThrow( - new NotFoundException(`Block with ID ${blockToDelete.id} not found`), - ); + ).rejects.toThrow(getUpdateOneError(Block.name, blockToDelete.id)); }); }); }); diff --git a/api/src/chat/controllers/block.controller.ts b/api/src/chat/controllers/block.controller.ts index 949410d3..d6a87d67 100644 --- a/api/src/chat/controllers/block.controller.ts +++ b/api/src/chat/controllers/block.controller.ts @@ -296,12 +296,7 @@ export class BlockController extends BaseController< @Param('id') id: string, @Body() blockUpdate: BlockUpdateDto, ): Promise { - const result = await this.blockService.updateOne(id, blockUpdate); - if (!result) { - this.logger.warn(`Unable to update Block by id ${id}`); - throw new NotFoundException(`Block with ID ${id} not found`); - } - return result; + return await this.blockService.updateOne(id, blockUpdate); } /** diff --git a/api/src/chat/controllers/category.controller.ts b/api/src/chat/controllers/category.controller.ts index 16a54473..c071b108 100644 --- a/api/src/chat/controllers/category.controller.ts +++ b/api/src/chat/controllers/category.controller.ts @@ -114,12 +114,7 @@ export class CategoryController extends BaseController { @Param('id') id: string, @Body() categoryUpdate: CategoryUpdateDto, ): Promise { - const result = await this.categoryService.updateOne(id, categoryUpdate); - if (!result) { - this.logger.warn(`Unable to update Category by id ${id}`); - throw new NotFoundException(`Category with ID ${id} not found`); - } - return result; + return await this.categoryService.updateOne(id, categoryUpdate); } /** diff --git a/api/src/chat/controllers/context-var.controller.spec.ts b/api/src/chat/controllers/context-var.controller.spec.ts index d14a7dd6..0f07f985 100644 --- a/api/src/chat/controllers/context-var.controller.spec.ts +++ b/api/src/chat/controllers/context-var.controller.spec.ts @@ -12,6 +12,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { Test } from '@nestjs/testing'; import { LoggerService } from '@/logger/logger.service'; +import { getUpdateOneError } from '@/utils/test/errors/messages'; import { contextVarFixtures, installContextVarFixtures, @@ -211,9 +212,7 @@ describe('ContextVarController', () => { contextVarUpdatedDto, ), ).rejects.toThrow( - new NotFoundException( - `ContextVar with ID ${contextVarToDelete.id} not found`, - ), + getUpdateOneError(ContextVar.name, contextVarToDelete.id), ); }); }); diff --git a/api/src/chat/controllers/context-var.controller.ts b/api/src/chat/controllers/context-var.controller.ts index c9234d92..d07fe8c2 100644 --- a/api/src/chat/controllers/context-var.controller.ts +++ b/api/src/chat/controllers/context-var.controller.ts @@ -120,12 +120,7 @@ export class ContextVarController extends BaseController { @Param('id') id: string, @Body() contextVarUpdate: ContextVarUpdateDto, ): Promise { - const result = await this.contextVarService.updateOne(id, contextVarUpdate); - if (!result) { - this.logger.warn(`Unable to update ContextVar by id ${id}`); - throw new NotFoundException(`ContextVar with ID ${id} not found`); - } - return result; + return await this.contextVarService.updateOne(id, contextVarUpdate); } /** diff --git a/api/src/chat/controllers/label.controller.spec.ts b/api/src/chat/controllers/label.controller.spec.ts index b81c34f4..0c5fae41 100644 --- a/api/src/chat/controllers/label.controller.spec.ts +++ b/api/src/chat/controllers/label.controller.spec.ts @@ -23,6 +23,7 @@ import { UserModel } from '@/user/schemas/user.schema'; import { RoleService } from '@/user/services/role.service'; import { UserService } from '@/user/services/user.service'; import { IGNORED_TEST_FIELDS } from '@/utils/test/constants'; +import { getUpdateOneError } from '@/utils/test/errors/messages'; import { labelFixtures } from '@/utils/test/fixtures/label'; import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber'; import { getPageQuery } from '@/utils/test/pagination'; @@ -223,12 +224,10 @@ describe('LabelController', () => { ); }); - it('should throw a NotFoundException when attempting to update a non existing label by id', async () => { + it('should throw a NotFoundException when attempting to update a non existing label by id', async () => { await expect( labelController.updateOne(labelToDelete.id, labelUpdateDto), - ).rejects.toThrow( - new NotFoundException(`Label with ID ${labelToDelete.id} not found`), - ); + ).rejects.toThrow(getUpdateOneError(Label.name, labelToDelete.id)); }); }); }); diff --git a/api/src/chat/controllers/label.controller.ts b/api/src/chat/controllers/label.controller.ts index 65b6f13e..c3e61106 100644 --- a/api/src/chat/controllers/label.controller.ts +++ b/api/src/chat/controllers/label.controller.ts @@ -111,12 +111,7 @@ export class LabelController extends BaseController< @Param('id') id: string, @Body() labelUpdate: LabelUpdateDto, ) { - const result = await this.labelService.updateOne(id, labelUpdate); - if (!result) { - this.logger.warn(`Unable to update Label by id ${id}`); - throw new NotFoundException(`Label with ID ${id} not found`); - } - return result; + return await this.labelService.updateOne(id, labelUpdate); } @CsrfCheck(true) diff --git a/api/src/chat/controllers/subscriber.controller.ts b/api/src/chat/controllers/subscriber.controller.ts index d0688df0..a8d91132 100644 --- a/api/src/chat/controllers/subscriber.controller.ts +++ b/api/src/chat/controllers/subscriber.controller.ts @@ -178,11 +178,6 @@ export class SubscriberController extends BaseController< @Param('id') id: string, @Body() subscriberUpdate: SubscriberUpdateDto, ) { - const result = await this.subscriberService.updateOne(id, subscriberUpdate); - if (!result) { - this.logger.warn(`Unable to update Subscriber by id ${id}`); - throw new NotFoundException(`Subscriber with ID ${id} not found`); - } - return result; + return await this.subscriberService.updateOne(id, subscriberUpdate); } } diff --git a/api/src/chat/repositories/block.repository.spec.ts b/api/src/chat/repositories/block.repository.spec.ts index 8556266c..7dc3c5cb 100644 --- a/api/src/chat/repositories/block.repository.spec.ts +++ b/api/src/chat/repositories/block.repository.spec.ts @@ -36,6 +36,7 @@ describe('BlockRepository', () => { let hasNextBlocks: Block; let validIds: string[]; let validCategory: string; + let blockValidIds: string[]; beforeAll(async () => { const module = await Test.createTestingModule({ @@ -58,6 +59,7 @@ describe('BlockRepository', () => { hasNextBlocks = (await blockRepository.findOne({ name: 'hasNextBlocks', }))!; + blockValidIds = (await blockRepository.findAll()).map(({ id }) => id); }); afterEach(jest.clearAllMocks); @@ -167,24 +169,25 @@ describe('BlockRepository', () => { }); describe('prepareBlocksInCategoryUpdateScope', () => { + /******/ it('should update blocks within the scope based on category and ids', async () => { jest.spyOn(blockRepository, 'findOne').mockResolvedValue({ - id: validIds[0], + id: blockValidIds[0], category: 'oldCategory', - nextBlocks: [validIds[1]], - attachedBlock: validIds[1], + nextBlocks: [blockValidIds[1]], + attachedBlock: blockValidIds[1], } as Block); const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne'); await blockRepository.prepareBlocksInCategoryUpdateScope( validCategory, - validIds, + blockValidIds, ); - expect(mockUpdateOne).toHaveBeenCalledWith(validIds[0], { - nextBlocks: [validIds[1]], - attachedBlock: validIds[1], + expect(mockUpdateOne).toHaveBeenCalledWith(blockValidIds[0], { + nextBlocks: [blockValidIds[1]], + attachedBlock: blockValidIds[1], }); }); @@ -208,12 +211,13 @@ describe('BlockRepository', () => { }); describe('prepareBlocksOutOfCategoryUpdateScope', () => { + /******/ it('should update blocks outside the scope by removing references from attachedBlock', async () => { const otherBlocks = [ { - id: '64abc1234def567890fedcab', - attachedBlock: validIds[0], - nextBlocks: [validIds[0]], + id: blockValidIds[1], + attachedBlock: blockValidIds[0], + nextBlocks: [blockValidIds[0]], }, ] as Block[]; @@ -221,40 +225,42 @@ describe('BlockRepository', () => { await blockRepository.prepareBlocksOutOfCategoryUpdateScope( otherBlocks, - validIds, + blockValidIds, ); - expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', { + expect(mockUpdateOne).toHaveBeenCalledWith(blockValidIds[1], { attachedBlock: null, }); }); + /******/ it('should update blocks outside the scope by removing references from nextBlocks', async () => { const otherBlocks = [ { - id: '64abc1234def567890fedcab', + id: blockValidIds[1], attachedBlock: null, - nextBlocks: [validIds[0], validIds[1]], + nextBlocks: [blockValidIds[0], blockValidIds[1]], }, ] as unknown as Block[]; const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne'); await blockRepository.prepareBlocksOutOfCategoryUpdateScope(otherBlocks, [ - validIds[0], + blockValidIds[0], ]); - expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', { - nextBlocks: [validIds[1]], + expect(mockUpdateOne).toHaveBeenCalledWith(blockValidIds[1], { + nextBlocks: [blockValidIds[1]], }); }); }); describe('preUpdateMany', () => { + /******/ it('should update blocks in and out of the scope', async () => { const mockFind = jest.spyOn(blockRepository, 'find').mockResolvedValue([ { - id: '64abc1234def567890fedcab', + id: blockValidIds[1], attachedBlock: validIds[0], nextBlocks: [validIds[0]], }, @@ -278,17 +284,17 @@ describe('BlockRepository', () => { expect(mockFind).toHaveBeenCalled(); expect(prepareBlocksInCategoryUpdateScope).toHaveBeenCalledWith( validCategory, - ['64abc1234def567890fedcab'], + [blockValidIds[1]], ); expect(prepareBlocksOutOfCategoryUpdateScope).toHaveBeenCalledWith( [ { - id: '64abc1234def567890fedcab', + id: blockValidIds[1], attachedBlock: validIds[0], nextBlocks: [validIds[0]], }, ], - ['64abc1234def567890fedcab'], + [blockValidIds[1]], ); }); diff --git a/api/src/chat/repositories/conversation.repository.ts b/api/src/chat/repositories/conversation.repository.ts index 52995fb7..c17c9c62 100644 --- a/api/src/chat/repositories/conversation.repository.ts +++ b/api/src/chat/repositories/conversation.repository.ts @@ -48,7 +48,7 @@ export class ConversationRepository extends BaseRepository< * * @returns A promise resolving to the result of the update operation. */ - async end(convo: Conversation | ConversationFull) { + async end(convo: Conversation | ConversationFull): Promise { return await this.updateOne(convo.id, { active: false }); } } diff --git a/api/src/chat/repositories/subscriber.repository.ts b/api/src/chat/repositories/subscriber.repository.ts index 2d690766..cdbb8cfb 100644 --- a/api/src/chat/repositories/subscriber.repository.ts +++ b/api/src/chat/repositories/subscriber.repository.ts @@ -150,7 +150,7 @@ export class SubscriberRepository extends BaseRepository< async updateOneByForeignIdQuery( id: string, updates: SubscriberUpdateDto, - ): Promise { + ): Promise { return await this.updateOne({ foreign_id: id }, updates); } @@ -161,9 +161,7 @@ export class SubscriberRepository extends BaseRepository< * * @returns The updated subscriber entity. */ - async handBackByForeignIdQuery( - foreignId: string, - ): Promise { + async handBackByForeignIdQuery(foreignId: string): Promise { return await this.updateOne( { foreign_id: foreignId, @@ -186,7 +184,7 @@ export class SubscriberRepository extends BaseRepository< async handOverByForeignIdQuery( foreignId: string, userId: string, - ): Promise { + ): Promise { return await this.updateOne( { foreign_id: foreignId, diff --git a/api/src/chat/services/conversation.service.ts b/api/src/chat/services/conversation.service.ts index 118f367f..ca650703 100644 --- a/api/src/chat/services/conversation.service.ts +++ b/api/src/chat/services/conversation.service.ts @@ -184,11 +184,6 @@ export class ConversationService extends BaseService< const updatedConversation = await this.updateOne(convo.id, { context: convo.context, }); - if (!updatedConversation) { - throw new Error( - 'Conversation Model : No conversation has been updated', - ); - } //TODO: add check if nothing changed don't update const criteria = diff --git a/api/src/cms/controllers/content-type.controller.spec.ts b/api/src/cms/controllers/content-type.controller.spec.ts index 29d7646c..88dd5e74 100644 --- a/api/src/cms/controllers/content-type.controller.spec.ts +++ b/api/src/cms/controllers/content-type.controller.spec.ts @@ -17,6 +17,7 @@ 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 { getUpdateOneError } from '@/utils/test/errors/messages'; import { installContentFixtures } from '@/utils/test/fixtures/content'; import { contentTypeFixtures } from '@/utils/test/fixtures/contenttype'; import { getPageQuery } from '@/utils/test/pagination'; @@ -174,7 +175,7 @@ describe('ContentTypeController', () => { jest.spyOn(contentTypeService, 'updateOne'); await expect( contentTypeController.updateOne(updatedContent, NOT_FOUND_ID), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(getUpdateOneError(ContentType.name, NOT_FOUND_ID)); expect(contentTypeService.updateOne).toHaveBeenCalledWith( NOT_FOUND_ID, updatedContent, diff --git a/api/src/cms/controllers/content-type.controller.ts b/api/src/cms/controllers/content-type.controller.ts index b7204fb0..9477196b 100644 --- a/api/src/cms/controllers/content-type.controller.ts +++ b/api/src/cms/controllers/content-type.controller.ts @@ -148,17 +148,6 @@ export class ContentTypeController extends BaseController { @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; + return await this.contentTypeService.updateOne(id, contentTypeDto); } } diff --git a/api/src/cms/controllers/content.controller.spec.ts b/api/src/cms/controllers/content.controller.spec.ts index c6958345..e18df455 100644 --- a/api/src/cms/controllers/content.controller.spec.ts +++ b/api/src/cms/controllers/content.controller.spec.ts @@ -23,6 +23,7 @@ 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 { getUpdateOneError } from '@/utils/test/errors/messages'; import { contentFixtures, installContentFixtures, @@ -194,7 +195,7 @@ describe('ContentController', () => { it('should throw NotFoundException if the content is not found', async () => { await expect( contentController.updateOne(updatedContent, NOT_FOUND_ID), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(getUpdateOneError(Content.name, NOT_FOUND_ID)); }); }); diff --git a/api/src/cms/controllers/content.controller.ts b/api/src/cms/controllers/content.controller.ts index a4903bec..8913720c 100644 --- a/api/src/cms/controllers/content.controller.ts +++ b/api/src/cms/controllers/content.controller.ts @@ -300,13 +300,6 @@ export class ContentController extends BaseController< @Body() contentDto: ContentUpdateDto, @Param('id') id: string, ): Promise { - const updatedContent = await this.contentService.updateOne(id, contentDto); - 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; + return await this.contentService.updateOne(id, contentDto); } } diff --git a/api/src/cms/controllers/menu.controller.ts b/api/src/cms/controllers/menu.controller.ts index cf9cc164..28f70947 100644 --- a/api/src/cms/controllers/menu.controller.ts +++ b/api/src/cms/controllers/menu.controller.ts @@ -165,7 +165,10 @@ export class MenuController extends BaseController< */ @CsrfCheck(true) @Patch(':id') - async updateOne(@Body() body: MenuCreateDto, @Param('id') id: string) { + async updateOne( + @Body() body: MenuCreateDto, + @Param('id') id: string, + ): Promise { if (!id) return await this.create(body); return await this.menuService.updateOne(id, body); } diff --git a/api/src/i18n/controllers/language.controller.spec.ts b/api/src/i18n/controllers/language.controller.spec.ts index d260a502..e8aaf8df 100644 --- a/api/src/i18n/controllers/language.controller.spec.ts +++ b/api/src/i18n/controllers/language.controller.spec.ts @@ -7,7 +7,7 @@ */ import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { MongooseModule } from '@nestjs/mongoose'; import { Test } from '@nestjs/testing'; @@ -15,6 +15,7 @@ import { Test } from '@nestjs/testing'; import { I18nService } from '@/i18n/services/i18n.service'; import { LoggerService } from '@/logger/logger.service'; import { NOT_FOUND_ID } from '@/utils/constants/mock'; +import { getUpdateOneError } from '@/utils/test/errors/messages'; import { installLanguageFixtures, languageFixtures, @@ -169,7 +170,7 @@ describe('LanguageController', () => { jest.spyOn(languageService, 'updateOne'); await expect( languageController.updateOne(NOT_FOUND_ID, translationUpdateDto), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(getUpdateOneError(Language.name, NOT_FOUND_ID)); }); }); diff --git a/api/src/i18n/controllers/language.controller.ts b/api/src/i18n/controllers/language.controller.ts index c8a769f1..cfbaffea 100644 --- a/api/src/i18n/controllers/language.controller.ts +++ b/api/src/i18n/controllers/language.controller.ts @@ -123,12 +123,7 @@ export class LanguageController extends BaseController { } } - const result = await this.languageService.updateOne(id, languageUpdate); - if (!result) { - this.logger.warn(`Unable to update Language by id ${id}`); - throw new NotFoundException(`Language with ID ${id} not found`); - } - return result; + return await this.languageService.updateOne(id, languageUpdate); } /** diff --git a/api/src/i18n/controllers/translation.controller.spec.ts b/api/src/i18n/controllers/translation.controller.spec.ts index 484c90a2..70fbdcf0 100644 --- a/api/src/i18n/controllers/translation.controller.spec.ts +++ b/api/src/i18n/controllers/translation.controller.spec.ts @@ -7,7 +7,6 @@ */ import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { NotFoundException } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { MongooseModule } from '@nestjs/mongoose'; import { Test } from '@nestjs/testing'; @@ -38,6 +37,7 @@ import { NlpService } from '@/nlp/services/nlp.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; import { NOT_FOUND_ID } from '@/utils/constants/mock'; +import { getUpdateOneError } from '@/utils/test/errors/messages'; import { installTranslationFixtures, translationFixtures, @@ -209,7 +209,7 @@ describe('TranslationController', () => { jest.spyOn(translationService, 'updateOne'); await expect( translationController.updateOne(NOT_FOUND_ID, translationUpdateDto), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(getUpdateOneError(Translation.name, NOT_FOUND_ID)); }); }); }); diff --git a/api/src/i18n/controllers/translation.controller.ts b/api/src/i18n/controllers/translation.controller.ts index 9e571103..50b09717 100644 --- a/api/src/i18n/controllers/translation.controller.ts +++ b/api/src/i18n/controllers/translation.controller.ts @@ -90,15 +90,7 @@ export class TranslationController extends BaseController { @Param('id') id: string, @Body() translationUpdate: TranslationUpdateDto, ) { - const result = await this.translationService.updateOne( - id, - translationUpdate, - ); - if (!result) { - this.logger.warn(`Unable to update Translation by id ${id}`); - throw new NotFoundException(`Translation with ID ${id} not found`); - } - return result; + return await this.translationService.updateOne(id, translationUpdate); } /** diff --git a/api/src/migration/migration.service.ts b/api/src/migration/migration.service.ts index 0459e395..c0fec5c9 100644 --- a/api/src/migration/migration.service.ts +++ b/api/src/migration/migration.service.ts @@ -522,7 +522,7 @@ module.exports = { version, action, migrationDocument, - }: MigrationSuccessCallback) { + }: MigrationSuccessCallback): Promise { await this.updateStatus({ version, action, migrationDocument }); const migrationDisplayName = `${version} [${action}]`; this.logger.log(`"${migrationDisplayName}" migration done`); diff --git a/api/src/nlp/controllers/nlp-entity.controller.ts b/api/src/nlp/controllers/nlp-entity.controller.ts index ec38fdd6..fbb4889f 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.ts @@ -167,17 +167,7 @@ export class NlpEntityController extends BaseController< ); } - const result = await this.nlpEntityService.updateOne( - id, - updateNlpEntityDto, - ); - if (!result) { - this.logger.warn(`Failed to update NLP Entity by id ${id}`); - throw new InternalServerErrorException( - `Failed to update NLP Entity with ID ${id}`, - ); - } - return result; + return await this.nlpEntityService.updateOne(id, updateNlpEntityDto); } /** diff --git a/api/src/nlp/controllers/nlp-sample.controller.spec.ts b/api/src/nlp/controllers/nlp-sample.controller.spec.ts index e3285931..832e21da 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.spec.ts @@ -22,6 +22,7 @@ import { SettingRepository } from '@/setting/repositories/setting.repository'; import { SettingModel } from '@/setting/schemas/setting.schema'; import { SettingSeeder } from '@/setting/seeds/setting.seed'; import { SettingService } from '@/setting/services/setting.service'; +import { getUpdateOneError } from '@/utils/test/errors/messages'; import { installAttachmentFixtures } from '@/utils/test/fixtures/attachment'; import { nlpSampleFixtures } from '@/utils/test/fixtures/nlpsample'; import { installNlpSampleEntityFixtures } from '@/utils/test/fixtures/nlpsampleentity'; @@ -310,7 +311,7 @@ describe('NlpSampleController', () => { type: NlpSampleState.test, language: 'fr', }), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(getUpdateOneError(NlpSample.name, byeJhonSampleId!)); }); }); diff --git a/api/src/nlp/controllers/nlp-sample.controller.ts b/api/src/nlp/controllers/nlp-sample.controller.ts index cc49d41a..22dfa263 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.ts @@ -305,11 +305,6 @@ export class NlpSampleController extends BaseController< trained: false, }); - if (!sample) { - this.logger.warn(`Unable to update NLP Sample by id ${id}`); - throw new NotFoundException(`NLP Sample with ID ${id} not found`); - } - await this.nlpSampleEntityService.deleteMany({ sample: id }); const updatedSampleEntities = diff --git a/api/src/nlp/controllers/nlp-value.controller.spec.ts b/api/src/nlp/controllers/nlp-value.controller.spec.ts index cb535137..d78b336f 100644 --- a/api/src/nlp/controllers/nlp-value.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-value.controller.spec.ts @@ -12,6 +12,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; import { LoggerService } from '@/logger/logger.service'; +import { getUpdateOneError } from '@/utils/test/errors/messages'; import { nlpEntityFixtures } from '@/utils/test/fixtures/nlpentity'; import { installNlpValueFixtures, @@ -241,7 +242,7 @@ describe('NlpValueController', () => { expressions: [], builtin: true, }), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(getUpdateOneError(NlpValue.name, jhonNlpValue!.id)); }); }); describe('deleteMany', () => { diff --git a/api/src/nlp/controllers/nlp-value.controller.ts b/api/src/nlp/controllers/nlp-value.controller.ts index b85caab3..083bc46f 100644 --- a/api/src/nlp/controllers/nlp-value.controller.ts +++ b/api/src/nlp/controllers/nlp-value.controller.ts @@ -168,12 +168,7 @@ export class NlpValueController extends BaseController< @Param('id') id: string, @Body() updateNlpValueDto: NlpValueUpdateDto, ): Promise { - const result = await this.nlpValueService.updateOne(id, updateNlpValueDto); - if (!result) { - this.logger.warn(`Unable to update NLP Value by id ${id}`); - throw new NotFoundException(`NLP Value with ID ${id} not found`); - } - return result; + return await this.nlpValueService.updateOne(id, updateNlpValueDto); } /** diff --git a/api/src/nlp/services/nlp-value.service.ts b/api/src/nlp/services/nlp-value.service.ts index d721efd3..f246ad61 100644 --- a/api/src/nlp/services/nlp-value.service.ts +++ b/api/src/nlp/services/nlp-value.service.ts @@ -63,7 +63,7 @@ export class NlpValueService extends BaseService< sampleText: string, sampleEntities: NlpSampleEntityValue[], storedEntities: NlpEntity[], - ) { + ): Promise { const eMap: Record = storedEntities.reduce( (acc, curr) => { if (curr.name) acc[curr?.name] = curr; diff --git a/api/src/setting/controllers/setting.controller.ts b/api/src/setting/controllers/setting.controller.ts index 75a159ca..60108a2a 100644 --- a/api/src/setting/controllers/setting.controller.ts +++ b/api/src/setting/controllers/setting.controller.ts @@ -10,7 +10,6 @@ import { Body, Controller, Get, - NotFoundException, Param, Patch, Query, @@ -71,11 +70,6 @@ export class SettingController { @Param('id') id: string, @Body() settingUpdateDto: { value: any }, ): Promise { - const result = await this.settingService.updateOne(id, settingUpdateDto); - if (!result) { - this.logger.warn(`Unable to update setting by id ${id}`); - throw new NotFoundException(`Setting with ID ${id} not found`); - } - return result; + return await this.settingService.updateOne(id, settingUpdateDto); } } diff --git a/api/src/user/controllers/role.controller.ts b/api/src/user/controllers/role.controller.ts index a427c5b9..e494aca1 100644 --- a/api/src/user/controllers/role.controller.ts +++ b/api/src/user/controllers/role.controller.ts @@ -134,12 +134,7 @@ export class RoleController extends BaseController< @CsrfCheck(true) @Patch(':id') async updateOne(@Param('id') id: string, @Body() roleUpdate: RoleUpdateDto) { - const result = await this.roleService.updateOne(id, roleUpdate); - if (!result) { - this.logger.warn(`Unable to update Role by id ${id}`); - throw new NotFoundException(`Role with ID ${id} not found`); - } - return result; + return await this.roleService.updateOne(id, roleUpdate); } /** diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index 5f8ff25d..3546ee0a 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -304,7 +304,7 @@ export class ReadWriteUserController extends ReadOnlyUserController { ) : undefined; - const result = await this.userService.updateOne( + return await this.userService.updateOne( req.user.id, avatar ? { @@ -313,12 +313,6 @@ export class ReadWriteUserController extends ReadOnlyUserController { } : userUpdate, ); - - if (!result) { - this.logger.warn(`Unable to update User by id ${id}`); - throw new NotFoundException(`User with ID ${id} not found`); - } - return result; } /** diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index e0c4fd65..550b6c90 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -482,7 +482,7 @@ export abstract class BaseRepository< options: QueryOptions | null = { new: true, }, - ): Promise { + ): Promise { const query = this.model.findOneAndUpdate( { ...(typeof criteria === 'string' ? { _id: criteria } : criteria), @@ -512,7 +512,14 @@ export abstract class BaseRepository< filterCriteria, queryUpdates, ); - return await this.executeOne(query, this.cls); + const result = await this.executeOne(query, this.cls); + + if (!result) { + const errorMessage = `Unable to update ${this.cls.name}${typeof criteria === 'string' ? ` with ID ${criteria}` : ''}`; + throw new Error(errorMessage); + } + + return result; } async updateMany>( diff --git a/api/src/utils/generics/base-service.ts b/api/src/utils/generics/base-service.ts index bbedbcea..a7b04c55 100644 --- a/api/src/utils/generics/base-service.ts +++ b/api/src/utils/generics/base-service.ts @@ -179,7 +179,7 @@ export abstract class BaseService< criteria: string | TFilterQuery, dto: DtoInfer>, options?: QueryOptions> | null, - ): Promise { + ): Promise { return await this.repository.updateOne(criteria, dto, options); } diff --git a/api/src/utils/test/errors/messages.ts b/api/src/utils/test/errors/messages.ts new file mode 100644 index 00000000..150f1c61 --- /dev/null +++ b/api/src/utils/test/errors/messages.ts @@ -0,0 +1,10 @@ +/* + * Copyright © 2025 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). + */ + +export const getUpdateOneError = (entity: string, id?: string) => + new Error(`Unable to update ${entity}${id ? ` with ID ${id}` : ''}`); From 6eb29e9a96c6a3b250931e39321f7c5f4800439e Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Fri, 17 Jan 2025 11:56:59 +0100 Subject: [PATCH 03/13] fix: handle object error messages --- api/src/chat/repositories/block.repository.spec.ts | 4 ---- api/src/utils/generics/base-repository.ts | 2 +- api/src/utils/test/errors/messages.ts | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/api/src/chat/repositories/block.repository.spec.ts b/api/src/chat/repositories/block.repository.spec.ts index 7dc3c5cb..e37d7d62 100644 --- a/api/src/chat/repositories/block.repository.spec.ts +++ b/api/src/chat/repositories/block.repository.spec.ts @@ -169,7 +169,6 @@ describe('BlockRepository', () => { }); describe('prepareBlocksInCategoryUpdateScope', () => { - /******/ it('should update blocks within the scope based on category and ids', async () => { jest.spyOn(blockRepository, 'findOne').mockResolvedValue({ id: blockValidIds[0], @@ -211,7 +210,6 @@ describe('BlockRepository', () => { }); describe('prepareBlocksOutOfCategoryUpdateScope', () => { - /******/ it('should update blocks outside the scope by removing references from attachedBlock', async () => { const otherBlocks = [ { @@ -233,7 +231,6 @@ describe('BlockRepository', () => { }); }); - /******/ it('should update blocks outside the scope by removing references from nextBlocks', async () => { const otherBlocks = [ { @@ -256,7 +253,6 @@ describe('BlockRepository', () => { }); describe('preUpdateMany', () => { - /******/ it('should update blocks in and out of the scope', async () => { const mockFind = jest.spyOn(blockRepository, 'find').mockResolvedValue([ { diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index 550b6c90..523a42b9 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -515,7 +515,7 @@ export abstract class BaseRepository< const result = await this.executeOne(query, this.cls); if (!result) { - const errorMessage = `Unable to update ${this.cls.name}${typeof criteria === 'string' ? ` with ID ${criteria}` : ''}`; + const errorMessage = `Unable to update ${this.cls.name} with ${typeof criteria === 'string' ? 'ID' : 'criteria'} ${JSON.stringify(criteria)}`; throw new Error(errorMessage); } diff --git a/api/src/utils/test/errors/messages.ts b/api/src/utils/test/errors/messages.ts index 150f1c61..2ecc3aff 100644 --- a/api/src/utils/test/errors/messages.ts +++ b/api/src/utils/test/errors/messages.ts @@ -7,4 +7,4 @@ */ export const getUpdateOneError = (entity: string, id?: string) => - new Error(`Unable to update ${entity}${id ? ` with ID ${id}` : ''}`); + new Error(`Unable to update ${entity}${id ? ` with ID \"${id}\"` : ''}`); From 20c9a7691522d4fef67f02a14696dd8fa2e8a363 Mon Sep 17 00:00:00 2001 From: AselPeiris Date: Fri, 17 Jan 2025 17:35:58 +0530 Subject: [PATCH 04/13] fix: extracting menu item filtering to a hook --- frontend/src/hooks/useAvailableMenuItems.ts | 72 +++++++++++++++++++++ frontend/src/hooks/useHasPermission.ts | 5 +- frontend/src/layout/VerticalMenu.tsx | 43 +----------- 3 files changed, 78 insertions(+), 42 deletions(-) create mode 100644 frontend/src/hooks/useAvailableMenuItems.ts diff --git a/frontend/src/hooks/useAvailableMenuItems.ts b/frontend/src/hooks/useAvailableMenuItems.ts new file mode 100644 index 00000000..ca3a54c2 --- /dev/null +++ b/frontend/src/hooks/useAvailableMenuItems.ts @@ -0,0 +1,72 @@ +/* + * Copyright © 2025 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). + */ + +import { useMemo } from "react"; + +import { MenuItem } from "@/layout/VerticalMenu"; +import { EntityType } from "@/services/types"; +import { PermissionAction } from "@/types/permission.types"; + +import { useHasPermission } from "./useHasPermission"; + +// Helper function to check permissions for a menu item +const isMenuItemAllowed = ( + menuItem: MenuItem, + hasPermission: (entityType: EntityType, action: PermissionAction) => boolean, +): boolean => { + const requiredPermissions = Object.entries(menuItem.requires || {}); + + return ( + requiredPermissions.length === 0 || + requiredPermissions.every(([entityType, actions]) => + actions.every((action) => + hasPermission(entityType as EntityType, action), + ), + ) + ); +}; +const filterMenuItems = ( + menuItems: MenuItem[], + hasPermission: (entityType: EntityType, action: PermissionAction) => boolean, +): MenuItem[] => { + return menuItems + .map((menuItem) => { + // Validate top-level menu item without submenu + if ( + menuItem && + !menuItem.submenuItems && + isMenuItemAllowed(menuItem, hasPermission) + ) { + return menuItem; + } + + // Recursively process submenu items + if (menuItem.submenuItems) { + const filteredSubmenuItems = filterMenuItems( + menuItem.submenuItems, + hasPermission, + ); + + if (filteredSubmenuItems.length > 0) { + return { ...menuItem, submenuItems: filteredSubmenuItems }; + } + } + + return null; // Exclude invalid menu items + }) + .filter((menuItem): menuItem is MenuItem => !!menuItem); +}; +const useAvailableMenuItems = (menuItems: MenuItem[]): MenuItem[] => { + const hasPermission = useHasPermission(); + + return useMemo(() => { + return filterMenuItems(menuItems, hasPermission); + }, [menuItems, hasPermission]); +}; + +export default useAvailableMenuItems; diff --git a/frontend/src/hooks/useHasPermission.ts b/frontend/src/hooks/useHasPermission.ts index 09b6be68..1c1fa2a3 100644 --- a/frontend/src/hooks/useHasPermission.ts +++ b/frontend/src/hooks/useHasPermission.ts @@ -1,11 +1,12 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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). */ + import { useCallback, useContext } from "react"; import { PermissionContext } from "@/contexts/permission.context"; @@ -18,7 +19,7 @@ export const useHasPermission = () => { (type: EntityType, action: PermissionAction) => { const allowedActions = getAllowedActions(type); - return allowedActions?.includes(action); + return allowedActions?.includes(action) ? true : false; }, [getAllowedActions], ); diff --git a/frontend/src/layout/VerticalMenu.tsx b/frontend/src/layout/VerticalMenu.tsx index 8112485a..857f9dfa 100644 --- a/frontend/src/layout/VerticalMenu.tsx +++ b/frontend/src/layout/VerticalMenu.tsx @@ -6,7 +6,6 @@ * 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). */ - import { faAlignLeft, faAsterisk, @@ -33,13 +32,13 @@ import { CSSObject, Grid, IconButton, styled, Theme } from "@mui/material"; import MuiDrawer from "@mui/material/Drawer"; import { OverridableComponent } from "@mui/material/OverridableComponent"; import { useRouter } from "next/router"; -import { FC, useMemo } from "react"; +import { FC } from "react"; import { HexabotLogo } from "@/app-components/logos/HexabotLogo"; import { Sidebar } from "@/app-components/menus/Sidebar"; import { useAuth } from "@/hooks/useAuth"; +import useAvailableMenuItems from "@/hooks/useAvailableMenuItems"; import { useConfig } from "@/hooks/useConfig"; -import { useHasPermission } from "@/hooks/useHasPermission"; import { EntityType } from "@/services/types"; import { PermissionAction } from "@/types/permission.types"; import { getLayout } from "@/utils/laylout"; @@ -288,44 +287,8 @@ export const VerticalMenu: FC = ({ const { ssoEnabled } = useConfig(); const { isAuthenticated } = useAuth(); const router = useRouter(); - const hasPermission = useHasPermission(); const menuItems = getMenuItems(ssoEnabled); - // Filter menu item to which user is allowed access - const generateValidMenuItems = useMemo(() => { - return (menuItems: MenuItem[]): MenuItem[] => { - const validMenuItems = menuItems - .map((menuItem: MenuItem) => { - if (menuItem && !menuItem.submenuItems) { - const requiredPermissions = menuItem.requires!; - - if ( - requiredPermissions && - Object.entries(requiredPermissions).every((permission) => { - const entityType = permission[0] as EntityType; - const actions = permission[1]; - - return actions.every((action) => - hasPermission(entityType, action), - ); - }) - ) { - return menuItem; - } - } else if (menuItem.submenuItems) { - menuItem.submenuItems = generateValidMenuItems( - menuItem.submenuItems, - ); - - return menuItem; - } - }) - .filter((menuItem) => menuItem !== undefined) - .filter((menuItem) => menuItem?.submenuItems?.length !== 0); - - return validMenuItems; - }; - }, [menuItems, hasPermission]); - const availableMenuItems = generateValidMenuItems(menuItems); + const availableMenuItems = useAvailableMenuItems(menuItems); const hasTemporaryDrawer = getLayout(router.pathname.slice(1)) === "full_width"; From 377ef714bc9456b5b570fa4bdf3f66abc37c3462 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Fri, 17 Jan 2025 14:10:03 +0100 Subject: [PATCH 05/13] fix: resolve block list edit form missing default values --- .../visual-editor/form/ListMessageForm.tsx | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/visual-editor/form/ListMessageForm.tsx b/frontend/src/components/visual-editor/form/ListMessageForm.tsx index ae39c995..a0138239 100644 --- a/frontend/src/components/visual-editor/form/ListMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/ListMessageForm.tsx @@ -181,17 +181,19 @@ const ListMessageForm = () => { }} defaultValue={content?.fields?.title} render={({ field }) => { - const { onChange, ...rest } = field; + const { onChange, value, ...rest } = field; + const options = (contentType?.fields || []).filter( + ({ type }) => ContentFieldType.TEXT === type, + ); return ( - options={(contentType?.fields || []).filter( - ({ type }) => ContentFieldType.TEXT === type, - )} + options={options} idKey="name" labelKey="label" label={t("label.title")} multiple={false} + {...(options.length && { value })} {...rest} onChange={(_e, selected) => onChange(selected?.name)} error={!!errors?.options?.["content"]?.fields?.title} @@ -209,20 +211,22 @@ const ListMessageForm = () => { control={control} defaultValue={content?.fields?.subtitle} render={({ field }) => { - const { onChange, ...rest } = field; + const { onChange, value, ...rest } = field; + const options = (contentType?.fields || []).filter( + ({ type }) => + ContentFieldType.TEXT === type || + ContentFieldType.TEXTAREA === type, + ); return ( - options={(contentType?.fields || []).filter( - ({ type }) => - ContentFieldType.TEXT === type || - ContentFieldType.TEXTAREA === type, - )} + options={options} idKey="name" labelKey="label" label={t("label.subtitle")} multiple={false} onChange={(_e, selected) => onChange(selected?.name)} + {...(options.length && { value })} {...rest} /> ); @@ -235,18 +239,20 @@ const ListMessageForm = () => { control={control} defaultValue={content?.fields?.image_url} render={({ field }) => { - const { onChange, ...rest } = field; + const { onChange, value, ...rest } = field; + const options = (contentType?.fields || []).filter(({ type }) => + [ContentFieldType.FILE].includes(type), + ); return ( - options={(contentType?.fields || []).filter(({ type }) => - [ContentFieldType.FILE].includes(type), - )} + options={options} idKey="name" labelKey="label" label={t("label.image_url")} multiple={false} onChange={(_e, selected) => onChange(selected?.name)} + {...(options.length && { value })} {...rest} /> ); @@ -259,18 +265,20 @@ const ListMessageForm = () => { control={control} defaultValue={content?.fields?.url} render={({ field }) => { - const { onChange, ...rest } = field; + const { onChange, value, ...rest } = field; + const options = (contentType?.fields || []).filter(({ type }) => + [ContentFieldType.URL].includes(type), + ); return ( - options={(contentType?.fields || []).filter(({ type }) => - [ContentFieldType.URL].includes(type), - )} + options={options} idKey="name" labelKey="label" label={t("label.url")} multiple={false} onChange={(_e, selected) => onChange(selected?.name)} + {...(options.length && { value })} {...rest} /> ); From 81aed2e5db3128e90f10f15230f0191fbfd70365 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 20 Jan 2025 09:59:03 +0100 Subject: [PATCH 06/13] feat: refactor attachment storage to use helpers --- .../controllers/attachment.controller.spec.ts | 36 ++- .../attachment/services/attachment.service.ts | 305 +++--------------- .../helpers/local-storage/index.helper.ts | 191 +++++++++++ .../helpers/local-storage/package.json | 8 + .../helpers/local-storage/settings.ts | 17 + api/src/helper/helper.service.ts | 31 +- api/src/helper/lib/base-storage-helper.ts | 76 +++++ api/src/helper/types.ts | 17 +- .../1735836154221-v-2-2-0.migration.ts | 38 +++ api/src/plugins/base-storage-plugin.ts | 53 --- api/src/plugins/map-types.ts | 4 +- api/src/plugins/types.ts | 3 +- api/src/setting/seeds/setting.seed-model.ts | 22 +- api/src/utils/test/fixtures/setting.ts | 9 +- .../public/locales/en/chatbot_settings.json | 6 +- .../public/locales/fr/chatbot_settings.json | 6 +- .../src/components/settings/SettingInput.tsx | 17 + frontend/src/services/api.class.ts | 1 + frontend/src/services/entities.ts | 13 +- frontend/src/services/types.ts | 4 +- frontend/src/types/base.types.ts | 2 + 21 files changed, 515 insertions(+), 344 deletions(-) create mode 100644 api/src/extensions/helpers/local-storage/index.helper.ts create mode 100644 api/src/extensions/helpers/local-storage/package.json create mode 100644 api/src/extensions/helpers/local-storage/settings.ts create mode 100644 api/src/helper/lib/base-storage-helper.ts delete mode 100644 api/src/plugins/base-storage-plugin.ts diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index e8c96d88..53df9137 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -16,8 +16,13 @@ import { MongooseModule } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; import { Request } from 'express'; +import LocalStorageHelper from '@/extensions/helpers/local-storage/index.helper'; +import { HelperService } from '@/helper/helper.service'; import { LoggerService } from '@/logger/logger.service'; -import { PluginService } from '@/plugins/plugins.service'; +import { SettingRepository } from '@/setting/repositories/setting.repository'; +import { SettingModel } from '@/setting/schemas/setting.schema'; +import { SettingSeeder } from '@/setting/seeds/setting.seed'; +import { SettingService } from '@/setting/services/setting.service'; import { ModelRepository } from '@/user/repositories/model.repository'; import { PermissionRepository } from '@/user/repositories/permission.repository'; import { ModelModel } from '@/user/schemas/model.schema'; @@ -30,6 +35,7 @@ import { attachmentFixtures, installAttachmentFixtures, } from '@/utils/test/fixtures/attachment'; +import { installSettingFixtures } from '@/utils/test/fixtures/setting'; import { closeInMongodConnection, rootMongooseTestModule, @@ -51,16 +57,23 @@ describe('AttachmentController', () => { let attachmentController: AttachmentController; let attachmentService: AttachmentService; let attachmentToDelete: Attachment; + let helperService: HelperService; + let settingService: SettingService; + let loggerService: LoggerService; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AttachmentController], imports: [ - rootMongooseTestModule(installAttachmentFixtures), + rootMongooseTestModule(async () => { + await installSettingFixtures(); + await installAttachmentFixtures(); + }), MongooseModule.forFeature([ AttachmentModel, PermissionModel, ModelModel, + SettingModel, ]), ], providers: [ @@ -68,11 +81,20 @@ describe('AttachmentController', () => { AttachmentRepository, PermissionService, PermissionRepository, + SettingRepository, ModelService, ModelRepository, LoggerService, EventEmitter2, - PluginService, + SettingSeeder, + SettingService, + HelperService, + // { + // provide: HelperService, + // useValue: { + // getDefaultHelper: jest.fn(), + // }, + // }, { provide: CACHE_MANAGER, useValue: { @@ -89,6 +111,14 @@ describe('AttachmentController', () => { attachmentToDelete = (await attachmentService.findOne({ name: 'store1.jpg', }))!; + + helperService = module.get(HelperService); + settingService = module.get(SettingService); + loggerService = module.get(LoggerService); + + helperService.register( + new LocalStorageHelper(settingService, helperService, loggerService), + ); }); afterAll(closeInMongodConnection); diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 94e55985..821682fc 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -6,312 +6,87 @@ * 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). */ -import fs from 'fs'; -import os from 'os'; -import { join, normalize, resolve } from 'path'; import { Readable, Stream } from 'stream'; -import { - Injectable, - NotFoundException, - Optional, - StreamableFile, -} from '@nestjs/common'; -import fetch from 'node-fetch'; -import sanitizeFilename from 'sanitize-filename'; +import { Injectable, Optional, StreamableFile } from '@nestjs/common'; -import { config } from '@/config'; +import { HelperService } from '@/helper/helper.service'; +import { HelperType } from '@/helper/types'; import { LoggerService } from '@/logger/logger.service'; -import { PluginInstance } from '@/plugins/map-types'; -import { PluginService } from '@/plugins/plugins.service'; -import { PluginType } from '@/plugins/types'; import { BaseService } from '@/utils/generics/base-service'; import { AttachmentMetadataDto } from '../dto/attachment.dto'; import { AttachmentRepository } from '../repositories/attachment.repository'; import { Attachment } from '../schemas/attachment.schema'; -import { AttachmentResourceRef } from '../types'; -import { - fileExists, - generateUniqueFilename, - getStreamableFile, -} from '../utilities'; @Injectable() export class AttachmentService extends BaseService { - private storagePlugin: PluginInstance | null = null; - constructor( readonly repository: AttachmentRepository, private readonly logger: LoggerService, - @Optional() private readonly pluginService: PluginService, + @Optional() private readonly helperService: HelperService, ) { super(repository); } /** - * A storage plugin is a alternative way to store files, instead of local filesystem, you can - * have a plugin that would store files in a 3rd party system (Minio, AWS S3, ...) + * Stores a file using the default storage helper and creates an attachment record. * - * @param foreign_id The unique identifier of the user, used to locate the profile picture. - * @returns A singleton instance of the storage plugin - */ - getStoragePlugin() { - if (!this.storagePlugin) { - const plugins = this.pluginService.getAllByType(PluginType.storage); - - if (plugins.length === 1) { - this.storagePlugin = plugins[0]; - } else if (plugins.length > 1) { - throw new Error( - 'Multiple storage plugins are detected, please ensure only one is available', - ); - } - } - - return this.storagePlugin; - } - - /** - * Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration. + * This method retrieves the default storage helper via the `HelperService` and + * delegates the file storage operation to it. The returned metadata is then used + * to create a new `Attachment` record in the database. * - * @deprecated Use AttachmentService.download() instead - * @param foreign_id The unique identifier of the user, used to locate the profile picture. - * @returns A `StreamableFile` containing the user's profile picture. - */ - async downloadProfilePic( - foreign_id: string, - ): Promise { - if (this.getStoragePlugin()) { - try { - const pict = foreign_id + '.jpeg'; - const picture = - await this.getStoragePlugin()?.downloadProfilePic?.(pict); - return picture; - } catch (err) { - this.logger.error('Error downloading profile picture', err); - throw new NotFoundException('Profile picture not found'); - } - } else { - const path = resolve( - join(config.parameters.avatarDir, `${foreign_id}.jpeg`), - ); - if (fs.existsSync(path)) { - const picturetream = fs.createReadStream(path); - return new StreamableFile(picturetream); - } else { - throw new NotFoundException('Profile picture not found'); - } - } - } - - /** - * Uploads a profile picture to either 3rd party storage system or locally based on the configuration. - * - * @deprecated use store() method instead - * @param res - The response object from which the profile picture will be buffered or piped. - * @param filename - The filename - */ - async uploadProfilePic(data: Buffer | fetch.Response, filename: string) { - if (this.getStoragePlugin()) { - // Upload profile picture - const picture = { - originalname: filename, - buffer: Buffer.isBuffer(data) ? data : await data.buffer(), - } as Express.Multer.File; - try { - await this.getStoragePlugin()?.uploadAvatar?.(picture); - this.logger.log( - `Profile picture uploaded successfully to ${ - this.getStoragePlugin()?.name - }`, - ); - } catch (err) { - this.logger.error( - `Error while uploading profile picture to ${ - this.getStoragePlugin()?.name - }`, - err, - ); - } - } else { - // Save profile picture locally - const dirPath = resolve(join(config.parameters.avatarDir, filename)); - - try { - if (Buffer.isBuffer(data)) { - await fs.promises.writeFile(dirPath, data); - } else { - const dest = fs.createWriteStream(dirPath); - data.body.pipe(dest); - } - this.logger.debug( - 'Messenger Channel Handler : Profile picture fetched successfully', - ); - } catch (err) { - this.logger.error( - 'Messenger Channel Handler : Error while creating directory', - err, - ); - } - } - } - - /** - * Get the attachment root directory given the resource reference - * - * @param ref The attachment resource reference - * @returns The root directory path - */ - getRootDirByResourceRef(ref: AttachmentResourceRef) { - return ref === AttachmentResourceRef.SubscriberAvatar || - ref === AttachmentResourceRef.UserAvatar - ? config.parameters.avatarDir - : config.parameters.uploadDir; - } - - /** - * Uploads files to the server. If a storage plugin is configured it uploads files accordingly. - * Otherwise, uploads files to the local directory. - * - * @param file - The file - * @param metadata - The attachment metadata informations. - * @param rootDir - The root directory where attachment shoud be located. - * @returns A promise that resolves to an array of uploaded attachments. + * @param file - The file to be stored. This can be a buffer, a stream, a readable, or a file from an Express Multer upload. + * @param metadata - The metadata associated with the file, such as name, size, and type. + * @returns A promise resolving to the created `Attachment` record. */ async store( file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, - ): Promise { - if (this.getStoragePlugin()) { - const storedDto = await this.getStoragePlugin()?.store?.(file, metadata); - return storedDto ? await this.create(storedDto) : null; - } else { - const rootDir = this.getRootDirByResourceRef(metadata.resourceRef); - const uniqueFilename = generateUniqueFilename(metadata.name); - const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename))); - - if (Buffer.isBuffer(file)) { - await fs.promises.writeFile(filePath, file); - } else if (file instanceof Readable || file instanceof Stream) { - await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(filePath); - file.pipe(writeStream); - // @TODO: Calc size here? - writeStream.on('finish', resolve); - writeStream.on('error', reject); - }); - } else { - if (file.path) { - // For example, if the file is an instance of `Express.Multer.File` (diskStorage case) - const srcFilePath = fs.realpathSync(resolve(file.path)); - // Get the system's temporary directory in a cross-platform way - const tempDir = os.tmpdir(); - const normalizedTempDir = normalize(tempDir); - - if (!srcFilePath.startsWith(normalizedTempDir)) { - throw new Error('Invalid file path'); - } - - await fs.promises.copyFile(srcFilePath, filePath); - await fs.promises.unlink(srcFilePath); - } else { - await fs.promises.writeFile(filePath, file.buffer); - } - } - - const location = filePath.replace(rootDir, ''); - return await this.create({ - ...metadata, - location, - }); - } + ): Promise { + const storageHelper = await this.helperService.getDefaultHelper( + HelperType.STORAGE, + ); + const dto = await storageHelper.store(file, metadata); + return await this.create(dto); } /** - * Downloads an attachment identified by the provided parameters. + * Downloads the specified attachment using the default storage helper. * - * @param attachment - The attachment to download. - * @param rootDir - The root directory where attachment shoud be located. - * @returns A promise that resolves to a StreamableFile representing the downloaded attachment. + * @param The attachment object containing the metadata required for the download. + * @returns A promise resolving to a `StreamableFile` instance of the downloaded attachment. */ async download(attachment: Attachment): Promise { - if (this.getStoragePlugin()) { - const streamableFile = - await this.getStoragePlugin()?.download(attachment); - if (!streamableFile) { - throw new NotFoundException('No file was found'); - } - - return streamableFile; - } else { - const rootDir = this.getRootDirByResourceRef(attachment.resourceRef); - const path = resolve(join(rootDir, attachment.location)); - - if (!fileExists(path)) { - throw new NotFoundException('No file was found'); - } - - const disposition = `attachment; filename="${encodeURIComponent( - attachment.name, - )}"`; - - return getStreamableFile({ - path, - options: { - type: attachment.type, - length: attachment.size, - disposition, - }, - }); - } + const storageHelper = await this.helperService.getDefaultHelper( + HelperType.STORAGE, + ); + return storageHelper.download(attachment); } /** - * Downloads an attachment identified by the provided parameters as a Buffer. + * Reads the specified attachment as a buffer using the default storage helper. * - * @param attachment - The attachment to download. - * @param rootDir - Root folder path where the attachment should be located. - * @returns A promise that resolves to a Buffer representing the attachment file. + * @param attachment - The attachment object containing the metadata required to locate the file. + * @returns A promise resolving to the file content as a `Buffer`, or `undefined` if the file cannot be read. */ - async readAsBuffer( - attachment: Attachment, - rootDir = config.parameters.uploadDir, - ): Promise { - if (this.getStoragePlugin()) { - return await this.getStoragePlugin()?.readAsBuffer?.(attachment); - } else { - const path = resolve(join(rootDir, attachment.location)); - - if (!fileExists(path)) { - throw new NotFoundException('No file was found'); - } - - return await fs.promises.readFile(path); // Reads the file content as a Buffer - } + async readAsBuffer(attachment: Attachment): Promise { + const storageHelper = await this.helperService.getDefaultHelper( + HelperType.STORAGE, + ); + return storageHelper.readAsBuffer(attachment); } /** - * Returns an attachment identified by the provided parameters as a Stream. + * Reads the specified attachment as a stream using the default storage helper. * - * @param attachment - The attachment to download. - * @param rootDir - Root folder path where the attachment should be located. - * @returns A promise that resolves to a Stream representing the attachment file. + * @param attachment - The attachment object containing the metadata required to locate the file. + * @returns A promise resolving to the file content as a `Stream`, or `undefined` if the file cannot be read. */ - async readAsStream( - attachment: Attachment, - rootDir = config.parameters.uploadDir, - ): Promise { - if (this.getStoragePlugin()) { - return await this.getStoragePlugin()?.readAsStream?.(attachment); - } else { - const path = resolve(join(rootDir, attachment.location)); - - if (!fileExists(path)) { - throw new NotFoundException('No file was found'); - } - - return fs.createReadStream(path); // Reads the file content as a Buffer - } + async readAsStream(attachment: Attachment): Promise { + const storageHelper = await this.helperService.getDefaultHelper( + HelperType.STORAGE, + ); + return await storageHelper.readAsStream(attachment); } } diff --git a/api/src/extensions/helpers/local-storage/index.helper.ts b/api/src/extensions/helpers/local-storage/index.helper.ts new file mode 100644 index 00000000..9ea19aec --- /dev/null +++ b/api/src/extensions/helpers/local-storage/index.helper.ts @@ -0,0 +1,191 @@ +/* + * Copyright © 2025 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). + */ + +import fs from 'fs'; +import os from 'os'; +import { join, normalize, resolve } from 'path'; +import { Readable, Stream } from 'stream'; + +import { + Injectable, + NotFoundException, + OnModuleInit, + StreamableFile, +} from '@nestjs/common'; +import sanitizeFilename from 'sanitize-filename'; + +import { + AttachmentCreateDto, + AttachmentMetadataDto, +} from '@/attachment/dto/attachment.dto'; +import { Attachment } from '@/attachment/schemas/attachment.schema'; +import { AttachmentResourceRef } from '@/attachment/types'; +import { + fileExists, + generateUniqueFilename, + getStreamableFile, +} from '@/attachment/utilities'; +import { config } from '@/config'; +import { HelperService } from '@/helper/helper.service'; +import BaseStorageHelper from '@/helper/lib/base-storage-helper'; +import { LoggerService } from '@/logger/logger.service'; +import { SettingService } from '@/setting/services/setting.service'; + +import { LOCAL_STORAGE_HELPER_NAME } from './settings'; + +@Injectable() +export default class LocalStorageHelper + extends BaseStorageHelper + implements OnModuleInit +{ + constructor( + settingService: SettingService, + helperService: HelperService, + logger: LoggerService, + ) { + super(LOCAL_STORAGE_HELPER_NAME, settingService, helperService, logger); + } + + getPath() { + return __dirname; + } + + /** + * Get the attachment root directory given the resource reference + * + * @param ref The attachment resource reference + * @returns The root directory path + */ + private getRootDirByResourceRef(ref: AttachmentResourceRef) { + return ref === AttachmentResourceRef.SubscriberAvatar || + ref === AttachmentResourceRef.UserAvatar + ? config.parameters.avatarDir + : config.parameters.uploadDir; + } + + /** + * Stores a attachment file to the local directory. + * + * @param file - The file + * @param metadata - The attachment metadata informations. + * @returns A promise that resolves to the uploaded attachment. + */ + async store( + file: Buffer | Stream | Readable | Express.Multer.File, + metadata: AttachmentMetadataDto, + ): Promise { + const rootDir = this.getRootDirByResourceRef(metadata.resourceRef); + const uniqueFilename = generateUniqueFilename(metadata.name); + const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename))); + + if (Buffer.isBuffer(file)) { + await fs.promises.writeFile(filePath, file); + } else if (file instanceof Readable || file instanceof Stream) { + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(filePath); + file.pipe(writeStream); + // @TODO: Calc size here? + writeStream.on('finish', resolve); + writeStream.on('error', reject); + }); + } else { + if (file.path) { + // For example, if the file is an instance of `Express.Multer.File` (diskStorage case) + const srcFilePath = fs.realpathSync(resolve(file.path)); + // Get the system's temporary directory in a cross-platform way + const tempDir = os.tmpdir(); + const normalizedTempDir = normalize(tempDir); + + if (!srcFilePath.startsWith(normalizedTempDir)) { + throw new Error('Invalid file path'); + } + + await fs.promises.copyFile(srcFilePath, filePath); + await fs.promises.unlink(srcFilePath); + } else { + await fs.promises.writeFile(filePath, file.buffer); + } + } + + const location = filePath.replace(rootDir, ''); + return { + ...metadata, + location, + }; + } + + /** + * Downloads an attachment identified by the provided parameters. + * + * @param attachment - The attachment to download. + * @returns A promise that resolves to a StreamableFile representing the downloaded attachment. + */ + async download(attachment: Attachment): Promise { + const rootDir = this.getRootDirByResourceRef(attachment.resourceRef); + const path = resolve(join(rootDir, attachment.location)); + + if (!fileExists(path)) { + throw new NotFoundException('No file was found'); + } + + const disposition = `attachment; filename="${encodeURIComponent( + attachment.name, + )}"`; + + return getStreamableFile({ + path, + options: { + type: attachment.type, + length: attachment.size, + disposition, + }, + }); + } + + /** + * Returns an attachment identified by the provided parameters as a Buffer. + * + * @param attachment - The attachment to download. + * @returns A promise that resolves to a Buffer representing the attachment file. + */ + async readAsBuffer(attachment: Attachment): Promise { + const path = resolve( + join( + this.getRootDirByResourceRef(attachment.resourceRef), + attachment.location, + ), + ); + + if (!fileExists(path)) { + throw new NotFoundException('No file was found'); + } + + return await fs.promises.readFile(path); // Reads the file content as a Buffer + } + + /** + * Returns an attachment identified by the provided parameters as a Stream. + * + * @param attachment - The attachment to download. + * @returns A promise that resolves to a Stream representing the attachment file. + */ + async readAsStream(attachment: Attachment): Promise { + const path = resolve( + join( + this.getRootDirByResourceRef(attachment.resourceRef), + attachment.location, + ), + ); + + if (!fileExists(path)) { + throw new NotFoundException('No file was found'); + } + + return fs.createReadStream(path); // Reads the file content as a Buffer + } +} diff --git a/api/src/extensions/helpers/local-storage/package.json b/api/src/extensions/helpers/local-storage/package.json new file mode 100644 index 00000000..54f7433d --- /dev/null +++ b/api/src/extensions/helpers/local-storage/package.json @@ -0,0 +1,8 @@ +{ + "name": "hexabot-helper-local-storage", + "version": "2.2.0", + "description": "The default Hexabot Helper Extension for Hexabot to enable local storage for attachment files", + "dependencies": {}, + "author": "Hexastack", + "license": "AGPL-3.0-only" +} diff --git a/api/src/extensions/helpers/local-storage/settings.ts b/api/src/extensions/helpers/local-storage/settings.ts new file mode 100644 index 00000000..53d9035d --- /dev/null +++ b/api/src/extensions/helpers/local-storage/settings.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2025 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). + */ + +import { HelperSetting } from '@/helper/types'; + +export const LOCAL_STORAGE_HELPER_NAME = 'local-storage-helper'; + +export const LOCAL_STORAGE_HELPER_NAMESPACE = 'local-storage-helper'; + +export default [] as const satisfies HelperSetting< + typeof LOCAL_STORAGE_HELPER_NAME +>[]; diff --git a/api/src/helper/helper.service.ts b/api/src/helper/helper.service.ts index 1e103715..db0a8800 100644 --- a/api/src/helper/helper.service.ts +++ b/api/src/helper/helper.service.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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. @@ -108,6 +108,7 @@ export class HelperService { /** * Get default NLU helper. * + * @deprecated Use getDefaultHelper() instead * @returns - The helper */ async getDefaultNluHelper() { @@ -128,6 +129,7 @@ export class HelperService { /** * Get default LLM helper. * + * @deprecated Use getDefaultHelper() instead * @returns - The helper */ async getDefaultLlmHelper() { @@ -144,4 +146,31 @@ export class HelperService { return defaultHelper; } + + /** + * Get default helper for a specific type. + * + * @param type - The type of the helper (e.g., NLU, LLM, STORAGE). + * @returns - The helper + */ + async getDefaultHelper(type: T) { + if (type === HelperType.UTIL) { + throw new Error( + `Default helpers are not available for type: ${HelperType.UTIL}`, + ); + } + + const settings = await this.settingService.getSettings(); + const defaultHelperName = settings.chatbot_settings[ + `default_${type}_helper` as any + ] as HelperName; + + const defaultHelper = this.get(type, defaultHelperName); + + if (!defaultHelper) { + throw new Error(`Unable to find default ${type.toUpperCase()} helper`); + } + + return defaultHelper; + } } diff --git a/api/src/helper/lib/base-storage-helper.ts b/api/src/helper/lib/base-storage-helper.ts new file mode 100644 index 00000000..f07da0fd --- /dev/null +++ b/api/src/helper/lib/base-storage-helper.ts @@ -0,0 +1,76 @@ +/* + * Copyright © 2025 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). + */ + +import { Readable, Stream } from 'stream'; + +import { StreamableFile } from '@nestjs/common'; + +import { + AttachmentCreateDto, + AttachmentMetadataDto, +} from '@/attachment/dto/attachment.dto'; +import { Attachment } from '@/attachment/schemas/attachment.schema'; +import { LoggerService } from '@/logger/logger.service'; +import { SettingService } from '@/setting/services/setting.service'; + +import { HelperService } from '../helper.service'; +import { HelperName, HelperType } from '../types'; + +import BaseHelper from './base-helper'; + +export default abstract class BaseStorageHelper< + N extends HelperName = HelperName, +> extends BaseHelper { + protected readonly type: HelperType = HelperType.STORAGE; + + constructor( + name: N, + settingService: SettingService, + helperService: HelperService, + logger: LoggerService, + ) { + super(name, settingService, helperService, logger); + } + + /** + * Uploads files to the server. If a storage helper is configured it uploads files accordingly. + * Otherwise, uploads files to the local directory. + * + * @param file - The file + * @param metadata - The attachment metadata informations. + * @returns A promise that resolves to an array of uploaded attachments. + */ + abstract store( + _file: Buffer | Stream | Readable | Express.Multer.File, + _metadata: AttachmentMetadataDto, + ): Promise; + + /** + * Downloads an attachment identified by the provided parameters. + * + * @param attachment - The attachment to download. + * @returns A promise that resolves to a StreamableFile representing the downloaded attachment. + */ + abstract download(attachment: Attachment): Promise; + + /** + * Downloads an attachment identified by the provided parameters as a Buffer. + * + * @param attachment - The attachment to download. + * @returns A promise that resolves to a Buffer representing the attachment file. + */ + abstract readAsBuffer(attachment: Attachment): Promise; + + /** + * Returns an attachment identified by the provided parameters as a Stream. + * + * @param attachment - The attachment to download. + * @returns A promise that resolves to a Stream representing the attachment file. + */ + abstract readAsStream(attachment: Attachment): Promise; +} diff --git a/api/src/helper/types.ts b/api/src/helper/types.ts index 055d18e0..95f1601c 100644 --- a/api/src/helper/types.ts +++ b/api/src/helper/types.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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. @@ -12,6 +12,7 @@ import { HyphenToUnderscore } from '@/utils/types/extension'; import BaseHelper from './lib/base-helper'; import BaseLlmHelper from './lib/base-llm-helper'; import BaseNlpHelper from './lib/base-nlp-helper'; +import BaseStorageHelper from './lib/base-storage-helper'; export namespace NLU { export interface ParseEntity { @@ -84,16 +85,20 @@ export namespace LLM { export enum HelperType { NLU = 'nlu', LLM = 'llm', + STORAGE = 'storage', UTIL = 'util', } export type HelperName = `${string}-helper`; -export type TypeOfHelper = T extends HelperType.LLM - ? BaseLlmHelper - : T extends HelperType.NLU - ? BaseNlpHelper - : BaseHelper; +interface HelperTypeMap { + [HelperType.NLU]: BaseNlpHelper; + [HelperType.LLM]: BaseLlmHelper; + [HelperType.STORAGE]: BaseStorageHelper; + [HelperType.UTIL]: BaseHelper; +} + +export type TypeOfHelper = HelperTypeMap[T]; export type HelperRegistry = Map< HelperType, diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 20160b41..60d6a1a5 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -772,6 +772,42 @@ const migrateAndPopulateAttachmentMessages = async ({ } }; +const addDefaultStorageHelper = async ({ logger }: MigrationServices) => { + const SettingModel = mongoose.model(Setting.name, settingSchema); + try { + await SettingModel.create({ + group: 'chatbot_settings', + label: 'default_storage_helper', + value: 'local-storage-helper', + type: SettingType.select, + config: { + multiple: false, + allowCreate: false, + entity: 'Helper', + idKey: 'name', + labelKey: 'name', + }, + weight: 2, + }); + logger.log('Successfuly added the default local storage helper setting'); + } catch (err) { + logger.error('Unable to add the default local storage helper setting'); + } +}; + +const removeDefaultStorageHelper = async ({ logger }: MigrationServices) => { + const SettingModel = mongoose.model(Setting.name, settingSchema); + try { + await SettingModel.deleteOne({ + group: 'chatbot_settings', + label: 'default_storage_helper', + }); + logger.log('Successfuly removed the default local storage helper setting'); + } catch (err) { + logger.error('Unable to remove the default local storage helper setting'); + } +}; + module.exports = { async up(services: MigrationServices) { await updateOldAvatarsPath(services); @@ -784,6 +820,7 @@ module.exports = { await populateSettingAttachments(services); await populateUserAvatars(services); await populateSubscriberAvatars(services); + await addDefaultStorageHelper(services); return true; }, async down(services: MigrationServices) { @@ -792,6 +829,7 @@ module.exports = { await restoreOldAvatarsPath(services); await migrateAttachmentBlocks(MigrationAction.DOWN, services); await migrateAttachmentContents(MigrationAction.DOWN, services); + await removeDefaultStorageHelper(services); return true; }, }; diff --git a/api/src/plugins/base-storage-plugin.ts b/api/src/plugins/base-storage-plugin.ts deleted file mode 100644 index ebf3ee5f..00000000 --- a/api/src/plugins/base-storage-plugin.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright © 2025 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). - */ - -import { Readable, Stream } from 'stream'; - -import { Injectable, StreamableFile } from '@nestjs/common'; - -import { - AttachmentCreateDto, - AttachmentMetadataDto, -} from '@/attachment/dto/attachment.dto'; -import { Attachment } from '@/attachment/schemas/attachment.schema'; - -import { BasePlugin } from './base-plugin.service'; -import { PluginService } from './plugins.service'; -import { PluginName, PluginType } from './types'; - -@Injectable() -export abstract class BaseStoragePlugin extends BasePlugin { - public readonly type: PluginType = PluginType.storage; - - constructor(name: PluginName, pluginService: PluginService) { - super(name, pluginService); - } - - /** @deprecated use download() instead */ - fileExists?(attachment: Attachment): Promise; - - /** @deprecated use store() instead */ - upload?(file: Express.Multer.File): Promise; - - /** @deprecated use store() instead */ - uploadAvatar?(file: Express.Multer.File): Promise; - - abstract download(attachment: Attachment): Promise; - - /** @deprecated use download() instead */ - downloadProfilePic?(name: string): Promise; - - readAsBuffer?(attachment: Attachment): Promise; - - readAsStream?(attachment: Attachment): Promise; - - store?( - file: Buffer | Stream | Readable | Express.Multer.File, - metadata: AttachmentMetadataDto, - ): Promise; -} diff --git a/api/src/plugins/map-types.ts b/api/src/plugins/map-types.ts index 416b0b22..626ee98a 100644 --- a/api/src/plugins/map-types.ts +++ b/api/src/plugins/map-types.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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. @@ -9,13 +9,11 @@ import { BaseBlockPlugin } from './base-block-plugin'; import { BaseEventPlugin } from './base-event-plugin'; import { BasePlugin } from './base-plugin.service'; -import { BaseStoragePlugin } from './base-storage-plugin'; import { PluginType } from './types'; const PLUGIN_TYPE_MAP = { [PluginType.event]: BaseEventPlugin, [PluginType.block]: BaseBlockPlugin, - [PluginType.storage]: BaseStoragePlugin, }; export type PluginTypeMap = typeof PLUGIN_TYPE_MAP; diff --git a/api/src/plugins/types.ts b/api/src/plugins/types.ts index 2dfae9dc..d296de6d 100644 --- a/api/src/plugins/types.ts +++ b/api/src/plugins/types.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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. @@ -17,7 +17,6 @@ export type PluginName = `${string}-plugin`; export enum PluginType { event = 'event', block = 'block', - storage = 'storage', } export interface CustomBlocks {} diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index 6f7d08c7..27cf5785 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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. @@ -38,12 +38,26 @@ export const DEFAULT_SETTINGS = [ }, weight: 2, }, + { + group: 'chatbot_settings', + label: 'default_storage_helper', + value: 'local-storage-helper', + type: SettingType.select, + config: { + multiple: false, + allowCreate: false, + entity: 'Helper', + idKey: 'name', + labelKey: 'name', + }, + weight: 3, + }, { group: 'chatbot_settings', label: 'global_fallback', value: true, type: SettingType.checkbox, - weight: 3, + weight: 4, }, { group: 'chatbot_settings', @@ -58,7 +72,7 @@ export const DEFAULT_SETTINGS = [ idKey: 'id', labelKey: 'name', }, - weight: 4, + weight: 5, }, { group: 'chatbot_settings', @@ -68,7 +82,7 @@ export const DEFAULT_SETTINGS = [ "I'm really sorry but i don't quite understand what you are saying :(", ] as string[], type: SettingType.multiple_text, - weight: 5, + weight: 6, translatable: true, }, { diff --git a/api/src/utils/test/fixtures/setting.ts b/api/src/utils/test/fixtures/setting.ts index 0cff7046..39ccfadb 100644 --- a/api/src/utils/test/fixtures/setting.ts +++ b/api/src/utils/test/fixtures/setting.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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. @@ -13,6 +13,13 @@ import { SettingModel } from '@/setting/schemas/setting.schema'; import { SettingType } from '@/setting/schemas/types'; export const settingFixtures: SettingCreateDto[] = [ + { + group: 'chatbot_settings', + label: 'default_storage_helper', + value: 'local-storage-helper', + type: SettingType.text, + weight: 1, + }, { group: 'contact', label: 'contact_email_recipient', diff --git a/frontend/public/locales/en/chatbot_settings.json b/frontend/public/locales/en/chatbot_settings.json index 8335fdcc..f38a49da 100644 --- a/frontend/public/locales/en/chatbot_settings.json +++ b/frontend/public/locales/en/chatbot_settings.json @@ -7,12 +7,14 @@ "fallback_message": "Fallback Message", "fallback_block": "Fallback Block", "default_nlu_helper": "Default NLU Helper", - "default_llm_helper": "Default LLM Helper" + "default_llm_helper": "Default LLM Helper", + "default_storage_helper": "Default Storage Helper" }, "help": { "global_fallback": "Global fallback allows you to send custom messages when user entry does not match any of the block messages.", "fallback_message": "If no fallback block is selected, then one of these messages will be sent.", "default_nlu_helper": "The NLU helper is responsible for processing and understanding user inputs, including tasks like intent prediction, language detection, and entity recognition.", - "default_llm_helper": "The LLM helper leverages advanced generative AI to perform tasks such as text generation, chat completion, and complex query responses." + "default_llm_helper": "The LLM helper leverages advanced generative AI to perform tasks such as text generation, chat completion, and complex query responses.", + "default_storage_helper": "The Storage helper defines where to storage attachment files. By default, the default local storage stores them locally, but you can choose to use Minio or any other storage servers." } } diff --git a/frontend/public/locales/fr/chatbot_settings.json b/frontend/public/locales/fr/chatbot_settings.json index 6155a66c..5e1ae8e2 100644 --- a/frontend/public/locales/fr/chatbot_settings.json +++ b/frontend/public/locales/fr/chatbot_settings.json @@ -7,12 +7,14 @@ "fallback_message": "Message de secours", "fallback_block": "Bloc de secours", "default_nlu_helper": "Utilitaire NLU par défaut", - "default_llm_helper": "Utilitaire LLM par défaut" + "default_llm_helper": "Utilitaire LLM par défaut", + "default_storage_helper": "Utilitaire de stockage par défaut" }, "help": { "global_fallback": "La réponse de secours globale vous permet d'envoyer des messages personnalisés lorsque l'entrée de l'utilisateur ne correspond à aucun des messages des blocs.", "fallback_message": "Si aucun bloc de secours n'est sélectionné, l'un de ces messages sera envoyé.", "default_nlu_helper": "Utilitaire du traitement et de la compréhension des entrées des utilisateurs, incluant des tâches telles que la prédiction d'intention, la détection de langue et la reconnaissance d'entités.", - "default_llm_helper": "Utilitaire responsable de l'intelligence artificielle générative avancée pour effectuer des tâches telles que la génération de texte, la complétion de chat et les réponses à des requêtes complexes." + "default_llm_helper": "Utilitaire responsable de l'intelligence artificielle générative avancée pour effectuer des tâches telles que la génération de texte, la complétion de chat et les réponses à des requêtes complexes.", + "default_storage_helper": "Utilitaire de stockage définit l'emplacement où stocker les fichiers joints. Par défaut, le stockage local par défaut les conserve localement, mais vous pouvez choisir d'utiliser Minio ou tout autre serveur de stockage." } } diff --git a/frontend/src/components/settings/SettingInput.tsx b/frontend/src/components/settings/SettingInput.tsx index 889ce979..3bd53e85 100644 --- a/frontend/src/components/settings/SettingInput.tsx +++ b/frontend/src/components/settings/SettingInput.tsx @@ -160,6 +160,23 @@ const SettingInput: React.FC = ({ {...rest} /> ); + } else if (setting.label === "default_storage_helper") { + const { onChange, ...rest } = field; + + return ( + + searchFields={["name"]} + entity={EntityType.STORAGE_HELPER} + format={Format.BASIC} + labelKey="name" + idKey="name" + label={t("label.default_storage_helper")} + helperText={t("help.default_storage_helper")} + multiple={false} + onChange={(_e, selected, ..._) => onChange(selected?.name)} + {...rest} + /> + ); } return ( diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index 360abf80..444d1acc 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -74,6 +74,7 @@ export const ROUTES = { [EntityType.HELPER]: "/helper", [EntityType.NLU_HELPER]: "/helper/nlu", [EntityType.LLM_HELPER]: "/helper/llm", + [EntityType.STORAGE_HELPER]: "/helper/storage", } as const; export class ApiClient { diff --git a/frontend/src/services/entities.ts b/frontend/src/services/entities.ts index 3a3bc382..d3e955d6 100644 --- a/frontend/src/services/entities.ts +++ b/frontend/src/services/entities.ts @@ -1,11 +1,12 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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). */ + import { schema } from "normalizr"; import { IBaseSchema } from "@/types/base.types"; @@ -304,6 +305,15 @@ export const LlmHelperEntity = new schema.Entity( }, ); + +export const StorageHelperEntity = new schema.Entity( + EntityType.STORAGE_HELPER, + undefined, + { + idAttribute: ({ name }) => name, + }, +); + export const ENTITY_MAP = { [EntityType.SUBSCRIBER]: SubscriberEntity, [EntityType.LABEL]: LabelEntity, @@ -333,4 +343,5 @@ export const ENTITY_MAP = { [EntityType.HELPER]: HelperEntity, [EntityType.NLU_HELPER]: NluHelperEntity, [EntityType.LLM_HELPER]: LlmHelperEntity, + [EntityType.STORAGE_HELPER]: StorageHelperEntity, } as const; diff --git a/frontend/src/services/types.ts b/frontend/src/services/types.ts index 56a0a659..be04592e 100644 --- a/frontend/src/services/types.ts +++ b/frontend/src/services/types.ts @@ -1,11 +1,12 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 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). */ + import { UseMutationOptions } from "react-query"; export enum EntityType { @@ -38,6 +39,7 @@ export enum EntityType { HELPER = "Helper", NLU_HELPER = "NluHelper", LLM_HELPER = "LlmHelper", + STORAGE_HELPER = "StorageHelper", } export type NormalizedEntities = Record>; diff --git a/frontend/src/types/base.types.ts b/frontend/src/types/base.types.ts index 29bb91d6..30239595 100644 --- a/frontend/src/types/base.types.ts +++ b/frontend/src/types/base.types.ts @@ -117,6 +117,7 @@ export const POPULATE_BY_TYPE = { [EntityType.HELPER]: [], [EntityType.NLU_HELPER]: [], [EntityType.LLM_HELPER]: [], + [EntityType.STORAGE_HELPER]: [], } as const; export type Populate = @@ -208,6 +209,7 @@ export interface IEntityMapTypes { [EntityType.HELPER]: IEntityTypes; [EntityType.NLU_HELPER]: IEntityTypes; [EntityType.LLM_HELPER]: IEntityTypes; + [EntityType.STORAGE_HELPER]: IEntityTypes; } export type TType = From 6a4746dccdaef304aedbdcd50864099ec3420382 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 20 Jan 2025 11:58:29 +0100 Subject: [PATCH 07/13] fix: apply review feedback --- .../attachment/services/attachment.service.ts | 4 +-- .../1735836154221-v-2-2-0.migration.ts | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 821682fc..bf0674f1 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -61,7 +61,7 @@ export class AttachmentService extends BaseService { const storageHelper = await this.helperService.getDefaultHelper( HelperType.STORAGE, ); - return storageHelper.download(attachment); + return await storageHelper.download(attachment); } /** @@ -74,7 +74,7 @@ export class AttachmentService extends BaseService { const storageHelper = await this.helperService.getDefaultHelper( HelperType.STORAGE, ); - return storageHelper.readAsBuffer(attachment); + return await storageHelper.readAsBuffer(attachment); } /** diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 60d6a1a5..c7753857 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -775,20 +775,29 @@ const migrateAndPopulateAttachmentMessages = async ({ const addDefaultStorageHelper = async ({ logger }: MigrationServices) => { const SettingModel = mongoose.model(Setting.name, settingSchema); try { - await SettingModel.create({ - group: 'chatbot_settings', - label: 'default_storage_helper', - value: 'local-storage-helper', - type: SettingType.select, - config: { - multiple: false, - allowCreate: false, - entity: 'Helper', - idKey: 'name', - labelKey: 'name', + await SettingModel.updateOne( + { + group: 'chatbot_settings', + label: 'default_storage_helper', }, - weight: 2, - }); + { + group: 'chatbot_settings', + label: 'default_storage_helper', + value: 'local-storage-helper', + type: SettingType.select, + config: { + multiple: false, + allowCreate: false, + entity: 'Helper', + idKey: 'name', + labelKey: 'name', + }, + weight: 2, + }, + { + upsert: true, + }, + ); logger.log('Successfuly added the default local storage helper setting'); } catch (err) { logger.error('Unable to add the default local storage helper setting'); From c0691b0ab848e20ccd4c73c8c4731c426242f969 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Fri, 17 Jan 2025 19:46:12 +0100 Subject: [PATCH 08/13] fix: remove delete opearation --- .../src/components/media-library/index.tsx | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/frontend/src/components/media-library/index.tsx b/frontend/src/components/media-library/index.tsx index 4409cd44..5ee442d7 100644 --- a/frontend/src/components/media-library/index.tsx +++ b/frontend/src/components/media-library/index.tsx @@ -47,7 +47,6 @@ type MediaLibraryProps = { export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => { const { t } = useTranslate(); const { toast } = useToast(); - const deleteDialogCtl = useDialog(false); const formatFileSize = useFormattedFileSize(); const { onSearch, searchPayload } = useSearch({ $iLike: ["name"], @@ -77,26 +76,7 @@ export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => { }, }, ); - const { mutateAsync: deleteCategory } = useDelete(EntityType.ATTACHMENT, { - onError: () => { - toast.error(t("message.internal_server_error")); - }, - onSuccess: () => { - deleteDialogCtl.closeDialog(); - toast.success(t("message.item_delete_success")); - }, - }); - const actionColumns = useActionColumns( - EntityType.ATTACHMENT, - [ - { - label: ActionColumnLabel.Delete, - action: (row) => deleteDialogCtl.openDialog(row.id), - requires: [PermissionAction.DELETE], - }, - ], - t("label.operations"), - ); + const columns: GridColDef[] = [ { field: "id", headerName: "ID" }, { @@ -169,17 +149,10 @@ export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => { valueGetter: (params) => t("datetime.updated_at", getDateTimeFormatter(params)), }, - actionColumns, ]; return ( - { - if (deleteDialogCtl?.data) deleteCategory(deleteDialogCtl.data); - }} - /> Date: Fri, 17 Jan 2025 20:56:37 +0100 Subject: [PATCH 09/13] fix: pipeline --- frontend/src/components/media-library/index.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/frontend/src/components/media-library/index.tsx b/frontend/src/components/media-library/index.tsx index 5ee442d7..31985535 100644 --- a/frontend/src/components/media-library/index.tsx +++ b/frontend/src/components/media-library/index.tsx @@ -12,24 +12,14 @@ import { Box, Grid, Paper } from "@mui/material"; import { GridColDef, GridEventListener } from "@mui/x-data-grid"; import AttachmentThumbnail from "@/app-components/attachment/AttachmentThumbnail"; -import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; -import { - ActionColumnLabel, - useActionColumns, -} from "@/app-components/tables/columns/getColumns"; import { renderHeader } from "@/app-components/tables/columns/renderHeader"; import { DataGrid } from "@/app-components/tables/DataGrid"; -import { useDelete } from "@/hooks/crud/useDelete"; -import { useFind } from "@/hooks/crud/useFind"; -import { useDialog } from "@/hooks/useDialog"; import useFormattedFileSize from "@/hooks/useFormattedFileSize"; import { useSearch } from "@/hooks/useSearch"; -import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; import { PageHeader } from "@/layout/content/PageHeader"; import { EntityType } from "@/services/types"; -import { PermissionAction } from "@/types/permission.types"; import { TFilterStringFields } from "@/types/search.types"; import { getDateTimeFormatter } from "@/utils/date"; @@ -46,7 +36,6 @@ type MediaLibraryProps = { export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => { const { t } = useTranslate(); - const { toast } = useToast(); const formatFileSize = useFormattedFileSize(); const { onSearch, searchPayload } = useSearch({ $iLike: ["name"], @@ -76,7 +65,6 @@ export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => { }, }, ); - const columns: GridColDef[] = [ { field: "id", headerName: "ID" }, { From 515d0b7ea7cf22d20576f46ff419f3d09800d7c0 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Fri, 17 Jan 2025 20:58:38 +0100 Subject: [PATCH 10/13] fix: missing useFind import --- frontend/src/components/media-library/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/media-library/index.tsx b/frontend/src/components/media-library/index.tsx index 31985535..4602b557 100644 --- a/frontend/src/components/media-library/index.tsx +++ b/frontend/src/components/media-library/index.tsx @@ -15,6 +15,7 @@ import AttachmentThumbnail from "@/app-components/attachment/AttachmentThumbnail import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; import { renderHeader } from "@/app-components/tables/columns/renderHeader"; import { DataGrid } from "@/app-components/tables/DataGrid"; +import { useFind } from "@/hooks/crud/useFind"; import useFormattedFileSize from "@/hooks/useFormattedFileSize"; import { useSearch } from "@/hooks/useSearch"; import { useTranslate } from "@/hooks/useTranslate"; From ffd4aae3e01b859eb4f3584d4aa17fc181127d30 Mon Sep 17 00:00:00 2001 From: Med Marrouchi Date: Mon, 20 Jan 2025 14:43:11 +0100 Subject: [PATCH 11/13] fix: apply pr review --- frontend/src/hooks/useAvailableMenuItems.ts | 13 ++++++++++++- frontend/src/hooks/useHasPermission.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useAvailableMenuItems.ts b/frontend/src/hooks/useAvailableMenuItems.ts index ca3a54c2..3a702cf7 100644 --- a/frontend/src/hooks/useAvailableMenuItems.ts +++ b/frontend/src/hooks/useAvailableMenuItems.ts @@ -14,7 +14,12 @@ import { PermissionAction } from "@/types/permission.types"; import { useHasPermission } from "./useHasPermission"; -// Helper function to check permissions for a menu item +/** + * Helper function to check permissions for a menu item + * @param menuItem - The menu item + * @param hasPermission - Callback function + * @returns True if hasPermission() is true for all required permissions. + */ const isMenuItemAllowed = ( menuItem: MenuItem, hasPermission: (entityType: EntityType, action: PermissionAction) => boolean, @@ -30,6 +35,12 @@ const isMenuItemAllowed = ( ) ); }; + +/** + * Filters menu items based on user permissions. + * @param menuItems - The list of menu items to filter. + * @returns A filtered list of menu items that the user is allowed to access. + */ const filterMenuItems = ( menuItems: MenuItem[], hasPermission: (entityType: EntityType, action: PermissionAction) => boolean, diff --git a/frontend/src/hooks/useHasPermission.ts b/frontend/src/hooks/useHasPermission.ts index 1c1fa2a3..0d9079c6 100644 --- a/frontend/src/hooks/useHasPermission.ts +++ b/frontend/src/hooks/useHasPermission.ts @@ -19,7 +19,7 @@ export const useHasPermission = () => { (type: EntityType, action: PermissionAction) => { const allowedActions = getAllowedActions(type); - return allowedActions?.includes(action) ? true : false; + return !!allowedActions && allowedActions?.includes(action); }, [getAllowedActions], ); From 60f8429a6356deb7a16b1f41db2c582ddee6d947 Mon Sep 17 00:00:00 2001 From: Med Marrouchi Date: Mon, 20 Jan 2025 14:47:33 +0100 Subject: [PATCH 12/13] fix: lint --- frontend/src/hooks/useAvailableMenuItems.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/hooks/useAvailableMenuItems.ts b/frontend/src/hooks/useAvailableMenuItems.ts index 3a702cf7..f1bfd09d 100644 --- a/frontend/src/hooks/useAvailableMenuItems.ts +++ b/frontend/src/hooks/useAvailableMenuItems.ts @@ -35,7 +35,6 @@ const isMenuItemAllowed = ( ) ); }; - /** * Filters menu items based on user permissions. * @param menuItems - The list of menu items to filter. From da981ed972cc5c20e7463f68b348764342ed2500 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 20 Jan 2025 18:49:28 +0100 Subject: [PATCH 13/13] fix: apply pr review feedback --- .../attachment/controllers/attachment.controller.spec.ts | 6 ------ api/src/extensions/helpers/local-storage/index.helper.ts | 2 +- frontend/public/locales/en/chatbot_settings.json | 2 +- frontend/public/locales/fr/chatbot_settings.json | 2 +- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index 53df9137..124bfc13 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -89,12 +89,6 @@ describe('AttachmentController', () => { SettingSeeder, SettingService, HelperService, - // { - // provide: HelperService, - // useValue: { - // getDefaultHelper: jest.fn(), - // }, - // }, { provide: CACHE_MANAGER, useValue: { diff --git a/api/src/extensions/helpers/local-storage/index.helper.ts b/api/src/extensions/helpers/local-storage/index.helper.ts index 9ea19aec..5a77f74f 100644 --- a/api/src/extensions/helpers/local-storage/index.helper.ts +++ b/api/src/extensions/helpers/local-storage/index.helper.ts @@ -186,6 +186,6 @@ export default class LocalStorageHelper throw new NotFoundException('No file was found'); } - return fs.createReadStream(path); // Reads the file content as a Buffer + return fs.createReadStream(path); } } diff --git a/frontend/public/locales/en/chatbot_settings.json b/frontend/public/locales/en/chatbot_settings.json index f38a49da..170af944 100644 --- a/frontend/public/locales/en/chatbot_settings.json +++ b/frontend/public/locales/en/chatbot_settings.json @@ -15,6 +15,6 @@ "fallback_message": "If no fallback block is selected, then one of these messages will be sent.", "default_nlu_helper": "The NLU helper is responsible for processing and understanding user inputs, including tasks like intent prediction, language detection, and entity recognition.", "default_llm_helper": "The LLM helper leverages advanced generative AI to perform tasks such as text generation, chat completion, and complex query responses.", - "default_storage_helper": "The Storage helper defines where to storage attachment files. By default, the default local storage stores them locally, but you can choose to use Minio or any other storage servers." + "default_storage_helper": "The storage helper defines where to store attachment files. By default, the default local storage helper stores them locally, but you can choose to use Minio or any other storage solution." } } diff --git a/frontend/public/locales/fr/chatbot_settings.json b/frontend/public/locales/fr/chatbot_settings.json index 5e1ae8e2..4b1ed420 100644 --- a/frontend/public/locales/fr/chatbot_settings.json +++ b/frontend/public/locales/fr/chatbot_settings.json @@ -15,6 +15,6 @@ "fallback_message": "Si aucun bloc de secours n'est sélectionné, l'un de ces messages sera envoyé.", "default_nlu_helper": "Utilitaire du traitement et de la compréhension des entrées des utilisateurs, incluant des tâches telles que la prédiction d'intention, la détection de langue et la reconnaissance d'entités.", "default_llm_helper": "Utilitaire responsable de l'intelligence artificielle générative avancée pour effectuer des tâches telles que la génération de texte, la complétion de chat et les réponses à des requêtes complexes.", - "default_storage_helper": "Utilitaire de stockage définit l'emplacement où stocker les fichiers joints. Par défaut, le stockage local par défaut les conserve localement, mais vous pouvez choisir d'utiliser Minio ou tout autre serveur de stockage." + "default_storage_helper": "Utilitaire de stockage définit l'emplacement où stocker les fichiers joints. Par défaut, le stockage local les conserve localement, mais vous pouvez choisir d'utiliser Minio ou toute autre solution de stockage." } }