import { v } from 'convex/values'; import { api } from './_generated/api'; import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx } from './_generated/server'; import { mutation, query } from './_generated/server'; import { getCurrentUserFunction } from './auth'; 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.scheduler.runAfter(0, 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 console.warn( 'Erro ao agendar envio de email com template chamado_atualizado para solicitante, usando envio direto:', error ); 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() }); // Se o ticket estiver associado a uma conversa, registrar também uma mensagem de chat // Isso garante o "duplo canal": email + chat para notificações importantes. if (ticket.conversaId) { const conteudoChat = mensagem.length > 0 ? `${titulo}: ${mensagem}` : titulo; await ctx.db.insert('mensagens', { conversaId: ticket.conversaId, remetenteId: usuarioEvento, tipo: 'texto', conteudo: conteudoChat, enviadaEm: 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.scheduler.runAfter(0, 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 console.warn( 'Erro ao agendar envio de email com template chamado_atualizado para responsável, usando envio direto:', error ); 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; 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; 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, 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(); } });