- Ao iniciar chat: inicia timer EXTERNAL automaticamente se nao houver sessao ativa - Ao encerrar chat: pausa timer automaticamente se houver sessao ativa - Adiciona razao de pausa END_LIVE_CHAT para identificar pausas automaticas 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1118 lines
35 KiB
TypeScript
1118 lines
35 KiB
TypeScript
import { v } from "convex/values"
|
|
import { action, mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"
|
|
import { ConvexError } from "convex/values"
|
|
import { api } from "./_generated/api"
|
|
import type { Doc, Id } from "./_generated/dataModel"
|
|
import { sha256 } from "@noble/hashes/sha2.js"
|
|
import { bytesToHex as toHex } from "@noble/hashes/utils.js"
|
|
|
|
// ============================================
|
|
// HELPERS
|
|
// ============================================
|
|
|
|
const utf8 = (s: string) => new TextEncoder().encode(s)
|
|
|
|
function hashToken(token: string) {
|
|
return toHex(sha256(utf8(token)))
|
|
}
|
|
|
|
async function getLastHeartbeatAt(ctx: MutationCtx | QueryCtx, machineId: Id<"machines">) {
|
|
const heartbeat = await ctx.db
|
|
.query("machineHeartbeats")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
.first()
|
|
|
|
if (heartbeat?.lastHeartbeatAt) return heartbeat.lastHeartbeatAt
|
|
|
|
const machine = await ctx.db.get(machineId)
|
|
return machine?.lastHeartbeatAt ?? null
|
|
}
|
|
|
|
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 lastHeartbeatAt = await getLastHeartbeatAt(ctx, machine._id)
|
|
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
|
|
if (!lastHeartbeatAt || 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()
|
|
|
|
// Buscar ultima sessao encerrada usando ordem descendente (otimizado)
|
|
// Nota: se houver muitas sessoes encerradas, pegamos apenas as 10 mais recentes
|
|
const recentEndedSessions = await ctx.db
|
|
.query("liveChatSessions")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.filter((q) => q.eq(q.field("status"), "ENDED"))
|
|
.take(10)
|
|
|
|
const lastEndedSession = recentEndedSessions.reduce(
|
|
(latest, current) =>
|
|
!latest || (current.endedAt ?? 0) > (latest.endedAt ?? 0) ? current : latest,
|
|
null as typeof recentEndedSessions[number] | null
|
|
)
|
|
|
|
// Criar nova sessao
|
|
// unreadByMachine inicia em 0 - a janela de chat só abrirá quando o agente
|
|
// efetivamente enviar uma mensagem nova (incrementado em tickets.postChatMessage)
|
|
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,
|
|
})
|
|
|
|
// Iniciar timer automaticamente se nao houver sessao de trabalho ativa
|
|
// O chat ao vivo eh considerado trabalho EXTERNAL (interacao com cliente)
|
|
let workSessionId: Id<"ticketWorkSessions"> | null = null
|
|
if (!ticket.activeSessionId && ticket.assigneeId) {
|
|
workSessionId = await ctx.db.insert("ticketWorkSessions", {
|
|
ticketId,
|
|
agentId: ticket.assigneeId,
|
|
workType: "EXTERNAL",
|
|
startedAt: now,
|
|
})
|
|
|
|
await ctx.db.patch(ticketId, {
|
|
working: true,
|
|
activeSessionId: workSessionId,
|
|
status: "AWAITING_ATTENDANCE",
|
|
updatedAt: now,
|
|
})
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "WORK_STARTED",
|
|
payload: {
|
|
actorId,
|
|
actorName: agent.name,
|
|
actorAvatar: agent.avatarUrl,
|
|
sessionId: workSessionId,
|
|
workType: "EXTERNAL",
|
|
source: "live_chat_auto",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
}
|
|
|
|
return { sessionId, isNew: true, workSessionStarted: workSessionId !== null }
|
|
},
|
|
})
|
|
|
|
// 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,
|
|
})
|
|
|
|
// Pausar timer automaticamente se houver sessao de trabalho ativa
|
|
let workSessionPaused = false
|
|
const ticket = await ctx.db.get(session.ticketId)
|
|
if (ticket?.activeSessionId) {
|
|
const workSession = await ctx.db.get(ticket.activeSessionId)
|
|
if (workSession && !workSession.stoppedAt) {
|
|
const workDurationMs = now - workSession.startedAt
|
|
const sessionType = (workSession.workType ?? "INTERNAL").toUpperCase()
|
|
const deltaInternal = sessionType === "INTERNAL" ? workDurationMs : 0
|
|
const deltaExternal = sessionType === "EXTERNAL" ? workDurationMs : 0
|
|
|
|
// Encerrar sessao de trabalho
|
|
await ctx.db.patch(ticket.activeSessionId, {
|
|
stoppedAt: now,
|
|
durationMs: workDurationMs,
|
|
pauseReason: "END_LIVE_CHAT",
|
|
pauseNote: "Pausa automática ao encerrar chat ao vivo",
|
|
})
|
|
|
|
// Atualizar ticket
|
|
await ctx.db.patch(session.ticketId, {
|
|
working: false,
|
|
activeSessionId: undefined,
|
|
status: "PAUSED",
|
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + workDurationMs,
|
|
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
|
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
|
updatedAt: now,
|
|
})
|
|
|
|
// Registrar evento de pausa
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: session.ticketId,
|
|
type: "WORK_PAUSED",
|
|
payload: {
|
|
actorId,
|
|
actorName: actor.name,
|
|
actorAvatar: actor.avatarUrl,
|
|
sessionId: workSession._id,
|
|
sessionDurationMs: workDurationMs,
|
|
workType: sessionType,
|
|
pauseReason: "END_LIVE_CHAT",
|
|
pauseReasonLabel: "Chat ao vivo encerrado",
|
|
pauseNote: "Pausa automática ao encerrar chat ao vivo",
|
|
source: "live_chat_auto",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
workSessionPaused = true
|
|
}
|
|
}
|
|
|
|
return { ok: true, workSessionPaused }
|
|
},
|
|
})
|
|
|
|
// ============================================
|
|
// 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 (otimizado para evitar race conditions)
|
|
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
|
|
|
|
// Limitar quantidade de mensagens por chamada para evitar timeout
|
|
const maxMessages = 50
|
|
const messageIdsToProcess = args.messageIds.slice(0, maxMessages)
|
|
|
|
// Buscar todas as mensagens de uma vez
|
|
const messages = await Promise.all(
|
|
messageIdsToProcess.map((id) => ctx.db.get(id))
|
|
)
|
|
|
|
const now = Date.now()
|
|
|
|
// Filtrar mensagens válidas que precisam ser atualizadas
|
|
const messagesToUpdate = messages.filter((message): message is NonNullable<typeof message> => {
|
|
if (!message || message.ticketId.toString() !== args.ticketId.toString()) {
|
|
return false
|
|
}
|
|
const readBy = message.readBy ?? []
|
|
return !readBy.some((r) => r.userId.toString() === userId.toString())
|
|
})
|
|
|
|
// Executar todas as atualizações
|
|
await Promise.all(
|
|
messagesToUpdate.map((message) =>
|
|
ctx.db.patch(message._id, {
|
|
readBy: [...(message.readBy ?? []), { userId, readAt: now }],
|
|
})
|
|
)
|
|
)
|
|
|
|
// Zerar contador de não lidas pela máquina
|
|
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, processed: messagesToUpdate.length }
|
|
},
|
|
})
|
|
|
|
// ============================================
|
|
// 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")
|
|
)
|
|
// Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak)
|
|
.take(50)
|
|
|
|
// Filtrar apenas sessão problemática legada (ID hardcoded)
|
|
// Nota: lastAgentMessageAt pode ser undefined em sessões novas onde o agente ainda não enviou mensagem
|
|
const validSessions = sessions.filter(
|
|
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j"
|
|
)
|
|
|
|
const result = await Promise.all(
|
|
validSessions.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 máquina (otimizado para não carregar tudo)
|
|
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 sessão 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, unreadCount: 0 }
|
|
}
|
|
|
|
// Aplicar limite (máximo 100 mensagens por chamada)
|
|
const limit = Math.min(args.limit ?? 50, 200)
|
|
|
|
// Buscar mensagens usando índice, ordenando no servidor e limitando antes de trazer
|
|
let messagesQuery = ctx.db
|
|
.query("ticketChatMessages")
|
|
.withIndex("by_ticket_created", (q) => q.eq("ticketId", args.ticketId))
|
|
.order("desc")
|
|
|
|
if (args.since) {
|
|
messagesQuery = messagesQuery.filter((q) => q.gt(q.field("createdAt"), args.since!))
|
|
}
|
|
|
|
// Traz do mais recente para o mais antigo e reverte para manter ordem cronológica
|
|
const messages = (await messagesQuery.take(limit)).reverse()
|
|
|
|
// Obter userId da máquina para verificar se é autor
|
|
// Deve refletir o mesmo "userId" usado em postMachineMessage/markMachineMessagesRead
|
|
const machineUserId = machine.assignedUserId ?? machine.linkedUserIds?.[0] ?? ticket.requesterId
|
|
|
|
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 ?? "Usuário",
|
|
authorAvatarUrl: msg.authorSnapshot?.avatarUrl,
|
|
isFromMachine,
|
|
createdAt: msg.createdAt,
|
|
attachments: msg.attachments ?? [],
|
|
}
|
|
})
|
|
|
|
return { messages: result, hasSession: true, unreadCount: session.unreadByMachine ?? 0 }
|
|
},
|
|
})
|
|
|
|
// 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)
|
|
|
|
// Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak)
|
|
const rawSessions = await ctx.db
|
|
.query("liveChatSessions")
|
|
.withIndex("by_machine_status", (q) =>
|
|
q.eq("machineId", machine._id).eq("status", "ACTIVE")
|
|
)
|
|
.take(50)
|
|
|
|
// Filtrar sessões problemáticas (sem campos obrigatórios)
|
|
const sessions = rawSessions.filter(
|
|
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined
|
|
)
|
|
|
|
if (sessions.length === 0) {
|
|
return {
|
|
hasActiveSessions: false,
|
|
sessions: [],
|
|
totalUnread: 0,
|
|
}
|
|
}
|
|
|
|
// Buscar tickets para obter o reference
|
|
const sessionSummaries = await Promise.all(
|
|
sessions.map(async (s) => {
|
|
const ticket = await ctx.db.get(s.ticketId)
|
|
return {
|
|
ticketId: s.ticketId,
|
|
ticketRef: ticket?.reference ?? 0,
|
|
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 lastHeartbeatAt = machine ? await getLastHeartbeatAt(ctx, machine._id) : null
|
|
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
|
|
const machineOnline = lastHeartbeatAt !== null && 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 []
|
|
}
|
|
|
|
const role = agent.role?.toUpperCase() ?? ""
|
|
if (!["ADMIN", "MANAGER", "AGENT"].includes(role)) {
|
|
// Nao expor sessoes de chat para usuarios nao-staff (ex.: portal/collaborator)
|
|
return []
|
|
}
|
|
|
|
// Buscar sessoes ativas do tenant do agente (limitado para evitar OOM)
|
|
const sessions = await ctx.db
|
|
.query("liveChatSessions")
|
|
.withIndex("by_tenant_status", (q) =>
|
|
q.eq("tenantId", agent.tenantId).eq("status", "ACTIVE")
|
|
)
|
|
.take(100)
|
|
|
|
// 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 sessoes do ticket (limitado para evitar OOM em tickets muito antigos)
|
|
const sessions = await ctx.db
|
|
.query("liveChatSessions")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.take(50)
|
|
|
|
if (sessions.length === 0) {
|
|
return { sessions: [], totalMessages: 0 }
|
|
}
|
|
|
|
// Buscar mensagens do ticket (limitado a 500 mais recentes para performance)
|
|
const allMessages = await ctx.db
|
|
.query("ticketChatMessages")
|
|
.withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
|
|
.order("desc")
|
|
.take(500)
|
|
.then((msgs) => msgs.reverse())
|
|
|
|
// 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,
|
|
}
|
|
},
|
|
})
|
|
|
|
// ============================================
|
|
// ENCERRAMENTO AUTOMATICO POR INATIVIDADE
|
|
// ============================================
|
|
|
|
// Timeout de maquina offline: 5 minutos sem heartbeat
|
|
const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000
|
|
|
|
// Timeout de inatividade do chat: 12 horas sem atividade
|
|
// Isso evita acumular sessoes abertas indefinidamente quando usuario esquece de encerrar
|
|
const CHAT_INACTIVITY_TIMEOUT_MS = 12 * 60 * 60 * 1000
|
|
|
|
// Mutation interna para encerrar sessões inativas (chamada pelo cron)
|
|
// Critérios de encerramento:
|
|
// 1. Máquina offline (5 min sem heartbeat)
|
|
// 2. Chat inativo (12 horas sem atividade) - mesmo se máquina online
|
|
// 3. Ticket órfão (sem máquina vinculada)
|
|
export const autoEndInactiveSessions = mutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
console.log("cron: autoEndInactiveSessions iniciado")
|
|
const now = Date.now()
|
|
const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS
|
|
const inactivityCutoff = now - CHAT_INACTIVITY_TIMEOUT_MS
|
|
|
|
// Limitar a 50 sessões por execução para evitar timeout do cron (30s)
|
|
const maxSessionsPerRun = 50
|
|
|
|
// Buscar todas as sessões ativas
|
|
const rawActiveSessions = await ctx.db
|
|
.query("liveChatSessions")
|
|
.withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE"))
|
|
.take(maxSessionsPerRun)
|
|
|
|
// Filtrar sessões problemáticas (sem campos obrigatórios)
|
|
const activeSessions = rawActiveSessions.filter(
|
|
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined
|
|
)
|
|
|
|
let endedCount = 0
|
|
let checkedCount = 0
|
|
const reasons: Record<string, number> = {}
|
|
|
|
for (const session of activeSessions) {
|
|
checkedCount++
|
|
|
|
// Buscar o ticket para obter a máquina
|
|
const ticket = await ctx.db.get(session.ticketId)
|
|
if (!ticket || !ticket.machineId) {
|
|
// Ticket sem máquina - encerrar sessão órfã
|
|
await ctx.db.patch(session._id, {
|
|
status: "ENDED",
|
|
endedAt: now,
|
|
})
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: session.ticketId,
|
|
type: "LIVE_CHAT_ENDED",
|
|
payload: {
|
|
sessionId: session._id,
|
|
agentId: session.agentId,
|
|
agentName: session.agentSnapshot?.name ?? "Sistema",
|
|
durationMs: now - session.startedAt,
|
|
startedAt: session.startedAt,
|
|
endedAt: now,
|
|
autoEnded: true,
|
|
reason: "ticket_sem_maquina",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
endedCount++
|
|
reasons["ticket_sem_maquina"] = (reasons["ticket_sem_maquina"] ?? 0) + 1
|
|
continue
|
|
}
|
|
|
|
// Verificar inatividade do chat (12 horas sem atividade)
|
|
// Isso tem prioridade sobre o status da máquina
|
|
const chatIsInactive = session.lastActivityAt < inactivityCutoff
|
|
if (chatIsInactive) {
|
|
await ctx.db.patch(session._id, {
|
|
status: "ENDED",
|
|
endedAt: now,
|
|
})
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: session.ticketId,
|
|
type: "LIVE_CHAT_ENDED",
|
|
payload: {
|
|
sessionId: session._id,
|
|
agentId: session.agentId,
|
|
agentName: session.agentSnapshot?.name ?? "Sistema",
|
|
durationMs: now - session.startedAt,
|
|
startedAt: session.startedAt,
|
|
endedAt: now,
|
|
autoEnded: true,
|
|
reason: "inatividade_chat",
|
|
inactiveForMs: now - session.lastActivityAt,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
endedCount++
|
|
reasons["inatividade_chat"] = (reasons["inatividade_chat"] ?? 0) + 1
|
|
continue
|
|
}
|
|
|
|
// Verificar heartbeat da máquina
|
|
const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId)
|
|
const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff
|
|
|
|
// Se máquina está online e chat está ativo, manter sessão
|
|
if (machineIsOnline) {
|
|
continue
|
|
}
|
|
|
|
// Máquina está offline - encerrar sessão
|
|
await ctx.db.patch(session._id, {
|
|
status: "ENDED",
|
|
endedAt: now,
|
|
})
|
|
|
|
const durationMs = now - session.startedAt
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: session.ticketId,
|
|
type: "LIVE_CHAT_ENDED",
|
|
payload: {
|
|
sessionId: session._id,
|
|
agentId: session.agentId,
|
|
agentName: session.agentSnapshot?.name ?? "Sistema",
|
|
durationMs,
|
|
startedAt: session.startedAt,
|
|
endedAt: now,
|
|
autoEnded: true,
|
|
reason: "maquina_offline",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
endedCount++
|
|
reasons["maquina_offline"] = (reasons["maquina_offline"] ?? 0) + 1
|
|
}
|
|
|
|
const reasonsSummary = Object.entries(reasons).map(([r, c]) => `${r}=${c}`).join(", ")
|
|
console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (${reasonsSummary || "nenhuma"})`)
|
|
return { endedCount, checkedCount, reasons, hasMore: activeSessions.length === maxSessionsPerRun }
|
|
},
|
|
})
|
|
|
|
// Mutation para corrigir sessoes antigas sem campos obrigatorios
|
|
export const fixLegacySessions = mutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
// IDs problematicos conhecidos - sessoes sem lastAgentMessageAt
|
|
const knownProblematicIds = [
|
|
"pd71bvfbxx7th3npdj519hcf3s7xbe2j",
|
|
]
|
|
|
|
let deleted = 0
|
|
const results: string[] = []
|
|
|
|
for (const sessionId of knownProblematicIds) {
|
|
try {
|
|
// Deletar a sessao problematica diretamente (evita erro de shape ao ler)
|
|
await ctx.db.delete(sessionId as Id<"liveChatSessions">)
|
|
deleted++
|
|
results.push(`${sessionId}: deleted`)
|
|
} catch (error) {
|
|
results.push(`${sessionId}: error - ${error}`)
|
|
}
|
|
}
|
|
|
|
console.log(`fixLegacySessions: deleted=${deleted}, results=${results.join(", ")}`)
|
|
return { deleted, results }
|
|
},
|
|
})
|
|
|
|
// ============================================
|
|
// UPLOAD DE ARQUIVOS (Maquina/Cliente)
|
|
// ============================================
|
|
|
|
// Tipos de arquivo permitidos para upload
|
|
const ALLOWED_MIME_TYPES = [
|
|
// Imagens
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
// Documentos
|
|
"application/pdf",
|
|
"text/plain",
|
|
"application/msword",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"application/vnd.ms-excel",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
]
|
|
|
|
const ALLOWED_EXTENSIONS = [
|
|
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
|
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
|
|
]
|
|
|
|
// Tamanho maximo: 10MB
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
|
|
// Mutation interna para validar token (usada pela action)
|
|
export const validateMachineTokenForUpload = query({
|
|
args: { machineToken: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const tokenHash = hashToken(args.machineToken)
|
|
const tokenRecord = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash))
|
|
.first()
|
|
|
|
if (!tokenRecord) {
|
|
return { valid: false }
|
|
}
|
|
|
|
const machine = await ctx.db.get(tokenRecord.machineId)
|
|
if (!machine || machine.status === "REVOKED") {
|
|
return { valid: false }
|
|
}
|
|
|
|
return { valid: true, tenantId: tokenRecord.tenantId }
|
|
},
|
|
})
|
|
|
|
// Action para gerar URL de upload (validada por token de maquina)
|
|
export const generateMachineUploadUrl = action({
|
|
args: {
|
|
machineToken: v.string(),
|
|
fileName: v.string(),
|
|
fileType: v.string(),
|
|
fileSize: v.number(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
// Validar token
|
|
const validation = await ctx.runQuery(api.liveChat.validateMachineTokenForUpload, {
|
|
machineToken: args.machineToken,
|
|
})
|
|
|
|
if (!validation.valid) {
|
|
throw new ConvexError("Token de máquina inválido")
|
|
}
|
|
|
|
// Validar tipo de arquivo
|
|
const ext = args.fileName.toLowerCase().slice(args.fileName.lastIndexOf("."))
|
|
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
|
throw new ConvexError(`Tipo de arquivo não permitido. Permitidos: ${ALLOWED_EXTENSIONS.join(", ")}`)
|
|
}
|
|
|
|
if (!ALLOWED_MIME_TYPES.includes(args.fileType)) {
|
|
throw new ConvexError("Tipo MIME não permitido")
|
|
}
|
|
|
|
// Validar tamanho
|
|
if (args.fileSize > MAX_FILE_SIZE) {
|
|
throw new ConvexError(`Arquivo muito grande. Máximo: ${MAX_FILE_SIZE / 1024 / 1024}MB`)
|
|
}
|
|
|
|
// Gerar URL de upload
|
|
const uploadUrl = await ctx.storage.generateUploadUrl()
|
|
|
|
return { uploadUrl }
|
|
},
|
|
})
|