feat(whatsapp): integrate evolution api and admin settings
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m16s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m16s
Details
This commit is contained in:
parent
a4a1f35550
commit
4e28601237
|
|
@ -17,6 +17,7 @@
|
|||
"multer": "^1.4.5-lts.1",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
|
|
|
|||
|
|
@ -239,6 +239,12 @@ model SystemSettings {
|
|||
asaasWebhookSecret String @default("") @map("asaas_webhook_secret")
|
||||
helpText String @db.Text @default("# Central de Ajuda") @map("help_text")
|
||||
|
||||
evolutionApiUrl String @default("https://evolution.microtecinformaticacurso.com.br") @map("evolution_api_url")
|
||||
evolutionApiKey String @default("") @map("evolution_api_key")
|
||||
evolutionInstance String @default("microtecflix") @map("evolution_instance")
|
||||
whatsappConnected Boolean @default(false) @map("whatsapp_connected")
|
||||
whatsappPhoneNumber String? @map("whatsapp_phone_number")
|
||||
|
||||
@@map("system_settings")
|
||||
}
|
||||
|
||||
|
|
|
|||
121
server.ts
121
server.ts
|
|
@ -3,6 +3,7 @@ import path from 'path';
|
|||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import multer from 'multer';
|
||||
import axios from 'axios';
|
||||
import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3';
|
||||
import { createServer as createViteServer } from 'vite';
|
||||
import { prisma } from './src/db/prisma';
|
||||
|
|
@ -986,7 +987,12 @@ app.get('/api/admin/settings', authenticateToken, requireAdmin, async (req: Auth
|
|||
asaasApiKey: settings?.asaasApiKey || '',
|
||||
asaasWebhookUrl: settings?.asaasWebhookUrl || 'https://admin-estudo.microtecinformaticacurso.com.br/api/webhooks/asaas',
|
||||
asaasWebhookSecret: settings?.asaasWebhookSecret || 'devflix_webhook_secret_key',
|
||||
helpText: settings?.helpText || ''
|
||||
helpText: settings?.helpText || '',
|
||||
evolutionApiUrl: settings?.evolutionApiUrl || 'https://evolution.microtecinformaticacurso.com.br',
|
||||
evolutionApiKey: settings?.evolutionApiKey || '',
|
||||
evolutionInstance: settings?.evolutionInstance || 'microtecflix',
|
||||
whatsappConnected: settings?.whatsappConnected || false,
|
||||
whatsappPhoneNumber: settings?.whatsappPhoneNumber || ''
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1019,6 +1025,119 @@ app.post('/api/admin/test-asaas', authenticateToken, requireAdmin, async (req: A
|
|||
}
|
||||
});
|
||||
|
||||
// --- EVOLUTION API WHATSAPP ---
|
||||
app.get('/api/admin/whatsapp/qrcode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
if (!settings || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
|
||||
return res.status(400).json({ error: 'Credenciais da Evolution API não configuradas.' });
|
||||
}
|
||||
|
||||
const apiUrl = settings.evolutionApiUrl.replace(/\/$/, '');
|
||||
const instance = settings.evolutionInstance || 'microtecflix';
|
||||
const apiKey = settings.evolutionApiKey;
|
||||
|
||||
const headers = { apikey: apiKey };
|
||||
|
||||
// Check connection state
|
||||
try {
|
||||
const stateRes = await axios.get(`${apiUrl}/instance/connectionState/${instance}`, { headers });
|
||||
|
||||
if (stateRes.data && stateRes.data.instance && stateRes.data.instance.state === 'open') {
|
||||
if (!settings.whatsappConnected) {
|
||||
await prisma.systemSettings.update({
|
||||
where: { id: settings.id },
|
||||
data: { whatsappConnected: true }
|
||||
});
|
||||
}
|
||||
return res.json({ connected: true, status: 'open' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) {
|
||||
// Instance does not exist, create it
|
||||
await axios.post(`${apiUrl}/instance/create`, {
|
||||
instanceName: instance,
|
||||
qrcode: true
|
||||
}, { headers });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Get QR Code
|
||||
const qrRes = await axios.get(`${apiUrl}/instance/connect/${instance}`, { headers });
|
||||
|
||||
// Some versions of evolution return { base64: "...", pairingCode: "..." }
|
||||
res.json({
|
||||
connected: false,
|
||||
qrCode: qrRes.data.base64 || qrRes.data.qrcode || null,
|
||||
pairingCode: qrRes.data.pairingCode || null
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Erro na Evolution API (QR Code):', err.message);
|
||||
res.status(500).json({ error: 'Falha ao comunicar com Evolution API.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/whatsapp/logout', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
if (!settings || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
|
||||
return res.status(400).json({ error: 'Credenciais não configuradas.' });
|
||||
}
|
||||
|
||||
const apiUrl = settings.evolutionApiUrl.replace(/\/$/, '');
|
||||
const instance = settings.evolutionInstance || 'microtecflix';
|
||||
const apiKey = settings.evolutionApiKey;
|
||||
|
||||
try {
|
||||
await axios.delete(`${apiUrl}/instance/logout/${instance}`, { headers: { apikey: apiKey } });
|
||||
} catch (err: any) {
|
||||
// ignore 404 or others during logout
|
||||
}
|
||||
|
||||
await prisma.systemSettings.update({
|
||||
where: { id: settings.id },
|
||||
data: { whatsappConnected: false }
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error('Erro na Evolution API (Logout):', err.message);
|
||||
res.status(500).json({ error: 'Falha ao desconectar Evolution API.' });
|
||||
}
|
||||
});
|
||||
|
||||
export async function sendWhatsappNotification(phone: string, text: string) {
|
||||
try {
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
if (!settings?.whatsappConnected || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
|
||||
return false; // Not configured or not connected
|
||||
}
|
||||
|
||||
// Clean phone number (leave only digits)
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
if (!cleanPhone) return false;
|
||||
|
||||
const apiUrl = settings.evolutionApiUrl.replace(/\/$/, '');
|
||||
const instance = settings.evolutionInstance || 'microtecflix';
|
||||
|
||||
await axios.post(`${apiUrl}/message/sendText/${instance}`, {
|
||||
number: cleanPhone,
|
||||
options: { delay: 1200 },
|
||||
textMessage: { text }
|
||||
}, {
|
||||
headers: { apikey: settings.evolutionApiKey }
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Falha ao enviar WhatsApp:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin manually unlock or lock a course for a student
|
||||
app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { courseId, unlocked } = req.body;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Cell, PieChart, Pie } from 'recharts';
|
||||
import { SubscriptionStatus } from '../types';
|
||||
import { AdminPricingManager } from './AdminPricingManager';
|
||||
import { WhatsappConnectCard } from './WhatsappConnectCard';
|
||||
|
||||
interface WebhookLog {
|
||||
id: string;
|
||||
|
|
@ -999,6 +1000,62 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
|||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Integração Evolution API */}
|
||||
<div className="glass-card p-4 rounded-lg border border-white/5 space-y-4 bg-white/5 md:col-span-2">
|
||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-2">Integração WhatsApp (Evolution API)</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-400 font-bold">URL da Evolution API</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.evolutionApiUrl || ''}
|
||||
onChange={(e) => setSettings({...settings, evolutionApiUrl: e.target.value})}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="https://evolution.seu-dominio.com.br"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-400 font-bold">Global API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.evolutionApiKey || ''}
|
||||
onChange={(e) => setSettings({...settings, evolutionApiKey: e.target.value})}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="Sua chave secreta global"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-400 font-bold">Nome da Instância</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.evolutionInstance || ''}
|
||||
onChange={(e) => setSettings({...settings, evolutionInstance: e.target.value})}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="microtecflix"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-400 font-bold">Seu Número de WhatsApp (Testes)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.whatsappPhoneNumber || ''}
|
||||
onChange={(e) => setSettings({...settings, whatsappPhoneNumber: e.target.value})}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="5511999999999"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Whatsapp Integration Component */}
|
||||
<div className="pt-6 border-t border-white/10">
|
||||
<WhatsappConnectCard token={token} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Smartphone, QrCode, LogOut, CheckCircle2, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface WhatsappConnectCardProps {
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
export const WhatsappConnectCard: React.FC<WhatsappConnectCardProps> = ({ token }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/whatsapp/qrcode', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error || 'Erro ao comunicar com WhatsApp');
|
||||
|
||||
if (data.connected) {
|
||||
setConnected(true);
|
||||
setQrCode(null);
|
||||
} else if (data.qrCode) {
|
||||
setQrCode(data.qrCode);
|
||||
setConnected(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetch('/api/admin/whatsapp/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
setConnected(false);
|
||||
setQrCode(null);
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-6 border border-white/5 space-y-6 mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-500/20 p-2.5 rounded-xl">
|
||||
<Smartphone className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider">Conexão WhatsApp (Evolution API)</h3>
|
||||
<p className="text-xs text-zinc-400">Escaneie o QR Code para conectar a API e enviar notificações automáticas.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start space-x-3">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-black/30 rounded-xl p-6 flex flex-col items-center justify-center border border-white/5 min-h-[250px]">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<Loader2 className="w-8 h-8 text-green-500 animate-spin" />
|
||||
<p className="text-xs text-zinc-400">Processando conexão...</p>
|
||||
</div>
|
||||
) : connected ? (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="bg-green-500/20 p-4 rounded-full">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-500" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h4 className="text-sm font-bold text-white">WhatsApp Conectado!</h4>
|
||||
<p className="text-xs text-zinc-400 mt-1">A plataforma já pode enviar notificações automáticas.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 flex items-center space-x-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 text-xs font-bold px-4 py-2 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Desconectar Instância</span>
|
||||
</button>
|
||||
</div>
|
||||
) : qrCode ? (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="bg-white p-2 rounded-xl">
|
||||
<img src={qrCode.startsWith('data:') ? qrCode : `data:image/png;base64,${qrCode}`} alt="WhatsApp QR Code" className="w-48 h-48 object-contain" />
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 text-center max-w-xs">
|
||||
Abra o WhatsApp no seu celular, vá em "Aparelhos Conectados" e escaneie o código acima.
|
||||
</p>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="text-xs text-green-400 hover:text-green-300 transition-colors mt-2 cursor-pointer"
|
||||
>
|
||||
Já escaneei (Atualizar)
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<QrCode className="w-12 h-12 text-zinc-600" />
|
||||
<p className="text-xs text-zinc-400">Nenhuma instância conectada.</p>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="bg-green-600 hover:bg-green-500 text-white text-xs font-bold px-4 py-2 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Gerar QR Code de Conexão
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -26,6 +26,11 @@ export interface SystemSettings {
|
|||
asaasWebhookSecret: string;
|
||||
helpText: string;
|
||||
enableBoleto?: boolean;
|
||||
evolutionApiUrl?: string;
|
||||
evolutionApiKey?: string;
|
||||
evolutionInstance?: string;
|
||||
whatsappConnected?: boolean;
|
||||
whatsappPhoneNumber?: string;
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
|
|
|
|||
Loading…
Reference in New Issue