From 30f7981916964862537b5fcf701c6f8ef16c171d Mon Sep 17 00:00:00 2001 From: Gabriel Viell Castilho Date: Fri, 24 Apr 2026 18:44:19 -0300 Subject: [PATCH 1/2] feat: image profile --- CONTRIBUTING.md | 144 +++++++++++++----- .../modules/file/application/file.service.ts | 51 ++++++- .../file/config/profile-multer.config.ts | 45 ++++++ backend/src/modules/file/file.module.ts | 6 +- .../file/infra/storage/local.storage.ts | 27 +++- .../file/presentation/file.controller.ts | 40 ++++- backend/src/modules/user/user.interface.ts | 1 + backend/src/modules/user/user.schema.ts | 3 + backend/src/modules/user/user.service.ts | 1 + 9 files changed, 267 insertions(+), 51 deletions(-) create mode 100644 backend/src/modules/file/config/profile-multer.config.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcf7a2b..5fa7d02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,71 +1,135 @@ # Como Contribuir - Seu Passaporte de Entrada -Estamos felizes em receber você aqui e saber que está interessado em contribuir para o nosso projeto. Como um projeto de código aberto, cada contribuição é valorizada e ajuda a impulsionar o crescimento e a qualidade do nosso trabalho. Este guia foi criado para orientá-lo sobre como você pode participar e fazer parte da nossa comunidade de desenvolvimento. Estamos ansiosos para ver suas contribuições e trabalhar juntos para tornar nosso projeto ainda melhor! +Estamos felizes em receber você aqui e saber que está interessado em contribuir para o nosso projeto. Como um projeto de código aberto, cada contribuição é valorizada e ajuda a impulsionar o crescimento e a qualidade do nosso trabalho. Este guia foi criado para orientá-lo sobre como você pode participar e fazer parte da nossa comunidade de desenvolvimento. + +--- ## Código de Conduta Para garantir um ambiente respeitável e inclusivo, leia e siga nosso [Código de Conduta](./CODE_OF_CONDUCT.md). +--- + ## Começando a Contribuir -Contribuir para o nosso projeto é fácil e estamos ansiosos para receber suas contribuições! Antes de entrarmos nos passos para instalação da aplicação, você precisará configurar algumas ferramentas e preparar seu ambiente de desenvolvimento. +Antes de iniciar, você precisará configurar seu ambiente de desenvolvimento. -Aqui está o que você precisa: +### Requisitos -- Uma conta no [GitHub](https://github.com/). -- O *version control system* [Git](https://git-scm.com/) instalado. -- Um IDE para o desenvolvimento. Recomendamos o [Visual Studio Code](https://code.visualstudio.com). -- O [Node.js v22.11.0](https://nodejs.org/en) ou superior. -- [MongoDB Community Server](https://www.mongodb.com/try/download/community). +- Uma conta no GitHub +- Git instalado +- IDE (recomendado: VS Code) +- Node.js v22.11.0 ou superior +- MongoDB Community Server + +--- ## Instalação ### 1. Clonar o Repositório -O primeiro passo é clonar o repositório do projeto para o seu ambiente local. +```bash +git clone https://github.com/Bug-Busters-F/ProDesk-backend +cd ProDesk-backend/backend +``` + +--- -1. Abra um terminal. +### 2. Instalar Dependências e Variáveis de Ambiente -2. Execute o seguinte comando para clonar o repositório: - ```bash - git clone https://github.com/Bug-Busters-F/ProDesk-backend - ``` +```bash +npm install +``` -3. Navegue até o diretório do projeto: - ```bash - cd ProDesk-backend\\backend - ``` +Copie o arquivo de variáveis: -### 2. Instalar Dependências e Variáveis de Ambiente +```bash +cp .env.example .env +``` + +Edite o `.env`: + +```env +# DATABASE +MONGO_URI=mongodb://localhost:27017/prodesk + +# APP +PORT=3000 +NODE_ENV=development + +# EMAIL (Gmail) +EMAIL_USER=seuemail@gmail.com +EMAIL_PASS=sua_senha_de_app +``` + +--- + +## Como configurar EMAIL_USER e EMAIL_PASS + +Para que o envio de emails funcione (ex: recuperação de senha), é necessário configurar uma conta do Gmail com senha de aplicativo. + +### Importante + +- Não utilize sua senha normal do Gmail +- É obrigatório ter a verificação em duas etapas ativada -Com o ambiente configurado, basta instalar as dependências do Node.js e iniciar o servidor de desenvolvimento. +--- -1. Instale as dependências do projeto: - ```sh - npm install - ``` +### Passo a passo -2. Configure as variáveis de ambiente +1. Acesse: +https://myaccount.google.com/apppasswords - ```sh - cp .env.example .env - ``` +2. Faça login na sua conta Google -3. Abra o arquivo `.env` e edite a conexão com o banco de dados. +3. Ative a verificação em duas etapas - ```sh - # DATABASE - MONGO_URI=mongodb://localhost:27017/prodesk +4. Em Selecionar app, escolha: Mail - # APP - PORT=3000 - NODE_ENV=development - ``` +5. Em Selecionar dispositivo, escolha: Outro (Personalizado) -### 3. Rodar o Projeto +6. Informe um nome, por exemplo: NestJS -Execute a aplicação em modo de desenvolvimento: +7. Clique em Gerar -```sh +--- + +### Resultado + +O Google irá gerar um código semelhante a: + +``` +abcd efgh ijkl mnop +``` + +Copie esse código e utilize no `.env` sem espaços: + +```env +EMAIL_PASS=abcdefghijklmnop +``` + +--- + +## 3. Rodar o Projeto + +```bash npm run start:dev -``` \ No newline at end of file +``` + +--- + +## Resultado esperado + +- Aplicação rodando em http://localhost:3000 +- Swagger disponível em http://localhost:3000/api +- Envio de email funcionando corretamente + +## Primeiro acesso (usuário admin padrão) + +Ao iniciar a aplicação pela primeira vez, um usuário administrador é criado automaticamente com as seguintes credenciais: + +```json +{ + "email": "admin@pro4tech.com", + "password": "Pro4Tech" +} \ No newline at end of file diff --git a/backend/src/modules/file/application/file.service.ts b/backend/src/modules/file/application/file.service.ts index cbcd5ca..7cbd933 100644 --- a/backend/src/modules/file/application/file.service.ts +++ b/backend/src/modules/file/application/file.service.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; 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'; +import { UserDocument } from '../../user/user.schema'; @Injectable() export class FileService { @@ -12,6 +13,9 @@ export class FileService { private readonly fileModel: Model, + @InjectModel('User') + private readonly userModel: Model, + private readonly storage: LocalStorage ) {} @@ -50,6 +54,41 @@ export class FileService { ); } +async uploadProfileImage( + file: Express.Multer.File, + userId: string + ): Promise { + + const fileData = this.storage.save( + file, + `profile/${userId}`, + userId + ); + + 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(); + + const user = await this.userModel.findById(userId); + + if (user?.profileImage) { + this.storage.delete(user.profileImage); + } + + await this.userModel.findByIdAndUpdate(userId, { + profileImage: fileData.path + }); + + return this._mapToEntity(savedFile); + } + private _mapToEntity( file: any ): FileEntity { @@ -68,4 +107,14 @@ export class FileService { file.createdAt }; } + + async getProfileImage(userId: string): Promise { + const user = await this.userModel.findById(userId); + + if (!user || !user.profileImage) { + throw new NotFoundException('Profile image not found'); + } + + return user.profileImage; +} } \ No newline at end of file diff --git a/backend/src/modules/file/config/profile-multer.config.ts b/backend/src/modules/file/config/profile-multer.config.ts new file mode 100644 index 0000000..7d4e983 --- /dev/null +++ b/backend/src/modules/file/config/profile-multer.config.ts @@ -0,0 +1,45 @@ +import { diskStorage } from 'multer'; +import { extname } from 'path'; +import { BadRequestException } from '@nestjs/common'; +import * as fs from 'fs'; + +export const profileMulterConfig = { + storage: diskStorage({ + destination: (req: any, file, cb) => { + const userId = req.user?.id || 'temp'; + const path = `./uploads/profile/${userId}`; + + fs.mkdirSync(path, { recursive: true }); + + cb(null, path); + }, + + filename: (req, file, cb) => { + const uniqueSuffix = + Date.now() + '-' + Math.round(Math.random() * 1e9); + + const extension = extname(file.originalname); + + cb(null, `${uniqueSuffix}${extension}`); + }, + }), + + limits: { + fileSize: 2 * 1024 * 1024, // 2MB + }, + + fileFilter: (req, file, cb) => { + const allowed = ['image/jpeg', 'image/png', 'image/jpg']; + + if (!allowed.includes(file.mimetype)) { + return cb( + new BadRequestException( + 'Only image files (jpg, jpeg, png) are allowed', + ), + false, + ); + } + + cb(null, true); + }, +}; \ 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 caa490f..59cc6d9 100644 --- a/backend/src/modules/file/file.module.ts +++ b/backend/src/modules/file/file.module.ts @@ -4,6 +4,7 @@ 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'; +import { UserSchema } from '../user/user.schema'; @Module({ imports: [ @@ -11,7 +12,10 @@ import { LocalStorage } from './infra/storage/local.storage'; { name: 'File', schema: FileSchema - } + }, + { name: 'User', + schema: UserSchema + }, ]) ], controllers: [ diff --git a/backend/src/modules/file/infra/storage/local.storage.ts b/backend/src/modules/file/infra/storage/local.storage.ts index efa2691..d478c31 100644 --- a/backend/src/modules/file/infra/storage/local.storage.ts +++ b/backend/src/modules/file/infra/storage/local.storage.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { FileEntity } from '../../domain/file.entity'; import { existsSync, mkdirSync, renameSync } from 'fs'; import { join } from 'path'; +import * as fs from 'fs'; + @Injectable() export class LocalStorage { @@ -22,22 +24,31 @@ export class LocalStorage { { recursive: true } ); } - const newPath = - join( - uploadPath, file.filename - ); - renameSync( - file.path, newPath - ); + const newPath = join(uploadPath, file.filename); + + renameSync(file.path, newPath); + return { id: undefined, filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, size: file.size, - path: file.path, + path: newPath, uploadedBy, createdAt: new Date() }; } + +delete(filePath: string): void { + try { + const normalizedPath = filePath.replace(/^https?:\/\/.*?\//, ''); + + if (fs.existsSync(normalizedPath)) { + fs.unlinkSync(normalizedPath); + } + } catch (error) { + console.error('Error deleting file:', error); + } + } } \ 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 87f775d..d925c24 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, Param } from '@nestjs/common'; +import { Controller, Post, UploadedFile, UseInterceptors, UseGuards, Body, Req, Param, Get, Res } 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'; @@ -10,6 +10,8 @@ import { UploadFileDTO } from './dtos/uploadFileDTO'; import { multerConfig } from '../config/multer.config'; import type { Express, Request } from 'express'; import { UploadChatFileParamsDTO } from './dtos/uploadChatFileParamsDTO'; +import { profileMulterConfig } from '../config/profile-multer.config'; +import type { Response } from 'express'; @ApiTags('Files') @ApiBearerAuth() @@ -86,4 +88,40 @@ export class FileController { const subFolder = `chat/${params.chatId}`; return this.fileService.createFile(file, subFolder, userId); } + + @Post('profile') + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) + @UseInterceptors(FileInterceptor('file', profileMulterConfig)) + @ApiOperation({ summary: 'Upload de foto de perfil do usuário' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + async uploadProfileImage( + @UploadedFile() file: Express.Multer.File, + @Req() req: Request & { user: { id: string } }, + ) { + const userId = req.user.id; + + return this.fileService.uploadProfileImage(file, userId); + } + + @Get('profile/:id') + async getProfileImage( + @Param('id') userId: string, + @Res() res: Response + ) { + const filePath = await this.fileService.getProfileImage(userId); + + return res.sendFile(filePath, { root: './' }); +} } \ No newline at end of file diff --git a/backend/src/modules/user/user.interface.ts b/backend/src/modules/user/user.interface.ts index 774e0f2..4dbb1a2 100644 --- a/backend/src/modules/user/user.interface.ts +++ b/backend/src/modules/user/user.interface.ts @@ -9,4 +9,5 @@ export interface UserDetails { role: UserRole; company?: CompanyDetails; categories?: CategoryDetails[]; + profileImage?: string; } diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index eaf937b..484a334 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -44,6 +44,9 @@ export class User { categories: (Types.ObjectId | Category)[]; createdAt?: Date; + + @Prop({ required: false }) + profileImage?: string; } export const UserSchema = SchemaFactory.createForClass(User); \ No newline at end of file diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 6bcf2d3..6d5e3e7 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -199,6 +199,7 @@ export class UserService { name: user.name, email: user.email, role: user.role, + profileImage: user.profileImage, company: user.companyId ? { From 8d77c10d9d5111cc97ac04c617582c843b97ab70 Mon Sep 17 00:00:00 2001 From: Gabriel Viell Castilho Date: Fri, 24 Apr 2026 19:13:07 -0300 Subject: [PATCH 2/2] feat: company image --- .../src/modules/company/company.interface.ts | 1 + backend/src/modules/company/company.schema.ts | 3 + .../src/modules/company/company.service.ts | 1 + .../modules/file/application/file.service.ts | 74 +++++++++++++++++-- backend/src/modules/file/file.module.ts | 5 ++ .../file/presentation/file.controller.ts | 73 +++++++++++++++--- 6 files changed, 142 insertions(+), 15 deletions(-) diff --git a/backend/src/modules/company/company.interface.ts b/backend/src/modules/company/company.interface.ts index 08f3233..2e77476 100644 --- a/backend/src/modules/company/company.interface.ts +++ b/backend/src/modules/company/company.interface.ts @@ -2,4 +2,5 @@ export interface CompanyDetails { id: string; name: string; cnpj: string; + logo?: string; } diff --git a/backend/src/modules/company/company.schema.ts b/backend/src/modules/company/company.schema.ts index 4837fb9..fe66833 100644 --- a/backend/src/modules/company/company.schema.ts +++ b/backend/src/modules/company/company.schema.ts @@ -10,6 +10,9 @@ export class Company { @Prop({ required: true, unique: true }) cnpj: string; + + @Prop({ required: false }) + logo?: string; } export const CompanySchema = SchemaFactory.createForClass(Company); diff --git a/backend/src/modules/company/company.service.ts b/backend/src/modules/company/company.service.ts index 1c02881..c925ed2 100644 --- a/backend/src/modules/company/company.service.ts +++ b/backend/src/modules/company/company.service.ts @@ -89,6 +89,7 @@ export class CompanyService { id: company._id.toString(), name: company.name, cnpj: company.cnpj, + logo: company.logo, }; } } diff --git a/backend/src/modules/file/application/file.service.ts b/backend/src/modules/file/application/file.service.ts index 7cbd933..f007470 100644 --- a/backend/src/modules/file/application/file.service.ts +++ b/backend/src/modules/file/application/file.service.ts @@ -5,6 +5,8 @@ import { FileDocument } from '../infra/schemas/file.schema'; import { FileEntity } from '../domain/file.entity'; import { LocalStorage } from '../infra/storage/local.storage'; import { UserDocument } from '../../user/user.schema'; +import * as fs from 'fs'; +import { CompanyDocument } from '../../company/company.schema'; @Injectable() export class FileService { @@ -16,6 +18,9 @@ export class FileService { @InjectModel('User') private readonly userModel: Model, + @InjectModel('Company') + private readonly companyModel: Model, + private readonly storage: LocalStorage ) {} @@ -108,13 +113,70 @@ async uploadProfileImage( }; } - async getProfileImage(userId: string): Promise { - const user = await this.userModel.findById(userId); + async getProfileImage(userId: string): Promise { + const user = await this.userModel.findById(userId); + + if (!user || !user.profileImage) { + return null; + } + + if (!fs.existsSync(user.profileImage)) { + return null; + } - if (!user || !user.profileImage) { - throw new NotFoundException('Profile image not found'); + return user.profileImage; } - return user.profileImage; -} + async uploadCompanyLogo( + file: Express.Multer.File, + companyId: string + ): Promise { + + const fileData = this.storage.save( + file, + `company/${companyId}`, + companyId + ); + + const newFile = new this.fileModel({ + filename: fileData.filename, + originalname: fileData.originalname, + mimetype: fileData.mimetype, + size: fileData.size, + path: fileData.path, + uploadedBy: companyId + }); + + const savedFile = await newFile.save(); + + const company = await this.companyModel.findById(companyId); + + + if (company?.logo) { + this.storage.delete(company.logo); + } + + await this.companyModel.findByIdAndUpdate(companyId, { + logo: fileData.path + }); + + return this._mapToEntity(savedFile); + } + + async getCompanyLogo(companyId: string): Promise { + const company = await this.companyModel.findById(companyId); + + if (!company || !company.logo) { + return null; + } + + if (!fs.existsSync(company.logo)) { + await this.companyModel.findByIdAndUpdate(companyId, { + logo: null + }); + return null; + } + + return company.logo; + } } \ 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 59cc6d9..3e3f733 100644 --- a/backend/src/modules/file/file.module.ts +++ b/backend/src/modules/file/file.module.ts @@ -5,6 +5,7 @@ import { FileService } from './application/file.service'; import { FileSchema } from './infra/schemas/file.schema'; import { LocalStorage } from './infra/storage/local.storage'; import { UserSchema } from '../user/user.schema'; +import { CompanySchema } from '../company/company.schema'; @Module({ imports: [ @@ -16,6 +17,10 @@ import { UserSchema } from '../user/user.schema'; { name: 'User', schema: UserSchema }, + { + name: 'Company', + schema: CompanySchema + }, ]) ], controllers: [ diff --git a/backend/src/modules/file/presentation/file.controller.ts b/backend/src/modules/file/presentation/file.controller.ts index d925c24..38235bd 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, Param, Get, Res } from '@nestjs/common'; +import { Controller, Post, UploadedFile, UseInterceptors, UseGuards, Body, Req, Param, Get, Res, BadRequestException } 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'; @@ -110,18 +110,73 @@ export class FileController { @UploadedFile() file: Express.Multer.File, @Req() req: Request & { user: { id: string } }, ) { + if (!file) { + throw new BadRequestException('File is required'); + } + const userId = req.user.id; return this.fileService.uploadProfileImage(file, userId); } - @Get('profile/:id') - async getProfileImage( - @Param('id') userId: string, - @Res() res: Response - ) { - const filePath = await this.fileService.getProfileImage(userId); + @Get('profile/:id') + async getProfileImage( + @Param('id') userId: string, + @Res() res: Response + ) { + const filePath = await this.fileService.getProfileImage(userId); + + if (!filePath) { + return res.status(200).json({ + message: 'User has no profile image', + profileImage: null + }); + } + + return res.sendFile(filePath, { root: './' }); + } + + @Post('company/:companyId') + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @UseInterceptors(FileInterceptor('file', profileMulterConfig)) + @ApiOperation({ summary: 'Upload de logo da empresa' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + async uploadCompanyLogo( + @UploadedFile() file: Express.Multer.File, + @Param('companyId') companyId: string, + ) { + if (!file) { + throw new BadRequestException('File is required'); + } + return this.fileService.uploadCompanyLogo(file, companyId); + } + + @Get('company/:companyId') + async getCompanyLogo( + @Param('companyId') companyId: string, + @Res() res: Response + ) { + const filePath = await this.fileService.getCompanyLogo(companyId); - return res.sendFile(filePath, { root: './' }); -} + if (!filePath) { + return res.status(200).json({ + message: 'Company has no logo', + logo: null + }); + } + + return res.sendFile(filePath, { root: './' }); + } } \ No newline at end of file