fix(chat): melhora realtime e anexos no desktop

This commit is contained in:
esdrasrenan 2025-12-12 21:36:32 -03:00
parent 3d45fe3b04
commit 8cf13c43de
5 changed files with 603 additions and 141 deletions

View file

@ -0,0 +1,95 @@
import { z } from "zod"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit"
const attachmentUrlSchema = z.object({
machineToken: z.string().min(1),
ticketId: z.string().min(1),
storageId: z.string().min(1),
})
const CORS_METHODS = "POST, OPTIONS"
export async function OPTIONS(request: Request) {
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
}
// POST /api/machines/chat/attachments/url
// Retorna URL assinada para download/preview de um anexo do chat (validado por machineToken).
export async function POST(request: Request) {
const origin = request.headers.get("origin")
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
let payload
try {
const raw = await request.json()
payload = attachmentUrlSchema.parse(raw)
} catch (error) {
return jsonWithCors(
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
const rateLimit = checkRateLimit(
`chat-attachment:${payload.machineToken}`,
RATE_LIMITS.CHAT_MESSAGES.maxRequests,
RATE_LIMITS.CHAT_MESSAGES.windowMs
)
if (!rateLimit.allowed) {
return jsonWithCors(
{ error: "Rate limit exceeded", retryAfterMs: rateLimit.retryAfterMs },
429,
origin,
CORS_METHODS,
rateLimitHeaders(rateLimit)
)
}
try {
const messagesResult = await client.query(api.liveChat.listMachineMessages, {
machineToken: payload.machineToken,
ticketId: payload.ticketId as Id<"tickets">,
limit: 200,
})
if (!messagesResult.hasSession) {
return jsonWithCors({ error: "Chat nao esta ativo" }, 403, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
}
const attachmentExists = messagesResult.messages.some((message) =>
(message.attachments ?? []).some((att) => String(att.storageId) === payload.storageId)
)
if (!attachmentExists) {
return jsonWithCors({ error: "Anexo nao encontrado" }, 404, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
}
const url = await client.action(api.files.getUrl, { storageId: payload.storageId as Id<"_storage"> })
if (!url) {
return jsonWithCors({ error: "URL nao disponivel" }, 404, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
}
return jsonWithCors({ url }, 200, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
} catch (error) {
console.error("[machines.chat.attachments.url] Falha ao obter URL de anexo", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao obter URL de anexo", details }, 500, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
}
}

View file

@ -58,7 +58,9 @@ type ChatSession = {
ticketRef: number
ticketSubject: string
sessionId: string
agentId: string
unreadCount: number
lastActivityAt: number
}
type UploadedFile = {
@ -242,8 +244,8 @@ export function ChatWidget() {
// pois o chat nativo do Tauri ja esta disponivel
const isTauriContext = typeof window !== "undefined" && "__TAURI__" in window
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
const { convexUserId, isStaff } = useAuth()
const viewerId = isStaff ? (convexUserId ?? null) : null
// Inicializar estado a partir do localStorage (para persistir entre reloads)
const [isOpen, setIsOpen] = useState(() => {
@ -290,8 +292,8 @@ export function ChatWidget() {
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const dropAreaRef = useRef<HTMLDivElement | null>(null)
const prevSessionCountRef = useRef<number>(-1) // -1 indica "ainda nao inicializado"
const hasRestoredStateRef = useRef<boolean>(false) // Flag para evitar sobrescrever estado do localStorage
const hasInitializedSessionsRef = useRef(false)
const prevSessionIdsRef = useRef<Set<string>>(new Set())
// Buscar sessões de chat ativas do agente
const activeSessions = useQuery(
@ -366,40 +368,32 @@ export function ChatWidget() {
}
}, [activeTicketId, activeSessions])
// Auto-abrir widget quando uma nova sessão é iniciada (apenas para sessoes NOVAS, nao na montagem inicial)
// Auto-abrir o widget quando ESTE agente iniciar uma nova sessão de chat.
// Nao roda na montagem inicial para nao sobrescrever o estado do localStorage.
useEffect(() => {
if (!activeSessions) return
const currentCount = activeSessions.length
const prevCount = prevSessionCountRef.current
// Primeira execucao: apenas inicializar o ref, nao abrir automaticamente
// Isso preserva o estado do localStorage (se usuario tinha minimizado, mantem minimizado)
if (prevCount === -1) {
prevSessionCountRef.current = currentCount
hasRestoredStateRef.current = true
const currentIds = new Set(activeSessions.map((s) => s.sessionId))
if (!hasInitializedSessionsRef.current) {
prevSessionIdsRef.current = currentIds
hasInitializedSessionsRef.current = true
return
}
// Se aumentou o número de sessões APOS a montagem inicial, é uma nova sessão - abrir o widget expandido
if (currentCount > prevCount && hasRestoredStateRef.current) {
// O estado do widget e definido com base nas nao lidas.
// Selecionar a sessão mais recente (última da lista ou primeira se única)
const newestSession = activeSessions[activeSessions.length - 1] ?? activeSessions[0]
const hasUnreadForAgent = (newestSession?.unreadCount ?? 0) > 0
const newSessions = activeSessions.filter((s) => !prevSessionIdsRef.current.has(s.sessionId))
prevSessionIdsRef.current = currentIds
if (!isOpen) {
setIsOpen(true)
setIsMinimized(!hasUnreadForAgent)
} else if (isMinimized && hasUnreadForAgent) {
setIsMinimized(false)
}
if (newestSession) {
setActiveTicketId(newestSession.ticketId)
}
}
if (newSessions.length === 0) return
if (!viewerId) return
prevSessionCountRef.current = currentCount
}, [activeSessions, isOpen, isMinimized])
const mine = newSessions.find((s) => s.agentId === viewerId) ?? null
if (!mine) return
setIsOpen(true)
setIsMinimized(false)
setActiveTicketId(mine.ticketId)
}, [activeSessions, viewerId])
// Scroll para última mensagem
useEffect(() => {