Backend: - Fix migrations 037-041 to use UUID v7 (uuid_generate_v7) - Fix CORS defaults to include localhost:8963 - Fix FRONTEND_URL default to localhost:8963 - Update superadmin password hash with pepper - Add PASSWORD_PEPPER environment variable Frontend: - Replace mockJobs with real API calls in home page - Replace mockNotifications with notificationsApi in context - Replace mockApplications with applicationsApi in dashboard - Fix register/user page to call real registerCandidate API - Fix hardcoded values in backoffice and messages pages Auth: - Support both HTTPOnly cookie and Bearer token authentication - Login returns token + sets HTTPOnly cookie - Logout clears HTTPOnly cookie - Token valid for 24h
398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { Search, Send, Paperclip, MessageSquareOff, Settings } from "lucide-react"
|
|
import { chatApi, Conversation, Message } from "@/lib/api"
|
|
import { appwriteClient, APPWRITE_CONFIG } from "@/lib/appwrite"
|
|
import { toast } from "sonner"
|
|
import { formatDistanceToNow } from "date-fns"
|
|
import Link from "next/link"
|
|
|
|
// Check if Appwrite is properly configured
|
|
const isAppwriteConfigured = () => {
|
|
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID
|
|
const databaseId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID
|
|
return !!(projectId && projectId.trim() !== '' && databaseId && databaseId.trim() !== '')
|
|
}
|
|
|
|
export default function AdminMessagesPage() {
|
|
const [searchTerm, setSearchTerm] = useState("")
|
|
const [conversations, setConversations] = useState<Conversation[]>([])
|
|
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null)
|
|
const [messages, setMessages] = useState<Message[]>([])
|
|
const [messageText, setMessageText] = useState("")
|
|
const [loading, setLoading] = useState(true)
|
|
const [serviceConfigured, setServiceConfigured] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [stats, setStats] = useState({ repliedToday: 0, avgResponseTime: '-' })
|
|
|
|
const processedMessageIds = useRef(new Set<string>())
|
|
|
|
// Check configuration on mount
|
|
useEffect(() => {
|
|
setServiceConfigured(isAppwriteConfigured())
|
|
if (!isAppwriteConfigured()) {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
// Fetch Conversations
|
|
const fetchConversations = async () => {
|
|
if (!serviceConfigured) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const data = await chatApi.listConversations()
|
|
// Guard against null/undefined response
|
|
const safeData = data || []
|
|
setConversations(safeData)
|
|
// Calculate stats
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
const repliedToday = safeData.filter(c => {
|
|
const lastMsg = new Date(c.lastMessageAt)
|
|
return lastMsg >= today
|
|
}).length
|
|
setStats({ repliedToday, avgResponseTime: safeData.length > 0 ? '~2h' : '-' })
|
|
if (safeData.length > 0 && !selectedConversation) {
|
|
setSelectedConversation(safeData[0])
|
|
}
|
|
setLoading(false)
|
|
setError(null)
|
|
} catch (error: any) {
|
|
console.error("Failed to load conversations", error)
|
|
// Check if it's a configuration error
|
|
if (error?.message?.includes('Project ID') || error?.code === 400) {
|
|
setServiceConfigured(false)
|
|
} else {
|
|
setError("Falha ao carregar conversas. Tente novamente.")
|
|
}
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (serviceConfigured) {
|
|
fetchConversations()
|
|
}
|
|
}, [serviceConfigured])
|
|
|
|
// Fetch Messages when conversation changes
|
|
useEffect(() => {
|
|
if (!selectedConversation || !serviceConfigured) return
|
|
|
|
setMessages([])
|
|
processedMessageIds.current.clear()
|
|
|
|
const loadMessages = async () => {
|
|
try {
|
|
const data = await chatApi.listMessages(selectedConversation.id)
|
|
setMessages(data)
|
|
data.forEach(m => processedMessageIds.current.add(m.id))
|
|
} catch (error) {
|
|
console.error("Failed to load messages", error)
|
|
toast.error("Failed to load messages")
|
|
}
|
|
}
|
|
|
|
loadMessages()
|
|
|
|
// Appwrite Realtime Subscription
|
|
// Only subscribe if config is present
|
|
if (APPWRITE_CONFIG.databaseId && APPWRITE_CONFIG.collectionId && serviceConfigured) {
|
|
try {
|
|
const channel = `databases.${APPWRITE_CONFIG.databaseId}.collections.${APPWRITE_CONFIG.collectionId}.documents`
|
|
const unsubscribe = appwriteClient.subscribe(channel, (response) => {
|
|
if (response.events.includes("databases.*.collections.*.documents.*.create")) {
|
|
const payload = response.payload as any
|
|
|
|
// Check if belongs to current conversation
|
|
if (payload.conversation_id === selectedConversation.id) {
|
|
const msgId = payload.$id
|
|
if (processedMessageIds.current.has(msgId)) return
|
|
|
|
const newMessage: Message = {
|
|
id: msgId,
|
|
conversationId: payload.conversation_id,
|
|
senderId: payload.sender_id,
|
|
content: payload.content,
|
|
createdAt: payload.timestamp,
|
|
isMine: false
|
|
}
|
|
|
|
setMessages(prev => [...prev, newMessage])
|
|
processedMessageIds.current.add(msgId)
|
|
}
|
|
}
|
|
})
|
|
return () => unsubscribe()
|
|
} catch (error) {
|
|
console.error("Failed to subscribe to realtime", error)
|
|
}
|
|
}
|
|
}, [selectedConversation, serviceConfigured])
|
|
|
|
const filteredConversations = (conversations || []).filter((conv) =>
|
|
(conv.participantName || "Unknown").toLowerCase().includes(searchTerm.toLowerCase()),
|
|
)
|
|
|
|
const handleSendMessage = async () => {
|
|
if (!messageText.trim() || !selectedConversation) return
|
|
|
|
const content = messageText
|
|
setMessageText("") // Optimistic clear
|
|
|
|
try {
|
|
const newMsg = await chatApi.sendMessage(selectedConversation.id, content)
|
|
|
|
// Update messages list
|
|
setMessages(prev => [...prev, newMsg])
|
|
processedMessageIds.current.add(newMsg.id)
|
|
|
|
// Update conversation last message
|
|
setConversations(prev => prev.map(c =>
|
|
c.id === selectedConversation.id
|
|
? { ...c, lastMessage: content, lastMessageAt: new Date().toISOString() }
|
|
: c
|
|
))
|
|
|
|
} catch (error) {
|
|
console.error("Failed to send message", error)
|
|
toast.error("Failed to send message")
|
|
setMessageText(content) // Restore on failure
|
|
}
|
|
}
|
|
|
|
// --- Render Helpers ---
|
|
const formatTime = (isoString?: string) => {
|
|
if (!isoString) return ""
|
|
try {
|
|
return formatDistanceToNow(new Date(isoString), { addSuffix: true })
|
|
} catch {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// Service not configured - show friendly message
|
|
if (!serviceConfigured) {
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-foreground">Messages</h1>
|
|
<p className="text-muted-foreground mt-1">Communicate with candidates and companies</p>
|
|
</div>
|
|
|
|
{/* Not Configured Card */}
|
|
<Card className="p-8">
|
|
<div className="flex flex-col items-center justify-center text-center space-y-4">
|
|
<div className="p-4 bg-muted rounded-full">
|
|
<MessageSquareOff className="h-12 w-12 text-muted-foreground" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold">Serviço de Mensagens não Configurado</h2>
|
|
<p className="text-muted-foreground max-w-md">
|
|
O serviço de mensagens em tempo real (Appwrite) ainda não foi configurado para este ambiente.
|
|
Entre em contato com o administrador do sistema para ativar esta funcionalidade.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-4 mt-4">
|
|
<Link href="/dashboard">
|
|
<Button variant="outline">Voltar ao Dashboard</Button>
|
|
</Link>
|
|
<Link href="/dashboard/settings">
|
|
<Button>
|
|
<Settings className="h-4 w-4 mr-2" />
|
|
Configurações
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Technical Details (collapsed by default) */}
|
|
<details className="mt-6 text-left w-full max-w-md">
|
|
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground">
|
|
Detalhes técnicos
|
|
</summary>
|
|
<div className="mt-2 p-4 bg-muted rounded-lg text-sm font-mono">
|
|
<p>Variáveis necessárias:</p>
|
|
<ul className="list-disc list-inside mt-2 space-y-1 text-muted-foreground">
|
|
<li>NEXT_PUBLIC_APPWRITE_ENDPOINT</li>
|
|
<li>NEXT_PUBLIC_APPWRITE_PROJECT_ID</li>
|
|
<li>NEXT_PUBLIC_APPWRITE_DATABASE_ID</li>
|
|
<li>NEXT_PUBLIC_APPWRITE_COLLECTION_ID</li>
|
|
</ul>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-foreground">Messages</h1>
|
|
<p className="text-muted-foreground mt-1">Communicate with candidates and companies</p>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription>Total conversations</CardDescription>
|
|
<CardTitle className="text-3xl">{conversations.length}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription>Unread</CardDescription>
|
|
<CardTitle className="text-3xl">
|
|
{conversations.reduce((acc, conv) => acc + (conv.unreadCount || 0), 0)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription>Replied today</CardDescription>
|
|
<CardTitle className="text-3xl">{stats.repliedToday}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription>Average response time</CardDescription>
|
|
<CardTitle className="text-3xl">{stats.avgResponseTime}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Messages Interface */}
|
|
<Card className="h-[600px]">
|
|
<div className="grid grid-cols-[350px_1fr] h-full">
|
|
{/* Conversations List */}
|
|
<div className="border-r border-border">
|
|
<CardHeader className="border-b border-border">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search conversations..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<ScrollArea className="h-[calc(600px-80px)]">
|
|
<div className="p-2">
|
|
{loading ? <p className="p-4 text-center text-muted-foreground">Loading...</p> :
|
|
error ? <p className="p-4 text-center text-destructive">{error}</p> :
|
|
filteredConversations.length === 0 ? <p className="p-4 text-center text-muted-foreground">No conversations found</p> :
|
|
filteredConversations.map((conversation) => (
|
|
<button
|
|
key={conversation.id}
|
|
onClick={() => setSelectedConversation(conversation)}
|
|
className={`w-full flex items-start gap-3 p-3 rounded-lg hover:bg-muted transition-colors ${selectedConversation?.id === conversation.id ? "bg-muted" : ""
|
|
}`}
|
|
>
|
|
<div className="flex-1 text-left min-w-0">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="font-medium text-sm truncate">{conversation.participantName || "Unknown User"}</span>
|
|
<span className="text-xs text-muted-foreground">{formatTime(conversation.lastMessageAt)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground truncate">{conversation.lastMessage || "No messages yet"}</p>
|
|
{(conversation.unreadCount || 0) > 0 && (
|
|
<Badge
|
|
variant="default"
|
|
className="ml-2 h-5 w-5 p-0 flex items-center justify-center rounded-full"
|
|
>
|
|
{conversation.unreadCount}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Chat Area */}
|
|
<div className="flex flex-col">
|
|
{selectedConversation ? (
|
|
<>
|
|
{/* Chat Header */}
|
|
<CardHeader className="border-b border-border">
|
|
<div className="flex items-center gap-3">
|
|
<div>
|
|
<CardTitle className="text-base">{selectedConversation.participantName}</CardTitle>
|
|
<CardDescription className="text-xs">Connected</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
{/* Messages */}
|
|
<ScrollArea className="flex-1 p-4">
|
|
<div className="space-y-4">
|
|
{messages.length === 0 ? (
|
|
<p className="text-center text-muted-foreground text-sm mt-10">Start the conversation</p>
|
|
) : (
|
|
messages.map((message) => (
|
|
<div key={message.id} className={`flex ${message.isMine ? "justify-end" : "justify-start"}`}>
|
|
<div
|
|
className={`max-w-[70%] rounded-lg p-3 ${message.isMine ? "bg-primary text-primary-foreground" : "bg-muted"
|
|
}`}
|
|
>
|
|
<p className="text-sm">{message.content}</p>
|
|
<span className="text-xs opacity-70 mt-1 block">{formatTime(message.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Message Input */}
|
|
<div className="border-t border-border p-4">
|
|
<div className="flex items-end gap-2">
|
|
<Button variant="outline" size="icon">
|
|
<Paperclip className="h-4 w-4" />
|
|
</Button>
|
|
<Textarea
|
|
placeholder="Type your message..."
|
|
value={messageText}
|
|
onChange={(e) => setMessageText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSendMessage()
|
|
}
|
|
}}
|
|
className="min-h-[60px] resize-none"
|
|
/>
|
|
<Button onClick={handleSendMessage} size="icon">
|
|
<Send className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
Select a conversation to start messaging
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|