Implementa sistema de chat em tempo real entre agente e cliente
- Adiciona tabela liveChatSessions no schema Convex - Cria convex/liveChat.ts com mutations e queries para chat - Adiciona API routes para maquinas (sessions, messages, poll) - Cria modulo chat.rs no Tauri com ChatRuntime e polling - Adiciona comandos de chat no lib.rs (start/stop polling, open/close window) - Cria componentes React do chat widget (ChatWidget, types) - Adiciona botao "Iniciar Chat" no dashboard (ticket-chat-panel) - Implementa menu de chat no system tray - Polling de 2 segundos para maior responsividade - Janela de chat flutuante, frameless, always-on-top 🤖 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
0c8d53c0b6
commit
ba91c1e0f5
15 changed files with 2004 additions and 15 deletions
512
convex/liveChat.ts
Normal file
512
convex/liveChat.ts
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
import { v } from "convex/values"
|
||||
import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"
|
||||
import { ConvexError } from "convex/values"
|
||||
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 maquina invalido")
|
||||
}
|
||||
|
||||
if (tokenRecord.revoked) {
|
||||
throw new ConvexError("Token de maquina revogado")
|
||||
}
|
||||
|
||||
if (tokenRecord.expiresAt < Date.now()) {
|
||||
throw new ConvexError("Token de maquina expirado")
|
||||
}
|
||||
|
||||
const machine = await ctx.db.get(tokenRecord.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Maquina nao 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 nao encontrado")
|
||||
}
|
||||
|
||||
if (!ticket.machineId) {
|
||||
throw new ConvexError("Este ticket nao esta vinculado a uma maquina")
|
||||
}
|
||||
|
||||
// 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("Maquina nao encontrada")
|
||||
}
|
||||
|
||||
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
|
||||
if (!machine.lastHeartbeatAt || machine.lastHeartbeatAt < fiveMinutesAgo) {
|
||||
throw new ConvexError("Maquina offline. A maquina 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 })
|
||||
}
|
||||
|
||||
return { sessionId, isNew: true }
|
||||
},
|
||||
})
|
||||
|
||||
// Agente encerra sessao de chat
|
||||
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("Sessao nao encontrada")
|
||||
}
|
||||
|
||||
// Verificar permissao
|
||||
const agent = await ctx.db.get(actorId)
|
||||
if (!agent || agent.tenantId !== session.tenantId) {
|
||||
throw new ConvexError("Acesso negado")
|
||||
}
|
||||
|
||||
if (session.status !== "ACTIVE") {
|
||||
throw new ConvexError("Sessao ja encerrada")
|
||||
}
|
||||
|
||||
await ctx.db.patch(sessionId, {
|
||||
status: "ENDED",
|
||||
endedAt: Date.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 nao encontrado")
|
||||
}
|
||||
|
||||
if (ticket.machineId?.toString() !== machine._id.toString()) {
|
||||
throw new ConvexError("Esta maquina nao esta 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 sessao 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 (maximo 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 nao encontrado")
|
||||
}
|
||||
|
||||
if (ticket.machineId?.toString() !== machine._id.toString()) {
|
||||
throw new ConvexError("Esta maquina nao esta 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 nao encontrado")
|
||||
}
|
||||
|
||||
if (ticket.machineId?.toString() !== machine._id.toString()) {
|
||||
throw new ConvexError("Esta maquina nao esta 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
|
||||
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
|
||||
}
|
||||
|
||||
const session = await ctx.db
|
||||
.query("liveChatSessions")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
||||
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
|
||||
.first()
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Verificar se maquina esta online
|
||||
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
|
||||
|
||||
return {
|
||||
sessionId: session._id,
|
||||
agentId: session.agentId,
|
||||
agentName: session.agentSnapshot?.name,
|
||||
startedAt: session.startedAt,
|
||||
lastActivityAt: session.lastActivityAt,
|
||||
unreadByAgent: session.unreadByAgent ?? 0,
|
||||
machineOnline: Boolean(machineOnline),
|
||||
}
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue