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/sha2.js" import { bytesToHex as toHex } from "@noble/hashes/utils.js" // ============================================ // HELPERS // ============================================ const utf8 = (s: string) => new TextEncoder().encode(s) function hashToken(token: string) { return toHex(sha256(utf8(token))) } async function getLastHeartbeatAt(ctx: MutationCtx | QueryCtx, machineId: Id<"machines">) { const heartbeat = await ctx.db .query("machineHeartbeats") .withIndex("by_machine", (q) => q.eq("machineId", machineId)) .first() if (heartbeat?.lastHeartbeatAt) return heartbeat.lastHeartbeatAt const machine = await ctx.db.get(machineId) return machine?.lastHeartbeatAt ?? null } 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 lastHeartbeatAt = await getLastHeartbeatAt(ctx, machine._id) const fiveMinutesAgo = Date.now() - 5 * 60 * 1000 if (!lastHeartbeatAt || 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() // Buscar ultima sessao encerrada usando ordem descendente (otimizado) // Nota: se houver muitas sessoes encerradas, pegamos apenas as 10 mais recentes const recentEndedSessions = await ctx.db .query("liveChatSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .filter((q) => q.eq(q.field("status"), "ENDED")) .take(10) const lastEndedSession = recentEndedSessions.reduce( (latest, current) => !latest || (current.endedAt ?? 0) > (latest.endedAt ?? 0) ? current : latest, null as typeof recentEndedSessions[number] | null ) // Criar nova sessao // unreadByMachine inicia em 0 - a janela de chat só abrirá quando o agente // efetivamente enviar uma mensagem nova (incrementado em tickets.postChatMessage) 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 (otimizado para evitar race conditions) 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 // Limitar quantidade de mensagens por chamada para evitar timeout const maxMessages = 50 const messageIdsToProcess = args.messageIds.slice(0, maxMessages) // Buscar todas as mensagens de uma vez const messages = await Promise.all( messageIdsToProcess.map((id) => ctx.db.get(id)) ) const now = Date.now() // Filtrar mensagens válidas que precisam ser atualizadas const messagesToUpdate = messages.filter((message): message is NonNullable => { if (!message || message.ticketId.toString() !== args.ticketId.toString()) { return false } const readBy = message.readBy ?? [] return !readBy.some((r) => r.userId.toString() === userId.toString()) }) // Executar todas as atualizações await Promise.all( messagesToUpdate.map((message) => ctx.db.patch(message._id, { readBy: [...(message.readBy ?? []), { userId, readAt: now }], }) ) ) // Zerar contador de não lidas pela máquina 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, processed: messagesToUpdate.length } }, }) // ============================================ // 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") ) // Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak) .take(50) 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 máquina (otimizado para não carregar tudo) 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 sessão 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, unreadCount: 0 } } // Aplicar limite (máximo 100 mensagens por chamada) const limit = Math.min(args.limit ?? 50, 200) // Buscar mensagens usando índice, ordenando no servidor e limitando antes de trazer let messagesQuery = ctx.db .query("ticketChatMessages") .withIndex("by_ticket_created", (q) => q.eq("ticketId", args.ticketId)) .order("desc") if (args.since) { messagesQuery = messagesQuery.filter((q) => q.gt(q.field("createdAt"), args.since!)) } // Traz do mais recente para o mais antigo e reverte para manter ordem cronológica const messages = (await messagesQuery.take(limit)).reverse() // Obter userId da máquina para verificar se é 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 ?? "Usuário", authorAvatarUrl: msg.authorSnapshot?.avatarUrl, isFromMachine, createdAt: msg.createdAt, attachments: msg.attachments ?? [], } }) return { messages: result, hasSession: true, unreadCount: session.unreadByMachine ?? 0 } }, }) // 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) // Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak) const sessions = await ctx.db .query("liveChatSessions") .withIndex("by_machine_status", (q) => q.eq("machineId", machine._id).eq("status", "ACTIVE") ) .take(50) if (sessions.length === 0) { return { hasActiveSessions: false, sessions: [], totalUnread: 0, } } // Buscar tickets para obter o reference const sessionSummaries = await Promise.all( sessions.map(async (s) => { const ticket = await ctx.db.get(s.ticketId) return { ticketId: s.ticketId, ticketRef: ticket?.reference ?? 0, 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 lastHeartbeatAt = machine ? await getLastHeartbeatAt(ctx, machine._id) : null const fiveMinutesAgo = Date.now() - 5 * 60 * 1000 const machineOnline = lastHeartbeatAt !== null && 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 sessoes ativas do tenant do agente (limitado para evitar OOM) const sessions = await ctx.db .query("liveChatSessions") .withIndex("by_tenant_status", (q) => q.eq("tenantId", agent.tenantId).eq("status", "ACTIVE") ) .take(100) // 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 sessoes do ticket (limitado para evitar OOM em tickets muito antigos) const sessions = await ctx.db .query("liveChatSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .take(50) if (sessions.length === 0) { return { sessions: [], totalMessages: 0 } } // Buscar mensagens do ticket (limitado a 500 mais recentes para performance) const allMessages = await ctx.db .query("ticketChatMessages") .withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId)) .order("desc") .take(500) .then((msgs) => msgs.reverse()) // 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 maquina offline: 5 minutos sem heartbeat const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000 // Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron) // Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat // Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens export const autoEndInactiveSessions = mutation({ args: {}, handler: async (ctx) => { console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)") const now = Date.now() const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS // Limitar a 50 sessões por execução para evitar timeout do cron (30s) const maxSessionsPerRun = 50 // Buscar todas as sessões ativas const activeSessions = await ctx.db .query("liveChatSessions") .withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE")) .take(maxSessionsPerRun) let endedCount = 0 let checkedCount = 0 for (const session of activeSessions) { checkedCount++ // Buscar o ticket para obter a máquina const ticket = await ctx.db.get(session.ticketId) if (!ticket || !ticket.machineId) { // Ticket sem máquina - encerrar sessão órfã await ctx.db.patch(session._id, { status: "ENDED", endedAt: now, }) 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: now - session.startedAt, startedAt: session.startedAt, endedAt: now, autoEnded: true, reason: "ticket_sem_maquina", }, createdAt: now, }) endedCount++ continue } // Verificar heartbeat da máquina const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId) const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff // Se máquina está online, manter sessão ativa if (machineIsOnline) { continue } // Máquina está offline - encerrar sessão await ctx.db.patch(session._id, { status: "ENDED", endedAt: now, }) const durationMs = now - session.startedAt 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, reason: "maquina_offline", }, createdAt: now, }) endedCount++ } console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`) return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun } }, }) // ============================================ // 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 } }, })