mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
Merge pull request #517 from Hexastack/refactor/avatar-upload
Refactor Subscriber/User Avatar Upload/Download
This commit is contained in:
commit
c720d3b602
@ -1,15 +1,19 @@
|
|||||||
/*
|
/*
|
||||||
* 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:
|
* 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.
|
* 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).
|
* 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 { Module } from '@nestjs/common';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
import { Module, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
|
||||||
|
import { config } from '@/config';
|
||||||
|
|
||||||
import { AttachmentController } from './controllers/attachment.controller';
|
import { AttachmentController } from './controllers/attachment.controller';
|
||||||
import { AttachmentRepository } from './repositories/attachment.repository';
|
import { AttachmentRepository } from './repositories/attachment.repository';
|
||||||
import { AttachmentModel } from './schemas/attachment.schema';
|
import { AttachmentModel } from './schemas/attachment.schema';
|
||||||
@ -26,4 +30,14 @@ import { AttachmentService } from './services/attachment.service';
|
|||||||
controllers: [AttachmentController],
|
controllers: [AttachmentController],
|
||||||
exports: [AttachmentService],
|
exports: [AttachmentService],
|
||||||
})
|
})
|
||||||
export class AttachmentModule {}
|
export class AttachmentModule implements OnApplicationBootstrap {
|
||||||
|
onApplicationBootstrap() {
|
||||||
|
// Ensure the directories exists
|
||||||
|
if (!existsSync(config.parameters.uploadDir)) {
|
||||||
|
mkdirSync(config.parameters.uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!existsSync(config.parameters.avatarDir)) {
|
||||||
|
mkdirSync(config.parameters.avatarDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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:
|
* 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.
|
* 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 fs, { createReadStream, promises as fsPromises } from 'fs';
|
import fs, { createReadStream, promises as fsPromises } from 'fs';
|
||||||
import path, { join } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -73,6 +73,7 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
/**
|
/**
|
||||||
* Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration.
|
* Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration.
|
||||||
*
|
*
|
||||||
|
* @deprecated Use AttachmentService.download() instead
|
||||||
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
|
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
|
||||||
* @returns A `StreamableFile` containing the user's profile picture.
|
* @returns A `StreamableFile` containing the user's profile picture.
|
||||||
*/
|
*/
|
||||||
@ -87,7 +88,9 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
throw new NotFoundException('Profile picture not found');
|
throw new NotFoundException('Profile picture not found');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const path = join(config.parameters.avatarDir, `${foreign_id}.jpeg`);
|
const path = resolve(
|
||||||
|
join(config.parameters.avatarDir, `${foreign_id}.jpeg`),
|
||||||
|
);
|
||||||
if (fs.existsSync(path)) {
|
if (fs.existsSync(path)) {
|
||||||
const picturetream = createReadStream(path);
|
const picturetream = createReadStream(path);
|
||||||
return new StreamableFile(picturetream);
|
return new StreamableFile(picturetream);
|
||||||
@ -100,6 +103,7 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
/**
|
/**
|
||||||
* Uploads a profile picture to either 3rd party storage system or locally based on the configuration.
|
* 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 res - The response object from which the profile picture will be buffered or piped.
|
||||||
* @param filename - The filename
|
* @param filename - The filename
|
||||||
*/
|
*/
|
||||||
@ -127,14 +131,9 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Save profile picture locally
|
// Save profile picture locally
|
||||||
const dirPath = path.join(config.parameters.avatarDir, filename);
|
const dirPath = resolve(join(config.parameters.avatarDir, filename));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure the directory exists
|
|
||||||
await fs.promises.mkdir(config.parameters.avatarDir, {
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Buffer.isBuffer(data)) {
|
if (Buffer.isBuffer(data)) {
|
||||||
await fs.promises.writeFile(dirPath, data);
|
await fs.promises.writeFile(dirPath, data);
|
||||||
} else {
|
} else {
|
||||||
@ -157,6 +156,7 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
|
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
|
||||||
* Otherwise, uploads files to the local directory.
|
* Otherwise, uploads files to the local directory.
|
||||||
*
|
*
|
||||||
|
* @deprecated use store() instead
|
||||||
* @param files - An array of files to upload.
|
* @param files - An array of files to upload.
|
||||||
* @returns A promise that resolves to an array of uploaded attachments.
|
* @returns A promise that resolves to an array of uploaded attachments.
|
||||||
*/
|
*/
|
||||||
@ -192,23 +192,25 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
* Otherwise, uploads files to the local directory.
|
* Otherwise, uploads files to the local directory.
|
||||||
*
|
*
|
||||||
* @param file - The file
|
* @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.
|
* @returns A promise that resolves to an array of uploaded attachments.
|
||||||
*/
|
*/
|
||||||
async store(
|
async store(
|
||||||
file: Buffer | Readable | Express.Multer.File,
|
file: Buffer | Readable | Express.Multer.File,
|
||||||
metadata: AttachmentMetadataDto,
|
metadata: AttachmentMetadataDto,
|
||||||
|
rootDir = config.parameters.uploadDir,
|
||||||
): Promise<Attachment> {
|
): Promise<Attachment> {
|
||||||
if (this.getStoragePlugin()) {
|
if (this.getStoragePlugin()) {
|
||||||
const storedDto = await this.getStoragePlugin().store(file, metadata);
|
const storedDto = await this.getStoragePlugin().store(
|
||||||
|
file,
|
||||||
|
metadata,
|
||||||
|
rootDir,
|
||||||
|
);
|
||||||
return await this.create(storedDto);
|
return await this.create(storedDto);
|
||||||
} else {
|
} else {
|
||||||
const dirPath = path.join(config.parameters.uploadDir);
|
|
||||||
const uniqueFilename = generateUniqueFilename(metadata.name);
|
const uniqueFilename = generateUniqueFilename(metadata.name);
|
||||||
const filePath = path.resolve(dirPath, sanitizeFilename(uniqueFilename));
|
const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename)));
|
||||||
|
|
||||||
if (!filePath.startsWith(dirPath)) {
|
|
||||||
throw new Error('Invalid file path');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Buffer.isBuffer(file)) {
|
if (Buffer.isBuffer(file)) {
|
||||||
await fsPromises.writeFile(filePath, file);
|
await fsPromises.writeFile(filePath, file);
|
||||||
@ -222,7 +224,7 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
} else {
|
} else {
|
||||||
if (file.path) {
|
if (file.path) {
|
||||||
// For example, if the file is an instance of `Express.Multer.File` (diskStorage case)
|
// For example, if the file is an instance of `Express.Multer.File` (diskStorage case)
|
||||||
const srcFilePath = path.resolve(file.path);
|
const srcFilePath = resolve(file.path);
|
||||||
await fsPromises.copyFile(srcFilePath, filePath);
|
await fsPromises.copyFile(srcFilePath, filePath);
|
||||||
await fsPromises.unlink(srcFilePath);
|
await fsPromises.unlink(srcFilePath);
|
||||||
} else {
|
} else {
|
||||||
@ -230,7 +232,7 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const location = filePath.replace(dirPath, '');
|
const location = filePath.replace(rootDir, '');
|
||||||
return await this.create({
|
return await this.create({
|
||||||
...metadata,
|
...metadata,
|
||||||
location,
|
location,
|
||||||
@ -242,18 +244,22 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
* Downloads an attachment identified by the provided parameters.
|
* Downloads an attachment identified by the provided parameters.
|
||||||
*
|
*
|
||||||
* @param attachment - The attachment to download.
|
* @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.
|
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
|
||||||
*/
|
*/
|
||||||
async download(attachment: Attachment) {
|
async download(
|
||||||
|
attachment: Attachment,
|
||||||
|
rootDir = config.parameters.uploadDir,
|
||||||
|
) {
|
||||||
if (this.getStoragePlugin()) {
|
if (this.getStoragePlugin()) {
|
||||||
return await this.getStoragePlugin().download(attachment);
|
return await this.getStoragePlugin().download(attachment);
|
||||||
} else {
|
} else {
|
||||||
if (!fileExists(attachment.location)) {
|
const path = resolve(join(rootDir, attachment.location));
|
||||||
|
|
||||||
|
if (!fileExists(path)) {
|
||||||
throw new NotFoundException('No file was found');
|
throw new NotFoundException('No file was found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join(config.parameters.uploadDir, attachment.location);
|
|
||||||
|
|
||||||
const disposition = `attachment; filename="${encodeURIComponent(
|
const disposition = `attachment; filename="${encodeURIComponent(
|
||||||
attachment.name,
|
attachment.name,
|
||||||
)}"`;
|
)}"`;
|
||||||
@ -273,17 +279,23 @@ export class AttachmentService extends BaseService<Attachment> {
|
|||||||
* Downloads an attachment identified by the provided parameters as a Buffer.
|
* Downloads an attachment identified by the provided parameters as a Buffer.
|
||||||
*
|
*
|
||||||
* @param attachment - The attachment to download.
|
* @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 downloaded attachment.
|
* @returns A promise that resolves to a Buffer representing the downloaded attachment.
|
||||||
*/
|
*/
|
||||||
async readAsBuffer(attachment: Attachment): Promise<Buffer> {
|
async readAsBuffer(
|
||||||
|
attachment: Attachment,
|
||||||
|
rootDir = config.parameters.uploadDir,
|
||||||
|
): Promise<Buffer> {
|
||||||
if (this.getStoragePlugin()) {
|
if (this.getStoragePlugin()) {
|
||||||
return await this.getStoragePlugin().readAsBuffer(attachment);
|
return await this.getStoragePlugin().readAsBuffer(attachment);
|
||||||
} else {
|
} else {
|
||||||
if (!fileExists(attachment.location)) {
|
const path = resolve(join(rootDir, attachment.location));
|
||||||
|
|
||||||
|
if (!fileExists(path)) {
|
||||||
throw new NotFoundException('No file was found');
|
throw new NotFoundException('No file was found');
|
||||||
}
|
}
|
||||||
const filePath = join(config.parameters.uploadDir, attachment.location);
|
|
||||||
return await fs.promises.readFile(filePath); // Reads the file content as a Buffer
|
return await fs.promises.readFile(path); // Reads the file content as a Buffer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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:
|
* 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.
|
* 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 { createReadStream, existsSync } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import { extname, join } from 'path';
|
import { extname } from 'path';
|
||||||
|
|
||||||
import { Logger, StreamableFile } from '@nestjs/common';
|
import { Logger, StreamableFile } from '@nestjs/common';
|
||||||
import { StreamableFileOptions } from '@nestjs/common/file-stream/interfaces/streamable-options.interface';
|
import { StreamableFileOptions } from '@nestjs/common/file-stream/interfaces/streamable-options.interface';
|
||||||
@ -29,20 +29,18 @@ export const isMime = (type: string): boolean => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a file exists in the specified upload directory.
|
* Checks if a file exists in the specified upload directory.
|
||||||
* @param location The relative location of the file.
|
* @param filePath The relative location of the file.
|
||||||
* @returns Whether the file exists.
|
* @returns True if the file exists.
|
||||||
*/
|
*/
|
||||||
export const fileExists = (location: string): boolean => {
|
export const fileExists = (filePath: string): boolean => {
|
||||||
// bypass test env
|
// bypass test env
|
||||||
if (config.env === 'test') {
|
if (config.env === 'test') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const dirPath = config.parameters.uploadDir;
|
return existsSync(filePath);
|
||||||
const fileLocation = join(dirPath, location);
|
|
||||||
return existsSync(fileLocation);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
new Logger(`Attachment Model : Unable to locate file: ${location}`);
|
new Logger(`Attachment Model : Unable to locate file: ${filePath}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -19,9 +19,10 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||||
|
|
||||||
|
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||||
|
import { config } from '@/config';
|
||||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||||
import { LoggerService } from '@/logger/logger.service';
|
import { LoggerService } from '@/logger/logger.service';
|
||||||
import { Roles } from '@/utils/decorators/roles.decorator';
|
|
||||||
import { BaseController } from '@/utils/generics/base-controller';
|
import { BaseController } from '@/utils/generics/base-controller';
|
||||||
import { generateInitialsAvatar } from '@/utils/helpers/avatar';
|
import { generateInitialsAvatar } from '@/utils/helpers/avatar';
|
||||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||||
@ -49,11 +50,21 @@ export class SubscriberController extends BaseController<
|
|||||||
> {
|
> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly subscriberService: SubscriberService,
|
private readonly subscriberService: SubscriberService,
|
||||||
|
private readonly attachmentService: AttachmentService,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {
|
) {
|
||||||
super(subscriberService);
|
super(subscriberService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a paginated list of subscribers based on provided query parameters.
|
||||||
|
* Supports filtering, pagination, and population of related fields.
|
||||||
|
*
|
||||||
|
* @param pageQuery - The pagination and sorting options.
|
||||||
|
* @param populate - List of fields to populate in the response.
|
||||||
|
* @param filters - Search filters to apply on the Subscriber model.
|
||||||
|
* @returns A promise containing the paginated and optionally populated list of subscribers.
|
||||||
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
async findPage(
|
async findPage(
|
||||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Subscriber>,
|
@Query(PageQueryPipe) pageQuery: PageQueryDto<Subscriber>,
|
||||||
@ -79,8 +90,10 @@ export class SubscriberController extends BaseController<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counts the filtered number of subscribers.
|
* Retrieves the count of subscribers that match the provided search filters.
|
||||||
* @returns A promise that resolves to an object representing the filtered number of subscribers.
|
*
|
||||||
|
* @param filters - Optional search filters to apply on the Subscriber model.
|
||||||
|
* @returns A promise containing the count of subscribers matching the filters.
|
||||||
*/
|
*/
|
||||||
@Get('count')
|
@Get('count')
|
||||||
async filterCount(
|
async filterCount(
|
||||||
@ -100,6 +113,14 @@ export class SubscriberController extends BaseController<
|
|||||||
return await this.count(filters);
|
return await this.count(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a single subscriber by their unique ID.
|
||||||
|
* Supports optional population of related fields.
|
||||||
|
*
|
||||||
|
* @param id - The unique identifier of the subscriber to retrieve.
|
||||||
|
* @param populate - An optional list of related fields to populate in the response.
|
||||||
|
* @returns The subscriber document, populated if requested.
|
||||||
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(
|
async findOne(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@ -116,23 +137,36 @@ export class SubscriberController extends BaseController<
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles('public')
|
/**
|
||||||
@Get(':foreign_id/profile_pic')
|
* Retrieves the profile picture (avatar) of a subscriber by their unique ID.
|
||||||
async findProfilePic(
|
* If no avatar is set, generates an initials-based avatar.
|
||||||
@Param('foreign_id') foreign_id: string,
|
*
|
||||||
): Promise<StreamableFile> {
|
* @param id - The unique identifier of the subscriber whose profile picture is to be retrieved.
|
||||||
try {
|
* @returns A streamable file containing the avatar image.
|
||||||
const pic = await this.subscriberService.findProfilePic(foreign_id);
|
*/
|
||||||
return pic;
|
@Get(':id/profile_pic')
|
||||||
} catch (e) {
|
async getAvatar(@Param('id') id: string): Promise<StreamableFile> {
|
||||||
const [subscriber] = await this.subscriberService.find({ foreign_id });
|
const subscriber = await this.subscriberService.findOneAndPopulate(id);
|
||||||
if (subscriber) {
|
|
||||||
return generateInitialsAvatar(subscriber);
|
if (!subscriber) {
|
||||||
} else {
|
throw new NotFoundException(`Subscriber with ID ${id} not found`);
|
||||||
throw new NotFoundException(
|
|
||||||
`Subscriber with ID ${foreign_id} not found`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!subscriber.avatar) {
|
||||||
|
throw new Error('User has no avatar');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.attachmentService.download(
|
||||||
|
subscriber.avatar,
|
||||||
|
config.parameters.avatarDir,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.verbose(
|
||||||
|
'Subscriber has no avatar, generating initials avatar ...',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return await generateInitialsAvatar(subscriber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -9,9 +9,7 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
NotFoundException,
|
|
||||||
Optional,
|
Optional,
|
||||||
StreamableFile,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
@ -139,22 +137,6 @@ export class SubscriberService extends BaseService<
|
|||||||
return await this.repository.handOverByForeignIdQuery(foreignId, userId);
|
return await this.repository.handOverByForeignIdQuery(foreignId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the profile picture of a subscriber based on the foreign ID.
|
|
||||||
*
|
|
||||||
* @param foreign_id - The foreign ID of the subscriber.
|
|
||||||
*
|
|
||||||
* @returns A streamable file representing the profile picture.
|
|
||||||
*/
|
|
||||||
async findProfilePic(foreign_id: string): Promise<StreamableFile> {
|
|
||||||
try {
|
|
||||||
return await this.attachmentService.downloadProfilePic(foreign_id);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error('Error downloading profile picture', err);
|
|
||||||
throw new NotFoundException('Profile picture not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply updates on end-user such as :
|
* Apply updates on end-user such as :
|
||||||
* - Assign labels to specific end-user
|
* - Assign labels to specific end-user
|
||||||
|
@ -105,12 +105,12 @@ export const config: Config = {
|
|||||||
from: process.env.EMAIL_SMTP_FROM || 'noreply@example.com',
|
from: process.env.EMAIL_SMTP_FROM || 'noreply@example.com',
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
uploadDir: process.env.UPLOAD_DIR
|
uploadDir: join(process.cwd(), process.env.UPLOAD_DIR || '/uploads'),
|
||||||
? join(process.cwd(), process.env.UPLOAD_DIR)
|
avatarDir: join(
|
||||||
: join(process.cwd(), 'uploads'),
|
process.cwd(),
|
||||||
avatarDir: process.env.AVATAR_DIR
|
process.env.UPLOAD_DIR || '/uploads',
|
||||||
? join(process.cwd(), process.env.AVATAR_DIR)
|
'/avatars',
|
||||||
: join(process.cwd(), 'avatars'),
|
),
|
||||||
storageMode: 'disk',
|
storageMode: 'disk',
|
||||||
maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES
|
maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES
|
||||||
? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES)
|
? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES)
|
||||||
|
@ -53,7 +53,7 @@ export class MigrationService implements OnApplicationBootstrap {
|
|||||||
if (mongoose.connection.readyState !== 1) {
|
if (mongoose.connection.readyState !== 1) {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
}
|
}
|
||||||
this.logger.log('Mongoose connection established');
|
this.logger.log('Mongoose connection established!');
|
||||||
|
|
||||||
if (!this.isCLI && config.mongo.autoMigrate) {
|
if (!this.isCLI && config.mongo.autoMigrate) {
|
||||||
this.logger.log('Executing migrations ...');
|
this.logger.log('Executing migrations ...');
|
||||||
|
274
api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts
Normal file
274
api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
/*
|
||||||
|
* 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 { existsSync } from 'fs';
|
||||||
|
import { join, resolve } from 'path';
|
||||||
|
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
import attachmentSchema, {
|
||||||
|
Attachment,
|
||||||
|
} from '@/attachment/schemas/attachment.schema';
|
||||||
|
import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema';
|
||||||
|
import { config } from '@/config';
|
||||||
|
import userSchema, { User } from '@/user/schemas/user.schema';
|
||||||
|
import { moveFile, moveFiles } from '@/utils/helpers/fs';
|
||||||
|
|
||||||
|
import { MigrationServices } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates subscriber documents with their corresponding avatar attachments
|
||||||
|
* and moves avatar files to a new directory.
|
||||||
|
*
|
||||||
|
* @returns Resolves when the migration process is complete.
|
||||||
|
*/
|
||||||
|
const populateSubscriberAvatar = async ({ logger }: MigrationServices) => {
|
||||||
|
const AttachmentModel = mongoose.model<Attachment>(
|
||||||
|
Attachment.name,
|
||||||
|
attachmentSchema,
|
||||||
|
);
|
||||||
|
const SubscriberModel = mongoose.model<Subscriber>(
|
||||||
|
Subscriber.name,
|
||||||
|
subscriberSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cursor = SubscriberModel.find().cursor();
|
||||||
|
|
||||||
|
for await (const subscriber of cursor) {
|
||||||
|
const foreignId = subscriber.foreign_id;
|
||||||
|
if (!foreignId) {
|
||||||
|
logger.debug(`No foreign id found for subscriber ${subscriber._id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = await AttachmentModel.findOne({
|
||||||
|
name: RegExp(`^${foreignId}.jpe?g$`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
await SubscriberModel.updateOne(
|
||||||
|
{ _id: subscriber._id },
|
||||||
|
{ $set: { avatar: attachment._id } },
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
`Subscriber ${subscriber._id} avatar attachment successfully updated for `,
|
||||||
|
);
|
||||||
|
|
||||||
|
const src = resolve(
|
||||||
|
join(config.parameters.uploadDir, attachment.location),
|
||||||
|
);
|
||||||
|
if (existsSync(src)) {
|
||||||
|
try {
|
||||||
|
const dst = resolve(
|
||||||
|
join(config.parameters.avatarDir, attachment.location),
|
||||||
|
);
|
||||||
|
await moveFile(src, dst);
|
||||||
|
logger.log(
|
||||||
|
`Subscriber ${subscriber._id} avatar file successfully moved to the new "avatars" folder`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
logger.warn(`Unable to move subscriber ${subscriber._id} avatar!`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`Subscriber ${subscriber._id} avatar attachment file was not found!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`No avatar attachment found for subscriber ${subscriber._id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts what the previous function does
|
||||||
|
*
|
||||||
|
* @returns Resolves when the migration process is complete.
|
||||||
|
*/
|
||||||
|
const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => {
|
||||||
|
const AttachmentModel = mongoose.model<Attachment>(
|
||||||
|
Attachment.name,
|
||||||
|
attachmentSchema,
|
||||||
|
);
|
||||||
|
const SubscriberModel = mongoose.model<Subscriber>(
|
||||||
|
Subscriber.name,
|
||||||
|
subscriberSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rollback logic: unset the "avatar" field in all subscriber documents
|
||||||
|
const cursor = SubscriberModel.find({ avatar: { $exists: true } }).cursor();
|
||||||
|
|
||||||
|
for await (const subscriber of cursor) {
|
||||||
|
if (subscriber.avatar) {
|
||||||
|
const attachment = await AttachmentModel.findOne({
|
||||||
|
_id: subscriber.avatar,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
// Move file to the old folder
|
||||||
|
const src = resolve(
|
||||||
|
join(config.parameters.avatarDir, attachment.location),
|
||||||
|
);
|
||||||
|
if (existsSync(src)) {
|
||||||
|
try {
|
||||||
|
const dst = resolve(
|
||||||
|
join(config.parameters.uploadDir, attachment.location),
|
||||||
|
);
|
||||||
|
await moveFile(src, dst);
|
||||||
|
logger.log(
|
||||||
|
`Avatar attachment successfully moved back to the old "avatars" folder`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
logger.warn(
|
||||||
|
`Unable to move back subscriber ${subscriber._id} avatar to the old folder!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn('Avatar attachment file was not found!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset avatar to null
|
||||||
|
await SubscriberModel.updateOne(
|
||||||
|
{ _id: subscriber._id },
|
||||||
|
{ $set: { avatar: null } },
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
`Avatar attachment successfully updated for subscriber ${subscriber._id}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`No avatar attachment found for subscriber ${subscriber._id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates and updates the paths of old folder "avatars" files for subscribers and users.
|
||||||
|
*
|
||||||
|
* @returns Resolves when the migration process is complete.
|
||||||
|
*/
|
||||||
|
const updateOldAvatarsPath = async ({ logger }: MigrationServices) => {
|
||||||
|
// Make sure the old folder is moved
|
||||||
|
const oldPath = join(process.cwd(), process.env.AVATAR_DIR || '/avatars');
|
||||||
|
if (existsSync(oldPath)) {
|
||||||
|
logger.verbose(
|
||||||
|
`Moving subscriber avatar files from ${oldPath} to ${config.parameters.avatarDir} ...`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await moveFiles(oldPath, config.parameters.avatarDir);
|
||||||
|
logger.log('Avatars folder successfully moved to its new location ...');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
logger.error('Unable to move files from the old "avatars" folder');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.log(`No old avatars folder found: ${oldPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move users avatars to the "uploads/avatars" folder
|
||||||
|
const AttachmentModel = mongoose.model<Attachment>(
|
||||||
|
Attachment.name,
|
||||||
|
attachmentSchema,
|
||||||
|
);
|
||||||
|
const UserModel = mongoose.model<User>(User.name, userSchema);
|
||||||
|
|
||||||
|
const cursor = UserModel.find().cursor();
|
||||||
|
|
||||||
|
for await (const user of cursor) {
|
||||||
|
try {
|
||||||
|
if (user.avatar) {
|
||||||
|
const avatar = await AttachmentModel.findOne({ _id: user.avatar });
|
||||||
|
if (avatar) {
|
||||||
|
const src = resolve(
|
||||||
|
join(config.parameters.uploadDir, avatar.location),
|
||||||
|
);
|
||||||
|
const dst = resolve(
|
||||||
|
join(config.parameters.avatarDir, avatar.location),
|
||||||
|
);
|
||||||
|
logger.verbose(`Moving user avatar file from ${src} to ${dst} ...`);
|
||||||
|
await moveFile(src, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
logger.error('Unable to move user avatar to the new folder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts what the previous function does
|
||||||
|
*
|
||||||
|
* @returns Resolves when the migration process is complete.
|
||||||
|
*/
|
||||||
|
const restoreOldAvatarsPath = async ({ logger }: MigrationServices) => {
|
||||||
|
// Move users avatars to the "/app/avatars" folder
|
||||||
|
const AttachmentModel = mongoose.model<Attachment>(
|
||||||
|
Attachment.name,
|
||||||
|
attachmentSchema,
|
||||||
|
);
|
||||||
|
const UserModel = mongoose.model<User>(User.name, userSchema);
|
||||||
|
|
||||||
|
const cursor = UserModel.find().cursor();
|
||||||
|
|
||||||
|
for await (const user of cursor) {
|
||||||
|
try {
|
||||||
|
if (user.avatar) {
|
||||||
|
const avatar = await AttachmentModel.findOne({ _id: user.avatar });
|
||||||
|
if (avatar) {
|
||||||
|
const src = resolve(
|
||||||
|
join(config.parameters.avatarDir, avatar.location),
|
||||||
|
);
|
||||||
|
const dst = resolve(
|
||||||
|
join(config.parameters.uploadDir, avatar.location),
|
||||||
|
);
|
||||||
|
logger.verbose(`Moving user avatar file from ${src} to ${dst} ...`);
|
||||||
|
await moveFile(src, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
logger.error('Unable to move user avatar to the new folder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
const oldPath = resolve(
|
||||||
|
join(process.cwd(), process.env.AVATAR_DIR || '/avatars'),
|
||||||
|
);
|
||||||
|
if (existsSync(config.parameters.avatarDir)) {
|
||||||
|
try {
|
||||||
|
await moveFiles(config.parameters.avatarDir, oldPath);
|
||||||
|
logger.log('Avatars folder successfully moved to the old location ...');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
logger.log('Unable to move avatar files to the old folder ...');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.log('No avatars folder found ...');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(services: MigrationServices) {
|
||||||
|
await populateSubscriberAvatar(services);
|
||||||
|
await updateOldAvatarsPath(services);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async down(services: MigrationServices) {
|
||||||
|
await unpopulateSubscriberAvatar(services);
|
||||||
|
await restoreOldAvatarsPath(services);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
@ -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:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -28,20 +28,28 @@ export abstract class BaseStoragePlugin extends BasePlugin {
|
|||||||
super(name, pluginService);
|
super(name, pluginService);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fileExists(attachment: Attachment): Promise<boolean>;
|
/** @deprecated use download() instead */
|
||||||
|
fileExists?(attachment: Attachment): Promise<boolean>;
|
||||||
|
|
||||||
abstract upload(file: Express.Multer.File): Promise<AttachmentCreateDto>;
|
/** @deprecated use store() instead */
|
||||||
|
upload?(file: Express.Multer.File): Promise<AttachmentCreateDto>;
|
||||||
|
|
||||||
abstract uploadAvatar(file: Express.Multer.File): Promise<any>;
|
/** @deprecated use store() instead */
|
||||||
|
uploadAvatar?(file: Express.Multer.File): Promise<any>;
|
||||||
|
|
||||||
abstract download(attachment: Attachment): Promise<StreamableFile>;
|
abstract download(
|
||||||
|
attachment: Attachment,
|
||||||
|
rootLocation?: string,
|
||||||
|
): Promise<StreamableFile>;
|
||||||
|
|
||||||
abstract downloadProfilePic(name: string): Promise<StreamableFile>;
|
/** @deprecated use download() instead */
|
||||||
|
downloadProfilePic?(name: string): Promise<StreamableFile>;
|
||||||
|
|
||||||
readAsBuffer?(attachment: Attachment): Promise<Buffer>;
|
readAsBuffer?(attachment: Attachment): Promise<Buffer>;
|
||||||
|
|
||||||
store?(
|
store?(
|
||||||
file: Buffer | Readable | Express.Multer.File,
|
file: Buffer | Readable | Express.Multer.File,
|
||||||
metadata: AttachmentMetadataDto,
|
metadata: AttachmentMetadataDto,
|
||||||
|
rootDir?: string,
|
||||||
): Promise<Attachment>;
|
): Promise<Attachment>;
|
||||||
}
|
}
|
||||||
|
@ -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:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -21,13 +21,17 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
Session,
|
Session,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
UploadedFile,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { Session as ExpressSession } from 'express-session';
|
import { Session as ExpressSession } from 'express-session';
|
||||||
|
import { diskStorage, memoryStorage } from 'multer';
|
||||||
|
|
||||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||||
|
import { config } from '@/config';
|
||||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||||
import { LoggerService } from '@/logger/logger.service';
|
import { LoggerService } from '@/logger/logger.service';
|
||||||
import { Roles } from '@/utils/decorators/roles.decorator';
|
import { Roles } from '@/utils/decorators/roles.decorator';
|
||||||
@ -83,7 +87,7 @@ export class ReadOnlyUserController extends BaseController<
|
|||||||
*/
|
*/
|
||||||
@Roles('public')
|
@Roles('public')
|
||||||
@Get('bot/profile_pic')
|
@Get('bot/profile_pic')
|
||||||
async botProfilePic(@Query('color') color: string) {
|
async getBotAvatar(@Query('color') color: string) {
|
||||||
return await getBotAvatar(color);
|
return await getBotAvatar(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,19 +98,28 @@ export class ReadOnlyUserController extends BaseController<
|
|||||||
*
|
*
|
||||||
* @returns A promise that resolves to the user's avatar or an avatar generated from initials if not found.
|
* @returns A promise that resolves to the user's avatar or an avatar generated from initials if not found.
|
||||||
*/
|
*/
|
||||||
@Roles('public')
|
|
||||||
@Get(':id/profile_pic')
|
@Get(':id/profile_pic')
|
||||||
async UserProfilePic(@Param('id') id: string) {
|
async getAvatar(@Param('id') id: string) {
|
||||||
try {
|
const user = await this.userService.findOneAndPopulate(id);
|
||||||
const res = await this.userService.userProfilePic(id);
|
if (!user) {
|
||||||
return res;
|
|
||||||
} catch (e) {
|
|
||||||
const user = await this.userService.findOne(id);
|
|
||||||
if (user) {
|
|
||||||
return await generateInitialsAvatar(user);
|
|
||||||
} else {
|
|
||||||
throw new NotFoundException(`user with ID ${id} not found`);
|
throw new NotFoundException(`user with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!user.avatar) {
|
||||||
|
throw new Error('User has no avatar');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.attachmentService.download(
|
||||||
|
user.avatar,
|
||||||
|
config.parameters.avatarDir,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.verbose(
|
||||||
|
'User has no avatar, generating initials avatar ...',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return await generateInitialsAvatar(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,17 +264,54 @@ export class ReadWriteUserController extends ReadOnlyUserController {
|
|||||||
* @returns A promise that resolves to the updated user.
|
* @returns A promise that resolves to the updated user.
|
||||||
*/
|
*/
|
||||||
@CsrfCheck(true)
|
@CsrfCheck(true)
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('avatar', {
|
||||||
|
limits: {
|
||||||
|
fileSize: config.parameters.maxUploadSize,
|
||||||
|
},
|
||||||
|
storage: (() => {
|
||||||
|
if (config.parameters.storageMode === 'memory') {
|
||||||
|
return memoryStorage();
|
||||||
|
} else {
|
||||||
|
return diskStorage({});
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
@Patch('edit/:id')
|
@Patch('edit/:id')
|
||||||
async updateOne(
|
async updateOne(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() userUpdate: UserEditProfileDto,
|
@Body() userUpdate: UserEditProfileDto,
|
||||||
|
@UploadedFile() avatarFile?: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
if (!('id' in req.user && req.user.id) || req.user.id !== id) {
|
if (!('id' in req.user && req.user.id) || req.user.id !== id) {
|
||||||
throw new UnauthorizedException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.userService.updateOne(req.user.id, userUpdate);
|
// Upload Avatar if provided
|
||||||
|
const avatar = avatarFile
|
||||||
|
? await this.attachmentService.store(
|
||||||
|
avatarFile,
|
||||||
|
{
|
||||||
|
name: avatarFile.originalname,
|
||||||
|
size: avatarFile.size,
|
||||||
|
type: avatarFile.mimetype,
|
||||||
|
},
|
||||||
|
config.parameters.avatarDir,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const result = await this.userService.updateOne(
|
||||||
|
req.user.id,
|
||||||
|
avatar
|
||||||
|
? {
|
||||||
|
...userUpdate,
|
||||||
|
avatar: avatar.id,
|
||||||
|
}
|
||||||
|
: userUpdate,
|
||||||
|
);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.logger.warn(`Unable to update User by id ${id}`);
|
this.logger.warn(`Unable to update User by id ${id}`);
|
||||||
throw new NotFoundException(`User with ID ${id} not found`);
|
throw new NotFoundException(`User with ID ${id} not found`);
|
||||||
|
@ -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:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -14,12 +14,12 @@ import {
|
|||||||
PartialType,
|
PartialType,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsEmail,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsString,
|
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
|
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
|
||||||
@ -66,6 +66,7 @@ export class UserCreateDto {
|
|||||||
export class UserEditProfileDto extends OmitType(PartialType(UserCreateDto), [
|
export class UserEditProfileDto extends OmitType(PartialType(UserCreateDto), [
|
||||||
'username',
|
'username',
|
||||||
'roles',
|
'roles',
|
||||||
|
'avatar',
|
||||||
]) {
|
]) {
|
||||||
@ApiPropertyOptional({ description: 'User language', type: String })
|
@ApiPropertyOptional({ description: 'User language', type: String })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -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:
|
* 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.
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||||
@ -53,9 +53,18 @@ export class Ability implements CanActivate {
|
|||||||
|
|
||||||
if (user?.roles?.length) {
|
if (user?.roles?.length) {
|
||||||
if (
|
if (
|
||||||
['/auth/logout', '/logout', '/auth/me', '/channel', '/i18n'].includes(
|
[
|
||||||
_parsedUrl.pathname,
|
// Allow access to all routes available for authenticated users
|
||||||
)
|
'/auth/logout',
|
||||||
|
'/logout',
|
||||||
|
'/auth/me',
|
||||||
|
'/channel',
|
||||||
|
'/i18n',
|
||||||
|
// Allow to update own profile
|
||||||
|
`/user/edit/${user.id}`,
|
||||||
|
// Allow access to own avatar
|
||||||
|
`/user/${user.id}/profile_pic`,
|
||||||
|
].includes(_parsedUrl.pathname)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,13 @@
|
|||||||
/*
|
/*
|
||||||
* 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:
|
* 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.
|
* 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).
|
* 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 { join } from 'path';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Injectable, NotFoundException, StreamableFile } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { getStreamableFile } from '@/attachment/utilities';
|
|
||||||
import { config } from '@/config';
|
|
||||||
import { BaseService } from '@/utils/generics/base-service';
|
import { BaseService } from '@/utils/generics/base-service';
|
||||||
|
|
||||||
import { UserRepository } from '../repositories/user.repository';
|
import { UserRepository } from '../repositories/user.repository';
|
||||||
@ -22,33 +18,4 @@ export class UserService extends BaseService<User, UserPopulate, UserFull> {
|
|||||||
constructor(readonly repository: UserRepository) {
|
constructor(readonly repository: UserRepository) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the user's profile picture as a streamable file.
|
|
||||||
*
|
|
||||||
* @param id - The ID of the user whose profile picture is requested.
|
|
||||||
*
|
|
||||||
* @returns A promise that resolves with the streamable file of the user's profile picture.
|
|
||||||
*/
|
|
||||||
async userProfilePic(id: string): Promise<StreamableFile> {
|
|
||||||
const user = await this.findOneAndPopulate(id);
|
|
||||||
if (user) {
|
|
||||||
const attachment = user.avatar;
|
|
||||||
const path = join(config.parameters.uploadDir, attachment.location);
|
|
||||||
const disposition = `attachment; filename="${encodeURIComponent(
|
|
||||||
attachment.name,
|
|
||||||
)}"`;
|
|
||||||
|
|
||||||
return getStreamableFile({
|
|
||||||
path,
|
|
||||||
options: {
|
|
||||||
type: attachment.type,
|
|
||||||
length: attachment.size,
|
|
||||||
disposition,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new NotFoundException('Profile Not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
66
api/src/utils/helpers/fs.ts
Normal file
66
api/src/utils/helpers/fs.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* 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 { basename, join, resolve } from 'path';
|
||||||
|
|
||||||
|
export async function moveFile(
|
||||||
|
sourcePath: string,
|
||||||
|
destinationPath: string,
|
||||||
|
overwrite: boolean = true,
|
||||||
|
): Promise<string> {
|
||||||
|
// Check if the file exists at the destination
|
||||||
|
try {
|
||||||
|
if (overwrite) {
|
||||||
|
await fs.promises.unlink(destinationPath); // Remove existing file if overwrite is true
|
||||||
|
} else {
|
||||||
|
await fs.promises.access(destinationPath);
|
||||||
|
throw new Error(`File already exists at destination: ${destinationPath}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore if file does not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the file
|
||||||
|
await fs.promises.copyFile(sourcePath, destinationPath);
|
||||||
|
await fs.promises.unlink(sourcePath);
|
||||||
|
return destinationPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves all files from a source folder to a destination folder.
|
||||||
|
* @param sourceFolder - The folder containing the files to move.
|
||||||
|
* @param destinationFolder - The folder where the files should be moved.
|
||||||
|
* @param overwrite - Whether to overwrite files if they already exist at the destination (default: false).
|
||||||
|
* @returns A promise that resolves when all files have been moved.
|
||||||
|
*/
|
||||||
|
export async function moveFiles(
|
||||||
|
sourceFolder: string,
|
||||||
|
destinationFolder: string,
|
||||||
|
overwrite: boolean = true,
|
||||||
|
): Promise<void> {
|
||||||
|
// Read the contents of the source folder
|
||||||
|
const files = await fs.promises.readdir(sourceFolder);
|
||||||
|
|
||||||
|
// Filter only files (skip directories)
|
||||||
|
const filePaths = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(sourceFolder, file);
|
||||||
|
const stat = await fs.promises.stat(filePath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
filePaths.push(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move each file to the destination folder
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
const fileName = basename(filePath);
|
||||||
|
const destination = resolve(join(destinationFolder, fileName));
|
||||||
|
await moveFile(filePath, destination, overwrite);
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,7 @@ API_PORT=4000
|
|||||||
APP_FRONTEND_PORT=8080
|
APP_FRONTEND_PORT=8080
|
||||||
APP_SCRIPT_COMPODOC_PORT=9003
|
APP_SCRIPT_COMPODOC_PORT=9003
|
||||||
API_ORIGIN=http://${APP_DOMAIN}:${API_PORT}
|
API_ORIGIN=http://${APP_DOMAIN}:${API_PORT}
|
||||||
|
# Specifies if the current instance has primary write (used in DB Migrations)
|
||||||
API_IS_PRIMARY_NODE=true
|
API_IS_PRIMARY_NODE=true
|
||||||
FRONTEND_BASE_URL=http://${APP_DOMAIN}:${APP_FRONTEND_PORT}
|
FRONTEND_BASE_URL=http://${APP_DOMAIN}:${APP_FRONTEND_PORT}
|
||||||
FRONTEND_ORIGIN=${FRONTEND_BASE_URL},http://${APP_DOMAIN}:8081,http://${APP_DOMAIN}:5173,http://${APP_DOMAIN},https://${APP_DOMAIN}
|
FRONTEND_ORIGIN=${FRONTEND_BASE_URL},http://${APP_DOMAIN}:8081,http://${APP_DOMAIN}:5173,http://${APP_DOMAIN},https://${APP_DOMAIN}
|
||||||
@ -15,8 +16,9 @@ SALT_LENGTH=12
|
|||||||
HTTPS_ENABLED=false
|
HTTPS_ENABLED=false
|
||||||
SESSION_SECRET=f661ff500fff6b0c8f91310b6fff6b0c
|
SESSION_SECRET=f661ff500fff6b0c8f91310b6fff6b0c
|
||||||
SESSION_NAME=s.id
|
SESSION_NAME=s.id
|
||||||
|
# Relative attachments upload directory path to the app folder
|
||||||
UPLOAD_DIR=/uploads
|
UPLOAD_DIR=/uploads
|
||||||
AVATAR_DIR=/avatars
|
# Max attachments upload size in bytes
|
||||||
UPLOAD_MAX_SIZE_IN_BYTES=20971520
|
UPLOAD_MAX_SIZE_IN_BYTES=20971520
|
||||||
INVITATION_JWT_SECRET=dev_only
|
INVITATION_JWT_SECRET=dev_only
|
||||||
INVITATION_EXPIRES_IN=24h
|
INVITATION_EXPIRES_IN=24h
|
||||||
@ -24,7 +26,9 @@ PASSWORD_RESET_JWT_SECRET=dev_only
|
|||||||
PASSWORD_RESET_EXPIRES_IN=1h
|
PASSWORD_RESET_EXPIRES_IN=1h
|
||||||
CONFIRM_ACCOUNT_SECRET=dev_only
|
CONFIRM_ACCOUNT_SECRET=dev_only
|
||||||
CONFIRM_ACCOUNT_EXPIRES_IN=1h
|
CONFIRM_ACCOUNT_EXPIRES_IN=1h
|
||||||
|
# Public attachments download URLs JWT Sign secret
|
||||||
SIGNED_URL_SECRET=dev_only
|
SIGNED_URL_SECRET=dev_only
|
||||||
|
# Public attachments download URLs download expiration
|
||||||
SIGNED_URL_EXPIRES_IN=1h
|
SIGNED_URL_EXPIRES_IN=1h
|
||||||
I18N_TRANSLATION_FILENAME=messages
|
I18N_TRANSLATION_FILENAME=messages
|
||||||
|
|
||||||
|
@ -12,7 +12,6 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
volumes:
|
volumes:
|
||||||
- api-data:/app/uploads
|
- api-data:/app/uploads
|
||||||
- api-avatars-data:/app/avatars
|
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -56,7 +55,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
api-data:
|
api-data:
|
||||||
api-avatars-data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
db-network:
|
db-network:
|
||||||
|
@ -532,6 +532,7 @@
|
|||||||
"invite": "Invite",
|
"invite": "Invite",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"fields": "Fields",
|
"fields": "Fields",
|
||||||
|
"upload": "Upload",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
|
@ -533,6 +533,7 @@
|
|||||||
"invite": "Inviter",
|
"invite": "Inviter",
|
||||||
"send": "Envoyer",
|
"send": "Envoyer",
|
||||||
"fields": "Champs",
|
"fields": "Champs",
|
||||||
|
"upload": "Téléverser",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"manage": "Gérer",
|
"manage": "Gérer",
|
||||||
|
89
frontend/src/app-components/inputs/AvatarInput.tsx
Normal file
89
frontend/src/app-components/inputs/AvatarInput.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* 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 { Avatar, Box, FormHelperText, FormLabel } from "@mui/material";
|
||||||
|
import { forwardRef, useState } from "react";
|
||||||
|
|
||||||
|
import { getAvatarSrc } from "@/components/inbox/helpers/mapMessages";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useConfig } from "@/hooks/useConfig";
|
||||||
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
|
import { theme } from "@/layout/themes/theme";
|
||||||
|
import { EntityType } from "@/services/types";
|
||||||
|
|
||||||
|
import FileUploadButton from "./FileInput";
|
||||||
|
|
||||||
|
type AvatarInputProps = {
|
||||||
|
label: string;
|
||||||
|
value: File | undefined | null;
|
||||||
|
accept: string;
|
||||||
|
size: number;
|
||||||
|
onChange: (file: File) => void;
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AvatarInput = forwardRef<HTMLDivElement, AvatarInputProps>(
|
||||||
|
({ label, accept, size, onChange, error, helperText }, ref) => {
|
||||||
|
const { apiUrl } = useConfig();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [avatarSrc, setAvatarSrc] = useState(
|
||||||
|
getAvatarSrc(apiUrl, EntityType.USER, user?.id),
|
||||||
|
);
|
||||||
|
const { t } = useTranslate();
|
||||||
|
const handleChange = (file: File) => {
|
||||||
|
onChange(file);
|
||||||
|
setAvatarSrc(URL.createObjectURL(file));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormLabel
|
||||||
|
component="h2"
|
||||||
|
style={{ display: "inline-block", marginBottom: 1 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
<Avatar
|
||||||
|
src={avatarSrc}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
sx={{ width: size, height: size, margin: "auto" }}
|
||||||
|
variant="rounded"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "50%",
|
||||||
|
bottom: "1rem",
|
||||||
|
transform: "translateX(50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileUploadButton
|
||||||
|
accept={accept}
|
||||||
|
label={t("button.upload")}
|
||||||
|
onChange={handleChange}
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{helperText ? (
|
||||||
|
<FormHelperText error={error}>{helperText}</FormHelperText>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AvatarInput.displayName = "AttachmentInput";
|
||||||
|
|
||||||
|
export default AvatarInput;
|
@ -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:
|
* 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.
|
* 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).
|
* 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 { Grid } from "@mui/material";
|
import { Grid } from "@mui/material";
|
||||||
import { GridRenderCellParams } from "@mui/x-data-grid";
|
import { GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
|
||||||
@ -29,13 +30,7 @@ export const buildRenderPicture = (
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getAvatarSrc(
|
src={getAvatarSrc(apiUrl, entityType, params.row.id)}
|
||||||
apiUrl,
|
|
||||||
entityType,
|
|
||||||
entityType === EntityType.USER
|
|
||||||
? params.row.id
|
|
||||||
: params.row.foreign_id,
|
|
||||||
)}
|
|
||||||
style={{ width: "36px", height: "36px" }}
|
style={{ width: "36px", height: "36px" }}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
@ -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:
|
* 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.
|
* 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).
|
* 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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
ChatContainer,
|
ChatContainer,
|
||||||
@ -70,11 +71,7 @@ export function Chat() {
|
|||||||
<ConversationHeader>
|
<ConversationHeader>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={subscriber?.first_name}
|
name={subscriber?.first_name}
|
||||||
src={getAvatarSrc(
|
src={getAvatarSrc(apiUrl, EntityType.SUBSCRIBER, subscriber.id)}
|
||||||
apiUrl,
|
|
||||||
EntityType.SUBSCRIBER,
|
|
||||||
subscriber.foreign_id,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<ConversationHeader.Content>
|
<ConversationHeader.Content>
|
||||||
<ChatHeader />
|
<ChatHeader />
|
||||||
@ -127,9 +124,8 @@ export function Chat() {
|
|||||||
message.sender
|
message.sender
|
||||||
? EntityType.SUBSCRIBER
|
? EntityType.SUBSCRIBER
|
||||||
: EntityType.USER,
|
: EntityType.USER,
|
||||||
(message.sender
|
(message.sender ? subscriber.id : message.sentBy) ||
|
||||||
? subscriber.foreign_id
|
"",
|
||||||
: message.sentBy) || "",
|
|
||||||
)}
|
)}
|
||||||
/>,
|
/>,
|
||||||
]
|
]
|
||||||
|
@ -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:
|
* 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.
|
* 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).
|
* 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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Conversation,
|
Conversation,
|
||||||
@ -50,30 +51,26 @@ export const SubscribersList = (props: {
|
|||||||
loadingMore={isFetching}
|
loadingMore={isFetching}
|
||||||
onYReachEnd={handleLoadMore}
|
onYReachEnd={handleLoadMore}
|
||||||
>
|
>
|
||||||
{subscribers.map((conversation) => (
|
{subscribers.map((subscriber) => (
|
||||||
<Conversation
|
<Conversation
|
||||||
onClick={() => chat.setSubscriberId(conversation.id)}
|
onClick={() => chat.setSubscriberId(subscriber.id)}
|
||||||
className="changeColor"
|
className="changeColor"
|
||||||
key={conversation.id}
|
key={subscriber.id}
|
||||||
active={chat.subscriber?.id === conversation.id}
|
active={chat.subscriber?.id === subscriber.id}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={getAvatarSrc(
|
src={getAvatarSrc(apiUrl, EntityType.SUBSCRIBER, subscriber.id)}
|
||||||
apiUrl,
|
|
||||||
EntityType.SUBSCRIBER,
|
|
||||||
conversation.foreign_id,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<Conversation.Content>
|
<Conversation.Content>
|
||||||
<div>
|
<div>
|
||||||
{conversation.first_name} {conversation.last_name}
|
{subscriber.first_name} {subscriber.last_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="cs-conversation__info">
|
<div className="cs-conversation__info">
|
||||||
{conversation.lastvisit?.toLocaleString(i18n.language)}
|
{subscriber.lastvisit?.toLocaleString(i18n.language)}
|
||||||
</div>
|
</div>
|
||||||
</Conversation.Content>
|
</Conversation.Content>
|
||||||
<Conversation.Operations visible>
|
<Conversation.Operations visible>
|
||||||
<Chip size="small" label={conversation.channel.name} />
|
<Chip size="small" label={subscriber.channel.name} />
|
||||||
</Conversation.Operations>
|
</Conversation.Operations>
|
||||||
</Conversation>
|
</Conversation>
|
||||||
))}
|
))}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
/*
|
/*
|
||||||
* 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:
|
* 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.
|
* 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).
|
* 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 CheckIcon from "@mui/icons-material/Check";
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import EmailIcon from "@mui/icons-material/Email";
|
import EmailIcon from "@mui/icons-material/Email";
|
||||||
import KeyIcon from "@mui/icons-material/Key";
|
import KeyIcon from "@mui/icons-material/Key";
|
||||||
import LanguageIcon from "@mui/icons-material/Language";
|
import LanguageIcon from "@mui/icons-material/Language";
|
||||||
@ -16,10 +16,10 @@ import { FC } from "react";
|
|||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useQueryClient } from "react-query";
|
import { useQueryClient } from "react-query";
|
||||||
|
|
||||||
import AttachmentInput from "@/app-components/attachment/AttachmentInput";
|
|
||||||
import { ContentItem } from "@/app-components/dialogs";
|
import { ContentItem } from "@/app-components/dialogs";
|
||||||
import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer";
|
import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer";
|
||||||
import { Adornment } from "@/app-components/inputs/Adornment";
|
import { Adornment } from "@/app-components/inputs/Adornment";
|
||||||
|
import AvatarInput from "@/app-components/inputs/AvatarInput";
|
||||||
import { Input } from "@/app-components/inputs/Input";
|
import { Input } from "@/app-components/inputs/Input";
|
||||||
import { PasswordInput } from "@/app-components/inputs/PasswordInput";
|
import { PasswordInput } from "@/app-components/inputs/PasswordInput";
|
||||||
import { useUpdateProfile } from "@/hooks/entities/auth-hooks";
|
import { useUpdateProfile } from "@/hooks/entities/auth-hooks";
|
||||||
@ -27,11 +27,9 @@ import { CURRENT_USER_KEY } from "@/hooks/useAuth";
|
|||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { useValidationRules } from "@/hooks/useValidationRules";
|
import { useValidationRules } from "@/hooks/useValidationRules";
|
||||||
import { IUser, IUserAttributes } from "@/types/user.types";
|
import { IProfileAttributes, IUser } from "@/types/user.types";
|
||||||
import { MIME_TYPES } from "@/utils/attachment";
|
import { MIME_TYPES } from "@/utils/attachment";
|
||||||
|
|
||||||
type TUserProfileExtendedPayload = IUserAttributes & { password2: string };
|
|
||||||
|
|
||||||
type ProfileFormProps = { user: IUser };
|
type ProfileFormProps = { user: IUser };
|
||||||
|
|
||||||
export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
|
export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
|
||||||
@ -55,14 +53,12 @@ export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
register,
|
register,
|
||||||
setValue,
|
setValue,
|
||||||
getValues,
|
} = useForm<IProfileAttributes>({
|
||||||
} = useForm<TUserProfileExtendedPayload>({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
first_name: user.first_name,
|
first_name: user.first_name,
|
||||||
last_name: user.last_name,
|
last_name: user.last_name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
avatar: user.avatar,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const rules = useValidationRules();
|
const rules = useValidationRules();
|
||||||
@ -89,7 +85,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
|
|||||||
password,
|
password,
|
||||||
password2: _password2,
|
password2: _password2,
|
||||||
...rest
|
...rest
|
||||||
}: TUserProfileExtendedPayload) => {
|
}: IProfileAttributes) => {
|
||||||
await updateProfile({
|
await updateProfile({
|
||||||
...rest,
|
...rest,
|
||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
@ -106,32 +102,13 @@ export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ position: "relative" }}>
|
<Box sx={{ position: "relative" }}>
|
||||||
<AttachmentInput
|
<AvatarInput
|
||||||
label={t("label.avatar")}
|
label={t("label.avatar")}
|
||||||
format="small"
|
|
||||||
accept={MIME_TYPES["images"].join(",")}
|
accept={MIME_TYPES["images"].join(",")}
|
||||||
enableMediaLibrary={false}
|
|
||||||
size={256}
|
size={256}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(attachment) => setValue("avatar", attachment)}
|
onChange={(file) => setValue("avatar", file)}
|
||||||
/>
|
/>
|
||||||
{getValues("avatar") ? (
|
|
||||||
<Button
|
|
||||||
startIcon={<DeleteIcon />}
|
|
||||||
onClick={() => setValue("avatar", null)}
|
|
||||||
color="error"
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
right: "50%",
|
|
||||||
bottom: "1rem",
|
|
||||||
transform: "translateX(50%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("button.remove")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
|
@ -1,17 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* 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:
|
* 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.
|
* 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).
|
* 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 { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
|
|
||||||
import { EntityType, TMutationOptions } from "@/services/types";
|
import { EntityType, TMutationOptions } from "@/services/types";
|
||||||
import { ILoginAttributes } from "@/types/auth/login.types";
|
import { ILoginAttributes } from "@/types/auth/login.types";
|
||||||
import { IUser, IUserAttributes, IUserStub } from "@/types/user.types";
|
import {
|
||||||
|
IProfileAttributes,
|
||||||
|
IUser,
|
||||||
|
IUserAttributes,
|
||||||
|
IUserStub,
|
||||||
|
} from "@/types/user.types";
|
||||||
import { useSocket } from "@/websocket/socket-hooks";
|
import { useSocket } from "@/websocket/socket-hooks";
|
||||||
|
|
||||||
import { useFind } from "../crud/useFind";
|
import { useFind } from "../crud/useFind";
|
||||||
@ -156,7 +162,7 @@ export const useLoadSettings = () => {
|
|||||||
|
|
||||||
export const useUpdateProfile = (
|
export const useUpdateProfile = (
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
TMutationOptions<IUserStub, Error, Partial<IUserAttributes>>,
|
TMutationOptions<IUserStub, Error, Partial<IProfileAttributes>>,
|
||||||
"mutationFn"
|
"mutationFn"
|
||||||
>,
|
>,
|
||||||
) => {
|
) => {
|
||||||
|
@ -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:
|
* 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.
|
* 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).
|
* 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 { AxiosInstance, AxiosResponse } from "axios";
|
import { AxiosInstance, AxiosResponse } from "axios";
|
||||||
|
|
||||||
import { ILoginAttributes } from "@/types/auth/login.types";
|
import { ILoginAttributes } from "@/types/auth/login.types";
|
||||||
@ -15,7 +16,12 @@ import { ICsrf } from "@/types/csrf.types";
|
|||||||
import { IInvitation, IInvitationAttributes } from "@/types/invitation.types";
|
import { IInvitation, IInvitationAttributes } from "@/types/invitation.types";
|
||||||
import { INlpDatasetSampleAttributes } from "@/types/nlp-sample.types";
|
import { INlpDatasetSampleAttributes } from "@/types/nlp-sample.types";
|
||||||
import { IResetPayload, IResetRequest } from "@/types/reset.types";
|
import { IResetPayload, IResetRequest } from "@/types/reset.types";
|
||||||
import { IUser, IUserAttributes, IUserStub } from "@/types/user.types";
|
import {
|
||||||
|
IProfileAttributes,
|
||||||
|
IUser,
|
||||||
|
IUserAttributes,
|
||||||
|
IUserStub,
|
||||||
|
} from "@/types/user.types";
|
||||||
|
|
||||||
import { EntityType, Format, TCount, TypeByFormat } from "./types";
|
import { EntityType, Format, TCount, TypeByFormat } from "./types";
|
||||||
|
|
||||||
@ -100,15 +106,27 @@ export class ApiClient {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(id: string, payload: Partial<IUserAttributes>) {
|
async updateProfile(id: string, payload: Partial<IProfileAttributes>) {
|
||||||
const { _csrf } = await this.getCsrf();
|
const { _csrf } = await this.getCsrf();
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
formData.append(key, value as string | Blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the CSRF token
|
||||||
|
formData.append("_csrf", _csrf);
|
||||||
|
|
||||||
const { data } = await this.request.patch<
|
const { data } = await this.request.patch<
|
||||||
IUserStub,
|
IUserStub,
|
||||||
AxiosResponse<IUserStub>,
|
AxiosResponse<IUserStub>,
|
||||||
Partial<IUserAttributes> & ICsrf
|
Partial<IProfileAttributes>
|
||||||
>(`${ROUTES.PROFILE}/${id}`, {
|
>(`${ROUTES.PROFILE}/${id}?_csrf=${_csrf}`, payload, {
|
||||||
...payload,
|
headers: {
|
||||||
_csrf,
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
@ -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:
|
* 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.
|
* 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).
|
* 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 { EntityType, Format } from "@/services/types";
|
import { EntityType, Format } from "@/services/types";
|
||||||
|
|
||||||
import { IAttachment } from "./attachment.types";
|
import { IAttachment } from "./attachment.types";
|
||||||
@ -27,6 +28,11 @@ export interface IUserStub
|
|||||||
extends IBaseSchema,
|
extends IBaseSchema,
|
||||||
OmitPopulate<IUserAttributes, EntityType.USER> {}
|
OmitPopulate<IUserAttributes, EntityType.USER> {}
|
||||||
|
|
||||||
|
export interface IProfileAttributes extends Partial<IUserStub> {
|
||||||
|
password2?: string;
|
||||||
|
avatar?: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IUser extends IUserStub, IFormat<Format.BASIC> {
|
export interface IUser extends IUserStub, IFormat<Format.BASIC> {
|
||||||
roles: string[]; //populated by default
|
roles: string[]; //populated by default
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
Loading…
Reference in New Issue
Block a user