sistema-de-chamados/convex/liveChat.ts
rever-tecnologia 86f818c6f3 feat(chat): adiciona encerramento automatico por inatividade (12h)
- Sessoes de chat sao encerradas apos 12 horas sem atividade
- Criterios de encerramento automatico:
  1. Maquina offline (5 min sem heartbeat)
  2. Chat inativo (12 horas sem atividade) - NOVO
  3. Ticket orfao (sem maquina vinculada)
- Log detalhado com contagem por motivo de encerramento
- Evento no timeline com reason "inatividade_chat"

Isso evita acumular sessoes abertas indefinidamente
quando usuario esquece de encerrar o chat.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:06:24 -03:00

988 lines
31 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,
})
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 (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)
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 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 sessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_machine_status", (q) =>
q.eq("machineId", machine._id).eq("status", "ACTIVE")
)
.take(50)
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 activeSessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE"))
.take(maxSessionsPerRun)
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 }
},
})
// ============================================
// 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 }
},
})