diff --git a/convex/crons.ts b/convex/crons.ts index eb4b1be..39d744f 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -33,4 +33,12 @@ crons.interval( {} ) +// Encerrar sessoes de chat inativas por mais de 5 minutos (sempre ativo) +crons.interval( + "auto-end-inactive-chat-sessions", + { minutes: 1 }, + api.liveChat.autoEndInactiveSessions, + {} +) + export default crons diff --git a/convex/liveChat.ts b/convex/liveChat.ts index a4c1781..4659bec 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -692,6 +692,67 @@ export const getTicketChatHistory = query({ }, }) +// ============================================ +// 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) // ============================================ diff --git a/convex/tickets.ts b/convex/tickets.ts index ef4d5e2..577b395 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -81,6 +81,123 @@ const VISIT_COMPLETED_STATUSES = new Set(["done", "no_show", "canceled"]); type AnyCtx = QueryCtx | MutationCtx; +// Tipos para eventos de chat +type ChatEventPayload = { + sessionId?: string; + agentId?: string; + agentName?: string; + machineHostname?: string; + durationMs?: number; + startedAt?: number; + endedAt?: number; + autoEnded?: boolean; + reason?: string; +}; + +type TimelineEvent = { + _id: Id<"ticketEvents">; + type: string; + payload?: unknown; + createdAt: number; +}; + +/** + * Consolida eventos de chat ao vivo na timeline. + * Em vez de mostrar "Chat iniciado" e "Chat finalizado" separadamente, + * mostra um unico evento "LIVE_CHAT_SUMMARY" com a duracao total. + */ +function consolidateChatEventsInTimeline(events: TimelineEvent[]): TimelineEvent[] { + const chatStartEvents: TimelineEvent[] = []; + const chatEndEvents: TimelineEvent[] = []; + const otherEvents: TimelineEvent[] = []; + + // Separar eventos de chat dos demais + for (const event of events) { + if (event.type === "LIVE_CHAT_STARTED") { + chatStartEvents.push(event); + } else if (event.type === "LIVE_CHAT_ENDED") { + chatEndEvents.push(event); + } else { + otherEvents.push(event); + } + } + + // Se nao houver eventos de chat, retornar como esta + if (chatStartEvents.length === 0 && chatEndEvents.length === 0) { + return events; + } + + // Calcular duracao total de todas as sessoes encerradas + let totalDurationMs = 0; + const sessionIds = new Set(); + let lastAgentName = ""; + let mostRecentActivity = 0; + let hasActiveSession = false; + + for (const endEvent of chatEndEvents) { + const payload = endEvent.payload as ChatEventPayload | undefined; + if (payload?.durationMs) { + totalDurationMs += payload.durationMs; + } + if (payload?.sessionId) { + sessionIds.add(payload.sessionId); + } + if (payload?.agentName) { + lastAgentName = payload.agentName; + } + if (endEvent.createdAt > mostRecentActivity) { + mostRecentActivity = endEvent.createdAt; + } + } + + // Verificar sessoes iniciadas mas nao encerradas (ativas) + const endedSessionIds = new Set( + chatEndEvents + .map((e) => (e.payload as ChatEventPayload | undefined)?.sessionId) + .filter(Boolean) + ); + + for (const startEvent of chatStartEvents) { + const payload = startEvent.payload as ChatEventPayload | undefined; + const sessionId = payload?.sessionId; + if (sessionId && !endedSessionIds.has(sessionId)) { + hasActiveSession = true; + sessionIds.add(sessionId); + } + if (payload?.agentName && !lastAgentName) { + lastAgentName = payload.agentName; + } + if (startEvent.createdAt > mostRecentActivity) { + mostRecentActivity = startEvent.createdAt; + } + } + + // Se so tem sessao iniciada sem encerrar, mostrar o evento de inicio + if (chatEndEvents.length === 0 && chatStartEvents.length > 0) { + // Retornar o evento de inicio mais recente + const latestStart = chatStartEvents.reduce((a, b) => + a.createdAt > b.createdAt ? a : b + ); + return [...otherEvents, latestStart]; + } + + // Criar evento consolidado + const sessionCount = sessionIds.size || chatEndEvents.length; + const consolidatedEvent: TimelineEvent = { + _id: chatEndEvents[0]._id, // Usar ID do primeiro evento para manter referencia + type: "LIVE_CHAT_SUMMARY", + payload: { + sessionCount, + totalDurationMs, + agentName: lastAgentName || "Agente", + hasActiveSession, + }, + createdAt: mostRecentActivity, + }; + + return [...otherEvents, consolidatedEvent]; +} + type TemplateSummary = { key: string; label: string; @@ -2024,7 +2141,7 @@ export const getById = query({ visitPerformedAt: t.visitPerformedAt ?? null, description: undefined, customFields: customFieldsRecord, - timeline: timelineRecords.map((ev) => { + timeline: consolidateChatEventsInTimeline(timelineRecords).map((ev) => { let payload = ev.payload; if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) { const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null); diff --git a/src/components/tickets/ticket-timeline.tsx b/src/components/tickets/ticket-timeline.tsx index 658f8ee..3ee7b5c 100644 --- a/src/components/tickets/ticket-timeline.tsx +++ b/src/components/tickets/ticket-timeline.tsx @@ -54,6 +54,7 @@ const timelineIcons: Record> = { TICKET_LINKED: IconLink, LIVE_CHAT_STARTED: IconMessage, LIVE_CHAT_ENDED: IconMessage, + LIVE_CHAT_SUMMARY: IconMessage, } const timelineLabels: Record = TICKET_TIMELINE_LABELS @@ -637,6 +638,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { if (entry.type === "LIVE_CHAT_ENDED") { const agentName = (payload as { agentName?: string }).agentName const durationMs = (payload as { durationMs?: number }).durationMs + const autoEnded = (payload as { autoEnded?: boolean }).autoEnded const durationFormatted = typeof durationMs === "number" ? formatDuration(durationMs) : null message = (
@@ -647,15 +649,48 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { {" "}por {agentName} )} + {autoEnded && ( + (encerrado por inatividade) + )} {durationFormatted && ( - Duração: {durationFormatted} + Duracao: {durationFormatted} )}
) } + if (entry.type === "LIVE_CHAT_SUMMARY") { + const chatPayload = payload as { + sessionCount?: number + totalDurationMs?: number + agentName?: string + hasActiveSession?: boolean + } + const sessionCount = chatPayload.sessionCount ?? 1 + const totalDurationMs = chatPayload.totalDurationMs ?? 0 + const durationFormatted = totalDurationMs > 0 ? formatDuration(totalDurationMs) : null + const sessionLabel = sessionCount === 1 ? "sessao" : "sessoes" + message = ( +
+ + Chat ao vivo + {chatPayload.hasActiveSession && ( + + + Ativo + + )} + + + {durationFormatted ? `${durationFormatted} total` : ""} + {durationFormatted && sessionCount > 0 ? " • " : ""} + {sessionCount} {sessionLabel} + +
+ ) + } if (!message) return null return ( diff --git a/src/lib/ticket-timeline-labels.ts b/src/lib/ticket-timeline-labels.ts index 520c688..f604118 100644 --- a/src/lib/ticket-timeline-labels.ts +++ b/src/lib/ticket-timeline-labels.ts @@ -24,4 +24,5 @@ export const TICKET_TIMELINE_LABELS: Record = { CUSTOM_FIELDS_UPDATED: "Campos personalizados atualizados", LIVE_CHAT_STARTED: "Chat iniciado", LIVE_CHAT_ENDED: "Chat finalizado", + LIVE_CHAT_SUMMARY: "Chat ao vivo", };