Timeline consolidation: - Replace multiple LIVE_CHAT_STARTED/ENDED events with single LIVE_CHAT_SUMMARY - Show total duration accumulated across all sessions - Display session count (e.g., "23min 15s total - 3 sessoes") - Show "Ativo" badge when session is active Auto-end inactive chat sessions: - Add cron job running every minute to check inactive sessions - Automatically end sessions after 5 minutes of client inactivity - Mark auto-ended sessions with "(encerrado por inatividade)" flag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
845 lines
24 KiB
TypeScript
845 lines
24 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/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,
|
|
}
|
|
},
|
|
})
|
|
|
|
// ============================================
|
|
// ENCERRAMENTO AUTOMATICO POR INATIVIDADE
|
|
// ============================================
|
|
|
|
// Timeout de inatividade: 5 minutos
|
|
const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000
|
|
|
|
// Mutation interna para encerrar sessoes inativas (chamada pelo cron)
|
|
export const autoEndInactiveSessions = mutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const now = Date.now()
|
|
const cutoffTime = now - INACTIVITY_TIMEOUT_MS
|
|
|
|
// Buscar todas as sessoes ativas com inatividade > 5 minutos
|
|
const inactiveSessions = await ctx.db
|
|
.query("liveChatSessions")
|
|
.filter((q) =>
|
|
q.and(
|
|
q.eq(q.field("status"), "ACTIVE"),
|
|
q.lt(q.field("lastActivityAt"), cutoffTime)
|
|
)
|
|
)
|
|
.collect()
|
|
|
|
let endedCount = 0
|
|
|
|
for (const session of inactiveSessions) {
|
|
// Encerrar a sessao
|
|
await ctx.db.patch(session._id, {
|
|
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: session._id,
|
|
agentId: session.agentId,
|
|
agentName: session.agentSnapshot?.name ?? "Sistema",
|
|
durationMs,
|
|
startedAt: session.startedAt,
|
|
endedAt: now,
|
|
autoEnded: true, // Flag para indicar encerramento automatico
|
|
reason: "inatividade",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
endedCount++
|
|
}
|
|
|
|
return { endedCount }
|
|
},
|
|
})
|
|
|
|
// ============================================
|
|
// 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 }
|
|
},
|
|
})
|