From ee966d330683c65abaa7554d39a99e0dc920bb41 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Thu, 28 May 2026 13:37:53 -0300 Subject: [PATCH] =?UTF-8?q?DOCS:=20Adicionado=20Swagger=20para=20documenta?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20rotas=20da=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 94 +++++++++- package.json | 2 + src/app.controller.ts | 7 + src/main.ts | 16 +- src/modules/admin/admin-access.controller.ts | 116 +++++++++++++ src/modules/admin/agent-notes.controller.ts | 27 +++ .../admin/agent-presence.controller.ts | 26 +++ .../admin/customer-contacts.controller.ts | 32 ++++ .../admin/knowledge-base.controller.ts | 114 ++++++++++++ src/modules/auth/auth.controller.ts | 35 ++++ src/modules/whatsapp/whatsapp.controller.ts | 164 ++++++++++++++++++ 11 files changed, 631 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b69a9b..ff5a5db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@nestjs/core": "^11.1.19", "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.21", + "@nestjs/swagger": "^11.4.4", "@nestjs/websockets": "^11.1.21", "dotenv": "^16.6.1", "jsonwebtoken": "^9.0.3", @@ -21,6 +22,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "socket.io": "^4.8.3", + "swagger-ui-express": "^5.0.1", "whatsapp-web.js": "^1.34.7", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" @@ -719,6 +721,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "11.0.21", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.21.tgz", @@ -852,6 +860,26 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.19", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.19.tgz", @@ -917,6 +945,39 @@ } } }, + "node_modules/@nestjs/swagger": { + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.4.tgz", + "integrity": "sha512-VaIo1ruV2G7b+f2zPzkBSUNy9a/WQ9sg8TLKhWlrTfg4O6U10M/PA7Xi6XMXadOVhwOqoesijba8jH3i/3adrA==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "4.1.1", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.6" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/websockets": { "version": "11.1.21", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.21.tgz", @@ -1046,6 +1107,13 @@ "node": ">=12" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -3201,6 +3269,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4174,7 +4243,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "devOptional": true, "license": "MIT" }, "node_modules/lodash.includes": { @@ -5990,6 +6058,30 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.6", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz", + "integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/package.json b/package.json index 648bee3..7ed737a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@nestjs/core": "^11.1.19", "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.21", + "@nestjs/swagger": "^11.4.4", "@nestjs/websockets": "^11.1.21", "dotenv": "^16.6.1", "jsonwebtoken": "^9.0.3", @@ -23,6 +24,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "socket.io": "^4.8.3", + "swagger-ui-express": "^5.0.1", "whatsapp-web.js": "^1.34.7", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" diff --git a/src/app.controller.ts b/src/app.controller.ts index f4ff396..36f2673 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,7 +1,14 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +@ApiTags('Saude') @Controller() export class AppController { + @ApiOperation({ + summary: 'Verifica se a API esta online', + description: 'Endpoint simples usado por deploy, monitoramento e testes manuais para confirmar que o backend esta respondendo.', + }) + @ApiResponse({ status: 200, description: 'API online.' }) @Get('health') health() { return { status: 'ok' }; diff --git a/src/main.ts b/src/main.ts index 71c6a95..8e323f2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { json, urlencoded } from 'express'; import { AppModule } from './app.module'; import { loadEnv } from './infra/config/load-env'; @@ -30,13 +31,26 @@ async function bootstrap() { callback(null, true); return; } - callback(new Error(`Origem CORS nao permitida: ${origin}`)); + Logger.warn(`Origem CORS bloqueada: ${origin}`, 'Cors'); + callback(null, false); }, credentials: true, }); + const swaggerConfig = new DocumentBuilder() + .setTitle('Omnichannel Backend API') + .setDescription('Documentacao das rotas da API do Omnichannel Sothis.') + .setVersion('1.0.0') + .addBearerAuth() + .build(); + const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('docs', app, swaggerDocument, { + jsonDocumentUrl: 'docs/openapi.json', + }); + await app.listen(port); Logger.log(`Backend ouvindo na porta ${port}`, 'Bootstrap'); + Logger.log(`Swagger disponivel em /docs`, 'Bootstrap'); } bootstrap(); diff --git a/src/modules/admin/admin-access.controller.ts b/src/modules/admin/admin-access.controller.ts index 82445f7..b1b94c0 100644 --- a/src/modules/admin/admin-access.controller.ts +++ b/src/modules/admin/admin-access.controller.ts @@ -1,40 +1,90 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AdminAccessService } from './admin-access.service'; +@ApiTags('Administracao') @Controller('admin/access') export class AdminAccessController { constructor(private readonly adminAccessService: AdminAccessService) {} + @ApiOperation({ + summary: 'Lista opcoes administrativas', + description: 'Retorna dados auxiliares usados nos formularios administrativos, como perfis, areas e opcoes de acesso.', + }) + @ApiResponse({ status: 200, description: 'Opcoes administrativas retornadas.' }) @Get('options') getOptions() { return this.adminAccessService.getOptions(); } + @ApiOperation({ + summary: 'Consulta visao geral operacional', + description: 'Retorna indicadores consolidados para o dashboard administrativo.', + }) @Get('overview') getOverview() { return this.adminAccessService.getOverview(); } + @ApiOperation({ + summary: 'Consulta ranking de atendentes', + description: 'Lista desempenho de atendentes, opcionalmente filtrando por area.', + }) + @ApiQuery({ name: 'areaId', required: false, description: 'ID da area para filtrar o ranking.' }) @Get('ranking') getRanking(@Query('areaId') areaId?: string) { return this.adminAccessService.getAttendantRanking(areaId ? Number(areaId) : null); } + @ApiOperation({ + summary: 'Lista auditoria administrativa', + description: 'Retorna eventos de auditoria paginados para rastrear alteracoes e acoes relevantes no painel.', + }) + @ApiQuery({ name: 'page', required: false, description: 'Pagina da consulta. Padrao: 1.' }) + @ApiQuery({ name: 'limit', required: false, description: 'Quantidade por pagina. Padrao: 100.' }) @Get('audit') listAuditLogs(@Query('page') page?: string, @Query('limit') limit?: string) { return this.adminAccessService.listAuditLogs(Number(page || 1), Number(limit || 100)); } + @ApiOperation({ + summary: 'Lista conteudos da IA', + description: 'Retorna os documentos e conteudos cadastrados para alimentar a base de conhecimento da IA.', + }) @Get('ai-contents') listAiContents() { return this.adminAccessService.listAiContents(); } + @ApiOperation({ + summary: 'Baixa arquivo de conteudo da IA', + description: 'Retorna o arquivo original de um conteudo cadastrado na base de conhecimento.', + }) + @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) @Get('ai-contents/:id/file') getAiContentFile(@Param('id') id: string) { return this.adminAccessService.getAiContentFile(Number(id)); } + @ApiOperation({ + summary: 'Cria conteudo da IA', + description: 'Cadastra um documento, anotacao ou arquivo para apoiar respostas e contexto da IA.', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + title: { type: 'string', example: 'Politica de ferias' }, + areaId: { type: 'number', nullable: true, example: 2 }, + filename: { type: 'string', nullable: true, example: 'ferias.pdf' }, + mimetype: { type: 'string', nullable: true, example: 'application/pdf' }, + fileSize: { type: 'number', nullable: true, example: 102400 }, + contentBase64: { type: 'string', nullable: true, description: 'Arquivo em base64.' }, + notes: { type: 'string', nullable: true, example: 'Regras internas de ferias.' }, + createdByUserId: { type: 'number', nullable: true, example: 10 }, + }, + }, + }) @Post('ai-contents') createAiContent(@Body() body: { title?: string; @@ -49,21 +99,50 @@ export class AdminAccessController { return this.adminAccessService.createAiContent(body); } + @ApiOperation({ + summary: 'Remove conteudo da IA', + description: 'Exclui um conteudo cadastrado na base de conhecimento.', + }) + @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) @Delete('ai-contents/:id') deleteAiContent(@Param('id') id: string) { return this.adminAccessService.deleteAiContent(Number(id)); } + @ApiOperation({ + summary: 'Lista areas de atendimento', + description: 'Retorna as areas usadas para roteamento, permissoes, especialidades e indicadores.', + }) @Get('areas') listAreas() { return this.adminAccessService.listAreas(); } + @ApiOperation({ + summary: 'Cria area de atendimento', + description: 'Cadastra uma nova area para roteamento de conversas e organizacao de usuarios.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['nome'], + properties: { + nome: { type: 'string', example: 'Recursos Humanos' }, + descricao: { type: 'string', nullable: true, example: 'Atendimentos de RH.' }, + responsavelUsuarioId: { type: 'number', nullable: true, example: 10 }, + }, + }, + }) @Post('areas') createArea(@Body() body: { nome: string; descricao?: string | null; responsavelUsuarioId?: number | null }) { return this.adminAccessService.createArea(body); } + @ApiOperation({ + summary: 'Atualiza area de atendimento', + description: 'Altera dados cadastrais, responsavel ou status de uma area.', + }) + @ApiParam({ name: 'id', description: 'ID da area.' }) @Put('areas/:id') updateArea( @Param('id') id: string, @@ -72,16 +151,53 @@ export class AdminAccessController { return this.adminAccessService.updateArea(Number(id), body); } + @ApiOperation({ + summary: 'Remove area de atendimento', + description: 'Exclui ou desativa uma area, conforme regra de negocio aplicada pelo service.', + }) + @ApiParam({ name: 'id', description: 'ID da area.' }) @Delete('areas/:id') deleteArea(@Param('id') id: string) { return this.adminAccessService.deleteArea(Number(id)); } + @ApiOperation({ + summary: 'Lista usuarios', + description: 'Retorna usuarios conhecidos pela administracao para ajuste de perfil, area e especialidades.', + }) @Get('users') listUsers() { return this.adminAccessService.listUsers(); } + @ApiOperation({ + summary: 'Atualiza acessos de usuario', + description: 'Altera perfil, perfis adicionais, area principal e especialidades de atendimento de um usuario.', + }) + @ApiParam({ name: 'id', description: 'ID do usuario.' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + perfilId: { type: 'number', nullable: true, example: 1 }, + perfilIds: { type: 'array', items: { type: 'number' }, example: [1, 2] }, + areaId: { type: 'number', nullable: true, example: 2 }, + especialidades: { + type: 'array', + items: { + type: 'object', + required: ['areaId'], + properties: { + areaId: { type: 'number', example: 2 }, + funcao: { type: 'string', nullable: true, example: 'atendente' }, + principal: { type: 'boolean', example: true }, + ativo: { type: 'boolean', example: true }, + }, + }, + }, + }, + }, + }) @Put('users/:id') updateUserAccess( @Param('id') id: string, diff --git a/src/modules/admin/agent-notes.controller.ts b/src/modules/admin/agent-notes.controller.ts index a72411c..b8193ac 100644 --- a/src/modules/admin/agent-notes.controller.ts +++ b/src/modules/admin/agent-notes.controller.ts @@ -1,20 +1,47 @@ import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import { AgentNotesService } from './agent-notes.service'; +@ApiTags('Notas do agente') @Controller('agent/notes') export class AgentNotesController { constructor(private readonly agentNotesService: AgentNotesService) {} + @ApiOperation({ + summary: 'Lista notas do agente', + description: 'Retorna lembretes e anotacoes pessoais cadastradas por um agente no painel.', + }) + @ApiQuery({ name: 'userId', required: true, description: 'ID do usuario dono das notas.' }) @Get() listNotes(@Query('userId') userId: string) { return this.agentNotesService.listNotes(Number(userId)); } + @ApiOperation({ + summary: 'Cria nota do agente', + description: 'Adiciona uma anotacao pessoal para organizacao do atendente.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['userId', 'text'], + properties: { + userId: { type: 'number', example: 10 }, + text: { type: 'string', example: 'Retornar contato no fim do dia.' }, + }, + }, + }) @Post() createNote(@Body() body: { userId: number; text: string }) { return this.agentNotesService.createNote(Number(body.userId), body.text); } + @ApiOperation({ + summary: 'Remove nota do agente', + description: 'Exclui uma nota pessoal do agente validando o usuario dono da anotacao.', + }) + @ApiParam({ name: 'id', description: 'ID da nota.' }) + @ApiQuery({ name: 'userId', required: true, description: 'ID do usuario dono da nota.' }) @Delete(':id') deleteNote(@Param('id') id: string, @Query('userId') userId: string) { return this.agentNotesService.deleteNote(Number(userId), Number(id)); diff --git a/src/modules/admin/agent-presence.controller.ts b/src/modules/admin/agent-presence.controller.ts index b5b2bf8..2c8ff0c 100644 --- a/src/modules/admin/agent-presence.controller.ts +++ b/src/modules/admin/agent-presence.controller.ts @@ -1,30 +1,56 @@ import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { AgentPresenceService } from './agent-presence.service'; +@ApiTags('Presenca do agente') @Controller('agent/presence') export class AgentPresenceController { constructor(private readonly agentPresenceService: AgentPresenceService) {} + @ApiOperation({ + summary: 'Lista presenca dos agentes', + description: 'Retorna o estado atual dos agentes para acompanhamento operacional, incluindo disponibilidade e pausas.', + }) @Get() listPresence() { return this.agentPresenceService.listPresence(); } + @ApiOperation({ + summary: 'Consulta presenca do agente logado', + description: 'Retorna o status de presenca de um usuario especifico.', + }) + @ApiQuery({ name: 'userId', required: true, description: 'ID do usuario.' }) @Get('me') getPresence(@Query('userId') userId: string) { return this.agentPresenceService.getPresence(Number(userId)); } + @ApiOperation({ + summary: 'Coloca agente em pausa', + description: 'Marca o agente como pausado para que ele nao receba novos atendimentos enquanto estiver indisponivel.', + }) + @ApiBody({ schema: { type: 'object', required: ['userId'], properties: { userId: { type: 'number', example: 10 } } } }) @Post('pause') pause(@Body() body: { userId: number }) { return this.agentPresenceService.pause(Number(body.userId)); } + @ApiOperation({ + summary: 'Retoma disponibilidade do agente', + description: 'Remove a pausa do agente e marca o usuario como disponivel novamente.', + }) + @ApiBody({ schema: { type: 'object', required: ['userId'], properties: { userId: { type: 'number', example: 10 } } } }) @Post('resume') resume(@Body() body: { userId: number }) { return this.agentPresenceService.resume(Number(body.userId)); } + @ApiOperation({ + summary: 'Marca agente como offline', + description: 'Atualiza a presenca do agente para offline, usado em saida do painel ou encerramento de expediente.', + }) + @ApiBody({ schema: { type: 'object', required: ['userId'], properties: { userId: { type: 'number', example: 10 } } } }) @Post('offline') offline(@Body() body: { userId: number }) { return this.agentPresenceService.offline(Number(body.userId)); diff --git a/src/modules/admin/customer-contacts.controller.ts b/src/modules/admin/customer-contacts.controller.ts index 621e106..f9ec7ca 100644 --- a/src/modules/admin/customer-contacts.controller.ts +++ b/src/modules/admin/customer-contacts.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CustomerContactsService } from './customer-contacts.service'; interface SaveContactBody { @@ -12,20 +13,51 @@ interface SaveContactBody { userId?: number | null; } +@ApiTags('Contatos') @Controller('contacts') export class CustomerContactsController { constructor(private readonly customerContactsService: CustomerContactsService) {} + @ApiOperation({ + summary: 'Lista contatos', + description: 'Retorna a agenda de contatos usada para atendimento, abertura ativa e consulta rapida no chat.', + }) + @ApiResponse({ status: 200, description: 'Lista de contatos retornada.' }) @Get() listContacts() { return this.customerContactsService.listContacts(); } + @ApiOperation({ + summary: 'Consulta contato por chat', + description: 'Busca os dados salvos de um contato usando o identificador da conversa.', + }) + @ApiParam({ name: 'chatId', description: 'Identificador codificado do chat.' }) @Get(':chatId') getContact(@Param('chatId') chatId: string) { return this.customerContactsService.getContact(decodeURIComponent(chatId)); } + @ApiOperation({ + summary: 'Salva contato', + description: 'Cria ou atualiza dados do contato associados ao chat, incluindo telefone, email, etiqueta de identificacao e observacao.', + }) + @ApiParam({ name: 'chatId', description: 'Identificador codificado do chat.' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + phone: { type: 'string', nullable: true, example: '5511999999999' }, + whatsappPhone: { type: 'string', nullable: true, example: '5511999999999' }, + callSmsPhone: { type: 'string', nullable: true, example: '5511988888888' }, + email: { type: 'string', nullable: true, example: 'cliente@empresa.com' }, + name: { type: 'string', nullable: true, example: 'Tony Silva' }, + company: { type: 'string', nullable: true, example: 'Departamento de RH' }, + note: { type: 'string', nullable: true, example: 'Contato prefere WhatsApp.' }, + userId: { type: 'number', nullable: true, example: 10 }, + }, + }, + }) @Put(':chatId') saveContact( @Param('chatId') chatId: string, diff --git a/src/modules/admin/knowledge-base.controller.ts b/src/modules/admin/knowledge-base.controller.ts index 14f57e8..97b8377 100644 --- a/src/modules/admin/knowledge-base.controller.ts +++ b/src/modules/admin/knowledge-base.controller.ts @@ -1,20 +1,51 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import { KnowledgeBaseService } from './knowledge-base.service'; +@ApiTags('Base de conhecimento') @Controller('admin/knowledge') export class KnowledgeBaseController { constructor(private readonly knowledgeBaseService: KnowledgeBaseService) {} + @ApiOperation({ + summary: 'Consulta fluxo do bot', + description: 'Retorna a arvore atual do agente virtual usada para perguntas, opcoes, encerramento e transferencia.', + }) @Get('bot-flow') getBotFlow() { return this.knowledgeBaseService.getBotFlow(); } + @ApiOperation({ + summary: 'Lista versoes publicadas do fluxo do bot', + description: 'Retorna historico de publicacoes do fluxo para auditoria e acompanhamento de mudancas.', + }) @Get('bot-flow/versions') listBotFlowVersions() { return this.knowledgeBaseService.listBotFlowVersions(); } + @ApiOperation({ + summary: 'Cria no no fluxo do bot', + description: 'Adiciona uma pergunta, acao de transferencia para agente ou encerramento dentro da arvore do agente virtual.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['parentId', 'nodeType'], + properties: { + parentId: { type: 'number', example: 1 }, + nodeType: { type: 'string', enum: ['question', 'agent', 'close'], example: 'question' }, + title: { type: 'string', example: 'Ferias' }, + messageText: { type: 'string', example: 'Voce quer saber sobre saldo ou solicitacao de ferias?' }, + keywords: { type: 'string', example: 'ferias, descanso, recesso' }, + fallbackMessage: { type: 'string', example: 'Nao entendi. Pode reformular?' }, + fallbackAttempts: { type: 'number', example: 2 }, + fallbackAreaId: { type: 'number', nullable: true, example: 2 }, + areaId: { type: 'number', nullable: true, example: 2 }, + }, + }, + }) @Post('bot-flow/nodes') createBotFlowNode(@Body() body: { parentId: number; @@ -30,6 +61,11 @@ export class KnowledgeBaseController { return this.knowledgeBaseService.createBotFlowNode(body); } + @ApiOperation({ + summary: 'Atualiza no do fluxo do bot', + description: 'Edita textos, palavras-chave, fallback, area e ordenacao de um no existente no agente virtual.', + }) + @ApiParam({ name: 'id', description: 'ID do no do fluxo.' }) @Put('bot-flow/nodes/:id') updateBotFlowNode(@Param('id') id: string, @Body() body: { title?: string; @@ -44,21 +80,52 @@ export class KnowledgeBaseController { return this.knowledgeBaseService.updateBotFlowNode(Number(id), body); } + @ApiOperation({ + summary: 'Remove no do fluxo do bot', + description: 'Exclui um no da arvore do agente virtual.', + }) + @ApiParam({ name: 'id', description: 'ID do no do fluxo.' }) @Delete('bot-flow/nodes/:id') deleteBotFlowNode(@Param('id') id: string) { return this.knowledgeBaseService.deleteBotFlowNode(Number(id)); } + @ApiOperation({ + summary: 'Publica fluxo do bot', + description: 'Marca a versao atual do fluxo como publicada para uso pelo agente virtual.', + }) @Post('bot-flow/publish') publishBotFlow() { return this.knowledgeBaseService.publishBotFlow(); } + @ApiOperation({ + summary: 'Consulta fluxo de triagem', + description: 'Retorna mensagens, publicos, intencoes e regras usadas pelo agente virtual para classificar e rotear o atendimento.', + }) @Get('triage-flow') getTriageFlow() { return this.knowledgeBaseService.getTriageFlow(); } + @ApiOperation({ + summary: 'Atualiza configuracao do fluxo de triagem', + description: 'Altera mensagens gerais, limite de tentativas e area de fallback do roteamento automatico.', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + greetingMessage: { type: 'string', example: 'Ola, sou o Agente Virtual Sothis.' }, + audienceQuestion: { type: 'string', example: 'Voce e colaborador, candidato ou cliente?' }, + intentQuestionTemplate: { type: 'string', example: 'Sobre qual assunto voce precisa falar?' }, + resolutionQuestion: { type: 'string', example: 'Consegui te ajudar?' }, + fallbackMessage: { type: 'string', example: 'Vou encaminhar seu atendimento para o time responsavel.' }, + fallbackAreaId: { type: 'number', nullable: true, example: 2 }, + maxAttempts: { type: 'number', example: 2 }, + }, + }, + }) @Put('triage-flow') updateTriageFlow(@Body() body: { greetingMessage?: string; @@ -72,16 +139,29 @@ export class KnowledgeBaseController { return this.knowledgeBaseService.updateTriageFlow(body); } + @ApiOperation({ + summary: 'Cria publico de triagem', + description: 'Cadastra um publico identificado pelo bot, como colaborador, candidato ou cliente.', + }) @Post('triage-flow/audiences') createAudience(@Body() body: { label: string; keywords?: string; sortOrder?: number }) { return this.knowledgeBaseService.createAudience(body); } + @ApiOperation({ + summary: 'Atualiza publico de triagem', + description: 'Edita nome, palavras-chave, ordenacao e status de um publico usado pelo bot.', + }) + @ApiParam({ name: 'id', description: 'ID do publico.' }) @Put('triage-flow/audiences/:id') updateAudience(@Param('id') id: string, @Body() body: { label?: string; keywords?: string; sortOrder?: number; active?: boolean }) { return this.knowledgeBaseService.updateAudience(Number(id), body); } + @ApiOperation({ + summary: 'Cria intencao de triagem', + description: 'Cadastra um assunto reconhecido pelo bot e associa a intencao a uma area de atendimento.', + }) @Post('triage-flow/intents') createIntent(@Body() body: { audienceId: number; @@ -96,6 +176,11 @@ export class KnowledgeBaseController { return this.knowledgeBaseService.createIntent(body); } + @ApiOperation({ + summary: 'Atualiza intencao de triagem', + description: 'Edita assunto, area, palavras-chave, mensagens e status de uma intencao reconhecida pelo bot.', + }) + @ApiParam({ name: 'id', description: 'ID da intencao.' }) @Put('triage-flow/intents/:id') updateIntent(@Param('id') id: string, @Body() body: { label?: string; @@ -110,21 +195,50 @@ export class KnowledgeBaseController { return this.knowledgeBaseService.updateIntent(Number(id), body); } + @ApiOperation({ + summary: 'Lista palavras-chave de roteamento', + description: 'Retorna palavras-chave usadas para identificar area responsavel quando o bot precisa transferir o atendimento.', + }) + @ApiQuery({ name: 'areaId', required: false, description: 'ID da area para filtrar as palavras-chave.' }) @Get('routing-keywords') listRoutingKeywords(@Query('areaId') areaId?: string) { return this.knowledgeBaseService.listRoutingKeywords(areaId ? Number(areaId) : null); } + @ApiOperation({ + summary: 'Cria palavra-chave de roteamento', + description: 'Associa uma palavra-chave a uma area para melhorar o roteamento automatico do agente virtual.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['areaId', 'keyword'], + properties: { + areaId: { type: 'number', example: 2 }, + keyword: { type: 'string', example: 'ferias' }, + }, + }, + }) @Post('routing-keywords') createRoutingKeyword(@Body() body: { areaId: number; keyword: string }) { return this.knowledgeBaseService.createRoutingKeyword(body); } + @ApiOperation({ + summary: 'Atualiza palavra-chave de roteamento', + description: 'Altera texto ou status de uma palavra-chave usada pelo roteamento automatico.', + }) + @ApiParam({ name: 'id', description: 'ID da palavra-chave.' }) @Put('routing-keywords/:id') updateRoutingKeyword(@Param('id') id: string, @Body() body: { keyword?: string; active?: boolean }) { return this.knowledgeBaseService.updateRoutingKeyword(Number(id), body); } + @ApiOperation({ + summary: 'Remove palavra-chave de roteamento', + description: 'Exclui uma palavra-chave do mecanismo de roteamento automatico.', + }) + @ApiParam({ name: 'id', description: 'ID da palavra-chave.' }) @Delete('routing-keywords/:id') deleteRoutingKeyword(@Param('id') id: string) { return this.knowledgeBaseService.deleteRoutingKeyword(Number(id)); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ba78b7b..9ba1765 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,26 +1,61 @@ import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { LoginData } from './auth.types'; +@ApiTags('Autenticacao') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} + @ApiOperation({ + summary: 'Consulta configuracao publica de login', + description: 'Informa ao frontend quais metodos de autenticacao estao disponiveis para exibir a tela de login corretamente.', + }) + @ApiResponse({ status: 200, description: 'Configuracao publica de autenticacao retornada.' }) @Get('config') getConfig() { return this.authService.getPublicConfig(); } + @ApiOperation({ + summary: 'Realiza login via LDAP/AD', + description: 'Valida as credenciais corporativas no provedor LDAP/Active Directory e retorna os dados do usuario autenticado com token JWT.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['username', 'password'], + properties: { + username: { type: 'string', example: 'rafael.lopes' }, + password: { type: 'string', example: 'senha-do-usuario' }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Login realizado com sucesso.' }) + @ApiResponse({ status: 401, description: 'Credenciais invalidas ou usuario sem acesso.' }) @Post('login') login(@Body() body: LoginData) { return this.authService.loginWithLdap(body); } + @ApiOperation({ + summary: 'Inicia login Microsoft OAuth', + description: 'Redireciona o navegador para a URL de autorizacao da Microsoft quando o login OAuth estiver configurado.', + }) + @ApiResponse({ status: 302, description: 'Redirecionamento para Microsoft OAuth.' }) @Get('oauth/microsoft/start') startMicrosoftLogin(@Res() response: any) { return response.redirect(this.authService.getMicrosoftAuthorizeUrl()); } + @ApiOperation({ + summary: 'Recebe callback Microsoft OAuth', + description: 'Processa o retorno da Microsoft, troca o codigo por usuario autenticado e redireciona o frontend com token e dados do usuario.', + }) + @ApiQuery({ name: 'code', required: false, description: 'Codigo de autorizacao retornado pela Microsoft.' }) + @ApiQuery({ name: 'state', required: false, description: 'Estado usado para validar a tentativa de login OAuth.' }) + @ApiResponse({ status: 302, description: 'Redirecionamento para o frontend com dados de autenticacao.' }) @Get('oauth/microsoft/callback') async microsoftCallback(@Query() query: { code?: string; state?: string }, @Res() response: any) { const authResult = await this.authService.loginWithMicrosoftCallback(query); diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index 63953b0..83334e2 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -1,7 +1,9 @@ import { Controller, Get, Post, Body, Delete, Param } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { WhatsappService } from './whatsapp.service'; import { WhatsappAssignmentService } from './whatsapp-assignment.service'; +@ApiTags('WhatsApp') @Controller('whatsapp') export class WhatsappController { constructor( @@ -9,86 +11,248 @@ export class WhatsappController { private readonly assignmentService: WhatsappAssignmentService ) {} + @ApiOperation({ + summary: 'Consulta status da conexao WhatsApp', + description: 'Retorna o estado atual da sessao WhatsApp usada pelo backend, como conectado, aguardando QR Code ou desconectado.', + }) + @ApiResponse({ status: 200, description: 'Status atual da conexao retornado.' }) @Get('status') getStatus() { return { status: this.whatsappService.getStatus() }; } + @ApiOperation({ + summary: 'Lista conversas do WhatsApp', + description: 'Retorna os chats conhecidos pelo backend para alimentar o painel de atendimento em tempo real.', + }) + @ApiResponse({ status: 200, description: 'Lista de conversas retornada.' }) @Get('chats') async getChats() { return this.whatsappService.getChats(); } + @ApiOperation({ + summary: 'Lista mensagens de uma conversa', + description: 'Busca o historico de mensagens de um chat especifico para abrir a conversa no painel do atendente.', + }) + @ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' }) + @ApiResponse({ status: 200, description: 'Mensagens do chat retornadas.' }) @Get('messages/:chatId') async getChatMessages(@Param('chatId') chatId: string) { return this.whatsappService.getChatMessages(chatId); } + @ApiOperation({ + summary: 'Baixa midia de uma mensagem', + description: 'Retorna a midia associada a uma mensagem do WhatsApp quando a mensagem possui anexo.', + }) + @ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' }) + @ApiParam({ name: 'messageId', description: 'Identificador da mensagem com midia.' }) + @ApiResponse({ status: 200, description: 'Midia da mensagem retornada.' }) @Get('media/:chatId/:messageId') async getMessageMedia(@Param('chatId') chatId: string, @Param('messageId') messageId: string) { return this.whatsappService.getMessageMedia(chatId, messageId); } + @ApiOperation({ + summary: 'Envia mensagem no WhatsApp', + description: 'Envia uma mensagem ativa para um contato ou responde uma conversa existente, opcionalmente com midia em base64.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['to', 'message'], + properties: { + to: { type: 'string', example: '5511999999999@c.us' }, + message: { type: 'string', example: 'Ola, como posso ajudar?' }, + senderName: { type: 'string', example: 'Rafael Lopes' }, + media: { + type: 'object', + nullable: true, + properties: { + data: { type: 'string', description: 'Arquivo em base64.' }, + mimetype: { type: 'string', example: 'application/pdf' }, + filename: { type: 'string', example: 'documento.pdf' }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Mensagem enviada.' }) @Post('send') async sendMessage(@Body() body: { to: string; message: string; senderName?: string; media?: { data: string; mimetype: string; filename?: string } }) { return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName); } + @ApiOperation({ + summary: 'Inicia atendimento ativo', + description: 'Abre uma conversa ativa usando um template aprovado e registra o atendimento para acompanhamento no painel.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['to', 'templateId', 'userId'], + properties: { + to: { type: 'string', example: '5511999999999' }, + templateId: { type: 'number', example: 1 }, + userId: { type: 'number', example: 10 }, + areaId: { type: 'number', nullable: true, example: 2 }, + variables: { type: 'object', additionalProperties: { type: 'string', nullable: true } }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' }) @Post('start-attendance') async startAttendance(@Body() body: { to: string; templateId: number; userId: number; areaId?: number | null; variables?: Record }) { return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables); } + @ApiOperation({ + summary: 'Assume uma conversa', + description: 'Vincula um chat a um atendente e, opcionalmente, a uma area de atendimento.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['chatId', 'userId'], + properties: { + chatId: { type: 'string', example: '5511999999999@c.us' }, + userId: { type: 'string', example: '10' }, + areaId: { type: 'string', example: '2' }, + }, + }, + }) @Post('assign') async assignChat(@Body() body: { chatId: string; userId: string; areaId?: string }) { return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId); } + @ApiOperation({ + summary: 'Transfere uma conversa', + description: 'Encaminha o chat para outra area ou responsavel e registra observacao para contextualizar o proximo atendimento.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['chatId', 'areaId'], + properties: { + chatId: { type: 'string', example: '5511999999999@c.us' }, + areaId: { type: 'number', example: 2 }, + userId: { type: 'number', nullable: true, example: 10 }, + note: { type: 'string', nullable: true, example: 'Tony e um colaborador e quer saber sobre ferias.' }, + }, + }, + }) @Post('transfer') async transferChat(@Body() body: { chatId: string; areaId: number; userId?: number | null; note?: string | null }) { return this.assignmentService.transferChat(body); } + @ApiOperation({ + summary: 'Libera conversa assumida', + description: 'Remove o vinculo de atendimento do chat, permitindo que outro atendente assuma a conversa.', + }) + @ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' }) @Delete('release/:chatId') async releaseChat(@Param('chatId') chatId: string) { return this.assignmentService.releaseChat(chatId); } + @ApiOperation({ + summary: 'Fecha atendimento', + description: 'Finaliza o atendimento de um chat e remove o vinculo ativo com o atendente.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['chatId'], + properties: { + chatId: { type: 'string', example: '5511999999999@c.us' }, + userId: { oneOf: [{ type: 'string' }, { type: 'number' }], nullable: true, example: 10 }, + }, + }, + }) @Post('close') async closeChat(@Body() body: { chatId: string; userId?: string | number | null }) { return this.assignmentService.closeChat(body.chatId, body.userId); } + @ApiOperation({ + summary: 'Consulta atribuicao do chat', + description: 'Retorna qual atendente ou area esta responsavel pela conversa no momento.', + }) + @ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' }) @Get('assignment/:chatId') async getAssignment(@Param('chatId') chatId: string) { return this.assignmentService.getAssignment(chatId); } + @ApiOperation({ + summary: 'Lista templates de atendimento', + description: 'Retorna os templates cadastrados para abertura ativa e mensagens padronizadas.', + }) @Get('templates') async getTemplates() { return this.whatsappService.getTemplates(); } + @ApiOperation({ + summary: 'Cria template de mensagem', + description: 'Cadastra um template para abertura ativa ou resposta padronizada. Pode depender de aprovacao conforme o perfil solicitante.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['name', 'content'], + properties: { + name: { type: 'string', example: 'Boas vindas' }, + content: { type: 'string', example: 'Ola {{nome}}, tudo bem?' }, + areaId: { type: 'number', nullable: true, example: 2 }, + requestedByRole: { type: 'string', example: 'admin' }, + category: { type: 'string', example: 'atendimento' }, + }, + }, + }) @Post('templates') async saveTemplate(@Body() body: { name: string; content: string; areaId?: number | null; requestedByRole?: string; category?: string }) { return this.whatsappService.saveTemplate(body.name, body.content, body.areaId, body.requestedByRole, body.category); } + @ApiOperation({ + summary: 'Atualiza template de mensagem', + description: 'Altera nome, conteudo, area ou categoria de um template existente.', + }) + @ApiParam({ name: 'id', description: 'ID do template.' }) @Post('templates/update/:id') async updateTemplate(@Param('id') id: string, @Body() body: { name: string; content: string; areaId?: number | null; category?: string }) { return this.whatsappService.updateTemplate(Number(id), body.name, body.content, body.areaId, body.category); } + @ApiOperation({ + summary: 'Aprova template pelo admin', + description: 'Marca um template como aprovado para uso nos fluxos de atendimento.', + }) + @ApiParam({ name: 'id', description: 'ID do template.' }) @Post('templates/approve-admin/:id') async approveTemplateByAdmin(@Param('id') id: string) { return this.whatsappService.approveTemplateByAdmin(Number(id)); } + @ApiOperation({ + summary: 'Rejeita template pelo admin', + description: 'Marca um template como rejeitado, impedindo seu uso nos fluxos de atendimento.', + }) + @ApiParam({ name: 'id', description: 'ID do template.' }) @Post('templates/reject-admin/:id') async rejectTemplateByAdmin(@Param('id') id: string) { return this.whatsappService.rejectTemplateByAdmin(Number(id)); } + @ApiOperation({ + summary: 'Remove template', + description: 'Exclui um template cadastrado.', + }) + @ApiParam({ name: 'id', description: 'ID do template.' }) @Delete('templates/:id') async deleteTemplate(@Param('id') id: string) { return this.whatsappService.deleteTemplate(Number(id));