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:
parent
409da8afda
commit
115c5128a6
5 changed files with 224 additions and 2 deletions
|
|
@ -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
|
export default crons
|
||||||
|
|
|
||||||
|
|
@ -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)
|
// UPLOAD DE ARQUIVOS (Maquina/Cliente)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,123 @@ const VISIT_COMPLETED_STATUSES = new Set(["done", "no_show", "canceled"]);
|
||||||
|
|
||||||
type AnyCtx = QueryCtx | MutationCtx;
|
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 = {
|
type TemplateSummary = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -2024,7 +2141,7 @@ export const getById = query({
|
||||||
visitPerformedAt: t.visitPerformedAt ?? null,
|
visitPerformedAt: t.visitPerformedAt ?? null,
|
||||||
description: undefined,
|
description: undefined,
|
||||||
customFields: customFieldsRecord,
|
customFields: customFieldsRecord,
|
||||||
timeline: timelineRecords.map((ev) => {
|
timeline: consolidateChatEventsInTimeline(timelineRecords).map((ev) => {
|
||||||
let payload = ev.payload;
|
let payload = ev.payload;
|
||||||
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
||||||
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
|
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
TICKET_LINKED: IconLink,
|
TICKET_LINKED: IconLink,
|
||||||
LIVE_CHAT_STARTED: IconMessage,
|
LIVE_CHAT_STARTED: IconMessage,
|
||||||
LIVE_CHAT_ENDED: IconMessage,
|
LIVE_CHAT_ENDED: IconMessage,
|
||||||
|
LIVE_CHAT_SUMMARY: IconMessage,
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
|
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
|
||||||
|
|
@ -637,6 +638,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
if (entry.type === "LIVE_CHAT_ENDED") {
|
if (entry.type === "LIVE_CHAT_ENDED") {
|
||||||
const agentName = (payload as { agentName?: string }).agentName
|
const agentName = (payload as { agentName?: string }).agentName
|
||||||
const durationMs = (payload as { durationMs?: number }).durationMs
|
const durationMs = (payload as { durationMs?: number }).durationMs
|
||||||
|
const autoEnded = (payload as { autoEnded?: boolean }).autoEnded
|
||||||
const durationFormatted = typeof durationMs === "number" ? formatDuration(durationMs) : null
|
const durationFormatted = typeof durationMs === "number" ? formatDuration(durationMs) : null
|
||||||
message = (
|
message = (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -647,15 +649,48 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
{" "}por <span className="font-semibold text-neutral-900">{agentName}</span>
|
{" "}por <span className="font-semibold text-neutral-900">{agentName}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{autoEnded && (
|
||||||
|
<span className="text-neutral-500"> (encerrado por inatividade)</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{durationFormatted && (
|
{durationFormatted && (
|
||||||
<span className="block text-xs text-neutral-500">
|
<span className="block text-xs text-neutral-500">
|
||||||
Duração: {durationFormatted}
|
Duracao: {durationFormatted}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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 = (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="block text-sm text-neutral-600">
|
||||||
|
<span className="font-semibold text-neutral-800">Chat ao vivo</span>
|
||||||
|
{chatPayload.hasActiveSession && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||||
|
<span className="size-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
Ativo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500">
|
||||||
|
{durationFormatted ? `${durationFormatted} total` : ""}
|
||||||
|
{durationFormatted && sessionCount > 0 ? " • " : ""}
|
||||||
|
{sessionCount} {sessionLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!message) return null
|
if (!message) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,5 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
|
||||||
CUSTOM_FIELDS_UPDATED: "Campos personalizados atualizados",
|
CUSTOM_FIELDS_UPDATED: "Campos personalizados atualizados",
|
||||||
LIVE_CHAT_STARTED: "Chat iniciado",
|
LIVE_CHAT_STARTED: "Chat iniciado",
|
||||||
LIVE_CHAT_ENDED: "Chat finalizado",
|
LIVE_CHAT_ENDED: "Chat finalizado",
|
||||||
|
LIVE_CHAT_SUMMARY: "Chat ao vivo",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue