import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import type { MutationCtx } from "./_generated/server"; import { api } from "./_generated/api"; import { getCurrentUserFunction } from "./auth"; import type { Doc, Id } from "./_generated/dataModel"; const ticketStatusValidator = v.union( v.literal("aberto"), v.literal("em_andamento"), v.literal("aguardando_usuario"), v.literal("resolvido"), v.literal("encerrado"), v.literal("cancelado") ); const ticketTipoValidator = v.union( v.literal("reclamacao"), v.literal("elogio"), v.literal("sugestao"), v.literal("chamado") ); const prioridadeValidator = v.union( v.literal("baixa"), v.literal("media"), v.literal("alta"), v.literal("critica") ); const arquivoValidator = v.object({ arquivoId: v.id("_storage"), nome: v.optional(v.string()), tipo: v.optional(v.string()), tamanho: v.optional(v.number()), }); async function assertAuth(ctx: Parameters[0]) { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error("Usuário não autenticado"); } return usuario; } type TicketDoc = Doc<"tickets">; type SlaDoc = Doc<"slaConfigs"> | null; function gerarNumeroTicket(): string { const agora = new Date(); const ano = agora.getFullYear(); const mes = String(agora.getMonth() + 1).padStart(2, "0"); const sequencia = Math.floor(Math.random() * 9000 + 1000); return `SGSE-${ano}${mes}-${sequencia}`; } function calcularPrazos(base: number, sla: SlaDoc) { const horaMs = 60 * 60 * 1000; const tempoResposta = sla?.tempoRespostaHoras ?? 4; const tempoConclusao = sla?.tempoConclusaoHoras ?? 24; const tempoEncerramento = sla?.tempoEncerramentoHoras ?? null; return { resposta: base + tempoResposta * horaMs, conclusao: base + tempoConclusao * horaMs, encerramento: tempoEncerramento ? base + tempoEncerramento * horaMs : null, }; } function montarTimeline(base: number, prazos: ReturnType) { const timeline: NonNullable = [ { etapa: "abertura", status: "concluido", prazo: base, concluidoEm: base, observacao: "Chamado registrado com sucesso", }, { etapa: "resposta_inicial", status: "pendente", prazo: prazos.resposta, }, { etapa: "conclusao", status: "pendente", prazo: prazos.conclusao, }, ]; if (prazos.encerramento) { timeline.push({ etapa: "encerramento", status: "pendente", prazo: prazos.encerramento, }); } return timeline; } async function selecionarSlaConfig( ctx: Parameters[0], prioridade: "baixa" | "media" | "alta" | "critica" ): Promise | null> { return await ctx.db .query("slaConfigs") .withIndex("by_prioridade", (q) => q.eq("prioridade", prioridade).eq("ativo", true)) .first(); } async function registrarNotificacoes( ctx: MutationCtx, params: { ticket: Doc<"tickets">; titulo: string; mensagem: string; usuarioEvento: Id<"usuarios">; } ) { const { ticket, titulo, mensagem, usuarioEvento } = params; // Obter URL do sistema let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; if (!urlSistema.match(/^https?:\/\//i)) { urlSistema = `http://${urlSistema}`; } // Notificar solicitante if (ticket.solicitanteEmail) { // Tentar usar template, senão usar envio direto try { await ctx.runAction(api.email.enviarEmailComTemplate, { destinatario: ticket.solicitanteEmail, destinatarioId: ticket.solicitanteId, templateCodigo: "chamado_atualizado", variaveis: { solicitante: ticket.solicitanteNome || "Usuário", numeroTicket: ticket.numero, mensagem: mensagem, urlSistema, }, enviadoPor: usuarioEvento, }); } catch (error) { // Fallback para envio direto await ctx.runMutation(api.email.enfileirarEmail, { destinatario: ticket.solicitanteEmail, destinatarioId: ticket.solicitanteId, assunto: `${titulo} - Chamado ${ticket.numero}`, corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, enviadoPor: usuarioEvento, }); } } await ctx.db.insert("notificacoes", { usuarioId: ticket.solicitanteId, tipo: "nova_mensagem", ...(ticket.conversaId ? { conversaId: ticket.conversaId } : {}), remetenteId: usuarioEvento, titulo, descricao: mensagem.length > 120 ? `${mensagem.slice(0, 117)}...` : mensagem, lida: false, criadaEm: Date.now(), }); // Notificar responsável (se houver) if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) { const responsavel = await ctx.db.get(ticket.responsavelId); if (responsavel?.email) { // Tentar usar template, senão usar envio direto try { await ctx.runAction(api.email.enviarEmailComTemplate, { destinatario: responsavel.email, destinatarioId: ticket.responsavelId, templateCodigo: "chamado_atualizado", variaveis: { solicitante: ticket.solicitanteNome || "Usuário", numeroTicket: ticket.numero, mensagem: mensagem, urlSistema, }, enviadoPor: usuarioEvento, }); } catch (error) { // Fallback para envio direto await ctx.runMutation(api.email.enfileirarEmail, { destinatario: responsavel.email, destinatarioId: ticket.responsavelId, assunto: `${titulo} - Chamado ${ticket.numero}`, corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, enviadoPor: usuarioEvento, }); } } await ctx.db.insert("notificacoes", { usuarioId: ticket.responsavelId, tipo: "nova_mensagem", ...(ticket.conversaId ? { conversaId: ticket.conversaId } : {}), remetenteId: usuarioEvento, titulo, descricao: mensagem.length > 120 ? `${mensagem.slice(0, 117)}...` : mensagem, lida: false, criadaEm: Date.now(), }); } } async function registrarInteracao( ctx: MutationCtx, params: { ticketId: Id<"tickets">; autorId: Id<"usuarios"> | null; origem: "usuario" | "ti" | "sistema"; tipo: "mensagem" | "status" | "anexo" | "alerta"; conteudo: string; visibilidade?: "publico" | "interno"; anexos?: Array<{ arquivoId: Id<"_storage">; nome?: string; tipo?: string; tamanho?: number; }>; statusAnterior?: TicketDoc["status"]; statusNovo?: TicketDoc["status"]; } ) { return await ctx.db.insert("ticketInteractions", { ticketId: params.ticketId, autorId: params.autorId || undefined, origem: params.origem, tipo: params.tipo, conteudo: params.conteudo, visibilidade: params.visibilidade ?? "publico", anexos: params.anexos, statusAnterior: params.statusAnterior, statusNovo: params.statusNovo, criadoEm: Date.now(), }); } export const abrirChamado = mutation({ args: { titulo: v.string(), descricao: v.string(), tipo: ticketTipoValidator, categoria: v.optional(v.string()), prioridade: prioridadeValidator, anexos: v.optional(v.array(arquivoValidator)), canalOrigem: v.optional(v.string()), }, returns: v.object({ ticketId: v.id("tickets"), numero: v.string(), }), handler: async (ctx, args) => { const usuario = await assertAuth(ctx); const agora = Date.now(); const sla = await selecionarSlaConfig(ctx, args.prioridade); const prazos = calcularPrazos(agora, sla); const timeline = montarTimeline(agora, prazos); const ticketId = await ctx.db.insert("tickets", { numero: gerarNumeroTicket(), titulo: args.titulo.trim(), descricao: args.descricao.trim(), tipo: args.tipo, categoria: args.categoria, status: "aberto", prioridade: args.prioridade, solicitanteId: usuario._id, solicitanteNome: usuario.nome, solicitanteEmail: usuario.email, responsavelId: undefined, setorResponsavel: undefined, slaConfigId: sla?._id, conversaId: undefined, prazoResposta: prazos.resposta, prazoConclusao: prazos.conclusao, prazoEncerramento: prazos.encerramento ?? undefined, timeline, alertasEmitidos: [], anexos: args.anexos, tags: undefined, canalOrigem: args.canalOrigem, ultimaInteracaoEm: agora, criadoEm: agora, atualizadoEm: agora, }); await registrarInteracao(ctx, { ticketId, autorId: usuario._id, origem: "usuario", tipo: "mensagem", conteudo: args.descricao, anexos: args.anexos, }); const ticket = await ctx.db.get(ticketId); if (ticket) { await registrarNotificacoes(ctx, { ticket, titulo: "Chamado registrado", mensagem: "Recebemos sua solicitação e iniciaremos o atendimento em breve.", usuarioEvento: usuario._id, }); } return { ticketId, numero: ticket ? ticket.numero : "", }; }, }); export const listarChamadosUsuario = query({ args: {}, handler: async (ctx) => { const usuario = await assertAuth(ctx); const tickets = await ctx.db .query("tickets") .withIndex("by_solicitante", (q) => q.eq("solicitanteId", usuario._id)) .collect(); tickets.sort((a, b) => b.criadoEm - a.criadoEm); return tickets; }, }); export const listarChamadosTI = query({ args: { status: v.optional(ticketStatusValidator), responsavelId: v.optional(v.id("usuarios")), setor: v.optional(v.string()), limite: v.optional(v.number()), }, handler: async (ctx, args) => { // Permitir apenas usuários autenticados (regras detalhadas devem ser aplicadas no frontend) await assertAuth(ctx); let tickets: Array> = []; if (args.responsavelId) { tickets = await ctx.db .query("tickets") .withIndex("by_responsavel", (q) => q.eq("responsavelId", args.responsavelId).eq("status", args.status ?? "aberto") ) .collect(); } else if (args.status) { tickets = await ctx.db .query("tickets") .withIndex("by_status", (q) => q.eq("status", args.status!)) .collect(); } else { tickets = await ctx.db.query("tickets").collect(); } const filtrados = tickets.filter((ticket) => { if (args.setor && ticket.setorResponsavel !== args.setor) { return false; } return true; }); // Enriquecer tickets com nome do responsável const ticketsEnriquecidos = await Promise.all( filtrados.map(async (ticket) => { let responsavelNome: string | undefined = undefined; if (ticket.responsavelId) { const responsavel = await ctx.db.get(ticket.responsavelId); responsavelNome = responsavel?.nome; } return { ...ticket, responsavelNome, }; }) ); ticketsEnriquecidos.sort((a, b) => b.atualizadoEm - a.atualizadoEm); return args.limite ? ticketsEnriquecidos.slice(0, args.limite) : ticketsEnriquecidos; }, }); export const obterChamado = query({ args: { ticketId: v.id("tickets"), }, handler: async (ctx, args) => { const usuario = await assertAuth(ctx); const ticket = await ctx.db.get(args.ticketId); if (!ticket) { throw new Error("Chamado não encontrado"); } const podeVer = ticket.solicitanteId === usuario._id || ticket.responsavelId === usuario._id || ticket.setorResponsavel === usuario.setor; if (!podeVer) { throw new Error("Acesso negado ao chamado"); } const interactions = await ctx.db .query("ticketInteractions") .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) .collect(); interactions.sort((a, b) => a.criadoEm - b.criadoEm); return { ticket, interactions, }; }, }); export const registrarAtualizacao = mutation({ args: { ticketId: v.id("tickets"), conteudo: v.string(), anexos: v.optional(v.array(arquivoValidator)), visibilidade: v.optional(v.union(v.literal("publico"), v.literal("interno"))), proximoStatus: v.optional(ticketStatusValidator), }, handler: async (ctx, args) => { const usuario = await assertAuth(ctx); const ticket = await ctx.db.get(args.ticketId); if (!ticket) { throw new Error("Chamado não encontrado"); } const agora = Date.now(); let novoStatus = ticket.status; if (args.proximoStatus && args.proximoStatus !== ticket.status) { novoStatus = args.proximoStatus; await ctx.db.patch(ticket._id, { status: novoStatus, atualizadoEm: agora, ultimaInteracaoEm: agora, }); } else { await ctx.db.patch(ticket._id, { atualizadoEm: agora, ultimaInteracaoEm: agora, }); } await registrarInteracao(ctx, { ticketId: ticket._id, autorId: usuario._id, origem: "ti", tipo: args.proximoStatus ? "status" : "mensagem", conteudo: args.conteudo, visibilidade: args.visibilidade, anexos: args.anexos, statusAnterior: args.proximoStatus ? ticket.status : undefined, statusNovo: args.proximoStatus ? novoStatus : undefined, }); const ticketAtualizado = await ctx.db.get(ticket._id); if (ticketAtualizado) { await registrarNotificacoes(ctx, { ticket: ticketAtualizado, titulo: `Atualização no chamado ${ticketAtualizado.numero}`, mensagem: args.conteudo, usuarioEvento: usuario._id, }); } return { status: novoStatus }; }, }); export const atribuirResponsavel = mutation({ args: { ticketId: v.id("tickets"), responsavelId: v.id("usuarios"), motivo: v.optional(v.string()), }, handler: async (ctx, args) => { const usuario = await assertAuth(ctx); const ticket = await ctx.db.get(args.ticketId); if (!ticket) { throw new Error("Chamado não encontrado"); } const responsavel = await ctx.db.get(args.responsavelId); if (!responsavel) { throw new Error("Responsável inválido"); } const agora = Date.now(); await ctx.db.patch(ticket._id, { responsavelId: args.responsavelId, setorResponsavel: responsavel.setor, atualizadoEm: agora, }); const assignmentsAtivos = await ctx.db .query("ticketAssignments") .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id).eq("ativo", true)) .collect(); for (const assignment of assignmentsAtivos) { await ctx.db.patch(assignment._id, { ativo: false, encerradoEm: agora, }); } await ctx.db.insert("ticketAssignments", { ticketId: ticket._id, responsavelId: args.responsavelId, atribuidoPor: usuario._id, motivo: args.motivo, ativo: true, criadoEm: agora, encerradoEm: undefined, }); await registrarInteracao(ctx, { ticketId: ticket._id, autorId: usuario._id, origem: "ti", tipo: "status", conteudo: `Chamado atribuído para ${responsavel.nome}`, }); const ticketAtualizado = await ctx.db.get(ticket._id); if (ticketAtualizado) { await registrarNotificacoes(ctx, { ticket: ticketAtualizado, titulo: "Chamado atribuído", mensagem: `Seu chamado agora está com ${responsavel.nome}.`, usuarioEvento: usuario._id, }); } return { responsavelId: args.responsavelId }; }, }); export const listarSlaConfigs = query({ args: {}, handler: async (ctx) => { await assertAuth(ctx); const slaConfigs = await ctx.db.query("slaConfigs").collect(); slaConfigs.sort((a, b) => b.criadoEm - a.criadoEm); return slaConfigs; }, }); export const obterEstatisticasChamados = query({ args: {}, handler: async (ctx) => { await assertAuth(ctx); const todosTickets = await ctx.db.query("tickets").collect(); const total = todosTickets.length; const abertos = todosTickets.filter((t) => t.status === "aberto").length; const emAndamento = todosTickets.filter((t) => t.status === "em_andamento").length; const vencidos = todosTickets.filter( (t) => (t.prazoConclusao && t.prazoConclusao < Date.now()) || t.status === "cancelado" ).length; return { total, abertos, emAndamento, vencidos }; }, }); export const obterDadosSlaGrafico = query({ args: {}, handler: async (ctx) => { await assertAuth(ctx); const agora = Date.now(); const todosTickets = await ctx.db.query("tickets").collect(); const slaConfigs = await ctx.db.query("slaConfigs").collect(); // Agrupar SLAs por prioridade const slaPorPrioridade = new Map>(); slaConfigs.filter(s => s.ativo).forEach(sla => { if (sla.prioridade) { slaPorPrioridade.set(sla.prioridade, sla); } }); // Calcular status de SLA para cada ticket const statusSla = { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, semPrazo: 0, }; const porPrioridade = { baixa: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 }, media: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 }, alta: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 }, critica: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 }, }; todosTickets.forEach(ticket => { if (!ticket.prazoConclusao) { statusSla.semPrazo++; return; } const prazoConclusao = ticket.prazoConclusao; const horasRestantes = (prazoConclusao - agora) / (1000 * 60 * 60); const sla = slaPorPrioridade.get(ticket.prioridade); const alertaHoras = sla?.alertaAntecedenciaHoras ?? 2; if (prazoConclusao < agora) { statusSla.vencido++; if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) { porPrioridade[ticket.prioridade as keyof typeof porPrioridade].vencido++; porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++; } } else if (horasRestantes <= alertaHoras) { statusSla.proximoVencimento++; if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) { porPrioridade[ticket.prioridade as keyof typeof porPrioridade].proximoVencimento++; porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++; } } else { statusSla.dentroPrazo++; if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) { porPrioridade[ticket.prioridade as keyof typeof porPrioridade].dentroPrazo++; porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++; } } }); // Calcular taxa de cumprimento const totalComPrazo = statusSla.dentroPrazo + statusSla.proximoVencimento + statusSla.vencido; const taxaCumprimento = totalComPrazo > 0 ? Math.round((statusSla.dentroPrazo / totalComPrazo) * 100) : 100; return { statusSla, porPrioridade, taxaCumprimento, totalComPrazo, atualizadoEm: agora, }; }, }); export const salvarSlaConfig = mutation({ args: { slaId: v.optional(v.id("slaConfigs")), nome: v.string(), descricao: v.optional(v.string()), prioridade: prioridadeValidator, tempoRespostaHoras: v.number(), tempoConclusaoHoras: v.number(), tempoEncerramentoHoras: v.optional(v.number()), alertaAntecedenciaHoras: v.number(), ativo: v.boolean(), }, handler: async (ctx, args) => { const usuario = await assertAuth(ctx); const agora = Date.now(); if (args.slaId) { await ctx.db.patch(args.slaId, { nome: args.nome, descricao: args.descricao, prioridade: args.prioridade, tempoRespostaHoras: args.tempoRespostaHoras, tempoConclusaoHoras: args.tempoConclusaoHoras, tempoEncerramentoHoras: args.tempoEncerramentoHoras, alertaAntecedenciaHoras: args.alertaAntecedenciaHoras, ativo: args.ativo, atualizadoPor: usuario._id, atualizadoEm: agora, }); return args.slaId; } return await ctx.db.insert("slaConfigs", { nome: args.nome, descricao: args.descricao, prioridade: args.prioridade, tempoRespostaHoras: args.tempoRespostaHoras, tempoConclusaoHoras: args.tempoConclusaoHoras, tempoEncerramentoHoras: args.tempoEncerramentoHoras, alertaAntecedenciaHoras: args.alertaAntecedenciaHoras, ativo: args.ativo, criadoPor: usuario._id, atualizadoPor: usuario._id, criadoEm: agora, atualizadoEm: agora, }); }, }); export const excluirSlaConfig = mutation({ args: { slaId: v.id("slaConfigs"), }, handler: async (ctx, args) => { await assertAuth(ctx); const sla = await ctx.db.get(args.slaId); if (!sla) { throw new Error("Configuração de SLA não encontrada"); } await ctx.db.delete(args.slaId); return { sucesso: true }; }, }); export const prorrogarChamado = mutation({ args: { ticketId: v.id("tickets"), horasAdicionais: v.number(), prazo: v.union(v.literal("resposta"), v.literal("conclusao")), motivo: v.string(), }, handler: async (ctx, args) => { const usuario = await assertAuth(ctx); const ticket = await ctx.db.get(args.ticketId); if (!ticket) { throw new Error("Chamado não encontrado"); } const agora = Date.now(); const horasMs = args.horasAdicionais * 60 * 60 * 1000; let novoPrazoResposta = ticket.prazoResposta; let novoPrazoConclusao = ticket.prazoConclusao; let prazoExtendido: number; if (args.prazo === "resposta") { prazoExtendido = (ticket.prazoResposta || agora) + horasMs; novoPrazoResposta = prazoExtendido; // Se o prazo de conclusão é antes do novo prazo de resposta, ajuste-o também if (ticket.prazoConclusao && ticket.prazoConclusao < prazoExtendido) { novoPrazoConclusao = prazoExtendido + (ticket.prazoConclusao - (ticket.prazoResposta || agora)); } } else { prazoExtendido = (ticket.prazoConclusao || agora) + horasMs; novoPrazoConclusao = prazoExtendido; } // Atualizar timeline const timelineAtualizada = ticket.timeline?.map((etapa) => { if (args.prazo === "resposta" && etapa.etapa === "resposta_inicial") { return { ...etapa, prazo: prazoExtendido, status: prazoExtendido > agora ? "pendente" : etapa.status, }; } if (args.prazo === "conclusao" && etapa.etapa === "conclusao") { return { ...etapa, prazo: prazoExtendido, status: prazoExtendido > agora ? "pendente" : etapa.status, }; } return etapa; }) || ticket.timeline; await ctx.db.patch(ticket._id, { prazoResposta: novoPrazoResposta, prazoConclusao: novoPrazoConclusao, timeline: timelineAtualizada, atualizadoEm: agora, ultimaInteracaoEm: agora, }); await registrarInteracao(ctx, { ticketId: ticket._id, autorId: usuario._id, origem: "ti", tipo: "status", conteudo: `Prazo ${args.prazo === "resposta" ? "de resposta" : "de conclusão"} prorrogado em ${args.horasAdicionais}h. Motivo: ${args.motivo}`, }); const ticketAtualizado = await ctx.db.get(ticket._id); if (ticketAtualizado) { await registrarNotificacoes(ctx, { ticket: ticketAtualizado, titulo: `Prazo prorrogado - Chamado ${ticketAtualizado.numero}`, mensagem: `O prazo ${args.prazo === "resposta" ? "de resposta" : "de conclusão"} foi prorrogado em ${args.horasAdicionais} horas. Motivo: ${args.motivo}`, usuarioEvento: usuario._id, }); } return { sucesso: true }; }, }); export const emitirAlertaPrazo = mutation({ args: { ticketId: v.id("tickets"), tipo: v.union( v.literal("resposta"), v.literal("conclusao"), v.literal("encerramento") ), mensagem: v.string(), }, handler: async (ctx, args) => { const usuario = await assertAuth(ctx); const ticket = await ctx.db.get(args.ticketId); if (!ticket) { throw new Error("Chamado não encontrado"); } const atualizado = [ ...(ticket.alertasEmitidos || []), { tipo: args.tipo, emitidoEm: Date.now() }, ]; await ctx.db.patch(ticket._id, { alertasEmitidos: atualizado, }); await registrarInteracao(ctx, { ticketId: ticket._id, autorId: usuario._id, origem: "sistema", tipo: "alerta", conteudo: args.mensagem, }); await registrarNotificacoes(ctx, { ticket, titulo: `Alerta de SLA (${args.tipo})`, mensagem: args.mensagem, usuarioEvento: usuario._id, }); return { sucesso: true }; }, }); export const generateUploadUrl = mutation({ args: {}, handler: async (ctx) => { await assertAuth(ctx); return await ctx.storage.generateUploadUrl(); }, });