Files
sgse-app/packages/backend/convex/chamados.ts
deyvisonwanderley 5ef6ef8550 feat: enhance SLA management and authentication handling
- Updated the useConvexWithAuth hook to improve token management and logging for better debugging.
- Integrated automatic authentication handling in the central-chamados route, ensuring seamless user experience.
- Added a new mutation for migrating old SLA configurations to include a priority field, enhancing data consistency.
- Improved the display of SLA configurations in the UI, including detailed views and migration feedback for better user interaction.
- Refactored ticket loading logic to enrich ticket data with responsible user names, improving clarity in ticket management.
2025-11-17 08:44:18 -03:00

758 lines
21 KiB
TypeScript

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<typeof getCurrentUserFunction>[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<typeof calcularPrazos>) {
const timeline: NonNullable<TicketDoc["timeline"]> = [
{
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<typeof getCurrentUserFunction>[0],
prioridade: "baixa" | "media" | "alta" | "critica"
): Promise<Doc<"slaConfigs"> | 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;
// Notificar solicitante
if (ticket.solicitanteEmail) {
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`,
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) {
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`,
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<Doc<"tickets">> = [];
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 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();
},
});
/**
* Migração: Adiciona o campo 'prioridade' aos SLAs antigos que não possuem
* Esta mutation corrige documentos criados antes da migração do schema
*/
export const migrarSlaConfigs = mutation({
args: {},
handler: async (ctx) => {
const usuario = await assertAuth(ctx);
// Buscar todos os SLAs
const slaConfigs = await ctx.db.query("slaConfigs").collect();
let migrados = 0;
for (const sla of slaConfigs) {
// Verificar se o documento não tem o campo 'prioridade'
// Usando type assertion para acessar campos não tipados
const slaDoc = sla as any;
if (!slaDoc.prioridade) {
// Adicionar prioridade padrão "media" para SLAs antigos
await ctx.db.patch(sla._id, {
prioridade: "media" as "baixa" | "media" | "alta" | "critica",
atualizadoPor: usuario._id,
atualizadoEm: Date.now(),
});
migrados++;
console.log(`✅ SLA migrado: ${sla.nome} (ID: ${sla._id})`);
}
}
return {
sucesso: true,
migrados,
total: slaConfigs.length,
};
},
});