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:
140
packages/backend/convex/_generated/api.d.ts
vendored
140
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
816
packages/backend/convex/chamados.ts
Normal file
816
packages/backend/convex/chamados.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"])
|
||||
});
|
||||
|
||||
2423
packages/backend/convex/security.ts
Normal file
2423
packages/backend/convex/security.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/better-auth": "^0.9.7",
|
||||
"@convex-dev/rate-limiter": "^0.3.0",
|
||||
"@dicebear/avataaars": "^9.2.4",
|
||||
"better-auth": "catalog:",
|
||||
"convex": "catalog:",
|
||||
|
||||
Reference in New Issue
Block a user