Consolidate chat timeline events + auto-end inactive sessions

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>
This commit is contained in:
esdrasrenan 2025-12-07 14:44:34 -03:00
parent 409da8afda
commit 115c5128a6
5 changed files with 224 additions and 2 deletions

View file

@ -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<string>();
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);