sistema-de-chamados/convex/liveChat.ts
esdrasrenan 88a3b37f2f Fix chat session management and add floating widget
- Fix session sync: events now send complete ChatSession data instead of
  partial ChatSessionSummary, ensuring proper ticket/agent info display
- Add session-ended event detection to remove closed sessions from client
- Add ChatFloatingWidget component for in-app chat experience
- Restrict endSession to ADMIN/MANAGER/AGENT roles only
- Improve polling logic to detect new and ended sessions properly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 11:16:56 -03:00

692 lines
20 KiB
TypeScript

import { v } from "convex/values"
import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"
import { ConvexError } from "convex/values"
import type { Doc, Id } from "./_generated/dataModel"
import { sha256 } from "@noble/hashes/sha256"
import { bytesToHex as toHex } from "@noble/hashes/utils"
// ============================================
// HELPERS
// ============================================
function hashToken(token: string) {
return toHex(sha256(token))
}
async function validateMachineToken(
ctx: MutationCtx | QueryCtx,
machineToken: string
): Promise<{ machine: Doc<"machines">; tenantId: string }> {
const tokenHash = hashToken(machineToken)
const tokenRecord = await ctx.db
.query("machineTokens")
.withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash))
.first()
if (!tokenRecord) {
throw new ConvexError("Token de máquina inválido")
}
if (tokenRecord.revoked) {
throw new ConvexError("Token de máquina revogado")
}
if (tokenRecord.expiresAt < Date.now()) {
throw new ConvexError("Token de máquina expirado")
}
const machine = await ctx.db.get(tokenRecord.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
}
return { machine, tenantId: tokenRecord.tenantId }
}
// ============================================
// MUTATIONS (Agente)
// ============================================
// Agente inicia sessao de chat com cliente
export const startSession = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
if (!ticket.machineId) {
throw new ConvexError("Este ticket não está vinculado a uma máquina")
}
// Verificar se agente tem permissao
const agent = await ctx.db.get(actorId)
if (!agent || agent.tenantId !== ticket.tenantId) {
throw new ConvexError("Acesso negado")
}
const role = agent.role?.toUpperCase() ?? ""
if (!["ADMIN", "MANAGER", "AGENT"].includes(role)) {
throw new ConvexError("Apenas agentes podem iniciar chat ao vivo")
}
// Verificar se maquina esta online (heartbeat nos ultimos 5 minutos)
const machine = await ctx.db.get(ticket.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
}
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
if (!machine.lastHeartbeatAt || machine.lastHeartbeatAt < fiveMinutesAgo) {
throw new ConvexError("Máquina offline. A máquina precisa estar online para iniciar o chat.")
}
// Verificar se ja existe sessao ativa para este ticket
const existingSession = await ctx.db
.query("liveChatSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
.first()
if (existingSession) {
// Retornar sessao existente
return { sessionId: existingSession._id, isNew: false }
}
const now = Date.now()
// Criar nova sessao
const sessionId = await ctx.db.insert("liveChatSessions", {
tenantId: ticket.tenantId,
ticketId,
machineId: ticket.machineId,
agentId: actorId,
agentSnapshot: {
name: agent.name,
email: agent.email,
avatarUrl: agent.avatarUrl,
},
status: "ACTIVE",
startedAt: now,
lastActivityAt: now,
unreadByMachine: 0,
unreadByAgent: 0,
})
// Habilitar chat no ticket se nao estiver
if (!ticket.chatEnabled) {
await ctx.db.patch(ticketId, { chatEnabled: true })
}
// Registrar evento na timeline
await ctx.db.insert("ticketEvents", {
ticketId,
type: "LIVE_CHAT_STARTED",
payload: {
sessionId,
agentId: actorId,
agentName: agent.name,
machineHostname: machine.hostname,
},
createdAt: now,
})
return { sessionId, isNew: true }
},
})
// Agente encerra sessao de chat (somente ADMIN, MANAGER ou AGENT podem encerrar)
export const endSession = mutation({
args: {
sessionId: v.id("liveChatSessions"),
actorId: v.id("users"),
},
handler: async (ctx, { sessionId, actorId }) => {
const session = await ctx.db.get(sessionId)
if (!session) {
throw new ConvexError("Sessão não encontrada")
}
// Verificar permissao do usuario
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== session.tenantId) {
throw new ConvexError("Acesso negado")
}
// Somente ADMIN, MANAGER ou AGENT podem encerrar sessoes de chat
const role = actor.role?.toUpperCase() ?? ""
if (!["ADMIN", "MANAGER", "AGENT"].includes(role)) {
throw new ConvexError("Apenas agentes de suporte podem encerrar sessões de chat")
}
if (session.status !== "ACTIVE") {
throw new ConvexError("Sessão já encerrada")
}
const now = Date.now()
await ctx.db.patch(sessionId, {
status: "ENDED",
endedAt: now,
})
// Calcular duracao da sessao
const durationMs = now - session.startedAt
// Registrar evento na timeline
await ctx.db.insert("ticketEvents", {
ticketId: session.ticketId,
type: "LIVE_CHAT_ENDED",
payload: {
sessionId,
agentId: actorId,
agentName: actor.name,
durationMs,
startedAt: session.startedAt,
endedAt: now,
},
createdAt: now,
})
return { ok: true }
},
})
// ============================================
// MUTATIONS (Maquina/Cliente)
// ============================================
// Cliente envia mensagem via machineToken
export const postMachineMessage = mutation({
args: {
machineToken: v.string(),
ticketId: v.id("tickets"),
body: v.string(),
attachments: v.optional(
v.array(
v.object({
storageId: v.id("_storage"),
name: v.string(),
size: v.optional(v.number()),
type: v.optional(v.string()),
})
)
),
},
handler: async (ctx, args) => {
const { machine, tenantId } = await validateMachineToken(ctx, args.machineToken)
const ticket = await ctx.db.get(args.ticketId)
if (!ticket || ticket.tenantId !== tenantId) {
throw new ConvexError("Ticket não encontrado")
}
if (ticket.machineId?.toString() !== machine._id.toString()) {
throw new ConvexError("Esta máquina não está vinculada a este ticket")
}
// Verificar se existe sessao ativa
const session = await ctx.db
.query("liveChatSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId))
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
.first()
if (!session) {
throw new ConvexError("Nenhuma sessão de chat ativa para este ticket")
}
// Obter usuario vinculado a maquina (ou usar nome do hostname)
let authorId: Id<"users"> | null = machine.assignedUserId ?? null
let authorSnapshot = {
name: machine.assignedUserName ?? machine.hostname,
email: machine.assignedUserEmail ?? undefined,
avatarUrl: undefined as string | undefined,
}
// Se nao tem usuario vinculado, buscar por linkedUserIds
if (!authorId && machine.linkedUserIds?.length) {
authorId = machine.linkedUserIds[0]
const linkedUser = await ctx.db.get(authorId)
if (linkedUser) {
authorSnapshot = {
name: linkedUser.name,
email: linkedUser.email,
avatarUrl: linkedUser.avatarUrl,
}
}
}
// Se ainda nao tem authorId, criar mensagem sem autor (usando ID do agente temporariamente)
if (!authorId) {
// Usar o primeiro user do tenant como fallback (requester do ticket)
authorId = ticket.requesterId
}
const now = Date.now()
// Limitar tamanho do body
if (args.body.length > 4000) {
throw new ConvexError("Mensagem muito longa (máximo 4000 caracteres)")
}
// Inserir mensagem
const messageId = await ctx.db.insert("ticketChatMessages", {
ticketId: args.ticketId,
authorId,
authorSnapshot,
body: args.body.trim(),
attachments: args.attachments,
createdAt: now,
updatedAt: now,
tenantId,
companyId: ticket.companyId,
readBy: [{ userId: authorId, readAt: now }], // Autor ja leu
})
// Atualizar sessao
await ctx.db.patch(session._id, {
lastActivityAt: now,
unreadByAgent: (session.unreadByAgent ?? 0) + 1,
})
return { messageId, createdAt: now }
},
})
// Cliente marca mensagens como lidas
export const markMachineMessagesRead = mutation({
args: {
machineToken: v.string(),
ticketId: v.id("tickets"),
messageIds: v.array(v.id("ticketChatMessages")),
},
handler: async (ctx, args) => {
const { machine, tenantId } = await validateMachineToken(ctx, args.machineToken)
const ticket = await ctx.db.get(args.ticketId)
if (!ticket || ticket.tenantId !== tenantId) {
throw new ConvexError("Ticket não encontrado")
}
if (ticket.machineId?.toString() !== machine._id.toString()) {
throw new ConvexError("Esta máquina não está vinculada a este ticket")
}
// Obter userId para marcar leitura
const userId = machine.assignedUserId ?? machine.linkedUserIds?.[0] ?? ticket.requesterId
const now = Date.now()
for (const messageId of args.messageIds) {
const message = await ctx.db.get(messageId)
if (!message || message.ticketId.toString() !== args.ticketId.toString()) {
continue
}
const readBy = message.readBy ?? []
const alreadyRead = readBy.some((r) => r.userId.toString() === userId.toString())
if (!alreadyRead) {
await ctx.db.patch(messageId, {
readBy: [...readBy, { userId, readAt: now }],
})
}
}
// Zerar contador de nao lidas pela maquina
const session = await ctx.db
.query("liveChatSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId))
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
.first()
if (session) {
await ctx.db.patch(session._id, { unreadByMachine: 0 })
}
return { ok: true }
},
})
// ============================================
// QUERIES (Maquina/Cliente)
// ============================================
// Listar sessoes ativas para uma maquina
export const listMachineSessions = query({
args: {
machineToken: v.string(),
},
handler: async (ctx, { machineToken }) => {
const { machine, tenantId } = await validateMachineToken(ctx, machineToken)
const sessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_machine_status", (q) =>
q.eq("machineId", machine._id).eq("status", "ACTIVE")
)
.collect()
const result = await Promise.all(
sessions.map(async (session) => {
const ticket = await ctx.db.get(session.ticketId)
return {
sessionId: session._id,
ticketId: session.ticketId,
ticketRef: ticket?.reference ?? 0,
ticketSubject: ticket?.subject ?? "",
agentName: session.agentSnapshot?.name ?? "Agente",
agentEmail: session.agentSnapshot?.email,
agentAvatarUrl: session.agentSnapshot?.avatarUrl,
unreadCount: session.unreadByMachine ?? 0,
lastActivityAt: session.lastActivityAt,
startedAt: session.startedAt,
}
})
)
return result
},
})
// Listar mensagens de um chat para maquina
export const listMachineMessages = query({
args: {
machineToken: v.string(),
ticketId: v.id("tickets"),
since: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const { machine, tenantId } = await validateMachineToken(ctx, args.machineToken)
const ticket = await ctx.db.get(args.ticketId)
if (!ticket || ticket.tenantId !== tenantId) {
throw new ConvexError("Ticket não encontrado")
}
if (ticket.machineId?.toString() !== machine._id.toString()) {
throw new ConvexError("Esta máquina não está vinculada a este ticket")
}
// Buscar sessao ativa
const session = await ctx.db
.query("liveChatSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId))
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
.first()
if (!session) {
return { messages: [], hasSession: false }
}
let query = ctx.db
.query("ticketChatMessages")
.withIndex("by_ticket_created", (q) => q.eq("ticketId", args.ticketId))
const allMessages = await query.collect()
// Filtrar por since se fornecido
let messages = args.since
? allMessages.filter((m) => m.createdAt > args.since!)
: allMessages
// Aplicar limite
const limit = args.limit ?? 50
messages = messages.slice(-limit)
// Obter userId da maquina para verificar se eh autor
const machineUserId = machine.assignedUserId ?? machine.linkedUserIds?.[0]
const result = messages.map((msg) => {
const isFromMachine = machineUserId
? msg.authorId.toString() === machineUserId.toString()
: false
return {
id: msg._id,
body: msg.body,
authorName: msg.authorSnapshot?.name ?? "Usuario",
authorAvatarUrl: msg.authorSnapshot?.avatarUrl,
isFromMachine,
createdAt: msg.createdAt,
attachments: msg.attachments ?? [],
}
})
return { messages: result, hasSession: true }
},
})
// Polling leve para verificar updates
export const checkMachineUpdates = query({
args: {
machineToken: v.string(),
lastCheckedAt: v.optional(v.number()),
},
handler: async (ctx, args) => {
const { machine } = await validateMachineToken(ctx, args.machineToken)
const sessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_machine_status", (q) =>
q.eq("machineId", machine._id).eq("status", "ACTIVE")
)
.collect()
if (sessions.length === 0) {
return {
hasActiveSessions: false,
sessions: [],
totalUnread: 0,
}
}
const sessionSummaries = sessions.map((s) => ({
ticketId: s.ticketId,
unreadCount: s.unreadByMachine ?? 0,
lastActivityAt: s.lastActivityAt,
}))
const totalUnread = sessionSummaries.reduce((sum, s) => sum + s.unreadCount, 0)
return {
hasActiveSessions: true,
sessions: sessionSummaries,
totalUnread,
}
},
})
// ============================================
// QUERIES (Agente)
// ============================================
// Listar sessao ativa de um ticket e status da maquina
export const getTicketSession = query({
args: {
ticketId: v.id("tickets"),
viewerId: v.id("users"),
},
handler: async (ctx, { ticketId, viewerId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
return null
}
const viewer = await ctx.db.get(viewerId)
if (!viewer || viewer.tenantId !== ticket.tenantId) {
return null
}
// Verificar se maquina esta online (sempre, mesmo sem sessao)
const machine = ticket.machineId ? await ctx.db.get(ticket.machineId) : null
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
const machineOnline = machine?.lastHeartbeatAt && machine.lastHeartbeatAt > fiveMinutesAgo
const session = await ctx.db
.query("liveChatSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
.first()
// Sempre retorna status da maquina, mesmo sem sessao ativa
if (!session) {
return {
sessionId: null,
agentId: null,
agentName: null,
startedAt: null,
lastActivityAt: null,
unreadByAgent: 0,
machineOnline: Boolean(machineOnline),
}
}
return {
sessionId: session._id,
agentId: session.agentId,
agentName: session.agentSnapshot?.name,
startedAt: session.startedAt,
lastActivityAt: session.lastActivityAt,
unreadByAgent: session.unreadByAgent ?? 0,
machineOnline: Boolean(machineOnline),
}
},
})
// Listar sessoes ativas de um agente (para widget flutuante)
export const listAgentSessions = query({
args: {
agentId: v.id("users"),
},
handler: async (ctx, { agentId }) => {
const agent = await ctx.db.get(agentId)
if (!agent) {
return []
}
// Buscar todas as sessoes ativas do tenant do agente
const sessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_tenant_status", (q) =>
q.eq("tenantId", agent.tenantId).eq("status", "ACTIVE")
)
.collect()
// Buscar detalhes dos tickets
const result = await Promise.all(
sessions.map(async (session) => {
const ticket = await ctx.db.get(session.ticketId)
return {
ticketId: session.ticketId,
ticketRef: ticket?.reference ?? 0,
ticketSubject: ticket?.subject ?? "",
sessionId: session._id,
agentId: session.agentId,
agentName: session.agentSnapshot?.name ?? "Agente",
unreadCount: session.unreadByAgent ?? 0,
lastActivityAt: session.lastActivityAt,
startedAt: session.startedAt,
}
})
)
// Ordenar por ultima atividade (mais recente primeiro)
return result.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
},
})
// Historico de sessoes de chat de um ticket (para exibicao no painel)
export const getTicketChatHistory = query({
args: {
ticketId: v.id("tickets"),
viewerId: v.id("users"),
},
handler: async (ctx, { ticketId, viewerId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
return { sessions: [], totalMessages: 0 }
}
const viewer = await ctx.db.get(viewerId)
if (!viewer || viewer.tenantId !== ticket.tenantId) {
return { sessions: [], totalMessages: 0 }
}
// Buscar todas as sessoes do ticket (ativas e finalizadas)
const sessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect()
if (sessions.length === 0) {
return { sessions: [], totalMessages: 0 }
}
// Buscar todas as mensagens do ticket
const allMessages = await ctx.db
.query("ticketChatMessages")
.withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
.collect()
// Agrupar mensagens por sessao (baseado no timestamp)
// Mensagens entre startedAt e endedAt pertencem a sessao
const sessionResults = await Promise.all(
sessions
.sort((a, b) => b.startedAt - a.startedAt) // Mais recente primeiro
.map(async (session) => {
const sessionMessages = allMessages.filter((msg) => {
// Mensagem criada durante a sessao
if (msg.createdAt < session.startedAt) return false
if (session.endedAt && msg.createdAt > session.endedAt) return false
return true
})
// Obter nome da maquina
let machineName = "Cliente"
if (ticket.machineId) {
const machine = await ctx.db.get(ticket.machineId)
if (machine?.hostname) {
machineName = machine.hostname
}
}
return {
sessionId: session._id,
agentName: session.agentSnapshot?.name ?? "Agente",
agentEmail: session.agentSnapshot?.email ?? null,
agentAvatarUrl: session.agentSnapshot?.avatarUrl ?? null,
machineName,
status: session.status,
startedAt: session.startedAt,
endedAt: session.endedAt ?? null,
messageCount: sessionMessages.length,
messages: sessionMessages
.sort((a, b) => a.createdAt - b.createdAt)
.map((msg) => ({
id: msg._id,
body: msg.body,
authorName: msg.authorSnapshot?.name ?? "Usuario",
authorId: String(msg.authorId),
createdAt: msg.createdAt,
attachments: (msg.attachments ?? []).map((att) => ({
storageId: att.storageId,
name: att.name,
type: att.type ?? null,
})),
})),
}
})
)
return {
sessions: sessionResults,
totalMessages: allMessages.length,
}
},
})