FEAT: Adicionado configuração da Meta API Fora do .env

This commit is contained in:
Rafael Alves Lopes 2026-06-22 15:26:20 -03:00
parent 5e9f328932
commit 197458e2a7
7 changed files with 228 additions and 12 deletions

View File

@ -155,3 +155,40 @@ export class UpdateWhatsappTemplateDto {
@MaxLength(40) @MaxLength(40)
category?: string; 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;
}

View File

@ -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;
}
}

View File

@ -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,
);
}
}

View File

@ -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<WhatsappConfig> {
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' };
}
}
}

View File

@ -12,6 +12,7 @@ import {
UpdateWhatsappTemplateDto, UpdateWhatsappTemplateDto,
} from './dto/whatsapp.dto'; } from './dto/whatsapp.dto';
import { WhatsappTemplateService } from './whatsapp-template.service'; import { WhatsappTemplateService } from './whatsapp-template.service';
import { WhatsappConfigService } from './whatsapp-config.service';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
@ -23,6 +24,7 @@ export class WhatsappController {
private readonly whatsappService: WhatsappService, private readonly whatsappService: WhatsappService,
private readonly assignmentService: AttendanceAssignmentService, private readonly assignmentService: AttendanceAssignmentService,
private readonly whatsappTemplateService: WhatsappTemplateService, private readonly whatsappTemplateService: WhatsappTemplateService,
private readonly configService: WhatsappConfigService,
) {} ) {}
@ApiOperation({ @ApiOperation({
@ -276,16 +278,18 @@ export class WhatsappController {
@Public() @Public()
@ApiOperation({ summary: 'Verificação do webhook Meta' }) @ApiOperation({ summary: 'Verificação do webhook Meta' })
@Get('webhook') @Get('webhook')
verifyWebhook( async verifyWebhook(
@Query('hub.mode') mode: string, @Query('hub.mode') mode: string,
@Query('hub.verify_token') token: string, @Query('hub.verify_token') token: string,
@Query('hub.challenge') challenge: string, @Query('hub.challenge') challenge: string,
) { ) {
if (mode === 'subscribe' && token === process.env.META_WEBHOOK_TOKEN) { const config = await this.configService.getConfig();
return challenge; 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() @Public()
@ApiOperation({ summary: 'Recebe eventos do webhook Meta' }) @ApiOperation({ summary: 'Recebe eventos do webhook Meta' })

View File

@ -2,17 +2,28 @@ import { Module } from '@nestjs/common';
import { WhatsappService } from './whatsapp.service'; import { WhatsappService } from './whatsapp.service';
import { WhatsappGateway } from './whatsapp.gateway'; import { WhatsappGateway } from './whatsapp.gateway';
import { WhatsappController } from './whatsapp.controller'; import { WhatsappController } from './whatsapp.controller';
import { WhatsappAdminController } from './whatsapp-admin.controller';
import { AttendanceModule } from '../attendance/attendance.module'; import { AttendanceModule } from '../attendance/attendance.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module'; import { ContactsModule } from '../contacts/contacts.module';
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository'; import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
import { WhatsappTemplateService } from './whatsapp-template.service'; import { WhatsappTemplateService } from './whatsapp-template.service';
import { MensagensRepository } from './repositories/mensagens.repository'; import { MensagensRepository } from './repositories/mensagens.repository';
import { WhatsappConfigRepository } from './repositories/whatsapp-config.repository';
import { WhatsappConfigService } from './whatsapp-config.service';
@Module({ @Module({
imports: [AttendanceModule, AuthModule, ContactsModule], imports: [AttendanceModule, AuthModule, ContactsModule],
providers: [WhatsappService, WhatsappGateway, WhatsappTemplateService, WhatsappTemplateRepository, MensagensRepository], providers: [
controllers: [WhatsappController], WhatsappService,
WhatsappGateway,
WhatsappTemplateService,
WhatsappTemplateRepository,
MensagensRepository,
WhatsappConfigRepository,
WhatsappConfigService,
],
controllers: [WhatsappController, WhatsappAdminController],
exports: [WhatsappService], exports: [WhatsappService],
}) })
export class WhatsappModule {} export class WhatsappModule {}

View File

@ -4,6 +4,7 @@ import { AttendanceAssignmentService } from '../attendance/attendance-assignment
import { ContactsService } from '../contacts/contacts.service'; import { ContactsService } from '../contacts/contacts.service';
import { WhatsappTemplateService } from './whatsapp-template.service'; import { WhatsappTemplateService } from './whatsapp-template.service';
import { MensagensRepository } from './repositories/mensagens.repository'; import { MensagensRepository } from './repositories/mensagens.repository';
import { WhatsappConfigService } from './whatsapp-config.service';
@Injectable() @Injectable()
export class WhatsappService { export class WhatsappService {
@ -17,6 +18,7 @@ export class WhatsappService {
private readonly contactsService: ContactsService, private readonly contactsService: ContactsService,
private readonly whatsappTemplateService: WhatsappTemplateService, private readonly whatsappTemplateService: WhatsappTemplateService,
private readonly mensagensRepository: MensagensRepository, private readonly mensagensRepository: MensagensRepository,
private readonly configService: WhatsappConfigService,
) {} ) {}
private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) { 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) { async sendMessage(to: string, message: string, _media?: { data: string; mimetype: string; filename?: string }, senderName?: string) {
const token = process.env.META_ACCESS_TOKEN; const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } =
const phoneNumberId = process.env.META_PHONE_NUMBER_ID; await this.configService.getConfig();
const apiVersion = process.env.META_API_VERSION || 'v25.0';
if (!token || !phoneNumberId) { 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; const body = senderName ? `*Atendente: ${senderName}*\n\n${message}` : message;