fix: resolve file conflicts

This commit is contained in:
yassinedorbozgithub
2025-01-21 08:11:46 +01:00
61 changed files with 750 additions and 614 deletions

View File

@@ -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.
@@ -111,7 +111,7 @@ export class BotStatsService extends BaseService<BotStats> {
* @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<void> {
const day = new Date();
day.setMilliseconds(0);
day.setSeconds(0);

View File

@@ -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,14 @@ describe('AttachmentController', () => {
AttachmentRepository,
PermissionService,
PermissionRepository,
SettingRepository,
ModelService,
ModelRepository,
LoggerService,
EventEmitter2,
PluginService,
SettingSeeder,
SettingService,
HelperService,
{
provide: CACHE_MANAGER,
useValue: {
@@ -89,6 +105,14 @@ describe('AttachmentController', () => {
attachmentToDelete = (await attachmentService.findOne({
name: 'store1.jpg',
}))!;
helperService = module.get<HelperService>(HelperService);
settingService = module.get<SettingService>(SettingService);
loggerService = module.get<LoggerService>(LoggerService);
helperService.register(
new LocalStorageHelper(settingService, helperService, loggerService),
);
});
afterAll(closeInMongodConnection);

View File

@@ -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<Attachment> {
private storagePlugin: PluginInstance<PluginType.storage> | 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<StreamableFile | undefined> {
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<Attachment | null> {
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<Attachment> {
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<StreamableFile> {
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 await 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<Buffer | undefined> {
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<Buffer | undefined> {
const storageHelper = await this.helperService.getDefaultHelper(
HelperType.STORAGE,
);
return await 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<Stream | undefined> {
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<Stream | undefined> {
const storageHelper = await this.helperService.getDefaultHelper(
HelperType.STORAGE,
);
return await storageHelper.readAsStream(attachment);
}
}

View File

@@ -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.
@@ -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));
});
});
});

View File

@@ -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.
@@ -296,12 +296,7 @@ export class BlockController extends BaseController<
@Param('id') id: string,
@Body() blockUpdate: BlockUpdateDto,
): Promise<Block> {
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);
}
/**

View File

@@ -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.
@@ -114,12 +114,7 @@ export class CategoryController extends BaseController<Category> {
@Param('id') id: string,
@Body() categoryUpdate: CategoryUpdateDto,
): Promise<Category> {
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);
}
/**

View File

@@ -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 { 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),
);
});
});

View File

@@ -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.
@@ -120,12 +120,7 @@ export class ContextVarController extends BaseController<ContextVar> {
@Param('id') id: string,
@Body() contextVarUpdate: ContextVarUpdateDto,
): Promise<ContextVar> {
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);
}
/**

View File

@@ -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));
});
});
});

View File

@@ -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.
@@ -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)

View File

@@ -174,11 +174,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);
}
}

View File

@@ -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.
@@ -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);
@@ -169,22 +171,22 @@ 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],
});
});
@@ -211,9 +213,9 @@ describe('BlockRepository', () => {
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,10 +223,10 @@ describe('BlockRepository', () => {
await blockRepository.prepareBlocksOutOfCategoryUpdateScope(
otherBlocks,
validIds,
blockValidIds,
);
expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', {
expect(mockUpdateOne).toHaveBeenCalledWith(blockValidIds[1], {
attachedBlock: null,
});
});
@@ -232,20 +234,20 @@ describe('BlockRepository', () => {
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]],
});
});
});
@@ -254,7 +256,7 @@ describe('BlockRepository', () => {
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 +280,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]],
);
});

View File

@@ -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.
@@ -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<Conversation> {
return await this.updateOne(convo.id, { active: false });
}
}

View File

@@ -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.
@@ -151,7 +151,7 @@ export class SubscriberRepository extends BaseRepository<
async updateOneByForeignIdQuery(
id: string,
updates: SubscriberUpdateDto,
): Promise<Subscriber | null> {
): Promise<Subscriber> {
return await this.updateOne({ foreign_id: id }, updates);
}
@@ -162,9 +162,7 @@ export class SubscriberRepository extends BaseRepository<
*
* @returns The updated subscriber entity.
*/
async handBackByForeignIdQuery(
foreignId: string,
): Promise<Subscriber | null> {
async handBackByForeignIdQuery(foreignId: string): Promise<Subscriber> {
return await this.updateOne(
{
foreign_id: foreignId,
@@ -187,7 +185,7 @@ export class SubscriberRepository extends BaseRepository<
async handOverByForeignIdQuery(
foreignId: string,
userId: string,
): Promise<Subscriber | null> {
): Promise<Subscriber> {
return await this.updateOne(
{
foreign_id: foreignId,

View File

@@ -173,11 +173,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 =

View File

@@ -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,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,

View File

@@ -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.
@@ -148,17 +148,6 @@ export class ContentTypeController extends BaseController<ContentType> {
@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);
}
}

View File

@@ -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.
@@ -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));
});
});

View File

@@ -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.
@@ -300,13 +300,6 @@ export class ContentController extends BaseController<
@Body() contentDto: ContentUpdateDto,
@Param('id') id: string,
): Promise<Content> {
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);
}
}

View File

@@ -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.
@@ -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<Menu> {
if (!id) return await this.create(body);
return await this.menuService.updateOne(id, body);
}

View File

@@ -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<typeof LOCAL_STORAGE_HELPER_NAME>
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<AttachmentCreateDto> {
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<StreamableFile> {
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<Buffer | undefined> {
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<Stream | undefined> {
const path = resolve(
join(
this.getRootDirByResourceRef(attachment.resourceRef),
attachment.location,
),
);
if (!fileExists(path)) {
throw new NotFoundException('No file was found');
}
return fs.createReadStream(path);
}
}

View File

@@ -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"
}

View File

@@ -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
>[];

View File

@@ -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<T extends HelperType>(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<T>(type, defaultHelperName);
if (!defaultHelper) {
throw new Error(`Unable to find default ${type.toUpperCase()} helper`);
}
return defaultHelper;
}
}

View File

@@ -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<N> {
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<AttachmentCreateDto>;
/**
* 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<StreamableFile>;
/**
* 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<Buffer | undefined>;
/**
* 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<Stream | undefined>;
}

View File

@@ -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> = T extends HelperType.LLM
? BaseLlmHelper<HelperName>
: T extends HelperType.NLU
? BaseNlpHelper<HelperName>
: BaseHelper;
interface HelperTypeMap {
[HelperType.NLU]: BaseNlpHelper<HelperName>;
[HelperType.LLM]: BaseLlmHelper<HelperName>;
[HelperType.STORAGE]: BaseStorageHelper<HelperName>;
[HelperType.UTIL]: BaseHelper;
}
export type TypeOfHelper<T extends HelperType> = HelperTypeMap[T];
export type HelperRegistry<H extends BaseHelper = BaseHelper> = Map<
HelperType,

View File

@@ -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.
@@ -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));
});
});

View File

@@ -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.
@@ -123,12 +123,7 @@ export class LanguageController extends BaseController<Language> {
}
}
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);
}
/**

View File

@@ -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.
@@ -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));
});
});
});

View File

@@ -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.
@@ -90,15 +90,7 @@ export class TranslationController extends BaseController<Translation> {
@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);
}
/**

View File

@@ -522,7 +522,7 @@ module.exports = {
version,
action,
migrationDocument,
}: MigrationSuccessCallback) {
}: MigrationSuccessCallback): Promise<void> {
await this.updateStatus({ version, action, migrationDocument });
const migrationDisplayName = `${version} [${action}]`;
this.logger.log(`"${migrationDisplayName}" migration done`);

View File

@@ -772,6 +772,51 @@ const migrateAndPopulateAttachmentMessages = async ({
}
};
const addDefaultStorageHelper = async ({ logger }: MigrationServices) => {
const SettingModel = mongoose.model<Setting>(Setting.name, settingSchema);
try {
await SettingModel.updateOne(
{
group: 'chatbot_settings',
label: 'default_storage_helper',
},
{
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');
}
};
const removeDefaultStorageHelper = async ({ logger }: MigrationServices) => {
const SettingModel = mongoose.model<Setting>(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 +829,7 @@ module.exports = {
await populateSettingAttachments(services);
await populateUserAvatars(services);
await populateSubscriberAvatars(services);
await addDefaultStorageHelper(services);
return true;
},
async down(services: MigrationServices) {
@@ -792,6 +838,7 @@ module.exports = {
await restoreOldAvatarsPath(services);
await migrateAttachmentBlocks(MigrationAction.DOWN, services);
await migrateAttachmentContents(MigrationAction.DOWN, services);
await removeDefaultStorageHelper(services);
return true;
},
};

View File

@@ -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.
@@ -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);
}
/**

View File

@@ -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.
@@ -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!));
});
});

View File

@@ -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.
@@ -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 =

View File

@@ -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 { 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', () => {

View File

@@ -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.
@@ -168,12 +168,7 @@ export class NlpValueController extends BaseController<
@Param('id') id: string,
@Body() updateNlpValueDto: NlpValueUpdateDto,
): Promise<NlpValue> {
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);
}
/**

View File

@@ -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.
@@ -63,7 +63,7 @@ export class NlpValueService extends BaseService<
sampleText: string,
sampleEntities: NlpSampleEntityValue[],
storedEntities: NlpEntity[],
) {
): Promise<NlpSampleEntityValue[]> {
const eMap: Record<string, NlpEntity> = storedEntities.reduce(
(acc, curr) => {
if (curr.name) acc[curr?.name] = curr;

View File

@@ -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<BasePlugin>) {
super(name, pluginService);
}
/** @deprecated use download() instead */
fileExists?(attachment: Attachment): Promise<boolean>;
/** @deprecated use store() instead */
upload?(file: Express.Multer.File): Promise<AttachmentCreateDto>;
/** @deprecated use store() instead */
uploadAvatar?(file: Express.Multer.File): Promise<any>;
abstract download(attachment: Attachment): Promise<StreamableFile>;
/** @deprecated use download() instead */
downloadProfilePic?(name: string): Promise<StreamableFile>;
readAsBuffer?(attachment: Attachment): Promise<Buffer>;
readAsStream?(attachment: Attachment): Promise<Stream>;
store?(
file: Buffer | Stream | Readable | Express.Multer.File,
metadata: AttachmentMetadataDto,
): Promise<Attachment>;
}

View File

@@ -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;

View File

@@ -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 {}

View File

@@ -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.
@@ -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<Setting> {
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);
}
}

View File

@@ -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,
},
{

View File

@@ -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.
@@ -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);
}
/**

View File

@@ -306,7 +306,7 @@ export class ReadWriteUserController extends ReadOnlyUserController {
})
: undefined;
const result = await this.userService.updateOne(
return await this.userService.updateOne(
req.user.id,
avatar
? {
@@ -315,12 +315,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;
}
/**

View File

@@ -482,7 +482,7 @@ export abstract class BaseRepository<
options: QueryOptions<D> | null = {
new: true,
},
): Promise<T | null> {
): Promise<T> {
const query = this.model.findOneAndUpdate<T>(
{
...(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} with ${typeof criteria === 'string' ? 'ID' : 'criteria'} ${JSON.stringify(criteria)}`;
throw new Error(errorMessage);
}
return result;
}
async updateMany<D extends Partial<U>>(

View File

@@ -179,7 +179,7 @@ export abstract class BaseService<
criteria: string | TFilterQuery<T>,
dto: DtoInfer<DtoAction.Update, Dto, Partial<U>>,
options?: QueryOptions<Partial<U>> | null,
): Promise<T | null> {
): Promise<T> {
return await this.repository.updateOne(criteria, dto, options);
}

View File

@@ -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}\"` : ''}`);

View File

@@ -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',