diff --git a/src/modules/whatsapp/dto/whatsapp.dto.ts b/src/modules/whatsapp/dto/whatsapp.dto.ts index a62e79b..b2a53ed 100644 --- a/src/modules/whatsapp/dto/whatsapp.dto.ts +++ b/src/modules/whatsapp/dto/whatsapp.dto.ts @@ -155,3 +155,40 @@ export class UpdateWhatsappTemplateDto { @MaxLength(40) category?: string; } + +export class SaveWhatsappConfigDto { + @IsOptional() + @IsString() + @IsNotEmpty() + access_token?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + phone_number_id?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + api_version?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + webhook_token?: string; +} + +export class ValidateWhatsappConfigDto { + @IsString() + @IsNotEmpty() + access_token: string; + + @IsString() + @IsNotEmpty() + phone_number_id: string; + + @IsOptional() + @IsString() + @MaxLength(20) + api_version?: string; +} diff --git a/src/modules/whatsapp/repositories/whatsapp-config.repository.ts b/src/modules/whatsapp/repositories/whatsapp-config.repository.ts new file mode 100644 index 0000000..f3ebbc9 --- /dev/null +++ b/src/modules/whatsapp/repositories/whatsapp-config.repository.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../../../infra/database/database.service'; + +@Injectable() +export class WhatsappConfigRepository { + constructor(private readonly database: DatabaseService) {} + + async find() { + const result = await this.database.query( + `SELECT * FROM whatsapp_config LIMIT 1`, + ); + return result.rows[0] || null; + } + + async update(fields: { + access_token?: string | null; + phone_number_id?: string | null; + api_version?: string | null; + webhook_token?: string | null; + updated_by?: number | null; + }) { + const result = await this.database.query( + `UPDATE whatsapp_config SET + access_token = CASE WHEN $1::TEXT IS NOT NULL THEN $1 ELSE access_token END, + phone_number_id = CASE WHEN $2::TEXT IS NOT NULL THEN $2 ELSE phone_number_id END, + api_version = CASE WHEN $3::TEXT IS NOT NULL THEN $3 ELSE api_version END, + webhook_token = CASE WHEN $4::TEXT IS NOT NULL THEN $4 ELSE webhook_token END, + updated_at = NOW(), + updated_by = $5 + RETURNING *`, + [ + fields.access_token ?? null, + fields.phone_number_id ?? null, + fields.api_version ?? null, + fields.webhook_token ?? null, + fields.updated_by ?? null, + ], + ); + return result.rows[0] || null; + } +} diff --git a/src/modules/whatsapp/whatsapp-admin.controller.ts b/src/modules/whatsapp/whatsapp-admin.controller.ts new file mode 100644 index 0000000..e1018a4 --- /dev/null +++ b/src/modules/whatsapp/whatsapp-admin.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Get, Post, Put, Request } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { WhatsappConfigService } from './whatsapp-config.service'; +import { SaveWhatsappConfigDto, ValidateWhatsappConfigDto } from './dto/whatsapp.dto'; + +@ApiTags('WhatsApp Admin') +@Roles('Admin') +@Controller('admin/whatsapp-config') +export class WhatsappAdminController { + constructor(private readonly configService: WhatsappConfigService) {} + + @ApiOperation({ summary: 'Retorna configuração atual do WhatsApp (sem expor o token completo)' }) + @Get() + async getConfig() { + return this.configService.getPublicConfig(); + } + + @ApiOperation({ summary: 'Salva configuração do WhatsApp no banco de dados' }) + @ApiBody({ type: SaveWhatsappConfigDto }) + @Put() + async saveConfig(@Body() body: SaveWhatsappConfigDto, @Request() req: any) { + const userId = req.user?.sub ? Number(req.user.sub) : undefined; + return this.configService.saveConfig(body, userId); + } + + @ApiOperation({ summary: 'Valida token e phone number ID junto à Meta API' }) + @ApiBody({ type: ValidateWhatsappConfigDto }) + @Post('validate') + async validateConfig(@Body() body: ValidateWhatsappConfigDto) { + return this.configService.validateCredentials( + body.access_token, + body.phone_number_id, + body.api_version, + ); + } +} diff --git a/src/modules/whatsapp/whatsapp-config.service.ts b/src/modules/whatsapp/whatsapp-config.service.ts new file mode 100644 index 0000000..6785877 --- /dev/null +++ b/src/modules/whatsapp/whatsapp-config.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { WhatsappConfigRepository } from './repositories/whatsapp-config.repository'; + +interface WhatsappConfig { + access_token: string | null; + phone_number_id: string | null; + api_version: string; + webhook_token: string | null; +} + +@Injectable() +export class WhatsappConfigService { + private _cache: { config: WhatsappConfig; expiresAt: number } | null = null; + private readonly CACHE_TTL_MS = 60_000; + + constructor(private readonly repo: WhatsappConfigRepository) {} + + async getConfig(): Promise { + if (this._cache && Date.now() < this._cache.expiresAt) { + return this._cache.config; + } + const row = await this.repo.find(); + const config: WhatsappConfig = { + access_token: row?.access_token || process.env.META_ACCESS_TOKEN || null, + phone_number_id: row?.phone_number_id || process.env.META_PHONE_NUMBER_ID || null, + api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0', + webhook_token: row?.webhook_token || process.env.META_WEBHOOK_TOKEN || null, + }; + this._cache = { config, expiresAt: Date.now() + this.CACHE_TTL_MS }; + return config; + } + + async getPublicConfig() { + const row = await this.repo.find(); + const effectiveToken = row?.access_token || process.env.META_ACCESS_TOKEN; + const effectivePhoneId = row?.phone_number_id || process.env.META_PHONE_NUMBER_ID; + return { + phone_number_id: effectivePhoneId || null, + api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0', + webhook_token: row?.webhook_token || process.env.META_WEBHOOK_TOKEN || null, + is_configured: !!(effectiveToken && effectivePhoneId), + has_token: !!effectiveToken, + token_source: row?.access_token ? 'database' : effectiveToken ? 'env' : null, + updated_at: row?.updated_at || null, + }; + } + + async saveConfig( + dto: { access_token?: string; phone_number_id?: string; api_version?: string; webhook_token?: string }, + userId?: number, + ) { + this._cache = null; + await this.repo.update({ + access_token: dto.access_token || null, + phone_number_id: dto.phone_number_id || null, + api_version: dto.api_version || null, + webhook_token: dto.webhook_token || null, + updated_by: userId, + }); + return this.getPublicConfig(); + } + + async validateCredentials(token: string, phoneNumberId: string, apiVersion = 'v25.0') { + const url = `https://graph.facebook.com/${apiVersion}/${phoneNumberId}?access_token=${token}&fields=display_phone_number,verified_name,quality_rating`; + try { + const response = await fetch(url); + const data = (await response.json()) as any; + if (!response.ok) { + return { + valid: false, + error: data?.error?.message || 'Credenciais inválidas', + code: data?.error?.code, + }; + } + return { + valid: true, + phone_number: data.display_phone_number, + verified_name: data.verified_name, + quality_rating: data.quality_rating, + }; + } catch { + return { valid: false, error: 'Erro de conexão com a Meta API' }; + } + } +} diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index 8478d1e..2c36a6a 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -12,6 +12,7 @@ import { UpdateWhatsappTemplateDto, } from './dto/whatsapp.dto'; import { WhatsappTemplateService } from './whatsapp-template.service'; +import { WhatsappConfigService } from './whatsapp-config.service'; import { Public } from '../auth/decorators/public.decorator'; @@ -23,6 +24,7 @@ export class WhatsappController { private readonly whatsappService: WhatsappService, private readonly assignmentService: AttendanceAssignmentService, private readonly whatsappTemplateService: WhatsappTemplateService, + private readonly configService: WhatsappConfigService, ) {} @ApiOperation({ @@ -275,17 +277,19 @@ export class WhatsappController { @Public() @ApiOperation({ summary: 'Verificação do webhook Meta' }) - @Get('webhook') - verifyWebhook( + @Get('webhook') + async verifyWebhook( @Query('hub.mode') mode: string, @Query('hub.verify_token') token: string, @Query('hub.challenge') challenge: string, ) { - if (mode === 'subscribe' && token === process.env.META_WEBHOOK_TOKEN) { - return challenge; + const config = await this.configService.getConfig(); + const expected = config.webhook_token; + if (mode === 'subscribe' && expected && token === expected) { + return challenge; + } + throw new ForbiddenException('Token inválido'); } - throw new ForbiddenException('Token inválido'); -} @Public() @ApiOperation({ summary: 'Recebe eventos do webhook Meta' }) diff --git a/src/modules/whatsapp/whatsapp.module.ts b/src/modules/whatsapp/whatsapp.module.ts index 762f51e..90ad9e3 100644 --- a/src/modules/whatsapp/whatsapp.module.ts +++ b/src/modules/whatsapp/whatsapp.module.ts @@ -2,17 +2,28 @@ import { Module } from '@nestjs/common'; import { WhatsappService } from './whatsapp.service'; import { WhatsappGateway } from './whatsapp.gateway'; import { WhatsappController } from './whatsapp.controller'; +import { WhatsappAdminController } from './whatsapp-admin.controller'; import { AttendanceModule } from '../attendance/attendance.module'; import { AuthModule } from '../auth/auth.module'; import { ContactsModule } from '../contacts/contacts.module'; import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository'; import { WhatsappTemplateService } from './whatsapp-template.service'; import { MensagensRepository } from './repositories/mensagens.repository'; +import { WhatsappConfigRepository } from './repositories/whatsapp-config.repository'; +import { WhatsappConfigService } from './whatsapp-config.service'; @Module({ imports: [AttendanceModule, AuthModule, ContactsModule], - providers: [WhatsappService, WhatsappGateway, WhatsappTemplateService, WhatsappTemplateRepository, MensagensRepository], - controllers: [WhatsappController], + providers: [ + WhatsappService, + WhatsappGateway, + WhatsappTemplateService, + WhatsappTemplateRepository, + MensagensRepository, + WhatsappConfigRepository, + WhatsappConfigService, + ], + controllers: [WhatsappController, WhatsappAdminController], exports: [WhatsappService], }) export class WhatsappModule {} diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index e3fdc15..1d8fd35 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -4,6 +4,7 @@ import { AttendanceAssignmentService } from '../attendance/attendance-assignment import { ContactsService } from '../contacts/contacts.service'; import { WhatsappTemplateService } from './whatsapp-template.service'; import { MensagensRepository } from './repositories/mensagens.repository'; +import { WhatsappConfigService } from './whatsapp-config.service'; @Injectable() export class WhatsappService { @@ -17,6 +18,7 @@ export class WhatsappService { private readonly contactsService: ContactsService, private readonly whatsappTemplateService: WhatsappTemplateService, private readonly mensagensRepository: MensagensRepository, + private readonly configService: WhatsappConfigService, ) {} private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) { @@ -112,12 +114,11 @@ export class WhatsappService { } async sendMessage(to: string, message: string, _media?: { data: string; mimetype: string; filename?: string }, senderName?: string) { - const token = process.env.META_ACCESS_TOKEN; - const phoneNumberId = process.env.META_PHONE_NUMBER_ID; - const apiVersion = process.env.META_API_VERSION || 'v25.0'; + const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } = + await this.configService.getConfig(); if (!token || !phoneNumberId) { - throw new Error('META_ACCESS_TOKEN e META_PHONE_NUMBER_ID são obrigatórios.'); + throw new Error('Token e Phone Number ID do WhatsApp não configurados. Acesse Admin → Canais e Integração → WhatsApp → Configurar.'); } const body = senderName ? `*Atendente: ${senderName}*\n\n${message}` : message;