From 45a645570d4c5d1136e9bb8149c6dcf9861e714f Mon Sep 17 00:00:00 2001 From: Humberto Date: Wed, 15 Apr 2026 08:16:58 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(file):=20Criar=20m=C3=B3dulo=20de=20up?= =?UTF-8?q?load=20de=20arquivos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona estrutura inicial do módulo de arquivos para upload e armazenamento local. --- .gitignore | 7 ++- backend/package-lock.json | 32 ++++++++-- backend/package.json | 4 +- .../modules/file/application/file.service.ts | 16 +++++ .../src/modules/file/config/multer.config.ts | 56 ++++++++++++++++++ .../src/modules/file/domain/file.entity.ts | 8 +++ backend/src/modules/file/file.module.ts | 0 .../file/infra/storage/local.storage.ts | 17 ++++++ .../file/presentation/file.controller.ts | 59 +++++++++++++++++++ .../shared/domain/ticket-category.enum.ts | 7 --- backend/tsconfig.json | 1 + 11 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 backend/src/modules/file/application/file.service.ts create mode 100644 backend/src/modules/file/config/multer.config.ts create mode 100644 backend/src/modules/file/domain/file.entity.ts create mode 100644 backend/src/modules/file/file.module.ts create mode 100644 backend/src/modules/file/infra/storage/local.storage.ts create mode 100644 backend/src/modules/file/presentation/file.controller.ts delete mode 100644 backend/src/modules/shared/domain/ticket-category.enum.ts diff --git a/.gitignore b/.gitignore index f5d0152..5d4b8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,9 @@ temp/ # Database local # ====================== *.sqlite -*.db \ No newline at end of file +*.db + +# ====================== +# Files local storage +# ====================== +uploads/ \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index d9d427b..b13e24e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,7 +15,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/mongoose": "^11.0.4", "@nestjs/passport": "^11.0.5", - "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/swagger": "^11.2.6", "@nestjs/websockets": "^11.1.17", @@ -24,6 +24,7 @@ "class-validator": "^0.15.1", "mongoose": "^9.3.1", "morgan": "^1.10.1", + "multer": "^2.1.1", "node-nlp": "^5.0.0-alpha.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -40,6 +41,7 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/morgan": "^1.9.10", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", @@ -2532,15 +2534,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.17", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.17.tgz", - "integrity": "sha512-mAf4eOsSBsTOn/VbrUO1gsjW6dVh91qqXPMXun4dN8SnNjf7PTQagM9o8d6ab8ZBpNe6UdZftdrZoDetU+n4Qg==", + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.19.tgz", + "integrity": "sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.1.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "funding": { @@ -2552,6 +2554,16 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@nestjs/platform-socket.io": { "version": "11.1.17", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.17.tgz", @@ -3775,6 +3787,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", diff --git a/backend/package.json b/backend/package.json index 9bf4ec0..f8201e5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,7 +27,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/mongoose": "^11.0.4", "@nestjs/passport": "^11.0.5", - "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/swagger": "^11.2.6", "@nestjs/websockets": "^11.1.17", @@ -36,6 +36,7 @@ "class-validator": "^0.15.1", "mongoose": "^9.3.1", "morgan": "^1.10.1", + "multer": "^2.1.1", "node-nlp": "^5.0.0-alpha.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -52,6 +53,7 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/morgan": "^1.9.10", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", diff --git a/backend/src/modules/file/application/file.service.ts b/backend/src/modules/file/application/file.service.ts new file mode 100644 index 0000000..d1c1843 --- /dev/null +++ b/backend/src/modules/file/application/file.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { LocalStorage } from '../infra/storage/local.storage'; +import { FileEntity } from '../domain/file.entity'; + +@Injectable() +export class FileService { + constructor( + private readonly storage: LocalStorage + ) {} + + upload( + file: Express.Multer.File + ): FileEntity { + return this.storage.save(file); + } +} \ No newline at end of file diff --git a/backend/src/modules/file/config/multer.config.ts b/backend/src/modules/file/config/multer.config.ts new file mode 100644 index 0000000..41ba4a3 --- /dev/null +++ b/backend/src/modules/file/config/multer.config.ts @@ -0,0 +1,56 @@ +import { diskStorage } from 'multer'; +import { extname } from 'path'; + +export const multerConfig = { + + storage: diskStorage({ + + destination: './uploads', + + filename: (req, file, cb) => { + + const uniqueSuffix = + Date.now() + + '-' + + Math.round(Math.random() * 1e9); + + const extension = + extname(file.originalname); + + const filename = + `${uniqueSuffix}${extension}`; + + cb(null, filename); + } + + }), + + limits: { + fileSize: 10 * 1024 * 1024 // 10MB + }, + + fileFilter: (req, file, cb) => { + + const allowedTypes = [ + 'image/jpeg', + 'image/png', + 'image/jpg', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ]; + + if (!allowedTypes.includes(file.mimetype)) { + + return cb( + new Error('Tipo de arquivo não permitido'), + false + ); + + } + + cb(null, true); + + } + +}; \ No newline at end of file diff --git a/backend/src/modules/file/domain/file.entity.ts b/backend/src/modules/file/domain/file.entity.ts new file mode 100644 index 0000000..e84f50b --- /dev/null +++ b/backend/src/modules/file/domain/file.entity.ts @@ -0,0 +1,8 @@ +export class FileEntity { + filename: string; + originalname: string; + mimetype: string; + size: number; + path: string; + uploadedAt: Date; +} \ No newline at end of file diff --git a/backend/src/modules/file/file.module.ts b/backend/src/modules/file/file.module.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/modules/file/infra/storage/local.storage.ts b/backend/src/modules/file/infra/storage/local.storage.ts new file mode 100644 index 0000000..cb9bb9c --- /dev/null +++ b/backend/src/modules/file/infra/storage/local.storage.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { FileEntity } from '../../domain/file.entity'; + +@Injectable() +export class LocalStorage { + + save(file: Express.Multer.File): FileEntity { + return { + filename: file.filename, + originalname: file.originalname, + mimetype: file.mimetype, + size: file.size, + path: file.path, + uploadedAt: new Date() + }; + } +} \ No newline at end of file diff --git a/backend/src/modules/file/presentation/file.controller.ts b/backend/src/modules/file/presentation/file.controller.ts new file mode 100644 index 0000000..1a84b5b --- /dev/null +++ b/backend/src/modules/file/presentation/file.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Post, + UploadedFile, + UseInterceptors, + Get, + Param, + Res +} from '@nestjs/common'; + +import { FileInterceptor } +from '@nestjs/platform-express'; + +import { multerConfig } +from '../config/multer.config'; + +import { FileService } +from '../application/file.service'; + +import type { Response } from 'express'; +import { createReadStream } from 'fs'; + +@Controller('files') +export class FileController { + + constructor( + private readonly fileService: FileService + ) {} + + @Post('upload') + @UseInterceptors( + FileInterceptor( + 'file', + multerConfig + ) + ) + uploadFile( + @UploadedFile() + file: Express.Multer.File + ) { + return this.fileService.upload(file); + } + + @Get(':filename') + getFile( + @Param('filename') + filename: string, + + @Res() + res: Response + ) { + + const stream = + createReadStream( + `uploads/${filename}` + ); + stream.pipe(res); + } +} \ No newline at end of file diff --git a/backend/src/modules/shared/domain/ticket-category.enum.ts b/backend/src/modules/shared/domain/ticket-category.enum.ts deleted file mode 100644 index 285371b..0000000 --- a/backend/src/modules/shared/domain/ticket-category.enum.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum TicketCategory { - WEB_APP = 'WEB_APP', - IA = 'ARTIFICIAL_INTELLIGENCE', - BI = 'BUSINESS_INTELLIGENCE', - IOT = 'INTERNET_OF_THINGS', - OTHER = 'OTHER', -} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 098d432..18a3784 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -13,6 +13,7 @@ "target": "ES2023", "sourceMap": true, "outDir": "./dist", + "rootDir": ".", // "baseUrl": "./", "incremental": true, "skipLibCheck": true, From cb3a30512cfce7d828ecc65bc432619abcbb113e Mon Sep 17 00:00:00 2001 From: Humberto Date: Fri, 17 Apr 2026 08:39:52 -0300 Subject: [PATCH 2/3] feat(file): Add modulo de upload de arquivos Cria o modulo file para upload local de arquivos. --- backend/src/app.module.ts | 2 + .../modules/file/application/file.service.ts | 65 ++++++++++++-- .../src/modules/file/config/multer.config.ts | 34 +------ .../src/modules/file/domain/file.entity.ts | 4 +- backend/src/modules/file/file.module.ts | 28 ++++++ .../modules/file/infra/schemas/file.schema.ts | 25 ++++++ .../file/infra/storage/local.storage.ts | 9 +- .../file/presentation/dtos/uploadFileDTO.ts | 1 + .../file/presentation/file.controller.ts | 89 ++++++++++--------- 9 files changed, 171 insertions(+), 86 deletions(-) create mode 100644 backend/src/modules/file/infra/schemas/file.schema.ts create mode 100644 backend/src/modules/file/presentation/dtos/uploadFileDTO.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f8e401e..847cd32 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { ChatModule } from './modules/chat/chat.module'; import { TriageModule } from './modules/triage/triage.module'; import { CategoryModule } from './modules/category/category.module'; import { TicketModule } from './modules/ticket/ticket.module'; +import { FileModule } from './modules/file/file.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { TicketModule } from './modules/ticket/ticket.module'; TriageModule, CategoryModule, TicketModule, + FileModule, ], controllers: [], providers: [], diff --git a/backend/src/modules/file/application/file.service.ts b/backend/src/modules/file/application/file.service.ts index d1c1843..3e53c3b 100644 --- a/backend/src/modules/file/application/file.service.ts +++ b/backend/src/modules/file/application/file.service.ts @@ -1,16 +1,69 @@ import { Injectable } from '@nestjs/common'; -import { LocalStorage } from '../infra/storage/local.storage'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { FileDocument } from '../infra/schemas/file.schema'; import { FileEntity } from '../domain/file.entity'; +import { LocalStorage } from '../infra/storage/local.storage'; @Injectable() export class FileService { constructor( - private readonly storage: LocalStorage + @InjectModel('File') + private readonly fileModel: + Model, + + private readonly storage: + LocalStorage ) {} - - upload( - file: Express.Multer.File + + async createFile( + file: Express.Multer.File, + uploadedBy?: string + ): Promise { + const fileData = + this.storage.save( + file, + uploadedBy + ); + + const newFile = + new this.fileModel({ + filename: fileData.filename, + originalname: + fileData.originalname, + mimetype: + fileData.mimetype, + size: + fileData.size, + path: + fileData.path, + uploadedBy: + fileData.uploadedBy + }); + + const savedFile = + await newFile.save(); + return this._mapToEntity( + savedFile + ); + } + + private _mapToEntity( + file: any ): FileEntity { - return this.storage.save(file); + return { + id: file._id, + filename: file.filename, + originalname: + file.originalname, + mimetype: + file.mimetype, + size: file.size, + path: file.path, + uploadedBy: + file.uploadedBy, + createdAt: + file.createdAt + }; } } \ No newline at end of file diff --git a/backend/src/modules/file/config/multer.config.ts b/backend/src/modules/file/config/multer.config.ts index 41ba4a3..0abf749 100644 --- a/backend/src/modules/file/config/multer.config.ts +++ b/backend/src/modules/file/config/multer.config.ts @@ -2,55 +2,23 @@ import { diskStorage } from 'multer'; import { extname } from 'path'; export const multerConfig = { - storage: diskStorage({ - destination: './uploads', filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const extension = extname(file.originalname); - const filename = `${uniqueSuffix}${extension}`; - cb(null, filename); } - }), limits: { - fileSize: 10 * 1024 * 1024 // 10MB - }, - - fileFilter: (req, file, cb) => { - - const allowedTypes = [ - 'image/jpeg', - 'image/png', - 'image/jpg', - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - ]; - - if (!allowedTypes.includes(file.mimetype)) { - - return cb( - new Error('Tipo de arquivo não permitido'), - false - ); - - } - - cb(null, true); - + fileSize: 10 * 1024 * 1024 } - }; \ No newline at end of file diff --git a/backend/src/modules/file/domain/file.entity.ts b/backend/src/modules/file/domain/file.entity.ts index e84f50b..21dd660 100644 --- a/backend/src/modules/file/domain/file.entity.ts +++ b/backend/src/modules/file/domain/file.entity.ts @@ -1,8 +1,10 @@ export class FileEntity { + id?: string; filename: string; originalname: string; mimetype: string; size: number; path: string; - uploadedAt: Date; + uploadedBy?: string; + createdAt?: Date; } \ No newline at end of file diff --git a/backend/src/modules/file/file.module.ts b/backend/src/modules/file/file.module.ts index e69de29..caa490f 100644 --- a/backend/src/modules/file/file.module.ts +++ b/backend/src/modules/file/file.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { FileController } from './presentation/file.controller'; +import { FileService } from './application/file.service'; +import { FileSchema } from './infra/schemas/file.schema'; +import { LocalStorage } from './infra/storage/local.storage'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: 'File', + schema: FileSchema + } + ]) + ], + controllers: [ + FileController + ], + providers: [ + FileService, + LocalStorage + ], + exports: [ + FileService + ] +}) +export class FileModule {} \ No newline at end of file diff --git a/backend/src/modules/file/infra/schemas/file.schema.ts b/backend/src/modules/file/infra/schemas/file.schema.ts new file mode 100644 index 0000000..6ffdc95 --- /dev/null +++ b/backend/src/modules/file/infra/schemas/file.schema.ts @@ -0,0 +1,25 @@ +import { Schema, Document } from 'mongoose'; + +export interface FileDocument extends Document { + filename: string; + originalname: string; + mimetype: string; + size: number; + path: string; + uploadedBy?: string; + createdAt?: Date; +} + +export const FileSchema = new Schema( + { + filename: { type: String, required: true }, + originalname: { type: String, required: true }, + mimetype: { type: String, required: true }, + size: { type: Number, required: true }, + path: { type: String, required: true }, + uploadedBy: { type: String } + }, + { + timestamps: true + } +); \ No newline at end of file diff --git a/backend/src/modules/file/infra/storage/local.storage.ts b/backend/src/modules/file/infra/storage/local.storage.ts index cb9bb9c..0bc8314 100644 --- a/backend/src/modules/file/infra/storage/local.storage.ts +++ b/backend/src/modules/file/infra/storage/local.storage.ts @@ -4,14 +4,19 @@ import { FileEntity } from '../../domain/file.entity'; @Injectable() export class LocalStorage { - save(file: Express.Multer.File): FileEntity { + save( + file: Express.Multer.File, + uploadedBy?: string + ): FileEntity { return { + id: undefined, filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, size: file.size, path: file.path, - uploadedAt: new Date() + uploadedBy, + createdAt: new Date() }; } } \ No newline at end of file diff --git a/backend/src/modules/file/presentation/dtos/uploadFileDTO.ts b/backend/src/modules/file/presentation/dtos/uploadFileDTO.ts new file mode 100644 index 0000000..87243a5 --- /dev/null +++ b/backend/src/modules/file/presentation/dtos/uploadFileDTO.ts @@ -0,0 +1 @@ +export class UploadFileDTO {} \ No newline at end of file diff --git a/backend/src/modules/file/presentation/file.controller.ts b/backend/src/modules/file/presentation/file.controller.ts index 1a84b5b..b96a689 100644 --- a/backend/src/modules/file/presentation/file.controller.ts +++ b/backend/src/modules/file/presentation/file.controller.ts @@ -1,59 +1,60 @@ -import { - Controller, - Post, - UploadedFile, - UseInterceptors, - Get, - Param, - Res -} from '@nestjs/common'; - -import { FileInterceptor } -from '@nestjs/platform-express'; - -import { multerConfig } -from '../config/multer.config'; - -import { FileService } -from '../application/file.service'; - -import type { Response } from 'express'; -import { createReadStream } from 'fs'; - +import { Controller, Post, UploadedFile, UseInterceptors, UseGuards, Body, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiConsumes, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { JwtGuard } from '../../auth/guards/jwt.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/guards/roles.decorator'; +import { UserRole } from '../../user/user.schema'; +import { FileService } from '../application/file.service'; +import { UploadFileDTO } from './dtos/uploadFileDTO'; +import { multerConfig } from '../config/multer.config'; +import type { Express, Request } from 'express'; + +@ApiTags('Files') +@ApiBearerAuth() @Controller('files') export class FileController { - constructor( - private readonly fileService: FileService + private fileService: FileService ) {} - @Post('upload') + @Post() + @UseGuards( JwtGuard, RolesGuard ) + @Roles( UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT ) + @ApiOperation({ summary: 'Upload de arquivo' }) + @ApiConsumes( 'multipart/form-data' ) + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary' + } + } + } + }) @UseInterceptors( FileInterceptor( 'file', multerConfig ) ) - uploadFile( + async uploadFile( @UploadedFile() - file: Express.Multer.File + file: Express.Multer.File, + @Body() + dto: UploadFileDTO, + @Req() + req: Request & { + user: { id: string } + } ) { - return this.fileService.upload(file); - } - - @Get(':filename') - getFile( - @Param('filename') - filename: string, - - @Res() - res: Response - ) { - - const stream = - createReadStream( - `uploads/${filename}` - ); - stream.pipe(res); + const userId = + req.user.id; + return this.fileService.createFile( + file, + userId + ); } } \ No newline at end of file From 2075596ccb6e27097f54ba122fb8fcbc4d40ea59 Mon Sep 17 00:00:00 2001 From: Humberto Date: Wed, 22 Apr 2026 21:29:44 -0300 Subject: [PATCH 3/3] feat(files,chat): Organizar uploads e anexar arquivos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona suporte a subpastas no armazenamento local, permitindo salvar arquivos em caminhos específicos como chat/. Implementa envio de mensagens com arquivos anexados, incluindo persistência de fileIds e novos testes relacionados ao envio de anexos. Remove tipo legado não utilizado da triagem. --- .../modules/Messages/infra/message.schema.ts | 3 + .../Messages/presentation/message.gateway.ts | 1 + .../application/chat.file-message.spec.ts | 192 ++++++++++++++++++ .../chat/application/chat.service.spec.ts | 1 + .../modules/chat/application/chat.service.ts | 2 + .../modules/file/application/file.service.ts | 2 + .../file/infra/storage/local.storage.ts | 21 ++ .../dtos/uploadChatFileParamsDTO.ts | 12 ++ .../file/presentation/file.controller.ts | 63 ++++-- .../triage/domain/triage-result.type.ts | 5 - .../presentation/triage.controller.spec.ts | 176 ++++++++++------ 11 files changed, 393 insertions(+), 85 deletions(-) create mode 100644 backend/src/modules/chat/application/chat.file-message.spec.ts create mode 100644 backend/src/modules/file/presentation/dtos/uploadChatFileParamsDTO.ts delete mode 100644 backend/src/modules/triage/domain/triage-result.type.ts diff --git a/backend/src/modules/Messages/infra/message.schema.ts b/backend/src/modules/Messages/infra/message.schema.ts index 7e93bd7..4712b7c 100644 --- a/backend/src/modules/Messages/infra/message.schema.ts +++ b/backend/src/modules/Messages/infra/message.schema.ts @@ -19,6 +19,9 @@ export class Message { @Prop({ required: false }) readAt?: Date; // Controle pra saber se a mensagem foi lida + + @Prop({ type: [String], default: [] }) + fileIds?: string[]; // Arquivos anexados a mensagem } export const MessageSchema = SchemaFactory.createForClass(Message); diff --git a/backend/src/modules/Messages/presentation/message.gateway.ts b/backend/src/modules/Messages/presentation/message.gateway.ts index 6536469..c7faba7 100644 --- a/backend/src/modules/Messages/presentation/message.gateway.ts +++ b/backend/src/modules/Messages/presentation/message.gateway.ts @@ -66,6 +66,7 @@ export class MessageGateway senderId: data.senderId, content: data.content, isSystemMessage: data.isSystemMessage || false, + fileIds: data.fileIds || [], }); console.log(`Mensagem salva no banco com ID: ${mensagemSalva.id}`); diff --git a/backend/src/modules/chat/application/chat.file-message.spec.ts b/backend/src/modules/chat/application/chat.file-message.spec.ts new file mode 100644 index 0000000..96f2a64 --- /dev/null +++ b/backend/src/modules/chat/application/chat.file-message.spec.ts @@ -0,0 +1,192 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ChatService } from './chat.service'; +import { UserRole } from '../../user/user.schema'; +import { NotFoundException } from '@nestjs/common'; + +import type { IChatRepository } from '../domain/chat.repository'; +import type { IMessageRepository } from '../../Messages/domain/message.repository'; + +/* =========================== + MOCKS +=========================== */ + +const mockChatRepository: jest.Mocked = { + create: jest.fn(), + findById: jest.fn(), + findByParticipant: jest.fn(), + findByTicketId: jest.fn(), + updateStatus: jest.fn(), +}; + +const mockMessageRepository: jest.Mocked = { + create: jest.fn(), + findByChatId: jest.fn(), +}; + +/* =========================== + CONSTANTES +=========================== */ + +const CHAT_ID = 'chat-001'; +const CLIENT_ID = 'user-001'; + +const mockChat = { + id: CHAT_ID, + ticketId: 'ticket-001', + clientId: CLIENT_ID, + agentId: 'agent-001', + groupId: 'group-001', + status: 'open', + createdAt: new Date(), +}; + +/* =========================== + TESTES +=========================== */ + +describe('ChatService — envio de mensagem com arquivo', () => { + let service: ChatService; + + beforeEach(async () => { + const module: TestingModule = + await Test.createTestingModule({ + providers: [ + ChatService, + { + provide: 'IChatRepository', + useValue: mockChatRepository, + }, + { + provide: 'IMessageRepository', + useValue: mockMessageRepository, + }, + ], + }).compile(); + + service = + module.get(ChatService); + + jest.clearAllMocks(); + }); + + /* =========================== + TESTE PRINCIPAL + =========================== */ + + it('should send message with attached file', async () => { + + const FILE_ID = 'file-001'; + + const mockSavedMessage = { + id: 'msg-001', + chatId: CHAT_ID, + senderId: CLIENT_ID, + content: 'Arquivo anexado', + fileIds: [FILE_ID], + isSystemMessage: false, + createdAt: new Date(), + }; + + /* Chat existe */ + mockChatRepository + .findById + .mockResolvedValue(mockChat as any); + + /* Mensagem salva */ + mockMessageRepository + .create + .mockResolvedValue(mockSavedMessage as any); + + const result = + await service.sendMessage( + CHAT_ID, + CLIENT_ID, + UserRole.CLIENT, + 'Arquivo anexado', + [FILE_ID], + ); + + expect(result) + .toEqual(mockSavedMessage); + + expect( + mockMessageRepository.create + ).toHaveBeenCalledWith({ + chatId: CHAT_ID, + senderId: CLIENT_ID, + content: 'Arquivo anexado', + isSystemMessage: false, + fileIds: [FILE_ID], + }); + }); + + /* =========================== + TESTE: SEM ARQUIVO + =========================== */ + + it('should send message without fileIds', async () => { + + const mockSavedMessage = { + id: 'msg-002', + chatId: CHAT_ID, + senderId: CLIENT_ID, + content: 'Mensagem normal', + fileIds: [], + isSystemMessage: false, + createdAt: new Date(), + }; + + mockChatRepository + .findById + .mockResolvedValue(mockChat as any); + + mockMessageRepository + .create + .mockResolvedValue(mockSavedMessage as any); + + const result = + await service.sendMessage( + CHAT_ID, + CLIENT_ID, + UserRole.CLIENT, + 'Mensagem normal', + ); + + expect(result) + .toEqual(mockSavedMessage); + + expect( + mockMessageRepository.create + ).toHaveBeenCalledWith({ + chatId: CHAT_ID, + senderId: CLIENT_ID, + content: 'Mensagem normal', + isSystemMessage: false, + fileIds: [], + }); + }); + + /* =========================== + TESTE: CHAT NÃO EXISTE + =========================== */ + + it('should throw NotFoundException when chat does not exist', async () => { + + mockChatRepository + .findById + .mockResolvedValue(null); + + await expect( + service.sendMessage( + 'invalid-chat', + CLIENT_ID, + UserRole.CLIENT, + 'Teste', + ['file-001'], + ) + ).rejects.toThrow( + NotFoundException, + ); + }); + +}); \ No newline at end of file diff --git a/backend/src/modules/chat/application/chat.service.spec.ts b/backend/src/modules/chat/application/chat.service.spec.ts index b423c41..90f6eb9 100644 --- a/backend/src/modules/chat/application/chat.service.spec.ts +++ b/backend/src/modules/chat/application/chat.service.spec.ts @@ -182,6 +182,7 @@ describe('ChatService', () => { senderId: CLIENT_ID, content: 'Olá, preciso de ajuda!', isSystemMessage: false, + fileIds: [], }); }); diff --git a/backend/src/modules/chat/application/chat.service.ts b/backend/src/modules/chat/application/chat.service.ts index 9c8651b..c2f2a6f 100644 --- a/backend/src/modules/chat/application/chat.service.ts +++ b/backend/src/modules/chat/application/chat.service.ts @@ -45,6 +45,7 @@ export class ChatService { senderId: string, senderRole: UserRole, content: string, + fileIds?: string[], ): Promise { const chat = await this.chatRepository.findById(chatId); if (!chat) { @@ -63,6 +64,7 @@ export class ChatService { senderId, content, isSystemMessage: false, + fileIds: fileIds || [], }); } diff --git a/backend/src/modules/file/application/file.service.ts b/backend/src/modules/file/application/file.service.ts index 3e53c3b..cbcd5ca 100644 --- a/backend/src/modules/file/application/file.service.ts +++ b/backend/src/modules/file/application/file.service.ts @@ -18,11 +18,13 @@ export class FileService { async createFile( file: Express.Multer.File, + subFolder: string, uploadedBy?: string ): Promise { const fileData = this.storage.save( file, + subFolder, uploadedBy ); diff --git a/backend/src/modules/file/infra/storage/local.storage.ts b/backend/src/modules/file/infra/storage/local.storage.ts index 0bc8314..efa2691 100644 --- a/backend/src/modules/file/infra/storage/local.storage.ts +++ b/backend/src/modules/file/infra/storage/local.storage.ts @@ -1,13 +1,34 @@ import { Injectable } from '@nestjs/common'; import { FileEntity } from '../../domain/file.entity'; +import { existsSync, mkdirSync, renameSync } from 'fs'; +import { join } from 'path'; @Injectable() export class LocalStorage { save( file: Express.Multer.File, + subFolder: string, uploadedBy?: string ): FileEntity { + const uploadPath = + join( + 'uploads', + subFolder + ); + if (!existsSync(uploadPath)) { + mkdirSync( + uploadPath, + { recursive: true } + ); + } + const newPath = + join( + uploadPath, file.filename + ); + renameSync( + file.path, newPath + ); return { id: undefined, filename: file.filename, diff --git a/backend/src/modules/file/presentation/dtos/uploadChatFileParamsDTO.ts b/backend/src/modules/file/presentation/dtos/uploadChatFileParamsDTO.ts new file mode 100644 index 0000000..968b02b --- /dev/null +++ b/backend/src/modules/file/presentation/dtos/uploadChatFileParamsDTO.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class UploadChatFileParamsDTO { + + @ApiProperty({ + example: '8156f5c6-9b82-4be9-881e-641082d0bb56' + }) + @IsString() + chatId: string; + +} \ No newline at end of file diff --git a/backend/src/modules/file/presentation/file.controller.ts b/backend/src/modules/file/presentation/file.controller.ts index b96a689..87f775d 100644 --- a/backend/src/modules/file/presentation/file.controller.ts +++ b/backend/src/modules/file/presentation/file.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, UploadedFile, UseInterceptors, UseGuards, Body, Req } from '@nestjs/common'; +import { Controller, Post, UploadedFile, UseInterceptors, UseGuards, Body, Req, Param } from '@nestjs/common'; import { ApiBearerAuth, ApiConsumes, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express'; import { JwtGuard } from '../../auth/guards/jwt.guard'; @@ -9,6 +9,7 @@ import { FileService } from '../application/file.service'; import { UploadFileDTO } from './dtos/uploadFileDTO'; import { multerConfig } from '../config/multer.config'; import type { Express, Request } from 'express'; +import { UploadChatFileParamsDTO } from './dtos/uploadChatFileParamsDTO'; @ApiTags('Files') @ApiBearerAuth() @@ -16,13 +17,13 @@ import type { Express, Request } from 'express'; export class FileController { constructor( private fileService: FileService - ) {} + ) { } @Post() - @UseGuards( JwtGuard, RolesGuard ) - @Roles( UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT ) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) @ApiOperation({ summary: 'Upload de arquivo' }) - @ApiConsumes( 'multipart/form-data' ) + @ApiConsumes('multipart/form-data') @ApiBody({ schema: { type: 'object', @@ -35,10 +36,7 @@ export class FileController { } }) @UseInterceptors( - FileInterceptor( - 'file', - multerConfig - ) + FileInterceptor('file', multerConfig) ) async uploadFile( @UploadedFile() @@ -46,15 +44,46 @@ export class FileController { @Body() dto: UploadFileDTO, @Req() - req: Request & { - user: { id: string } + req: Request & { user: { id: string } } + ) { + const userId = req.user.id; + return this.fileService.createFile(file, userId); + } + + @Post('chat/:chatId') + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) + @UseInterceptors( + FileInterceptor( + 'file', + multerConfig + ) + ) + @ApiOperation({ + summary: 'Upload de arquivo para chat' + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary' + } + } } + }) + async uploadChatFile( + @UploadedFile() + file: Express.Multer.File, + @Req() + req: Request & { user: { id: string } }, + @Param() + params: UploadChatFileParamsDTO ) { - const userId = - req.user.id; - return this.fileService.createFile( - file, - userId - ); + const userId = req.user.id; + const subFolder = `chat/${params.chatId}`; + return this.fileService.createFile(file, subFolder, userId); } } \ No newline at end of file diff --git a/backend/src/modules/triage/domain/triage-result.type.ts b/backend/src/modules/triage/domain/triage-result.type.ts deleted file mode 100644 index c2f2f78..0000000 --- a/backend/src/modules/triage/domain/triage-result.type.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type TriageResult = { - category: string; - confidence: number; - source: 'rule' | 'nlp' | 'fallback'; -}; diff --git a/backend/src/modules/triage/presentation/triage.controller.spec.ts b/backend/src/modules/triage/presentation/triage.controller.spec.ts index 59d1eed..dd6f335 100644 --- a/backend/src/modules/triage/presentation/triage.controller.spec.ts +++ b/backend/src/modules/triage/presentation/triage.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TriageController } from './triage.controller'; import { TriageService } from '../application/triage.service'; -import { TicketCategory } from '../../shared/domain/ticket-category.enum'; +import { Category } from '../domain/category.entity'; describe('TriageController', () => { let controller: TriageController; @@ -12,71 +12,121 @@ describe('TriageController', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [TriageController], - providers: [ - { - provide: TriageService, - useValue: mockTriageService, - }, - ], - }).compile(); - - controller = module.get(TriageController); - triageService = module.get(TriageService); + const module: TestingModule = + await Test.createTestingModule({ + controllers: [TriageController], + providers: [ + { + provide: TriageService, + useValue: mockTriageService, + }, + ], + }).compile(); + + controller = + module.get( + TriageController + ); + + triageService = + module.get(TriageService); }); afterEach(() => { jest.clearAllMocks(); }); - it('deve classificar corretamente uma descrição', async () => { - const mockResponse = { - value: TicketCategory.WEB_APP, - confidence: 0.95, - source: 'rule', - }; - - triageService.classify.mockResolvedValue(mockResponse as any); - - const body = { - description: 'site não abre', - }; - - const result = await controller.classify(body); - - expect(triageService.classify).toHaveBeenCalledWith(body.description); - expect(result).toEqual(mockResponse); - }); - - it('deve retornar fallback quando service retornar OTHER', async () => { - const mockResponse = { - value: TicketCategory.OTHER, - confidence: 0.5, - source: 'fallback', - }; - - triageService.classify.mockResolvedValue(mockResponse as any); - - const body = { - description: 'qualquer coisa aleatória', - }; - - const result = await controller.classify(body); - - expect(result.value).toBe(TicketCategory.OTHER); - expect(result.source).toBe('fallback'); - }); - - it('deve chamar o service apenas uma vez', async () => { - triageService.classify.mockResolvedValue({ - value: TicketCategory.BI, - confidence: 0.9, - source: 'rule', - } as any); - - await controller.classify({ description: 'erro no dashboard' }); - - expect(triageService.classify).toHaveBeenCalledTimes(1); - }); -}); + it( + 'deve classificar corretamente uma descrição', + async () => { + + const mockResponse = + new Category( + 'WEB_APP', + 0.95, + 'rule' + ); + + triageService.classify + .mockResolvedValue( + mockResponse + ); + + const body = { + description: 'site não abre', + }; + + const result = + await controller.classify( + body + ); + + expect( + triageService.classify + ).toHaveBeenCalledWith( + body.description + ); + + expect(result) + .toEqual(mockResponse); + } + ); + + it( + 'deve retornar fallback quando categoria for OTHER', + async () => { + + const mockResponse = + new Category( + 'OTHER', + 0.5, + 'fallback' + ); + + triageService.classify + .mockResolvedValue( + mockResponse + ); + + const body = { + description: + 'qualquer coisa aleatória', + }; + + const result = + await controller.classify( + body + ); + + expect(result.category) + .toBe('OTHER'); + + expect(result.source) + .toBe('fallback'); + } + ); + + it( + 'deve chamar o service apenas uma vez', + async () => { + + triageService.classify + .mockResolvedValue( + new Category( + 'BI', + 0.9, + 'rule' + ) + ); + + await controller.classify({ + description: + 'erro no dashboard', + }); + + expect( + triageService.classify + ).toHaveBeenCalledTimes(1); + } + ); +}); \ No newline at end of file