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
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
This commit is contained in:
parent
4f5ac1f363
commit
5e4a341e47
65
server.ts
65
server.ts
|
|
@ -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
|
||||
app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||
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
|
||||
app.post('/api/admin/whatsapp/broadcast', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
interface SupportTicket {
|
||||
|
|
@ -108,8 +108,11 @@ export default function AdminMessagesView() {
|
|||
const [currentTemplateText, setCurrentTemplateText] = useState('');
|
||||
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
||||
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 textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Broadcast state
|
||||
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 ref = isForBroadcast ? broadcastRef : textareaRef;
|
||||
const setter = isForBroadcast ? setBroadcastText : setCurrentTemplateText;
|
||||
|
|
@ -280,7 +354,15 @@ export default function AdminMessagesView() {
|
|||
];
|
||||
|
||||
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>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center space-x-2">
|
||||
|
|
@ -486,20 +568,34 @@ export default function AdminMessagesView() {
|
|||
</div>
|
||||
|
||||
{settings?.[t.id] && (
|
||||
<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 className="flex gap-2">
|
||||
<button
|
||||
onClick={(e) => triggerMediaSelect(t.id, e)}
|
||||
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"
|
||||
title="Testar envio de mídia (Imagem, Vídeo, PDF)"
|
||||
>
|
||||
{isSendingMedia === t.id ? (
|
||||
<div className="w-3 h-3 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin" />
|
||||
) : (
|
||||
<Paperclip className="w-3 h-3" />
|
||||
)}
|
||||
</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>
|
||||
|
|
@ -707,6 +803,7 @@ export default function AdminMessagesView() {
|
|||
</div>
|
||||
</div>
|
||||
</CustomModal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue