fix(chat): melhora realtime e anexos no desktop
This commit is contained in:
parent
3d45fe3b04
commit
8cf13c43de
5 changed files with 603 additions and 141 deletions
95
src/app/api/machines/chat/attachments/url/route.ts
Normal file
95
src/app/api/machines/chat/attachments/url/route.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue