Adicionado endpoint e botão para testar envio de mídias (imagens, PDFs, vídeos) pelo WhatsApp no painel admin
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details

This commit is contained in:
Sidney Gomes 2026-07-25 21:45:50 -03:00
parent 4f5ac1f363
commit 5e4a341e47
2 changed files with 178 additions and 16 deletions

View File

@ -1607,6 +1607,50 @@ export async function sendWhatsappNotification(phone: string, text: string) {
} }
} }
// Sends media (image, video, PDF) via Evolution API v2
export async function sendWhatsappMedia(phone: string, mediaBase64: string, mediaType: string, caption: string, filename?: string) {
try {
const settings = await prisma.systemSettings.findFirst();
if (!settings?.whatsappConnected || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
return false;
}
let cleanPhone = phone.replace(/\D/g, '');
if (!cleanPhone) return false;
if (cleanPhone.length <= 11) cleanPhone = '55' + cleanPhone;
const apiUrl = settings.evolutionApiUrl.replace(/\/$/, '');
const instance = settings.evolutionInstance || 'microtecflix';
// Determine mediatype for Evolution API: image, video, document, audio
let evolutionMediaType: string;
if (mediaType.startsWith('image/')) evolutionMediaType = 'image';
else if (mediaType.startsWith('video/')) evolutionMediaType = 'video';
else if (mediaType === 'application/pdf') evolutionMediaType = 'document';
else evolutionMediaType = 'document';
const payload: any = {
number: cleanPhone,
mediatype: evolutionMediaType,
mimetype: mediaType,
media: mediaBase64,
delay: 1200
};
if (caption) payload.caption = caption;
if (filename) payload.fileName = filename;
await axios.post(`${apiUrl}/message/sendMedia/${instance}`, payload, {
headers: { apikey: settings.evolutionApiKey, 'Content-Type': 'application/json' }
});
return true;
} catch (err) {
console.error('Falha ao enviar mídia WhatsApp:', err);
return false;
}
}
// Admin manually unlock or lock a course for a student // 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) => { app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { courseId, unlocked } = req.body; const { courseId, unlocked } = req.body;
@ -2616,6 +2660,27 @@ app.post('/api/admin/whatsapp/test-template', authenticateToken, requireAdmin, a
} }
}); });
// Admin: Send media file along with WhatsApp message
app.post('/api/admin/whatsapp/send-media', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const { phone, caption, mediaBase64, mediaType, fileName } = req.body;
if (!phone || !mediaBase64 || !mediaType) {
return res.status(400).json({ error: 'phone, mediaBase64 e mediaType são obrigatórios.' });
}
const success = await sendWhatsappMedia(phone, mediaBase64, mediaType, caption || '', fileName);
if (success) {
res.json({ success: true, message: 'Mídia enviada com sucesso!' });
} else {
res.status(500).json({ error: 'Falha ao enviar mídia. Verifique a conexão da Evolution API.' });
}
} catch (error) {
console.error('Error sending media:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Admin: Broadcast WhatsApp message to all students with anti-spam delay // Admin: Broadcast WhatsApp message to all students with anti-spam delay
app.post('/api/admin/whatsapp/broadcast', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { app.post('/api/admin/whatsapp/broadcast', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try { try {

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Mail, MessageSquare, BookOpen, Send, User, Reply, CheckCircle2, AlertCircle, Smartphone, Save, Smile, Megaphone, Clock } from 'lucide-react'; import { Mail, MessageSquare, BookOpen, Send, User, Reply, CheckCircle2, AlertCircle, Smartphone, Save, Smile, Megaphone, Clock, Paperclip } from 'lucide-react';
import CustomModal from './CustomModal'; import CustomModal from './CustomModal';
interface SupportTicket { interface SupportTicket {
@ -108,8 +108,11 @@ export default function AdminMessagesView() {
const [currentTemplateText, setCurrentTemplateText] = useState(''); const [currentTemplateText, setCurrentTemplateText] = useState('');
const [isSavingTemplate, setIsSavingTemplate] = useState(false); const [isSavingTemplate, setIsSavingTemplate] = useState(false);
const [isTestingTemplate, setIsTestingTemplate] = useState<string | null>(null); const [isTestingTemplate, setIsTestingTemplate] = useState<string | null>(null);
const [isSendingMedia, setIsSendingMedia] = useState<string | null>(null);
const [activeMediaTemplate, setActiveMediaTemplate] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Broadcast state // Broadcast state
const [broadcastText, setBroadcastText] = useState(''); const [broadcastText, setBroadcastText] = useState('');
@ -227,6 +230,77 @@ export default function AdminMessagesView() {
} }
}; };
const triggerMediaSelect = (templateId: string, e: React.MouseEvent) => {
e.stopPropagation();
setActiveMediaTemplate(templateId);
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleMediaSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !activeMediaTemplate) return;
if (fileInputRef.current) fileInputRef.current.value = ''; // Reset input
// In a real app, this should test with admin's profile phone number.
// For now we will rely on the backend checking settings for adminWhatsapp? No, the backend route expects `phone`.
// Let's use the admin's whatsapp saved in settings, or prompt if missing.
setIsSendingMedia(activeMediaTemplate);
try {
// First get admin's phone from their profile or settings. We'll fetch profile since it's the admin.
const token = localStorage.getItem('admin_token');
const profileRes = await fetch('/api/users/profile', {
headers: { 'Authorization': `Bearer ${token}` }
});
const profileData = await profileRes.json();
const adminPhone = profileData.whatsapp;
if (!adminPhone) {
setAlertModal({isOpen: true, title: 'Atenção', message: 'Configure seu número de WhatsApp no Perfil do Admin antes de testar mídias.', isError: true});
setIsSendingMedia(null);
setActiveMediaTemplate(null);
return;
}
const reader = new FileReader();
reader.onloadend = async () => {
const result = reader.result as string;
const mediaBase64 = result.split(',')[1];
const mediaType = file.type;
const res = await fetch('/api/admin/whatsapp/send-media', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: adminPhone,
caption: 'Teste de anexo',
mediaBase64,
mediaType,
fileName: file.name
})
});
const data = await res.json();
if (res.ok) {
setAlertModal({isOpen: true, title: 'Mídia Enviada! 📎📱', message: data.message});
} else {
setAlertModal({isOpen: true, title: 'Erro no Teste', message: data.error, isError: true});
}
setIsSendingMedia(null);
setActiveMediaTemplate(null);
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error sending media:', error);
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro interno ao processar e enviar mídia.', isError: true});
setIsSendingMedia(null);
setActiveMediaTemplate(null);
}
};
const insertEmoji = (emoji: string, isForBroadcast = false) => { const insertEmoji = (emoji: string, isForBroadcast = false) => {
const ref = isForBroadcast ? broadcastRef : textareaRef; const ref = isForBroadcast ? broadcastRef : textareaRef;
const setter = isForBroadcast ? setBroadcastText : setCurrentTemplateText; const setter = isForBroadcast ? setBroadcastText : setCurrentTemplateText;
@ -280,7 +354,15 @@ export default function AdminMessagesView() {
]; ];
return ( return (
<div className="space-y-6"> <div className="p-8">
<input
type="file"
ref={fileInputRef}
onChange={handleMediaSelect}
accept="image/*,video/*,application/pdf"
className="hidden"
/>
<div className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div> <div>
<h2 className="text-2xl font-bold text-white flex items-center space-x-2"> <h2 className="text-2xl font-bold text-white flex items-center space-x-2">
@ -486,20 +568,34 @@ export default function AdminMessagesView() {
</div> </div>
{settings?.[t.id] && ( {settings?.[t.id] && (
<button <div className="flex gap-2">
onClick={(e) => handleTestTemplate(t.id, e)} <button
disabled={isTestingTemplate === t.id} onClick={(e) => triggerMediaSelect(t.id, e)}
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300 px-3 py-1 rounded text-[10px] font-bold flex items-center gap-1 transition-colors" disabled={isSendingMedia === t.id}
> className="bg-zinc-800 hover:bg-zinc-700 text-amber-400 hover:text-amber-300 px-2 py-1 rounded text-[10px] font-bold flex items-center gap-1 transition-colors"
{isTestingTemplate === t.id ? ( title="Testar envio de mídia (Imagem, Vídeo, PDF)"
<div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" /> >
) : ( {isSendingMedia === t.id ? (
<> <div className="w-3 h-3 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin" />
<Send className="w-3 h-3" /> ) : (
<span>Testar Envio</span> <Paperclip className="w-3 h-3" />
</> )}
)} </button>
</button> <button
onClick={(e) => handleTestTemplate(t.id, e)}
disabled={isTestingTemplate === t.id}
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300 px-3 py-1 rounded text-[10px] font-bold flex items-center gap-1 transition-colors"
>
{isTestingTemplate === t.id ? (
<div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<Send className="w-3 h-3" />
<span>Testar Envio</span>
</>
)}
</button>
</div>
)} )}
</div> </div>
</div> </div>
@ -707,6 +803,7 @@ export default function AdminMessagesView() {
</div> </div>
</div> </div>
</CustomModal> </CustomModal>
</div>
</div> </div>
); );
} }