feat: enhance cybersecurity features and add ticket management components

- Introduced new components for managing tickets, including TicketForm, TicketCard, and TicketTimeline, to streamline the ticketing process.
- Added a new SlaChart component for visualizing SLA data.
- Implemented a CybersecurityWizcard component for enhanced security monitoring and reporting.
- Updated routing to replace the "Solicitar Acesso" page with "Abrir Chamado" for improved user navigation.
- Integrated rate limiting functionality to enhance security measures.
- Added a comprehensive test report for the cybersecurity system, detailing various attack simulations and their outcomes.
- Included new scripts for security testing and environment setup to facilitate automated security assessments.
This commit is contained in:
2025-11-17 16:54:43 -03:00
67 changed files with 14784 additions and 4084 deletions

View File

@@ -16,6 +16,7 @@ import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js";
import type * as atestadosLicencas from "../atestadosLicencas.js";
import type * as ausencias from "../ausencias.js";
import type * as auth_utils from "../auth/utils.js";
import type * as chamados from "../chamados.js";
import type * as auth from "../auth.js";
import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js";
@@ -29,7 +30,6 @@ import type * as ferias from "../ferias.js";
import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
import type * as limparPerfisAntigos from "../limparPerfisAntigos.js";
import type * as logsAcesso from "../logsAcesso.js";
import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js";
@@ -39,6 +39,7 @@ import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
import type * as pushNotifications from "../pushNotifications.js";
import type * as roles from "../roles.js";
import type * as saldoFerias from "../saldoFerias.js";
import type * as security from "../security.js";
import type * as seed from "../seed.js";
import type * as simbolos from "../simbolos.js";
import type * as solicitacoesAcesso from "../solicitacoesAcesso.js";
@@ -72,6 +73,7 @@ declare const fullApi: ApiFromModules<{
atestadosLicencas: typeof atestadosLicencas;
ausencias: typeof ausencias;
"auth/utils": typeof auth_utils;
chamados: typeof chamados;
auth: typeof auth;
chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail;
@@ -85,7 +87,6 @@ declare const fullApi: ApiFromModules<{
funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck;
http: typeof http;
limparPerfisAntigos: typeof limparPerfisAntigos;
logsAcesso: typeof logsAcesso;
logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin;
@@ -95,6 +96,7 @@ declare const fullApi: ApiFromModules<{
pushNotifications: typeof pushNotifications;
roles: typeof roles;
saldoFerias: typeof saldoFerias;
security: typeof security;
seed: typeof seed;
simbolos: typeof simbolos;
solicitacoesAcesso: typeof solicitacoesAcesso;
@@ -2216,4 +2218,138 @@ export declare const components: {
updateMany: FunctionReference<"mutation", "internal", any, any>;
};
};
rateLimiter: {
lib: {
checkRateLimit: FunctionReference<
"query",
"internal",
{
config:
| {
capacity?: number;
kind: "token bucket";
maxReserved?: number;
period: number;
rate: number;
shards?: number;
start?: null;
}
| {
capacity?: number;
kind: "fixed window";
maxReserved?: number;
period: number;
rate: number;
shards?: number;
start?: number;
};
count?: number;
key?: string;
name: string;
reserve?: boolean;
throws?: boolean;
},
{ ok: true; retryAfter?: number } | { ok: false; retryAfter: number }
>;
clearAll: FunctionReference<
"mutation",
"internal",
{ before?: number },
null
>;
getServerTime: FunctionReference<"mutation", "internal", {}, number>;
getValue: FunctionReference<
"query",
"internal",
{
config:
| {
capacity?: number;
kind: "token bucket";
maxReserved?: number;
period: number;
rate: number;
shards?: number;
start?: null;
}
| {
capacity?: number;
kind: "fixed window";
maxReserved?: number;
period: number;
rate: number;
shards?: number;
start?: number;
};
key?: string;
name: string;
sampleShards?: number;
},
{
config:
| {
capacity?: number;
kind: "token bucket";
maxReserved?: number;
period: number;
rate: number;
shards?: number;
start?: null;
}
| {
capacity?: number;
kind: "fixed window";
maxReserved?: number;
period: number;
rate: number;
shards?: number;
start?: number;
};
shard: number;
ts: number;
value: number;
}
>;
rateLimit: FunctionReference<
"mutation",
"internal",
{
config:
| {
capacity?: number;
kind: "token bucket";
maxReserved?: number;
period: number;
rate: number;
shards?: number;
start?: null;
}
| {
capacity?: number;
kind: "fixed window";
maxReserved?: number;
period: number;
rate: number;
shards?: number;
start?: number;
};
count?: number;
key?: string;
name: string;
reserve?: boolean;
throws?: boolean;
},
{ ok: true; retryAfter?: number } | { ok: false; retryAfter: number }
>;
resetRateLimit: FunctionReference<
"mutation",
"internal",
{ key?: string; name: string },
null
>;
};
time: {
getServerTime: FunctionReference<"mutation", "internal", {}, number>;
};
};
};

View File

@@ -0,0 +1,816 @@
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 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<string, Doc<"slaConfigs">>();
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();
},
});

View File

@@ -1,7 +1,9 @@
import { defineApp } from "convex/server";
import betterAuth from "@convex-dev/better-auth/convex.config";
import rateLimiter from "@convex-dev/rate-limiter/convex.config";
const app = defineApp();
app.use(betterAuth);
app.use(rateLimiter);
export default app;

View File

@@ -32,6 +32,27 @@ crons.interval(
{}
);
crons.interval(
"expirar-bloqueios-ip-automaticos",
{ minutes: 5 },
internal.security.expirarBloqueiosIpAutomaticos,
{}
);
crons.interval(
"sincronizar-threat-intel",
{ hours: 2 },
internal.security.atualizarThreatIntelFeedsInternal,
{}
);
// Monitorar logs de login e detectar brute force a cada 5 minutos
crons.interval(
"monitorar-logs-login-brute-force",
{ minutes: 5 },
internal.security.monitorarLogsLogin,
{}
);
export default crons;

View File

@@ -1,8 +1,74 @@
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
import { getClientIP } from "./utils/getClientIP";
const http = httpRouter();
// Action HTTP para análise de segurança de requisições
// Pode ser chamada do frontend ou de outros sistemas
http.route({
path: "/security/analyze",
method: "POST",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
const method = request.method;
// Extrair IP do cliente
const ipOrigem = getClientIP(request);
// Extrair headers
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Extrair query params
const queryParams: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
// Extrair body se disponível
let body: string | undefined;
try {
body = await request.text();
} catch {
// Ignorar erros ao ler body
}
// Analisar requisição para detectar ataques
const resultado = await ctx.runMutation(api.security.analisarRequisicaoHTTP, {
url: url.pathname + url.search,
method,
headers,
body,
queryParams,
ipOrigem,
userAgent: request.headers.get('user-agent') ?? undefined
});
return new Response(JSON.stringify(resultado), {
status: 200,
headers: { "Content-Type": "application/json" }
});
})
});
// Seed de rate limit para ambiente de desenvolvimento
http.route({
path: "/security/rate-limit/seed-dev",
method: "POST",
handler: httpAction(async (ctx) => {
const resultado = await ctx.runMutation(api.security.seedRateLimitDev, {});
return new Response(JSON.stringify(resultado), {
status: 200,
headers: { "Content-Type": "application/json" }
});
})
});
authComponent.registerRoutes(http, createAuth);
export default http;

View File

@@ -1,285 +0,0 @@
import { internalMutation, query } from "./_generated/server";
import { v } from "convex/values";
/**
* Listar todos os perfis (roles) do sistema
*/
export const listarTodosRoles = query({
args: {},
returns: v.array(
v.object({
_id: v.id("roles"),
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
setor: v.optional(v.string()),
customizado: v.optional(v.boolean()),
editavel: v.optional(v.boolean()),
_creationTime: v.number(),
})
),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
return roles.map((role) => ({
_id: role._id,
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
setor: role.setor,
customizado: role.customizado,
editavel: role.editavel,
_creationTime: role._creationTime,
}));
},
});
/**
* Limpar perfis antigos/duplicados
*
* CRITÉRIOS:
* - Manter apenas: ti_master (nível 0), admin (nível 2), ti_usuario (nível 2)
* - Remover: admin antigo (nível 0), ti genérico (nível 1), outros duplicados
*/
export const limparPerfisAntigos = internalMutation({
args: {},
returns: v.object({
removidos: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
motivo: v.string(),
})
),
mantidos: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
})
),
}),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
const removidos: Array<{
nome: string;
descricao: string;
nivel: number;
motivo: string;
}> = [];
const mantidos: Array<{
nome: string;
descricao: string;
nivel: number;
}> = [];
// Perfis que devem ser mantidos (apenas 1 de cada)
const perfisCorretos = new Map<string, boolean>();
perfisCorretos.set("ti_master", false);
perfisCorretos.set("admin", false);
perfisCorretos.set("ti_usuario", false);
for (const role of roles) {
let deveManter = false;
let motivo = "";
// TI_MASTER - Manter apenas o de nível 0
if (role.nome === "ti_master") {
if (role.nivel === 0 && !perfisCorretos.get("ti_master")) {
deveManter = true;
perfisCorretos.set("ti_master", true);
} else {
motivo =
role.nivel !== 0
? "TI_MASTER deve ser nível 0, este é nível " + role.nivel
: "TI_MASTER duplicado";
}
}
// ADMIN - Manter apenas o de nível 2
else if (role.nome === "admin") {
if (role.nivel === 2 && !perfisCorretos.get("admin")) {
deveManter = true;
perfisCorretos.set("admin", true);
} else {
motivo =
role.nivel !== 2
? "ADMIN deve ser nível 2, este é nível " + role.nivel
: "ADMIN duplicado";
}
}
// TI_USUARIO - Manter apenas o de nível 2
else if (role.nome === "ti_usuario") {
if (role.nivel === 2 && !perfisCorretos.get("ti_usuario")) {
deveManter = true;
perfisCorretos.set("ti_usuario", true);
} else {
motivo =
role.nivel !== 2
? "TI_USUARIO deve ser nível 2, este é nível " + role.nivel
: "TI_USUARIO duplicado";
}
}
// Perfis genéricos antigos (remover)
else if (role.nome === "ti") {
motivo =
"Perfil genérico 'ti' obsoleto - usar 'ti_master' ou 'ti_usuario'";
}
// Outros perfis específicos de setores (manter se forem nível >= 2)
else if (
role.nome === "rh" ||
role.nome === "financeiro" ||
role.nome === "controladoria" ||
role.nome === "licitacoes" ||
role.nome === "compras" ||
role.nome === "juridico" ||
role.nome === "comunicacao" ||
role.nome === "programas_esportivos" ||
role.nome === "secretaria_executiva" ||
role.nome === "gestao_pessoas" ||
role.nome === "usuario"
) {
if (role.nivel >= 2) {
deveManter = true;
} else {
motivo = `Perfil de setor com nível incorreto (${role.nivel}), deveria ser >= 2`;
}
}
// Perfis customizados (manter sempre)
else if (role.customizado) {
deveManter = true;
}
// Outros perfis desconhecidos
else {
motivo = "Perfil desconhecido ou obsoleto";
}
if (deveManter) {
mantidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
});
console.log(
`✅ MANTIDO: ${role.nome} (${role.descricao}) - Nível ${role.nivel}`
);
} else {
// Verificar se há usuários usando este perfil
const usuariosComRole = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", role._id))
.collect();
if (usuariosComRole.length > 0) {
console.log(
`⚠️ AVISO: Não é possível remover "${role.nome}" porque ${usuariosComRole.length} usuário(s) ainda usa(m) este perfil`
);
mantidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
});
} else {
// Remover permissões associadas
const permissoes = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", role._id))
.collect();
for (const perm of permissoes) {
await ctx.db.delete(perm._id);
}
// Remover o role
await ctx.db.delete(role._id);
removidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
motivo: motivo || "Não especificado",
});
console.log(
`🗑️ REMOVIDO: ${role.nome} (${role.descricao}) - Nível ${role.nivel} - Motivo: ${motivo}`
);
}
}
}
return { removidos, mantidos };
},
});
/**
* Verificar se existem perfis com níveis incorretos
*/
export const verificarNiveisIncorretos = query({
args: {},
returns: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivelAtual: v.number(),
nivelCorreto: v.number(),
problema: v.string(),
})
),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
const problemas: Array<{
nome: string;
descricao: string;
nivelAtual: number;
nivelCorreto: number;
problema: string;
}> = [];
for (const role of roles) {
// TI_MASTER deve ser nível 0
if (role.nome === "ti_master" && role.nivel !== 0) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 0,
problema: "TI_MASTER deve ter acesso total (nível 0)",
});
}
// ADMIN deve ser nível 2
if (role.nome === "admin" && role.nivel !== 2) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 2,
problema: "ADMIN deve ser editável (nível 2)",
});
}
// TI_USUARIO deve ser nível 2
if (role.nome === "ti_usuario" && role.nivel !== 2) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 2,
problema: "TI_USUARIO deve ser editável (nível 2)",
});
}
// Perfil genérico "ti" não deveria existir
if (role.nome === "ti") {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: -1, // Indica que deve ser removido
problema: "Perfil genérico obsoleto - usar ti_master ou ti_usuario",
});
}
}
return problemas;
},
});

View File

@@ -65,6 +65,38 @@ export async function registrarLogin(
sistema,
timestamp: Date.now(),
});
// Detecção automática de brute force após login falho
// Verificar se há múltiplas tentativas falhas do mesmo IP
if (!dados.sucesso && ipAddressValidado) {
const minutosAtras = 15;
const dataLimite = Date.now() - minutosAtras * 60 * 1000;
// Contar tentativas falhas recentes do mesmo IP
const tentativasFalhas = await ctx.db
.query("logsLogin")
.withIndex("by_ip", (q) => q.eq("ipAddress", ipAddressValidado))
.filter((q) =>
q.gte(q.field("timestamp"), dataLimite) &&
q.eq(q.field("sucesso"), false)
)
.collect();
// Se houver 5 ou mais tentativas falhas, registrar evento de segurança
if (tentativasFalhas.length >= 5) {
// Importar função de segurança dinamicamente para evitar dependência circular
const { internal } = await import("./_generated/api");
try {
await ctx.scheduler.runAfter(0, internal.security.detectarBruteForce, {
ipAddress: ipAddressValidado,
janelaMinutos: minutosAtras
});
} catch (error) {
// Log erro mas não bloqueia o registro de login
console.error("Erro ao agendar detecção de brute force:", error);
}
}
}
}
// Helpers para extrair informações do userAgent

View File

@@ -3,27 +3,271 @@ import { v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { getCurrentUserFunction } from './auth';
// Catálogo base de recursos e ações
// Ajuste/expanda conforme os módulos disponíveis no sistema
export const CATALOGO_RECURSOS = [
{
recurso: 'funcionarios',
acoes: [
'dashboard',
'ver',
'listar',
'criar',
'editar',
'excluir',
'aprovar_ausencias',
'aprovar_ferias'
]
},
{
recurso: 'simbolos',
acoes: ['dashboard', 'ver', 'listar', 'criar', 'editar', 'excluir']
}
] as const;
// Catálogo de permissões base para seed controlado via mutation
const PERMISSOES_BASE = {
permissoes: [
// Funcionários
{
nome: 'funcionarios.dashboard',
recurso: 'funcionarios',
acao: 'dashboard',
descricao: 'Acessar o painel de funcionários'
},
{
nome: 'funcionarios.ver',
recurso: 'funcionarios',
acao: 'ver',
descricao: 'Visualizar detalhes de funcionários'
},
{
nome: 'funcionarios.listar',
recurso: 'funcionarios',
acao: 'listar',
descricao: 'Listar funcionários'
},
{
nome: 'funcionarios.criar',
recurso: 'funcionarios',
acao: 'criar',
descricao: 'Criar novos funcionários'
},
{
nome: 'funcionarios.editar',
recurso: 'funcionarios',
acao: 'editar',
descricao: 'Editar dados de funcionários'
},
{
nome: 'funcionarios.excluir',
recurso: 'funcionarios',
acao: 'excluir',
descricao: 'Excluir funcionários'
},
{
nome: 'funcionarios.aprovar_ausencias',
recurso: 'funcionarios',
acao: 'aprovar_ausencias',
descricao: 'Aprovar ausências de funcionários'
},
{
nome: 'funcionarios.aprovar_ferias',
recurso: 'funcionarios',
acao: 'aprovar_ferias',
descricao: 'Aprovar férias de funcionários'
},
// Símbolos
{
nome: 'simbolos.dashboard',
recurso: 'simbolos',
acao: 'dashboard',
descricao: 'Acessar o painel de símbolos'
},
{
nome: 'simbolos.ver',
recurso: 'simbolos',
acao: 'ver',
descricao: 'Visualizar detalhes de símbolos'
},
{
nome: 'simbolos.listar',
recurso: 'simbolos',
acao: 'listar',
descricao: 'Listar símbolos'
},
{
nome: 'simbolos.criar',
recurso: 'simbolos',
acao: 'criar',
descricao: 'Criar novos símbolos'
},
{
nome: 'simbolos.editar',
recurso: 'simbolos',
acao: 'editar',
descricao: 'Editar símbolos'
},
{
nome: 'simbolos.excluir',
recurso: 'simbolos',
acao: 'excluir',
descricao: 'Excluir símbolos'
},
// TI - Usuários
{
nome: 'ti_usuarios.listar',
recurso: 'ti_usuarios',
acao: 'listar',
descricao: 'Listar usuários do sistema'
},
{
nome: 'ti_usuarios.criar',
recurso: 'ti_usuarios',
acao: 'criar',
descricao: 'Criar novos usuários de acesso'
},
{
nome: 'ti_usuarios.editar',
recurso: 'ti_usuarios',
acao: 'editar',
descricao: 'Editar usuários de acesso'
},
{
nome: 'ti_usuarios.bloquear',
recurso: 'ti_usuarios',
acao: 'bloquear',
descricao: 'Bloquear ou desbloquear usuários'
},
// TI - Perfis
{
nome: 'ti_perfis.listar',
recurso: 'ti_perfis',
acao: 'listar',
descricao: 'Listar perfis de acesso'
},
{
nome: 'ti_perfis.criar',
recurso: 'ti_perfis',
acao: 'criar',
descricao: 'Criar novos perfis de acesso'
},
{
nome: 'ti_perfis.editar',
recurso: 'ti_perfis',
acao: 'editar',
descricao: 'Editar perfis de acesso'
},
// TI - Painel de Permissões
{
nome: 'ti_painel_permissoes.gerenciar',
recurso: 'ti_painel_permissoes',
acao: 'gerenciar',
descricao: 'Gerenciar matriz de permissões por perfil'
},
// TI - Solicitações de Acesso
{
nome: 'ti_solicitacoes_acesso.ver',
recurso: 'ti_solicitacoes_acesso',
acao: 'ver',
descricao: 'Visualizar solicitações de acesso'
},
{
nome: 'ti_solicitacoes_acesso.aprovar',
recurso: 'ti_solicitacoes_acesso',
acao: 'aprovar',
descricao: 'Aprovar solicitações de acesso'
},
{
nome: 'ti_solicitacoes_acesso.reprovar',
recurso: 'ti_solicitacoes_acesso',
acao: 'reprovar',
descricao: 'Reprovar solicitações de acesso'
},
// TI - Configurações de E-mail
{
nome: 'ti_configuracoes_email.configurar',
recurso: 'ti_configuracoes_email',
acao: 'configurar',
descricao: 'Configurar parâmetros de envio de e-mail'
},
// TI - Monitoramento
{
nome: 'ti_monitoramento.ver',
recurso: 'ti_monitoramento',
acao: 'ver',
descricao: 'Acessar painel de monitoramento geral'
},
{
nome: 'ti_monitoramento_emails.ver',
recurso: 'ti_monitoramento_emails',
acao: 'ver',
descricao: 'Acessar monitoramento de envio de e-mails'
},
// TI - Notificações
{
nome: 'ti_notificacoes.configurar',
recurso: 'ti_notificacoes',
acao: 'configurar',
descricao: 'Configurar notificações do sistema'
},
// TI - Times
{
nome: 'ti_times.gerenciar',
recurso: 'ti_times',
acao: 'gerenciar',
descricao: 'Gerenciar times/equipes de TI'
},
// TI - Painel Administrativo
{
nome: 'ti_painel_administrativo.ver',
recurso: 'ti_painel_administrativo',
acao: 'ver',
descricao: 'Acessar painel administrativo de TI'
},
// Financeiro
{
nome: 'financeiro.ver',
recurso: 'financeiro',
acao: 'ver',
descricao: 'Acessar telas do módulo de financeiro'
},
// Controladoria
{
nome: 'controladoria.ver',
recurso: 'controladoria',
acao: 'ver',
descricao: 'Acessar telas do módulo de controladoria'
},
// Licitações
{
nome: 'licitacoes.ver',
recurso: 'licitacoes',
acao: 'ver',
descricao: 'Acessar telas do módulo de licitações'
},
// Compras
{
nome: 'compras.ver',
recurso: 'compras',
acao: 'ver',
descricao: 'Acessar telas do módulo de compras'
},
// Jurídico
{
nome: 'juridico.ver',
recurso: 'juridico',
acao: 'ver',
descricao: 'Acessar telas do módulo jurídico'
},
// Comunicação
{
nome: 'comunicacao.ver',
recurso: 'comunicacao',
acao: 'ver',
descricao: 'Acessar telas do módulo de comunicação'
},
// Programas Esportivos
{
nome: 'programas_esportivos.ver',
recurso: 'programas_esportivos',
acao: 'ver',
descricao: 'Acessar telas do módulo de programas esportivos'
},
// Secretaria Executiva
{
nome: 'secretaria_executiva.ver',
recurso: 'secretaria_executiva',
acao: 'ver',
descricao: 'Acessar telas do módulo de secretaria executiva'
},
// Gestão de Pessoas
{
nome: 'gestao_pessoas.ver',
recurso: 'gestao_pessoas',
acao: 'ver',
descricao: 'Acessar telas do módulo de gestão de pessoas'
}
]
} as const;
export const listarRecursosEAcoes = query({
args: {},
@@ -33,10 +277,18 @@ export const listarRecursosEAcoes = query({
acoes: v.array(v.string())
})
),
handler: async () => {
return CATALOGO_RECURSOS.map((r) => ({
recurso: r.recurso,
acoes: [...r.acoes]
handler: async (ctx) => {
const permissoes = await ctx.db.query('permissoes').collect();
const recursos: Record<string, Set<string>> = {};
for (const perm of permissoes) {
const set = (recursos[perm.recurso] ||= new Set<string>());
set.add(perm.acao);
}
return Object.entries(recursos).map(([recurso, acoes]) => ({
recurso,
acoes: Array.from(acoes).sort()
}));
}
});
@@ -56,7 +308,7 @@ export const listarPermissoesAcoesPorRole = query({
.withIndex('by_role', (q) => q.eq('roleId', args.roleId))
.collect();
// Carregar documentos de permissões
// Carregar documentos de permissões vinculadas a este role
const actionsByResource: Record<string, Set<string>> = {};
for (const rp of rolePerms) {
const perm = await ctx.db.get(rp.permissaoId);
@@ -65,13 +317,10 @@ export const listarPermissoesAcoesPorRole = query({
set.add(perm.acao);
}
// Normalizar para todos os recursos do catálogo
const result: Array<{ recurso: string; acoes: Array<string> }> = [];
for (const item of CATALOGO_RECURSOS) {
const granted = Array.from(actionsByResource[item.recurso] ?? new Set<string>());
result.push({ recurso: item.recurso, acoes: granted });
}
return result;
return Object.entries(actionsByResource).map(([recurso, acoes]) => ({
recurso,
acoes: Array.from(acoes).sort()
}));
}
});
@@ -84,24 +333,12 @@ export const atualizarPermissaoAcao = mutation({
},
returns: v.null(),
handler: async (ctx, args) => {
// Garantir documento de permissão (recurso+acao)
let permissao = await ctx.db
// Buscar documento de permissão (recurso+acao)
const permissao = await ctx.db
.query('permissoes')
.withIndex('by_recurso_e_acao', (q) => q.eq('recurso', args.recurso).eq('acao', args.acao))
.first();
if (!permissao) {
const nome = `${args.recurso}.${args.acao}`;
const descricao = `Permite ${args.acao} em ${args.recurso}`;
const id = await ctx.db.insert('permissoes', {
nome,
descricao,
recurso: args.recurso,
acao: args.acao
});
permissao = await ctx.db.get(id);
}
if (!permissao) return null;
// Verificar vínculo atual
@@ -128,6 +365,36 @@ export const atualizarPermissaoAcao = mutation({
}
});
export const seedPermissoesBase = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
console.log('🔐 Seed de permissões base...');
for (const perm of PERMISSOES_BASE.permissoes) {
const existente = await ctx.db
.query('permissoes')
.withIndex('by_nome', (q) => q.eq('nome', perm.nome))
.first();
if (existente) {
console.log(` Permissão já existe: ${perm.nome}`);
continue;
}
await ctx.db.insert('permissoes', {
nome: perm.nome,
descricao: perm.descricao,
recurso: perm.recurso,
acao: perm.acao
});
console.log(` ✅ Permissão criada: ${perm.nome}`);
}
return null;
}
});
export const verificarAcao = query({
args: {
usuarioId: v.id('usuarios'),

View File

@@ -1,5 +1,5 @@
import { v } from 'convex/values';
import { query, mutation } from './_generated/server';
import { internalMutation, query, mutation } from './_generated/server';
import type { Id } from './_generated/dataModel';
import { getCurrentUserFunction } from './auth';
@@ -98,13 +98,16 @@ export const criar = mutation({
permissoesParaCopiar = permissoesOrigem.map((item) => item.permissaoId);
}
const nivelAjustado = Math.min(Math.max(Math.round(args.nivel), 0), 10);
// Agora só existem níveis 0 e 1.
// 0 = máximo (acesso total), 1 = administrativo (também com acesso total).
// Qualquer valor informado diferente de 0 é normalizado para 1.
const nivelNormalizado = Math.round(args.nivel) <= 0 ? 0 : 1;
const setor = args.setor?.trim();
const roleId = await ctx.db.insert('roles', {
nome: nomeNormalizado,
descricao: args.descricao.trim() || args.nome.trim(),
nivel: nivelAjustado,
nivel: nivelNormalizado,
setor: setor && setor.length > 0 ? setor : undefined,
customizado: true,
criadoPor: usuarioAtual._id,
@@ -123,3 +126,26 @@ export const criar = mutation({
return { sucesso: true as const, roleId };
}
});
/**
* Migração de níveis de roles para o novo modelo (apenas 0 e 1).
* - Mantém níveis 0 e 1 como estão.
* - Converte qualquer nível > 1 para 1.
*/
export const migrarNiveisRoles = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const roles = await ctx.db.query('roles').collect();
for (const role of roles) {
if (role.nivel <= 1) continue;
await ctx.db.patch(role._id, {
nivel: 1
});
}
return null;
}
});

View File

@@ -7,6 +7,119 @@ export const simboloTipo = v.union(
);
export type SimboloTipo = Infer<typeof simboloTipo>;
export const ataqueCiberneticoTipo = v.union(
v.literal("phishing"),
v.literal("malware"),
v.literal("ransomware"),
v.literal("brute_force"),
v.literal("credential_stuffing"),
v.literal("sql_injection"),
v.literal("xss"),
v.literal("path_traversal"),
v.literal("command_injection"),
v.literal("nosql_injection"),
v.literal("xxe"),
v.literal("man_in_the_middle"),
v.literal("ddos"),
v.literal("engenharia_social"),
v.literal("cve_exploit"),
v.literal("apt"),
v.literal("zero_day"),
v.literal("supply_chain"),
v.literal("fileless_malware"),
v.literal("polymorphic_malware"),
v.literal("ransomware_lateral"),
v.literal("deepfake_phishing"),
v.literal("adversarial_ai"),
v.literal("side_channel"),
v.literal("firmware_bootloader"),
v.literal("bec"),
v.literal("botnet"),
v.literal("ot_ics"),
v.literal("quantum_attack")
);
export type AtaqueCiberneticoTipo = Infer<typeof ataqueCiberneticoTipo>;
export const severidadeSeguranca = v.union(
v.literal("informativo"),
v.literal("baixo"),
v.literal("moderado"),
v.literal("alto"),
v.literal("critico")
);
export type SeveridadeSeguranca = Infer<typeof severidadeSeguranca>;
export const statusEventoSeguranca = v.union(
v.literal("detectado"),
v.literal("investigando"),
v.literal("contido"),
v.literal("falso_positivo"),
v.literal("escalado"),
v.literal("resolvido")
);
export type StatusEventoSeguranca = Infer<typeof statusEventoSeguranca>;
export const sensorSegurancaTipo = v.union(
v.literal("network"),
v.literal("endpoint"),
v.literal("application"),
v.literal("gateway"),
v.literal("ot"),
v.literal("honeypot")
);
export type SensorSegurancaTipo = Infer<typeof sensorSegurancaTipo>;
export const sensorSegurancaStatus = v.union(
v.literal("ativo"),
v.literal("inativo"),
v.literal("degradado"),
v.literal("manutencao")
);
export type SensorSegurancaStatus = Infer<typeof sensorSegurancaStatus>;
export const threatIntelTipo = v.union(
v.literal("open_source"),
v.literal("commercial"),
v.literal("internal"),
v.literal("gov"),
v.literal("research")
);
export const threatIntelFormato = v.union(
v.literal("json"),
v.literal("stix"),
v.literal("csv"),
v.literal("text"),
v.literal("custom")
);
export const acaoIncidenteTipo = v.union(
v.literal("block_ip"),
v.literal("unblock_ip"),
v.literal("block_port"),
v.literal("liberar_porta"),
v.literal("notificar"),
v.literal("isolar_host"),
v.literal("gerar_relatorio"),
v.literal("criar_ticket"),
v.literal("ajuste_regra"),
v.literal("custom")
);
export const acaoIncidenteStatus = v.union(
v.literal("pendente"),
v.literal("executando"),
v.literal("concluido"),
v.literal("falhou")
);
export const reportStatus = v.union(
v.literal("pendente"),
v.literal("processando"),
v.literal("concluido"),
v.literal("falhou")
);
export default defineSchema({
todos: defineTable({
text: v.string(),
@@ -320,9 +433,12 @@ export default defineSchema({
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
gestorSuperiorId: v.optional(v.id("usuarios")),
ativo: v.boolean(),
cor: v.optional(v.string()), // Cor para identificação visual
}).index("by_gestor", ["gestorId"]),
})
.index("by_gestor", ["gestorId"])
.index("by_gestor_superior", ["gestorSuperiorId"]),
timesMembros: defineTable({
timeId: v.id("times"),
@@ -688,7 +804,8 @@ export default defineSchema({
v.literal("nova_mensagem"),
v.literal("mencao"),
v.literal("grupo_criado"),
v.literal("adicionado_grupo")
v.literal("adicionado_grupo"),
v.literal("alerta_seguranca")
),
conversaId: v.optional(v.id("conversas")),
mensagemId: v.optional(v.id("mensagens")),
@@ -787,4 +904,446 @@ export default defineSchema({
.index("by_timestamp", ["timestamp"])
.index("by_status", ["status"])
.index("by_config", ["configId", "timestamp"]),
tickets: defineTable({
numero: v.string(),
titulo: v.string(),
descricao: v.string(),
tipo: v.union(
v.literal("reclamacao"),
v.literal("elogio"),
v.literal("sugestao"),
v.literal("chamado")
),
categoria: v.optional(v.string()),
status: v.union(
v.literal("aberto"),
v.literal("em_andamento"),
v.literal("aguardando_usuario"),
v.literal("resolvido"),
v.literal("encerrado"),
v.literal("cancelado")
),
prioridade: v.union(
v.literal("baixa"),
v.literal("media"),
v.literal("alta"),
v.literal("critica")
),
solicitanteId: v.id("usuarios"),
solicitanteNome: v.string(),
solicitanteEmail: v.string(),
responsavelId: v.optional(v.id("usuarios")),
setorResponsavel: v.optional(v.string()),
slaConfigId: v.optional(v.id("slaConfigs")),
conversaId: v.optional(v.id("conversas")),
prazoResposta: v.optional(v.number()),
prazoConclusao: v.optional(v.number()),
prazoEncerramento: v.optional(v.number()),
timeline: v.optional(
v.array(
v.object({
etapa: v.string(),
status: v.union(
v.literal("pendente"),
v.literal("em_andamento"),
v.literal("concluido"),
v.literal("vencido")
),
prazo: v.optional(v.number()),
concluidoEm: v.optional(v.number()),
observacao: v.optional(v.string()),
})
)
),
alertasEmitidos: v.optional(
v.array(
v.object({
tipo: v.union(
v.literal("resposta"),
v.literal("conclusao"),
v.literal("encerramento")
),
emitidoEm: v.number(),
})
)
),
anexos: v.optional(
v.array(
v.object({
arquivoId: v.id("_storage"),
nome: v.optional(v.string()),
tipo: v.optional(v.string()),
tamanho: v.optional(v.number()),
})
)
),
tags: v.optional(v.array(v.string())),
canalOrigem: v.optional(v.string()),
ultimaInteracaoEm: v.number(),
criadoEm: v.number(),
atualizadoEm: v.number(),
})
.index("by_numero", ["numero"])
.index("by_status", ["status"])
.index("by_solicitante", ["solicitanteId", "status"])
.index("by_responsavel", ["responsavelId", "status"])
.index("by_setor", ["setorResponsavel", "status"]),
ticketInteractions: defineTable({
ticketId: v.id("tickets"),
autorId: v.optional(v.id("usuarios")),
origem: v.union(
v.literal("usuario"),
v.literal("ti"),
v.literal("sistema")
),
tipo: v.union(
v.literal("mensagem"),
v.literal("status"),
v.literal("anexo"),
v.literal("alerta")
),
conteudo: v.string(),
anexos: v.optional(
v.array(
v.object({
arquivoId: v.id("_storage"),
nome: v.optional(v.string()),
tipo: v.optional(v.string()),
tamanho: v.optional(v.number()),
})
)
),
statusAnterior: v.optional(
v.union(
v.literal("aberto"),
v.literal("em_andamento"),
v.literal("aguardando_usuario"),
v.literal("resolvido"),
v.literal("encerrado"),
v.literal("cancelado")
)
),
statusNovo: v.optional(
v.union(
v.literal("aberto"),
v.literal("em_andamento"),
v.literal("aguardando_usuario"),
v.literal("resolvido"),
v.literal("encerrado"),
v.literal("cancelado")
)
),
visibilidade: v.union(
v.literal("publico"),
v.literal("interno")
),
criadoEm: v.number(),
})
.index("by_ticket", ["ticketId"])
.index("by_ticket_type", ["ticketId", "tipo"])
.index("by_autor", ["autorId"]),
slaConfigs: defineTable({
nome: v.string(),
descricao: v.optional(v.string()),
prioridade: v.optional(
v.union(
v.literal("baixa"),
v.literal("media"),
v.literal("alta"),
v.literal("critica")
)
),
tempoRespostaHoras: v.number(),
tempoConclusaoHoras: v.number(),
tempoEncerramentoHoras: v.optional(v.number()),
alertaAntecedenciaHoras: v.number(),
ativo: v.boolean(),
criadoPor: v.id("usuarios"),
atualizadoPor: v.optional(v.id("usuarios")),
criadoEm: v.number(),
atualizadoEm: v.number(),
})
.index("by_ativo", ["ativo"])
.index("by_prioridade", ["prioridade", "ativo"])
.index("by_nome", ["nome"]),
ticketAssignments: defineTable({
ticketId: v.id("tickets"),
responsavelId: v.id("usuarios"),
atribuidoPor: v.id("usuarios"),
motivo: v.optional(v.string()),
ativo: v.boolean(),
criadoEm: v.number(),
encerradoEm: v.optional(v.number()),
})
.index("by_ticket", ["ticketId", "ativo"])
.index("by_responsavel", ["responsavelId", "ativo"]),
// Sistema de Segurança Cibernética
networkSensors: defineTable({
nome: v.string(),
tipo: sensorSegurancaTipo,
status: sensorSegurancaStatus,
escopo: v.optional(v.string()),
ipMonitorado: v.optional(v.string()),
hostname: v.optional(v.string()),
regioes: v.optional(v.array(v.string())),
portasMonitoradas: v.optional(v.array(v.number())),
protocolos: v.optional(v.array(v.string())),
capacidades: v.optional(v.array(v.string())),
ultimaSincronizacao: v.number(),
ultimoHeartbeat: v.optional(v.number()),
latenciaMs: v.optional(v.number()),
errosConsecutivos: v.optional(v.number()),
agenteVersao: v.optional(v.string()),
notas: v.optional(v.string()),
})
.index("by_tipo", ["tipo"])
.index("by_status", ["status"])
.index("by_hostname", ["hostname"]),
ipReputation: defineTable({
indicador: v.string(),
categoria: v.union(
v.literal("ip"),
v.literal("dominio"),
v.literal("hash"),
v.literal("email")
),
reputacao: v.number(), // -100 (malicioso) até 100 (confiável)
severidadeMax: severidadeSeguranca,
whitelist: v.boolean(),
blacklist: v.boolean(),
ocorrencias: v.number(),
primeiroRegistro: v.number(),
ultimoRegistro: v.number(),
bloqueadoAte: v.optional(v.number()),
origem: v.optional(v.string()),
comentarios: v.optional(v.string()),
classificacoes: v.optional(v.array(v.string())),
ultimaAcaoId: v.optional(v.id("incidentActions")),
})
.index("by_indicador", ["indicador"])
.index("by_reputacao", ["reputacao"])
.index("by_blacklist", ["blacklist"])
.index("by_whitelist", ["whitelist"]),
portRules: defineTable({
porta: v.number(),
protocolo: v.union(
v.literal("tcp"),
v.literal("udp"),
v.literal("icmp"),
v.literal("quic"),
v.literal("any")
),
acao: v.union(
v.literal("permitir"),
v.literal("bloquear"),
v.literal("monitorar"),
v.literal("rate_limit")
),
temporario: v.boolean(),
severidadeMin: severidadeSeguranca,
duracaoSegundos: v.optional(v.number()),
expiraEm: v.optional(v.number()),
criadoPor: v.id("usuarios"),
atualizadoPor: v.optional(v.id("usuarios")),
criadoEm: v.number(),
atualizadoEm: v.number(),
notas: v.optional(v.string()),
tags: v.optional(v.array(v.string())),
listaReferencia: v.optional(v.id("ipReputation")),
})
.index("by_porta_protocolo", ["porta", "protocolo"])
.index("by_acao", ["acao"])
.index("by_expiracao", ["expiraEm"]),
threatIntelFeeds: defineTable({
nomeFonte: v.string(),
tipo: threatIntelTipo,
formato: threatIntelFormato,
url: v.optional(v.string()),
ativo: v.boolean(),
prioridade: v.union(
v.literal("baixa"),
v.literal("media"),
v.literal("alta"),
v.literal("critica")
),
ultimaSincronizacao: v.optional(v.number()),
entradasProcessadas: v.optional(v.number()),
errosConsecutivos: v.optional(v.number()),
autenticacaoNecessaria: v.optional(v.boolean()),
configuracao: v.optional(
v.object({
tokenId: v.optional(v.id("_storage")),
escopo: v.optional(v.string()),
})
),
criadoPor: v.id("usuarios"),
atualizadoPor: v.optional(v.id("usuarios")),
criadoEm: v.number(),
atualizadoEm: v.number(),
})
.index("by_tipo", ["tipo"])
.index("by_ativo", ["ativo"])
.index("by_prioridade", ["prioridade"]),
securityEvents: defineTable({
referencia: v.string(),
timestamp: v.number(),
tipoAtaque: ataqueCiberneticoTipo,
severidade: severidadeSeguranca,
status: statusEventoSeguranca,
descricao: v.string(),
origemIp: v.optional(v.string()),
origemRegiao: v.optional(v.string()),
origemAsn: v.optional(v.string()),
destinoIp: v.optional(v.string()),
destinoPorta: v.optional(v.number()),
protocolo: v.optional(v.string()),
transporte: v.optional(v.string()),
sensorId: v.optional(v.id("networkSensors")),
detectadoPor: v.optional(v.string()),
mitreTechnique: v.optional(v.string()),
geolocalizacao: v.optional(
v.object({
pais: v.optional(v.string()),
regiao: v.optional(v.string()),
cidade: v.optional(v.string()),
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
})
),
fingerprint: v.optional(
v.object({
userAgent: v.optional(v.string()),
deviceId: v.optional(v.string()),
ja3: v.optional(v.string()),
tlsVersion: v.optional(v.string()),
})
),
indicadores: v.optional(
v.array(
v.object({
tipo: v.string(),
valor: v.string(),
confianca: v.optional(v.number()),
})
)
),
metricas: v.optional(
v.object({
pps: v.optional(v.number()),
bps: v.optional(v.number()),
rpm: v.optional(v.number()),
errosPorSegundo: v.optional(v.number()),
hostsAfetados: v.optional(v.number()),
})
),
correlacoes: v.optional(v.array(v.id("securityEvents"))),
referenciasExternas: v.optional(v.array(v.string())),
tags: v.optional(v.array(v.string())),
criadoPor: v.optional(v.id("usuarios")),
atualizadoEm: v.number(),
})
.index("by_referencia", ["referencia"])
.index("by_timestamp", ["timestamp"])
.index("by_tipo", ["tipoAtaque", "timestamp"])
.index("by_severidade", ["severidade", "timestamp"])
.index("by_status", ["status", "timestamp"]),
incidentActions: defineTable({
eventoId: v.id("securityEvents"),
tipo: acaoIncidenteTipo,
origem: v.union(v.literal("automatico"), v.literal("manual")),
status: acaoIncidenteStatus,
executadoPor: v.optional(v.id("usuarios")),
detalhes: v.optional(v.string()),
resultado: v.optional(v.string()),
relacionadoA: v.optional(v.id("ipReputation")),
criadoEm: v.number(),
atualizadoEm: v.number(),
})
.index("by_evento", ["eventoId", "status"])
.index("by_tipo", ["tipo", "status"]),
reportRequests: defineTable({
solicitanteId: v.id("usuarios"),
filtros: v.object({
dataInicio: v.number(),
dataFim: v.number(),
severidades: v.optional(v.array(severidadeSeguranca)),
tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)),
incluirIndicadores: v.optional(v.boolean()),
incluirMetricas: v.optional(v.boolean()),
incluirAcoes: v.optional(v.boolean()),
}),
status: reportStatus,
resultadoId: v.optional(v.id("_storage")),
observacoes: v.optional(v.string()),
criadoEm: v.number(),
atualizadoEm: v.number(),
concluidoEm: v.optional(v.number()),
erro: v.optional(v.string()),
})
.index("by_status", ["status"])
.index("by_solicitante", ["solicitanteId", "status"])
.index("by_criado_em", ["criadoEm"]),
rateLimitConfig: defineTable({
nome: v.string(),
tipo: v.union(
v.literal("ip"),
v.literal("usuario"),
v.literal("endpoint"),
v.literal("global")
),
identificador: v.optional(v.string()),
limite: v.number(),
janelaSegundos: v.number(),
estrategia: v.union(
v.literal("fixed_window"),
v.literal("sliding_window"),
v.literal("token_bucket")
),
acaoExcedido: v.union(
v.literal("bloquear"),
v.literal("throttle"),
v.literal("alertar")
),
bloqueioTemporarioSegundos: v.optional(v.number()),
ativo: v.boolean(),
prioridade: v.number(),
criadoPor: v.id("usuarios"),
atualizadoPor: v.optional(v.id("usuarios")),
criadoEm: v.number(),
atualizadoEm: v.number(),
notas: v.optional(v.string()),
tags: v.optional(v.array(v.string()))
})
.index("by_tipo_identificador", ["tipo", "identificador"])
.index("by_ativo", ["ativo"])
.index("by_prioridade", ["prioridade"])
,
alertConfigs: defineTable({
nome: v.string(),
canais: v.object({
email: v.boolean(),
chat: v.boolean(),
}),
emails: v.array(v.string()),
chatUsers: v.array(v.string()),
severidadeMin: severidadeSeguranca,
tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)),
reenvioMin: v.number(),
criadoPor: v.id("usuarios"),
criadoEm: v.number(),
atualizadoEm: v.number(),
})
.index("by_criadoEm", ["criadoEm"])
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -287,6 +287,115 @@ export const criarTemplatesPadrao = mutation({
+ "</div></body></html>",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
{
codigo: "chamado_registrado",
nome: "Chamado Registrado",
titulo: "Chamado {{numeroTicket}} registrado",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #2563EB;'>Chamado registrado com sucesso!</h2>"
+ "<p>Olá <strong>{{solicitante}}</strong>,</p>"
+ "<p>Recebemos sua solicitação e iniciaremos o atendimento em breve.</p>"
+ "<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prioridade:</strong> {{prioridade}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Categoria:</strong> {{categoria}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/perfil/chamados' "
+ "style='background-color: #2563EB; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Acompanhar chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["solicitante", "numeroTicket", "prioridade", "categoria", "urlSistema"],
},
{
codigo: "chamado_atualizado",
nome: "Atualização no Chamado",
titulo: "Atualização no chamado {{numeroTicket}}",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #2563EB;'>Nova atualização no seu chamado</h2>"
+ "<p>Olá <strong>{{solicitante}}</strong>,</p>"
+ "<p>Há uma nova atualização no seu chamado:</p>"
+ "<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Mensagem:</strong></p>"
+ "<p style='margin: 10px 0 0 0;'>{{mensagem}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/perfil/chamados' "
+ "style='background-color: #2563EB; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver detalhes"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["solicitante", "numeroTicket", "mensagem", "urlSistema"],
},
{
codigo: "chamado_atribuido",
nome: "Chamado Atribuído",
titulo: "Chamado {{numeroTicket}} atribuído",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #059669;'>Chamado atribuído</h2>"
+ "<p>Olá <strong>{{responsavel}}</strong>,</p>"
+ "<p>Um novo chamado foi atribuído para você:</p>"
+ "<div style='background-color: #ECFDF5; border-left: 4px solid #059669; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Solicitante:</strong> {{solicitante}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prioridade:</strong> {{prioridade}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Descrição:</strong> {{descricao}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/ti/central-chamados' "
+ "style='background-color: #059669; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Acessar chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["responsavel", "numeroTicket", "solicitante", "prioridade", "descricao", "urlSistema"],
},
{
codigo: "chamado_alerta_prazo",
nome: "Alerta de Prazo do Chamado",
titulo: "⚠️ Alerta de prazo - Chamado {{numeroTicket}}",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #DC2626;'>⚠️ Alerta de prazo</h2>"
+ "<p>Olá <strong>{{destinatario}}</strong>,</p>"
+ "<p>O chamado abaixo está próximo do prazo de {{tipoPrazo}}:</p>"
+ "<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prazo de {{tipoPrazo}}:</strong> {{prazo}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Status:</strong> {{status}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}{{rotaAcesso}}' "
+ "style='background-color: #DC2626; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"],
},
];
for (const template of templatesPadrao) {

View File

@@ -1,6 +1,6 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { Id, Doc } from "./_generated/dataModel";
import { getCurrentUserFunction } from "./auth";
// Query: Listar todos os times
// Tipo inferido automaticamente pelo Convex
@@ -127,12 +127,67 @@ export const listarPorGestor = query({
},
});
export const listarSubordinadosDoGestorAtual = query({
args: {},
handler: async (ctx) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
return [];
}
const timesGestor = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", usuario._id))
.collect();
const timesComoSuperior = await ctx.db
.query("times")
.withIndex("by_gestor_superior", (q) => q.eq("gestorSuperiorId", usuario._id))
.collect();
const timesMap = new Map(
[...timesGestor, ...timesComoSuperior]
.filter((time) => time.ativo)
.map((time) => [time._id, time])
);
const resultado = [];
for (const time of timesMap.values()) {
const membrosRelacoes = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
const membros = [];
for (const rel of membrosRelacoes) {
const funcionario = await ctx.db.get(rel.funcionarioId);
if (funcionario) {
membros.push({
relacaoId: rel._id,
funcionario,
dataEntrada: rel.dataEntrada,
});
}
}
resultado.push({
...time,
membros,
});
}
return resultado;
},
});
// Mutation: Criar time
export const criar = mutation({
args: {
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
gestorSuperiorId: v.optional(v.id("usuarios")),
cor: v.optional(v.string()),
},
returns: v.id("times"),
@@ -141,6 +196,7 @@ export const criar = mutation({
nome: args.nome,
descricao: args.descricao,
gestorId: args.gestorId,
gestorSuperiorId: args.gestorSuperiorId ?? args.gestorId,
ativo: true,
cor: args.cor || "#3B82F6",
});
@@ -156,12 +212,16 @@ export const atualizar = mutation({
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
gestorSuperiorId: v.optional(v.id("usuarios")),
cor: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...dados } = args;
await ctx.db.patch(id, dados);
await ctx.db.patch(id, {
...dados,
gestorSuperiorId: dados.gestorSuperiorId ?? dados.gestorId,
});
return null;
},
});
@@ -185,6 +245,8 @@ export const desativar = mutation({
ativo: false,
dataSaida: Date.now(),
});
await ctx.db.patch(membro.funcionarioId, { gestorId: undefined });
}
return null;
@@ -210,12 +272,19 @@ export const adicionarMembro = mutation({
throw new Error("Funcionário já está em um time ativo");
}
const time = await ctx.db.get(args.timeId);
if (!time || !time.ativo) {
throw new Error("Time inválido ou inativo");
}
const membroId = await ctx.db.insert("timesMembros", {
timeId: args.timeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true,
});
await ctx.db.patch(args.funcionarioId, { gestorId: time.gestorId });
return membroId;
},
@@ -226,10 +295,16 @@ export const removerMembro = mutation({
args: { membroId: v.id("timesMembros") },
returns: v.null(),
handler: async (ctx, args) => {
const membro = await ctx.db.get(args.membroId);
if (!membro) {
throw new Error("Membro não encontrado");
}
await ctx.db.patch(args.membroId, {
ativo: false,
dataSaida: Date.now(),
});
await ctx.db.patch(membro.funcionarioId, { gestorId: undefined });
return null;
},
});
@@ -257,12 +332,19 @@ export const transferirMembro = mutation({
}
// Adicionar ao novo time
const novoTime = await ctx.db.get(args.novoTimeId);
if (!novoTime || !novoTime.ativo) {
throw new Error("Novo time inválido ou inativo");
}
await ctx.db.insert("timesMembros", {
timeId: args.novoTimeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true,
});
await ctx.db.patch(args.funcionarioId, { gestorId: novoTime.gestorId });
return null;
},