diff --git a/api/src/chat/controllers/context-var.controller.spec.ts b/api/src/chat/controllers/context-var.controller.spec.ts index 7adfdafd..c38f4f63 100644 --- a/api/src/chat/controllers/context-var.controller.spec.ts +++ b/api/src/chat/controllers/context-var.controller.spec.ts @@ -6,7 +6,7 @@ * 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 { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { MongooseModule } from '@nestjs/mongoose'; import { Test } from '@nestjs/testing'; @@ -137,12 +137,50 @@ describe('ContextVarController', () => { contextVarController.deleteOne(contextVarToDelete.id), ).rejects.toThrow( new NotFoundException( - `ContextVar with ID ${contextVarToDelete.id} not found`, + `Context var with ID ${contextVarToDelete.id} not found.`, ), ); }); }); + describe('deleteMany', () => { + const deleteResult = { acknowledged: true, deletedCount: 2 }; + + it('should delete contextVars when valid IDs are provided', async () => { + jest + .spyOn(contextVarService, 'deleteMany') + .mockResolvedValue(deleteResult); + const result = await contextVarController.deleteMany([ + contextVarToDelete.id, + contextVar.id, + ]); + + expect(contextVarService.deleteMany).toHaveBeenCalledWith({ + _id: { $in: [contextVarToDelete.id, contextVar.id] }, + }); + expect(result).toEqual(deleteResult); + }); + + it('should throw BadRequestException when no IDs are provided', async () => { + await expect(contextVarController.deleteMany([])).rejects.toThrow( + new BadRequestException('No IDs provided for deletion.'), + ); + }); + + it('should throw NotFoundException when no contextVars are deleted', async () => { + jest.spyOn(contextVarService, 'deleteMany').mockResolvedValue({ + acknowledged: true, + deletedCount: 0, + }); + + await expect( + contextVarController.deleteMany([contextVarToDelete.id, contextVar.id]), + ).rejects.toThrow( + new NotFoundException('Context vars with provided IDs not found'), + ); + }); + }); + describe('updateOne', () => { const contextVarUpdatedDto: ContextVarUpdateDto = { name: 'updated_context_var_name', diff --git a/api/src/chat/controllers/context-var.controller.ts b/api/src/chat/controllers/context-var.controller.ts index 962f043e..e4c3dcd5 100644 --- a/api/src/chat/controllers/context-var.controller.ts +++ b/api/src/chat/controllers/context-var.controller.ts @@ -7,6 +7,7 @@ */ import { + BadRequestException, Body, Controller, Delete, @@ -144,4 +145,31 @@ export class ContextVarController extends BaseController { } return result; } + + /** + * Deletes multiple context variables by their IDs. + * @param ids - IDs of context variables to be deleted. + * @returns A Promise that resolves to the deletion result. + */ + @CsrfCheck(true) + @Delete('') + @HttpCode(204) + async deleteMany(@Body('ids') ids: string[]): Promise { + if (!ids || ids.length === 0) { + throw new BadRequestException('No IDs provided for deletion.'); + } + const deleteResult = await this.contextVarService.deleteMany({ + _id: { $in: ids }, + }); + + if (deleteResult.deletedCount === 0) { + this.logger.warn( + `Unable to delete context vars with provided IDs: ${ids}`, + ); + throw new NotFoundException('Context vars with provided IDs not found'); + } + + this.logger.log(`Successfully deleted context vars with IDs: ${ids}`); + return deleteResult; + } } diff --git a/api/src/chat/repositories/context-var.repository.ts b/api/src/chat/repositories/context-var.repository.ts index 57f920c8..cf2bc185 100644 --- a/api/src/chat/repositories/context-var.repository.ts +++ b/api/src/chat/repositories/context-var.repository.ts @@ -6,21 +6,72 @@ * 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 { Injectable } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, + Optional, +} from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; +import { Document, Model, Query, TFilterQuery } from 'mongoose'; -import { BaseRepository } from '@/utils/generics/base-repository'; +import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository'; import { ContextVar } from '../schemas/context-var.schema'; +import { BlockService } from '../services/block.service'; @Injectable() export class ContextVarRepository extends BaseRepository { + private readonly blockService: BlockService; + constructor( readonly eventEmitter: EventEmitter2, @InjectModel(ContextVar.name) readonly model: Model, + @Optional() blockService?: BlockService, ) { super(eventEmitter, model, ContextVar); + this.blockService = blockService; + } + + /** + * Pre-processing logic before deleting a context var. + * It avoids deleting a context var if its unique name is used in blocks within the capture_vars array. + * If the context var is found in blocks, the block IDs are returned in the exception message. + * + * @param query - The delete query. + * @param criteria - The filter criteria for finding context vars to delete. + */ + async preDelete( + _query: Query< + DeleteResult, + Document, + unknown, + ContextVar, + 'deleteOne' | 'deleteMany' + >, + criteria: TFilterQuery, + ) { + const ids = Array.isArray(criteria._id) ? criteria._id : [criteria._id]; + + for (const id of ids) { + const contextVar = await this.findOne({ _id: id }); + if (!contextVar) { + throw new NotFoundException(`Context var with ID ${id} not found.`); + } + + const associatedBlocks = await this.blockService?.find({ + capture_vars: { $elemMatch: { context_var: contextVar.name } }, + }); + + if (associatedBlocks?.length > 0) { + const blockNames = associatedBlocks + .map((block) => block.name) + .join(', '); + throw new ForbiddenException( + `Context var "${contextVar.name}" is associated with the following block(s): ${blockNames} and cannot be deleted.`, + ); + } + } } } diff --git a/api/src/chat/schemas/context-var.schema.ts b/api/src/chat/schemas/context-var.schema.ts index 68040932..fa1b4e8c 100644 --- a/api/src/chat/schemas/context-var.schema.ts +++ b/api/src/chat/schemas/context-var.schema.ts @@ -10,6 +10,7 @@ import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose'; import { THydratedDocument } from 'mongoose'; import { BaseSchema } from '@/utils/generics/base-schema'; +import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; @Schema({ timestamps: true }) export class ContextVar extends BaseSchema { @@ -40,10 +41,10 @@ export class ContextVar extends BaseSchema { permanent?: boolean; } -export const ContextVarModel: ModelDefinition = { +export const ContextVarModel: ModelDefinition = LifecycleHookManager.attach({ name: ContextVar.name, schema: SchemaFactory.createForClass(ContextVar), -}; +}); export type ContextVarDocument = THydratedDocument; diff --git a/frontend/src/components/categories/index.tsx b/frontend/src/components/categories/index.tsx index e7620bb3..d9126643 100644 --- a/frontend/src/components/categories/index.tsx +++ b/frontend/src/components/categories/index.tsx @@ -138,8 +138,7 @@ export const Categories = () => { if (selectedCategories.length > 0) { deleteCategories(selectedCategories), setSelectedCategories([]); deleteDialogCtl.closeDialog(); - } - if (deleteDialogCtl?.data) { + } else if (deleteDialogCtl?.data) { { deleteCategory(deleteDialogCtl.data); deleteDialogCtl.closeDialog(); diff --git a/frontend/src/components/context-vars/index.tsx b/frontend/src/components/context-vars/index.tsx index 3cb5e1c5..502dc580 100644 --- a/frontend/src/components/context-vars/index.tsx +++ b/frontend/src/components/context-vars/index.tsx @@ -8,9 +8,10 @@ import { faAsterisk } from "@fortawesome/free-solid-svg-icons"; import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; import { Button, Grid, Paper, Switch } from "@mui/material"; -import { GridColDef } from "@mui/x-data-grid"; -import React from "react"; +import { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; +import React, { useState } from "react"; import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; @@ -21,6 +22,7 @@ import { import { renderHeader } from "@/app-components/tables/columns/renderHeader"; import { DataGrid } from "@/app-components/tables/DataGrid"; import { useDelete } from "@/hooks/crud/useDelete"; +import { useDeleteMany } from "@/hooks/crud/useDeleteMany"; import { useFind } from "@/hooks/crud/useFind"; import { useUpdate } from "@/hooks/crud/useUpdate"; import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; @@ -61,14 +63,29 @@ export const ContextVars = () => { }, }); const { mutateAsync: deleteContextVar } = useDelete(EntityType.CONTEXT_VAR, { - onError: () => { - toast.error(t("message.internal_server_error")); + onError: (error) => { + toast.error(error); }, onSuccess() { deleteDialogCtl.closeDialog(); + setSelectedContextVars([]); toast.success(t("message.item_delete_success")); }, }); + const { mutateAsync: deleteContextVars } = useDeleteMany( + EntityType.CONTEXT_VAR, + { + onError: (error) => { + toast.error(error); + }, + onSuccess: () => { + deleteDialogCtl.closeDialog(); + setSelectedContextVars([]); + toast.success(t("message.item_delete_success")); + }, + }, + ); + const [selectedContextVars, setSelectedContextVars] = useState([]); const actionColumns = useActionColumns( EntityType.CONTEXT_VAR, [ @@ -143,6 +160,9 @@ export const ContextVars = () => { }, actionColumns, ]; + const handleSelectionChange = (selection: GridRowSelectionModel) => { + setSelectedContextVars(selection as string[]); + }; return ( @@ -152,7 +172,13 @@ export const ContextVars = () => { { - if (deleteDialogCtl?.data) deleteContextVar(deleteDialogCtl.data); + if (selectedContextVars.length > 0) { + deleteContextVars(selectedContextVars); + setSelectedContextVars([]); + deleteDialogCtl.closeDialog(); + } else if (deleteDialogCtl?.data) { + deleteContextVar(deleteDialogCtl.data); + } }} /> @@ -179,12 +205,29 @@ export const ContextVars = () => { ) : null} + {selectedContextVars.length > 0 && ( + + + + )} - +