Merge remote-tracking branch 'origin/feat-cotrole_acesso' into feat-funcionarios-ferias
This commit is contained in:
18
packages/backend/convex/_generated/api.d.ts
vendored
18
packages/backend/convex/_generated/api.d.ts
vendored
@@ -16,21 +16,30 @@ import type * as betterAuth__generated_server from "../betterAuth/_generated/ser
|
||||
import type * as betterAuth_adapter from "../betterAuth/adapter.js";
|
||||
import type * as betterAuth_auth from "../betterAuth/auth.js";
|
||||
import type * as chat from "../chat.js";
|
||||
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as dashboard from "../dashboard.js";
|
||||
import type * as documentos from "../documentos.js";
|
||||
import type * as email from "../email.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";
|
||||
import type * as menuPermissoes from "../menuPermissoes.js";
|
||||
import type * as migrarUsuariosAdmin from "../migrarUsuariosAdmin.js";
|
||||
import type * as monitoramento from "../monitoramento.js";
|
||||
import type * as perfisCustomizados from "../perfisCustomizados.js";
|
||||
import type * as roles from "../roles.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as simbolos from "../simbolos.js";
|
||||
import type * as solicitacoesAcesso from "../solicitacoesAcesso.js";
|
||||
import type * as templatesMensagens from "../templatesMensagens.js";
|
||||
import type * as todos from "../todos.js";
|
||||
import type * as usuarios from "../usuarios.js";
|
||||
import type * as verificarMatriculas from "../verificarMatriculas.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
@@ -55,21 +64,30 @@ declare const fullApi: ApiFromModules<{
|
||||
"betterAuth/adapter": typeof betterAuth_adapter;
|
||||
"betterAuth/auth": typeof betterAuth_auth;
|
||||
chat: typeof chat;
|
||||
configuracaoEmail: typeof configuracaoEmail;
|
||||
crons: typeof crons;
|
||||
dashboard: typeof dashboard;
|
||||
documentos: typeof documentos;
|
||||
email: typeof email;
|
||||
funcionarios: typeof funcionarios;
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
limparPerfisAntigos: typeof limparPerfisAntigos;
|
||||
logsAcesso: typeof logsAcesso;
|
||||
logsAtividades: typeof logsAtividades;
|
||||
logsLogin: typeof logsLogin;
|
||||
menuPermissoes: typeof menuPermissoes;
|
||||
migrarUsuariosAdmin: typeof migrarUsuariosAdmin;
|
||||
monitoramento: typeof monitoramento;
|
||||
perfisCustomizados: typeof perfisCustomizados;
|
||||
roles: typeof roles;
|
||||
seed: typeof seed;
|
||||
simbolos: typeof simbolos;
|
||||
solicitacoesAcesso: typeof solicitacoesAcesso;
|
||||
templatesMensagens: typeof templatesMensagens;
|
||||
todos: typeof todos;
|
||||
usuarios: typeof usuarios;
|
||||
verificarMatriculas: typeof verificarMatriculas;
|
||||
}>;
|
||||
declare const fullApiWithMounts: typeof fullApi;
|
||||
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { hashPassword, verifyPassword, generateToken, validarMatricula, validarSenha } from "./auth/utils";
|
||||
import { registrarLogin } from "./logsLogin";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
|
||||
/**
|
||||
* Login do usuário
|
||||
* Helper para verificar se usuário está bloqueado
|
||||
*/
|
||||
async function verificarBloqueioUsuario(ctx: any, usuarioId: Id<"usuarios">) {
|
||||
const bloqueio = await ctx.db
|
||||
.query("bloqueiosUsuarios")
|
||||
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioId))
|
||||
.filter((q: any) => q.eq(q.field("ativo"), true))
|
||||
.first();
|
||||
|
||||
return bloqueio !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper para verificar rate limiting por IP
|
||||
*/
|
||||
async function verificarRateLimitIP(ctx: any, ipAddress: string) {
|
||||
// Últimas 15 minutos
|
||||
const dataLimite = Date.now() - 15 * 60 * 1000;
|
||||
|
||||
const tentativas = await ctx.db
|
||||
.query("logsLogin")
|
||||
.withIndex("by_ip", (q: any) => q.eq("ipAddress", ipAddress))
|
||||
.filter((q: any) => q.gte(q.field("timestamp"), dataLimite))
|
||||
.collect();
|
||||
|
||||
const falhas = tentativas.filter((t: any) => !t.sucesso).length;
|
||||
|
||||
// Bloquear se 5 ou mais tentativas falhas em 15 minutos
|
||||
return falhas >= 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login do usuário (aceita matrícula OU email)
|
||||
*/
|
||||
export const login = mutation({
|
||||
args: {
|
||||
matricula: v.string(),
|
||||
matriculaOuEmail: v.string(), // Aceita matrícula ou email
|
||||
senha: v.string(),
|
||||
ipAddress: v.optional(v.string()),
|
||||
userAgent: v.optional(v.string()),
|
||||
@@ -36,46 +70,83 @@ export const login = mutation({
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Validar matrícula
|
||||
if (!validarMatricula(args.matricula)) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Matrícula inválida. Use apenas números.",
|
||||
};
|
||||
// Verificar rate limiting por IP
|
||||
if (args.ipAddress) {
|
||||
const ipBloqueado = await verificarRateLimitIP(ctx, args.ipAddress);
|
||||
if (ipBloqueado) {
|
||||
await registrarLogin(ctx, {
|
||||
matriculaOuEmail: args.matriculaOuEmail,
|
||||
sucesso: false,
|
||||
motivoFalha: "rate_limit_excedido",
|
||||
ipAddress: args.ipAddress,
|
||||
userAgent: args.userAgent,
|
||||
});
|
||||
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Muitas tentativas de login. Tente novamente em 15 minutos.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Determinar se é email ou matrícula
|
||||
const isEmail = args.matriculaOuEmail.includes("@");
|
||||
|
||||
// Buscar usuário
|
||||
const usuario = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
|
||||
.first();
|
||||
let usuario;
|
||||
if (isEmail) {
|
||||
usuario = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_email", (q) => q.eq("email", args.matriculaOuEmail))
|
||||
.first();
|
||||
} else {
|
||||
usuario = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail))
|
||||
.first();
|
||||
}
|
||||
|
||||
if (!usuario) {
|
||||
// Log de tentativa de acesso negado
|
||||
await ctx.db.insert("logsAcesso", {
|
||||
usuarioId: "" as any, // Não temos ID
|
||||
tipo: "acesso_negado",
|
||||
await registrarLogin(ctx, {
|
||||
matriculaOuEmail: args.matriculaOuEmail,
|
||||
sucesso: false,
|
||||
motivoFalha: "usuario_inexistente",
|
||||
ipAddress: args.ipAddress,
|
||||
userAgent: args.userAgent,
|
||||
detalhes: `Tentativa de login com matrícula inexistente: ${args.matricula}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Matrícula ou senha incorreta.",
|
||||
erro: "Credenciais incorretas.",
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar se usuário está bloqueado
|
||||
if (usuario.bloqueado || (await verificarBloqueioUsuario(ctx, usuario._id))) {
|
||||
await registrarLogin(ctx, {
|
||||
usuarioId: usuario._id,
|
||||
matriculaOuEmail: args.matriculaOuEmail,
|
||||
sucesso: false,
|
||||
motivoFalha: "usuario_bloqueado",
|
||||
ipAddress: args.ipAddress,
|
||||
userAgent: args.userAgent,
|
||||
});
|
||||
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Usuário bloqueado. Entre em contato com o TI.",
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar se usuário está ativo
|
||||
if (!usuario.ativo) {
|
||||
await ctx.db.insert("logsAcesso", {
|
||||
await registrarLogin(ctx, {
|
||||
usuarioId: usuario._id,
|
||||
tipo: "acesso_negado",
|
||||
matriculaOuEmail: args.matriculaOuEmail,
|
||||
sucesso: false,
|
||||
motivoFalha: "usuario_inativo",
|
||||
ipAddress: args.ipAddress,
|
||||
userAgent: args.userAgent,
|
||||
detalhes: "Tentativa de login com usuário inativo",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -84,25 +155,79 @@ export const login = mutation({
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar tentativas de login (bloqueio temporário)
|
||||
const tentativasRecentes = usuario.tentativasLogin || 0;
|
||||
const ultimaTentativa = usuario.ultimaTentativaLogin || 0;
|
||||
const tempoDecorrido = Date.now() - ultimaTentativa;
|
||||
const TEMPO_BLOQUEIO = 30 * 60 * 1000; // 30 minutos
|
||||
|
||||
// Se tentou 5 vezes e ainda não passou o tempo de bloqueio
|
||||
if (tentativasRecentes >= 5 && tempoDecorrido < TEMPO_BLOQUEIO) {
|
||||
await registrarLogin(ctx, {
|
||||
usuarioId: usuario._id,
|
||||
matriculaOuEmail: args.matriculaOuEmail,
|
||||
sucesso: false,
|
||||
motivoFalha: "bloqueio_temporario",
|
||||
ipAddress: args.ipAddress,
|
||||
userAgent: args.userAgent,
|
||||
});
|
||||
|
||||
const minutosRestantes = Math.ceil((TEMPO_BLOQUEIO - tempoDecorrido) / 60000);
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Conta temporariamente bloqueada. Tente novamente em ${minutosRestantes} minutos.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Resetar tentativas se passou o tempo de bloqueio
|
||||
if (tempoDecorrido > TEMPO_BLOQUEIO) {
|
||||
await ctx.db.patch(usuario._id, {
|
||||
tentativasLogin: 0,
|
||||
ultimaTentativaLogin: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Verificar senha
|
||||
const senhaValida = await verifyPassword(args.senha, usuario.senhaHash);
|
||||
|
||||
if (!senhaValida) {
|
||||
await ctx.db.insert("logsAcesso", {
|
||||
usuarioId: usuario._id,
|
||||
tipo: "acesso_negado",
|
||||
ipAddress: args.ipAddress,
|
||||
userAgent: args.userAgent,
|
||||
detalhes: "Senha incorreta",
|
||||
timestamp: Date.now(),
|
||||
// Incrementar tentativas
|
||||
const novasTentativas = tempoDecorrido > TEMPO_BLOQUEIO ? 1 : tentativasRecentes + 1;
|
||||
|
||||
await ctx.db.patch(usuario._id, {
|
||||
tentativasLogin: novasTentativas,
|
||||
ultimaTentativaLogin: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Matrícula ou senha incorreta.",
|
||||
};
|
||||
await registrarLogin(ctx, {
|
||||
usuarioId: usuario._id,
|
||||
matriculaOuEmail: args.matriculaOuEmail,
|
||||
sucesso: false,
|
||||
motivoFalha: "senha_incorreta",
|
||||
ipAddress: args.ipAddress,
|
||||
userAgent: args.userAgent,
|
||||
});
|
||||
|
||||
const tentativasRestantes = 5 - novasTentativas;
|
||||
if (tentativasRestantes > 0) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Credenciais incorretas. ${tentativasRestantes} tentativas restantes.`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Conta bloqueada por 30 minutos devido a múltiplas tentativas falhas.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Login bem-sucedido! Resetar tentativas
|
||||
await ctx.db.patch(usuario._id, {
|
||||
tentativasLogin: 0,
|
||||
ultimaTentativaLogin: undefined,
|
||||
});
|
||||
|
||||
// Buscar role do usuário
|
||||
const role = await ctx.db.get(usuario.roleId);
|
||||
if (!role) {
|
||||
@@ -135,6 +260,14 @@ export const login = mutation({
|
||||
});
|
||||
|
||||
// Log de login bem-sucedido
|
||||
await registrarLogin(ctx, {
|
||||
usuarioId: usuario._id,
|
||||
matriculaOuEmail: args.matriculaOuEmail,
|
||||
sucesso: true,
|
||||
ipAddress: args.ipAddress,
|
||||
userAgent: args.userAgent,
|
||||
});
|
||||
|
||||
await ctx.db.insert("logsAcesso", {
|
||||
usuarioId: usuario._id,
|
||||
tipo: "login",
|
||||
|
||||
166
packages/backend/convex/configuracaoEmail.ts
Normal file
166
packages/backend/convex/configuracaoEmail.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query, action } from "./_generated/server";
|
||||
import { hashPassword } from "./auth/utils";
|
||||
import { registrarAtividade } from "./logsAtividades";
|
||||
|
||||
/**
|
||||
* Obter configuração de email ativa (senha mascarada)
|
||||
*/
|
||||
export const obterConfigEmail = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const config = await ctx.db
|
||||
.query("configuracaoEmail")
|
||||
.withIndex("by_ativo", (q) => q.eq("ativo", true))
|
||||
.first();
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Retornar config com senha mascarada
|
||||
return {
|
||||
_id: config._id,
|
||||
servidor: config.servidor,
|
||||
porta: config.porta,
|
||||
usuario: config.usuario,
|
||||
senhaHash: "********", // Mascarar senha
|
||||
emailRemetente: config.emailRemetente,
|
||||
nomeRemetente: config.nomeRemetente,
|
||||
usarSSL: config.usarSSL,
|
||||
usarTLS: config.usarTLS,
|
||||
ativo: config.ativo,
|
||||
testadoEm: config.testadoEm,
|
||||
atualizadoEm: config.atualizadoEm,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Salvar configuração de email (apenas TI_MASTER)
|
||||
*/
|
||||
export const salvarConfigEmail = mutation({
|
||||
args: {
|
||||
servidor: v.string(),
|
||||
porta: v.number(),
|
||||
usuario: v.string(),
|
||||
senha: v.string(),
|
||||
emailRemetente: v.string(),
|
||||
nomeRemetente: v.string(),
|
||||
usarSSL: v.boolean(),
|
||||
usarTLS: v.boolean(),
|
||||
configuradoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), configId: v.id("configuracaoEmail") }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Validar email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(args.emailRemetente)) {
|
||||
return { sucesso: false as const, erro: "Email remetente inválido" };
|
||||
}
|
||||
|
||||
// Criptografar senha
|
||||
const senhaHash = await hashPassword(args.senha);
|
||||
|
||||
// Desativar config anterior
|
||||
const configsAntigas = await ctx.db
|
||||
.query("configuracaoEmail")
|
||||
.withIndex("by_ativo", (q) => q.eq("ativo", true))
|
||||
.collect();
|
||||
|
||||
for (const config of configsAntigas) {
|
||||
await ctx.db.patch(config._id, { ativo: false });
|
||||
}
|
||||
|
||||
// Criar nova config
|
||||
const configId = await ctx.db.insert("configuracaoEmail", {
|
||||
servidor: args.servidor,
|
||||
porta: args.porta,
|
||||
usuario: args.usuario,
|
||||
senhaHash,
|
||||
emailRemetente: args.emailRemetente,
|
||||
nomeRemetente: args.nomeRemetente,
|
||||
usarSSL: args.usarSSL,
|
||||
usarTLS: args.usarTLS,
|
||||
ativo: true,
|
||||
configuradoPor: args.configuradoPorId,
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.configuradoPorId,
|
||||
"configurar",
|
||||
"email",
|
||||
JSON.stringify({ servidor: args.servidor, porta: args.porta }),
|
||||
configId
|
||||
);
|
||||
|
||||
return { sucesso: true as const, configId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Testar conexão SMTP (action - precisa de Node.js)
|
||||
*
|
||||
* NOTA: Esta action será implementada quando instalarmos nodemailer.
|
||||
* Por enquanto, retorna sucesso simulado para não bloquear o desenvolvimento.
|
||||
*/
|
||||
export const testarConexaoSMTP = action({
|
||||
args: {
|
||||
servidor: v.string(),
|
||||
porta: v.number(),
|
||||
usuario: v.string(),
|
||||
senha: v.string(),
|
||||
usarSSL: v.boolean(),
|
||||
usarTLS: v.boolean(),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// TODO: Implementar teste real com nodemailer
|
||||
// Por enquanto, simula sucesso
|
||||
|
||||
try {
|
||||
// Validações básicas
|
||||
if (!args.servidor || args.servidor.trim() === "") {
|
||||
return { sucesso: false as const, erro: "Servidor SMTP não pode estar vazio" };
|
||||
}
|
||||
|
||||
if (args.porta < 1 || args.porta > 65535) {
|
||||
return { sucesso: false as const, erro: "Porta inválida" };
|
||||
}
|
||||
|
||||
// Simular delay de teste
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Retornar sucesso simulado
|
||||
console.log("⚠️ AVISO: Teste de conexão SMTP simulado (nodemailer não instalado ainda)");
|
||||
return { sucesso: true as const };
|
||||
} catch (error: any) {
|
||||
return { sucesso: false as const, erro: error.message || "Erro ao testar conexão" };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Marcar que a configuração foi testada com sucesso
|
||||
*/
|
||||
export const marcarConfigTestada = mutation({
|
||||
args: {
|
||||
configId: v.id("configuracaoEmail"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.configId, {
|
||||
testadoEm: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
259
packages/backend/convex/email.ts
Normal file
259
packages/backend/convex/email.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query, action, internalMutation } from "./_generated/server";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
import { renderizarTemplate } from "./templatesMensagens";
|
||||
|
||||
/**
|
||||
* Enfileirar email para envio
|
||||
*/
|
||||
export const enfileirarEmail = mutation({
|
||||
args: {
|
||||
destinatario: v.string(), // email
|
||||
destinatarioId: v.optional(v.id("usuarios")),
|
||||
assunto: v.string(),
|
||||
corpo: v.string(),
|
||||
templateId: v.optional(v.id("templatesMensagens")),
|
||||
enviadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), emailId: v.optional(v.id("notificacoesEmail")) }),
|
||||
handler: async (ctx, args) => {
|
||||
// Validar email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(args.destinatario)) {
|
||||
return { sucesso: false };
|
||||
}
|
||||
|
||||
// Adicionar à fila
|
||||
const emailId = await ctx.db.insert("notificacoesEmail", {
|
||||
destinatario: args.destinatario,
|
||||
destinatarioId: args.destinatarioId,
|
||||
assunto: args.assunto,
|
||||
corpo: args.corpo,
|
||||
templateId: args.templateId,
|
||||
status: "pendente",
|
||||
tentativas: 0,
|
||||
enviadoPor: args.enviadoPorId,
|
||||
criadoEm: Date.now(),
|
||||
});
|
||||
|
||||
return { sucesso: true, emailId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Enviar email usando template
|
||||
*/
|
||||
export const enviarEmailComTemplate = mutation({
|
||||
args: {
|
||||
destinatario: v.string(),
|
||||
destinatarioId: v.optional(v.id("usuarios")),
|
||||
templateCodigo: v.string(),
|
||||
variaveis: v.any(), // Record<string, string>
|
||||
enviadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), emailId: v.optional(v.id("notificacoesEmail")) }),
|
||||
handler: async (ctx, args) => {
|
||||
// Buscar template
|
||||
const template = await ctx.db
|
||||
.query("templatesMensagens")
|
||||
.withIndex("by_codigo", (q) => q.eq("codigo", args.templateCodigo))
|
||||
.first();
|
||||
|
||||
if (!template) {
|
||||
console.error("Template não encontrado:", args.templateCodigo);
|
||||
return { sucesso: false };
|
||||
}
|
||||
|
||||
// Renderizar template
|
||||
const assunto = renderizarTemplate(template.titulo, args.variaveis);
|
||||
const corpo = renderizarTemplate(template.corpo, args.variaveis);
|
||||
|
||||
// Enfileirar email
|
||||
const emailId = await ctx.db.insert("notificacoesEmail", {
|
||||
destinatario: args.destinatario,
|
||||
destinatarioId: args.destinatarioId,
|
||||
assunto,
|
||||
corpo,
|
||||
templateId: template._id,
|
||||
status: "pendente",
|
||||
tentativas: 0,
|
||||
enviadoPor: args.enviadoPorId,
|
||||
criadoEm: Date.now(),
|
||||
});
|
||||
|
||||
return { sucesso: true, emailId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Listar emails na fila
|
||||
*/
|
||||
export const listarFilaEmails = query({
|
||||
args: {
|
||||
status: v.optional(v.union(
|
||||
v.literal("pendente"),
|
||||
v.literal("enviando"),
|
||||
v.literal("enviado"),
|
||||
v.literal("falha")
|
||||
)),
|
||||
limite: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
let query = ctx.db.query("notificacoesEmail");
|
||||
|
||||
if (args.status) {
|
||||
query = query.withIndex("by_status", (q) => q.eq("status", args.status));
|
||||
} else {
|
||||
query = query.withIndex("by_criado_em");
|
||||
}
|
||||
|
||||
const emails = await query.order("desc").take(args.limite || 100);
|
||||
|
||||
return emails;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reenviar email falhado
|
||||
*/
|
||||
export const reenviarEmail = mutation({
|
||||
args: {
|
||||
emailId: v.id("notificacoesEmail"),
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
const email = await ctx.db.get(args.emailId);
|
||||
if (!email) {
|
||||
return { sucesso: false };
|
||||
}
|
||||
|
||||
// Resetar status para pendente
|
||||
await ctx.db.patch(args.emailId, {
|
||||
status: "pendente",
|
||||
tentativas: 0,
|
||||
ultimaTentativa: undefined,
|
||||
erroDetalhes: undefined,
|
||||
});
|
||||
|
||||
return { sucesso: true };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Action para enviar email (será implementado com nodemailer)
|
||||
*
|
||||
* NOTA: Este é um placeholder. Implementação real requer nodemailer.
|
||||
*/
|
||||
export const enviarEmailAction = action({
|
||||
args: {
|
||||
emailId: v.id("notificacoesEmail"),
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||
handler: async (ctx, args) => {
|
||||
// TODO: Implementar com nodemailer quando instalado
|
||||
|
||||
try {
|
||||
// Buscar email da fila
|
||||
const email = await ctx.runQuery(async (ctx) => {
|
||||
return await ctx.db.get(args.emailId);
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
return { sucesso: false, erro: "Email não encontrado" };
|
||||
}
|
||||
|
||||
// Buscar configuração SMTP
|
||||
const config = await ctx.runQuery(async (ctx) => {
|
||||
return await ctx.db
|
||||
.query("configuracaoEmail")
|
||||
.withIndex("by_ativo", (q) => q.eq("ativo", true))
|
||||
.first();
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
return { sucesso: false, erro: "Configuração de email não encontrada" };
|
||||
}
|
||||
|
||||
// Marcar como enviando
|
||||
await ctx.runMutation(async (ctx) => {
|
||||
await ctx.db.patch(args.emailId, {
|
||||
status: "enviando",
|
||||
tentativas: (email.tentativas || 0) + 1,
|
||||
ultimaTentativa: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Enviar email real com nodemailer aqui
|
||||
console.log("⚠️ AVISO: Envio de email simulado (nodemailer não instalado)");
|
||||
console.log(" Para:", email.destinatario);
|
||||
console.log(" Assunto:", email.assunto);
|
||||
|
||||
// Simular delay de envio
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Marcar como enviado
|
||||
await ctx.runMutation(async (ctx) => {
|
||||
await ctx.db.patch(args.emailId, {
|
||||
status: "enviado",
|
||||
enviadoEm: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
return { sucesso: true };
|
||||
} catch (error: any) {
|
||||
// Marcar como falha
|
||||
await ctx.runMutation(async (ctx) => {
|
||||
const email = await ctx.db.get(args.emailId);
|
||||
await ctx.db.patch(args.emailId, {
|
||||
status: "falha",
|
||||
erroDetalhes: error.message || "Erro desconhecido",
|
||||
tentativas: (email?.tentativas || 0) + 1,
|
||||
});
|
||||
});
|
||||
|
||||
return { sucesso: false, erro: error.message || "Erro ao enviar email" };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Processar fila de emails (cron job - processa emails pendentes)
|
||||
*/
|
||||
export const processarFilaEmails = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Buscar emails pendentes (max 10 por execução)
|
||||
const emailsPendentes = await ctx.db
|
||||
.query("notificacoesEmail")
|
||||
.withIndex("by_status", (q) => q.eq("status", "pendente"))
|
||||
.take(10);
|
||||
|
||||
let processados = 0;
|
||||
|
||||
for (const email of emailsPendentes) {
|
||||
// Verificar se não excedeu tentativas (max 3)
|
||||
if ((email.tentativas || 0) >= 3) {
|
||||
await ctx.db.patch(email._id, {
|
||||
status: "falha",
|
||||
erroDetalhes: "Número máximo de tentativas excedido",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Agendar envio (será feito por uma action separada)
|
||||
// Por enquanto, apenas marca como enviado para não bloquear
|
||||
await ctx.db.patch(email._id, {
|
||||
status: "enviado",
|
||||
enviadoEm: Date.now(),
|
||||
});
|
||||
|
||||
processados++;
|
||||
}
|
||||
|
||||
console.log(`📧 Fila de emails processada: ${processados} emails`);
|
||||
|
||||
return { processados };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
290
packages/backend/convex/limparPerfisAntigos.ts
Normal file
290
packages/backend/convex/limparPerfisAntigos.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
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.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 menu permissões associadas
|
||||
const menuPerms = await ctx.db
|
||||
.query("menuPermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", role._id))
|
||||
.collect();
|
||||
for (const menuPerm of menuPerms) {
|
||||
await ctx.db.delete(menuPerm._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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
159
packages/backend/convex/logsAtividades.ts
Normal file
159
packages/backend/convex/logsAtividades.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server";
|
||||
import { Doc, Id } from "./_generated/dataModel";
|
||||
|
||||
/**
|
||||
* Helper function para registrar atividades no sistema
|
||||
* Use em todas as mutations que modificam dados
|
||||
*/
|
||||
export async function registrarAtividade(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
usuarioId: Id<"usuarios">,
|
||||
acao: string,
|
||||
recurso: string,
|
||||
detalhes?: string,
|
||||
recursoId?: string
|
||||
) {
|
||||
await ctx.db.insert("logsAtividades", {
|
||||
usuarioId,
|
||||
acao,
|
||||
recurso,
|
||||
recursoId,
|
||||
detalhes,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista atividades com filtros
|
||||
*/
|
||||
export const listarAtividades = query({
|
||||
args: {
|
||||
usuarioId: v.optional(v.id("usuarios")),
|
||||
acao: v.optional(v.string()),
|
||||
recurso: v.optional(v.string()),
|
||||
dataInicio: v.optional(v.number()),
|
||||
dataFim: v.optional(v.number()),
|
||||
limite: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
let query = ctx.db.query("logsAtividades");
|
||||
|
||||
// Aplicar filtros
|
||||
if (args.usuarioId) {
|
||||
query = query.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId));
|
||||
} else if (args.acao) {
|
||||
query = query.withIndex("by_acao", (q) => q.eq("acao", args.acao));
|
||||
} else if (args.recurso) {
|
||||
query = query.withIndex("by_recurso", (q) => q.eq("recurso", args.recurso));
|
||||
} else {
|
||||
query = query.withIndex("by_timestamp");
|
||||
}
|
||||
|
||||
let atividades = await query.order("desc").take(args.limite || 100);
|
||||
|
||||
// Filtrar por range de datas se fornecido
|
||||
if (args.dataInicio || args.dataFim) {
|
||||
atividades = atividades.filter((log) => {
|
||||
if (args.dataInicio && log.timestamp < args.dataInicio) return false;
|
||||
if (args.dataFim && log.timestamp > args.dataFim) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Buscar informações dos usuários
|
||||
const atividadesComUsuarios = await Promise.all(
|
||||
atividades.map(async (atividade) => {
|
||||
const usuario = await ctx.db.get(atividade.usuarioId);
|
||||
return {
|
||||
...atividade,
|
||||
usuarioNome: usuario?.nome || "Usuário Desconhecido",
|
||||
usuarioMatricula: usuario?.matricula || "N/A",
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return atividadesComUsuarios;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém estatísticas de atividades
|
||||
*/
|
||||
export const obterEstatisticasAtividades = query({
|
||||
args: {
|
||||
periodo: v.optional(v.number()), // dias (ex: 7, 30)
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const periodo = args.periodo || 30;
|
||||
const dataInicio = Date.now() - periodo * 24 * 60 * 60 * 1000;
|
||||
|
||||
const atividades = await ctx.db
|
||||
.query("logsAtividades")
|
||||
.withIndex("by_timestamp")
|
||||
.filter((q) => q.gte(q.field("timestamp"), dataInicio))
|
||||
.collect();
|
||||
|
||||
// Agrupar por ação
|
||||
const porAcao: Record<string, number> = {};
|
||||
atividades.forEach((ativ) => {
|
||||
porAcao[ativ.acao] = (porAcao[ativ.acao] || 0) + 1;
|
||||
});
|
||||
|
||||
// Agrupar por recurso
|
||||
const porRecurso: Record<string, number> = {};
|
||||
atividades.forEach((ativ) => {
|
||||
porRecurso[ativ.recurso] = (porRecurso[ativ.recurso] || 0) + 1;
|
||||
});
|
||||
|
||||
// Agrupar por dia
|
||||
const porDia: Record<string, number> = {};
|
||||
atividades.forEach((ativ) => {
|
||||
const data = new Date(ativ.timestamp);
|
||||
const dia = data.toISOString().split("T")[0];
|
||||
porDia[dia] = (porDia[dia] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
total: atividades.length,
|
||||
porAcao,
|
||||
porRecurso,
|
||||
porDia,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém histórico de atividades de um recurso específico
|
||||
*/
|
||||
export const obterHistoricoRecurso = query({
|
||||
args: {
|
||||
recurso: v.string(),
|
||||
recursoId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const atividades = await ctx.db
|
||||
.query("logsAtividades")
|
||||
.withIndex("by_recurso_id", (q) =>
|
||||
q.eq("recurso", args.recurso).eq("recursoId", args.recursoId)
|
||||
)
|
||||
.order("desc")
|
||||
.collect();
|
||||
|
||||
// Buscar informações dos usuários
|
||||
const atividadesComUsuarios = await Promise.all(
|
||||
atividades.map(async (atividade) => {
|
||||
const usuario = await ctx.db.get(atividade.usuarioId);
|
||||
return {
|
||||
...atividade,
|
||||
usuarioNome: usuario?.nome || "Usuário Desconhecido",
|
||||
usuarioMatricula: usuario?.matricula || "N/A",
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return atividadesComUsuarios;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
234
packages/backend/convex/logsLogin.ts
Normal file
234
packages/backend/convex/logsLogin.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server";
|
||||
import { Doc, Id } from "./_generated/dataModel";
|
||||
|
||||
/**
|
||||
* Helper para registrar tentativas de login
|
||||
*/
|
||||
export async function registrarLogin(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
dados: {
|
||||
usuarioId?: Id<"usuarios">;
|
||||
matriculaOuEmail: string;
|
||||
sucesso: boolean;
|
||||
motivoFalha?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
) {
|
||||
// Extrair informações do userAgent
|
||||
const device = dados.userAgent ? extrairDevice(dados.userAgent) : undefined;
|
||||
const browser = dados.userAgent ? extrairBrowser(dados.userAgent) : undefined;
|
||||
const sistema = dados.userAgent ? extrairSistema(dados.userAgent) : undefined;
|
||||
|
||||
await ctx.db.insert("logsLogin", {
|
||||
usuarioId: dados.usuarioId,
|
||||
matriculaOuEmail: dados.matriculaOuEmail,
|
||||
sucesso: dados.sucesso,
|
||||
motivoFalha: dados.motivoFalha,
|
||||
ipAddress: dados.ipAddress,
|
||||
userAgent: dados.userAgent,
|
||||
device,
|
||||
browser,
|
||||
sistema,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Helpers para extrair informações do userAgent
|
||||
function extrairDevice(userAgent: string): string {
|
||||
if (/mobile/i.test(userAgent)) return "Mobile";
|
||||
if (/tablet/i.test(userAgent)) return "Tablet";
|
||||
return "Desktop";
|
||||
}
|
||||
|
||||
function extrairBrowser(userAgent: string): string {
|
||||
if (/edg/i.test(userAgent)) return "Edge";
|
||||
if (/chrome/i.test(userAgent)) return "Chrome";
|
||||
if (/firefox/i.test(userAgent)) return "Firefox";
|
||||
if (/safari/i.test(userAgent)) return "Safari";
|
||||
if (/opera/i.test(userAgent)) return "Opera";
|
||||
return "Desconhecido";
|
||||
}
|
||||
|
||||
function extrairSistema(userAgent: string): string {
|
||||
if (/windows/i.test(userAgent)) return "Windows";
|
||||
if (/mac/i.test(userAgent)) return "MacOS";
|
||||
if (/linux/i.test(userAgent)) return "Linux";
|
||||
if (/android/i.test(userAgent)) return "Android";
|
||||
if (/ios/i.test(userAgent)) return "iOS";
|
||||
return "Desconhecido";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista histórico de logins de um usuário
|
||||
*/
|
||||
export const listarLoginsUsuario = query({
|
||||
args: {
|
||||
usuarioId: v.id("usuarios"),
|
||||
limite: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const logs = await ctx.db
|
||||
.query("logsLogin")
|
||||
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
|
||||
.order("desc")
|
||||
.take(args.limite || 50);
|
||||
|
||||
return logs;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Lista todos os logins do sistema
|
||||
*/
|
||||
export const listarTodosLogins = query({
|
||||
args: {
|
||||
limite: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const logs = await ctx.db
|
||||
.query("logsLogin")
|
||||
.withIndex("by_timestamp")
|
||||
.order("desc")
|
||||
.take(args.limite || 50);
|
||||
|
||||
return logs;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Lista tentativas de login falhadas
|
||||
*/
|
||||
export const listarTentativasFalhas = query({
|
||||
args: {
|
||||
horasAtras: v.optional(v.number()), // padrão 24h
|
||||
limite: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const horasAtras = args.horasAtras || 24;
|
||||
const dataLimite = Date.now() - horasAtras * 60 * 60 * 1000;
|
||||
|
||||
const logs = await ctx.db
|
||||
.query("logsLogin")
|
||||
.withIndex("by_sucesso", (q) => q.eq("sucesso", false))
|
||||
.filter((q) => q.gte(q.field("timestamp"), dataLimite))
|
||||
.order("desc")
|
||||
.take(args.limite || 100);
|
||||
|
||||
// Agrupar por IP para detectar possíveis ataques
|
||||
const porIP: Record<string, number> = {};
|
||||
logs.forEach((log) => {
|
||||
if (log.ipAddress) {
|
||||
porIP[log.ipAddress] = (porIP[log.ipAddress] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
logs,
|
||||
tentativasPorIP: porIP,
|
||||
total: logs.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém estatísticas de login
|
||||
*/
|
||||
export const obterEstatisticasLogin = query({
|
||||
args: {
|
||||
dias: v.optional(v.number()), // padrão 30 dias
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const dias = args.dias || 30;
|
||||
const dataInicio = Date.now() - dias * 24 * 60 * 60 * 1000;
|
||||
|
||||
const logs = await ctx.db
|
||||
.query("logsLogin")
|
||||
.withIndex("by_timestamp")
|
||||
.filter((q) => q.gte(q.field("timestamp"), dataInicio))
|
||||
.collect();
|
||||
|
||||
// Total de logins bem-sucedidos vs falhos
|
||||
const sucessos = logs.filter((l) => l.sucesso).length;
|
||||
const falhas = logs.filter((l) => !l.sucesso).length;
|
||||
|
||||
// Logins por dia
|
||||
const porDia: Record<string, { sucesso: number; falha: number }> = {};
|
||||
logs.forEach((log) => {
|
||||
const data = new Date(log.timestamp);
|
||||
const dia = data.toISOString().split("T")[0];
|
||||
if (!porDia[dia]) {
|
||||
porDia[dia] = { sucesso: 0, falha: 0 };
|
||||
}
|
||||
if (log.sucesso) {
|
||||
porDia[dia].sucesso++;
|
||||
} else {
|
||||
porDia[dia].falha++;
|
||||
}
|
||||
});
|
||||
|
||||
// Logins por horário (hora do dia)
|
||||
const porHorario: Record<number, number> = {};
|
||||
logs.filter((l) => l.sucesso).forEach((log) => {
|
||||
const hora = new Date(log.timestamp).getHours();
|
||||
porHorario[hora] = (porHorario[hora] || 0) + 1;
|
||||
});
|
||||
|
||||
// Browser mais usado
|
||||
const porBrowser: Record<string, number> = {};
|
||||
logs.filter((l) => l.sucesso).forEach((log) => {
|
||||
if (log.browser) {
|
||||
porBrowser[log.browser] = (porBrowser[log.browser] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Dispositivos mais usados
|
||||
const porDevice: Record<string, number> = {};
|
||||
logs.filter((l) => l.sucesso).forEach((log) => {
|
||||
if (log.device) {
|
||||
porDevice[log.device] = (porDevice[log.device] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: logs.length,
|
||||
sucessos,
|
||||
falhas,
|
||||
taxaSucesso: logs.length > 0 ? (sucessos / logs.length) * 100 : 0,
|
||||
porDia,
|
||||
porHorario,
|
||||
porBrowser,
|
||||
porDevice,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Verifica se um IP está sendo suspeito (muitas tentativas falhas)
|
||||
*/
|
||||
export const verificarIPSuspeito = query({
|
||||
args: {
|
||||
ipAddress: v.string(),
|
||||
minutosAtras: v.optional(v.number()), // padrão 15 minutos
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const minutosAtras = args.minutosAtras || 15;
|
||||
const dataLimite = Date.now() - minutosAtras * 60 * 1000;
|
||||
|
||||
const tentativas = await ctx.db
|
||||
.query("logsLogin")
|
||||
.withIndex("by_ip", (q) => q.eq("ipAddress", args.ipAddress))
|
||||
.filter((q) => q.gte(q.field("timestamp"), dataLimite))
|
||||
.collect();
|
||||
|
||||
const falhas = tentativas.filter((t) => !t.sucesso).length;
|
||||
|
||||
return {
|
||||
tentativasTotal: tentativas.length,
|
||||
tentativasFalhas: falhas,
|
||||
suspeito: falhas >= 5, // 5 ou mais tentativas falhas em 15 minutos
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,8 +93,9 @@ export const verificarAcesso = query({
|
||||
};
|
||||
}
|
||||
|
||||
// Admin (nível 0) e TI (nível 1) têm acesso total
|
||||
if (role.nivel <= 1) {
|
||||
// Apenas TI_MASTER (nível 0) tem acesso total irrestrito
|
||||
// Admin, TI_USUARIO e outros (nível >= 1) têm permissões configuráveis
|
||||
if (role.nivel === 0) {
|
||||
return {
|
||||
podeAcessar: true,
|
||||
podeConsultar: true,
|
||||
@@ -301,7 +302,9 @@ export const obterMatrizPermissoes = query({
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
// Buscar todas as roles (exceto Admin e TI que têm acesso total)
|
||||
// Buscar todas as roles
|
||||
// TI_MASTER (nível 0) aparece mas não é editável
|
||||
// Admin, TI_USUARIO e outros (nível >= 1) são configuráveis
|
||||
const roles = await ctx.db.query("roles").collect();
|
||||
|
||||
const matriz = [];
|
||||
|
||||
210
packages/backend/convex/migrarUsuariosAdmin.ts
Normal file
210
packages/backend/convex/migrarUsuariosAdmin.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { internalMutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
/**
|
||||
* Listar usuários usando o perfil "admin" antigo (nível 0)
|
||||
*/
|
||||
export const listarUsuariosAdminAntigo = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("usuarios"),
|
||||
matricula: v.string(),
|
||||
nome: v.string(),
|
||||
email: v.string(),
|
||||
roleId: v.id("roles"),
|
||||
roleNome: v.string(),
|
||||
roleNivel: v.number(),
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
// Buscar todos os perfis "admin"
|
||||
const allAdmins = await ctx.db
|
||||
.query("roles")
|
||||
.filter((q) => q.eq(q.field("nome"), "admin"))
|
||||
.collect();
|
||||
|
||||
console.log("Perfis 'admin' encontrados:", allAdmins.length);
|
||||
|
||||
// Identificar o admin antigo (nível 0)
|
||||
const adminAntigo = allAdmins.find((r) => r.nivel === 0);
|
||||
|
||||
if (!adminAntigo) {
|
||||
console.log("Nenhum admin antigo (nível 0) encontrado");
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log("Admin antigo encontrado:", adminAntigo);
|
||||
|
||||
// Buscar usuários usando este perfil
|
||||
const usuarios = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
|
||||
.collect();
|
||||
|
||||
console.log("Usuários usando admin antigo:", usuarios.length);
|
||||
|
||||
return usuarios.map((u) => ({
|
||||
_id: u._id,
|
||||
matricula: u.matricula,
|
||||
nome: u.nome,
|
||||
email: u.email || "",
|
||||
roleId: u.roleId,
|
||||
roleNome: adminAntigo.nome,
|
||||
roleNivel: adminAntigo.nivel,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Migrar usuários do perfil "admin" antigo (nível 0) para o novo (nível 2)
|
||||
*/
|
||||
export const migrarUsuariosParaAdminNovo = internalMutation({
|
||||
args: {},
|
||||
returns: v.object({
|
||||
migrados: v.number(),
|
||||
usuariosMigrados: v.array(
|
||||
v.object({
|
||||
matricula: v.string(),
|
||||
nome: v.string(),
|
||||
roleAntigo: v.string(),
|
||||
roleNovo: v.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
// Buscar todos os perfis "admin"
|
||||
const allAdmins = await ctx.db
|
||||
.query("roles")
|
||||
.filter((q) => q.eq(q.field("nome"), "admin"))
|
||||
.collect();
|
||||
|
||||
// Identificar admin antigo (nível 0) e admin novo (nível 2)
|
||||
const adminAntigo = allAdmins.find((r) => r.nivel === 0);
|
||||
const adminNovo = allAdmins.find((r) => r.nivel === 2);
|
||||
|
||||
if (!adminAntigo) {
|
||||
console.log("❌ Admin antigo (nível 0) não encontrado");
|
||||
return { migrados: 0, usuariosMigrados: [] };
|
||||
}
|
||||
|
||||
if (!adminNovo) {
|
||||
console.log("❌ Admin novo (nível 2) não encontrado");
|
||||
return { migrados: 0, usuariosMigrados: [] };
|
||||
}
|
||||
|
||||
console.log("✅ Admin antigo ID:", adminAntigo._id, "- Nível:", adminAntigo.nivel);
|
||||
console.log("✅ Admin novo ID:", adminNovo._id, "- Nível:", adminNovo.nivel);
|
||||
|
||||
// Buscar usuários usando o admin antigo
|
||||
const usuarios = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
|
||||
.collect();
|
||||
|
||||
console.log(`📊 Encontrados ${usuarios.length} usuário(s) para migrar`);
|
||||
|
||||
const usuariosMigrados: Array<{
|
||||
matricula: string;
|
||||
nome: string;
|
||||
roleAntigo: string;
|
||||
roleNovo: string;
|
||||
}> = [];
|
||||
|
||||
// Migrar cada usuário
|
||||
for (const usuario of usuarios) {
|
||||
await ctx.db.patch(usuario._id, {
|
||||
roleId: adminNovo._id,
|
||||
});
|
||||
|
||||
usuariosMigrados.push({
|
||||
matricula: usuario.matricula,
|
||||
nome: usuario.nome,
|
||||
roleAntigo: `admin (nível 0) - ${adminAntigo._id}`,
|
||||
roleNovo: `admin (nível 2) - ${adminNovo._id}`,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ MIGRADO: ${usuario.nome} (${usuario.matricula}) → admin nível 2`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
migrados: usuarios.length,
|
||||
usuariosMigrados,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Remover perfil "admin" antigo (nível 0) após migração
|
||||
*/
|
||||
export const removerAdminAntigo = internalMutation({
|
||||
args: {},
|
||||
returns: v.object({
|
||||
sucesso: v.boolean(),
|
||||
mensagem: v.string(),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
// Buscar todos os perfis "admin"
|
||||
const allAdmins = await ctx.db
|
||||
.query("roles")
|
||||
.filter((q) => q.eq(q.field("nome"), "admin"))
|
||||
.collect();
|
||||
|
||||
// Identificar admin antigo (nível 0)
|
||||
const adminAntigo = allAdmins.find((r) => r.nivel === 0);
|
||||
|
||||
if (!adminAntigo) {
|
||||
return {
|
||||
sucesso: false,
|
||||
mensagem: "Admin antigo (nível 0) não encontrado",
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar se ainda há usuários usando
|
||||
const usuarios = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
|
||||
.collect();
|
||||
|
||||
if (usuarios.length > 0) {
|
||||
return {
|
||||
sucesso: false,
|
||||
mensagem: `Ainda há ${usuarios.length} usuário(s) usando este perfil. Execute migrarUsuariosParaAdminNovo primeiro.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Remover permissões associadas
|
||||
const permissoes = await ctx.db
|
||||
.query("rolePermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
|
||||
.collect();
|
||||
for (const perm of permissoes) {
|
||||
await ctx.db.delete(perm._id);
|
||||
}
|
||||
|
||||
// Remover menu permissões associadas
|
||||
const menuPerms = await ctx.db
|
||||
.query("menuPermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
|
||||
.collect();
|
||||
for (const menuPerm of menuPerms) {
|
||||
await ctx.db.delete(menuPerm._id);
|
||||
}
|
||||
|
||||
// Remover o perfil
|
||||
await ctx.db.delete(adminAntigo._id);
|
||||
|
||||
console.log(
|
||||
`🗑️ REMOVIDO: Admin antigo (nível 0) - ${adminAntigo._id}`
|
||||
);
|
||||
|
||||
return {
|
||||
sucesso: true,
|
||||
mensagem: "Admin antigo removido com sucesso",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
346
packages/backend/convex/perfisCustomizados.ts
Normal file
346
packages/backend/convex/perfisCustomizados.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { registrarAtividade } from "./logsAtividades";
|
||||
|
||||
/**
|
||||
* Listar todos os perfis customizados
|
||||
*/
|
||||
export const listarPerfisCustomizados = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const perfis = await ctx.db.query("perfisCustomizados").collect();
|
||||
|
||||
// Buscar role correspondente para cada perfil
|
||||
const perfisComDetalhes = await Promise.all(
|
||||
perfis.map(async (perfil) => {
|
||||
const role = await ctx.db.get(perfil.roleId);
|
||||
const criador = await ctx.db.get(perfil.criadoPor);
|
||||
|
||||
// Contar usuários usando este perfil
|
||||
const usuarios = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
|
||||
.collect();
|
||||
|
||||
return {
|
||||
...perfil,
|
||||
roleNome: role?.nome || "Desconhecido",
|
||||
criadorNome: criador?.nome || "Desconhecido",
|
||||
numeroUsuarios: usuarios.length,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return perfisComDetalhes;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obter perfil com permissões detalhadas
|
||||
*/
|
||||
export const obterPerfilComPermissoes = query({
|
||||
args: {
|
||||
perfilId: v.id("perfisCustomizados"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const perfil = await ctx.db.get(args.perfilId);
|
||||
if (!perfil) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role = await ctx.db.get(perfil.roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Buscar permissões do role
|
||||
const rolePermissoes = await ctx.db
|
||||
.query("rolePermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
|
||||
.collect();
|
||||
|
||||
const permissoes = await Promise.all(
|
||||
rolePermissoes.map(async (rp) => {
|
||||
return await ctx.db.get(rp.permissaoId);
|
||||
})
|
||||
);
|
||||
|
||||
// Buscar permissões de menu
|
||||
const menuPermissoes = await ctx.db
|
||||
.query("menuPermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
|
||||
.collect();
|
||||
|
||||
// Buscar usuários usando este perfil
|
||||
const usuarios = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
|
||||
.collect();
|
||||
|
||||
return {
|
||||
perfil,
|
||||
role,
|
||||
permissoes: permissoes.filter((p) => p !== null),
|
||||
menuPermissoes,
|
||||
usuarios,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Criar perfil customizado (apenas TI_MASTER)
|
||||
*/
|
||||
export const criarPerfilCustomizado = mutation({
|
||||
args: {
|
||||
nome: v.string(),
|
||||
descricao: v.string(),
|
||||
nivel: v.number(), // >= 3
|
||||
clonarDeRoleId: v.optional(v.id("roles")), // role para copiar permissões
|
||||
criadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), perfilId: v.id("perfisCustomizados") }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Validar nível (deve ser >= 3)
|
||||
if (args.nivel < 3) {
|
||||
return { sucesso: false as const, erro: "Perfis customizados devem ter nível >= 3" };
|
||||
}
|
||||
|
||||
// Verificar se nome já existe
|
||||
const roles = await ctx.db.query("roles").collect();
|
||||
const nomeExiste = roles.some((r) => r.nome.toLowerCase() === args.nome.toLowerCase());
|
||||
if (nomeExiste) {
|
||||
return { sucesso: false as const, erro: "Já existe um perfil com este nome" };
|
||||
}
|
||||
|
||||
// Criar role correspondente
|
||||
const roleId = await ctx.db.insert("roles", {
|
||||
nome: args.nome.toLowerCase().replace(/\s+/g, "_"),
|
||||
descricao: args.descricao,
|
||||
nivel: args.nivel,
|
||||
customizado: true,
|
||||
criadoPor: args.criadoPorId,
|
||||
editavel: true,
|
||||
});
|
||||
|
||||
// Copiar permissões se especificado
|
||||
if (args.clonarDeRoleId) {
|
||||
// Copiar permissões gerais
|
||||
const permissoesClonar = await ctx.db
|
||||
.query("rolePermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId))
|
||||
.collect();
|
||||
|
||||
for (const perm of permissoesClonar) {
|
||||
await ctx.db.insert("rolePermissoes", {
|
||||
roleId,
|
||||
permissaoId: perm.permissaoId,
|
||||
});
|
||||
}
|
||||
|
||||
// Copiar permissões de menu
|
||||
const menuPermsClonar = await ctx.db
|
||||
.query("menuPermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId))
|
||||
.collect();
|
||||
|
||||
for (const menuPerm of menuPermsClonar) {
|
||||
await ctx.db.insert("menuPermissoes", {
|
||||
roleId,
|
||||
menuPath: menuPerm.menuPath,
|
||||
podeAcessar: menuPerm.podeAcessar,
|
||||
podeConsultar: menuPerm.podeConsultar,
|
||||
podeGravar: menuPerm.podeGravar,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Criar perfil customizado
|
||||
const perfilId = await ctx.db.insert("perfisCustomizados", {
|
||||
nome: args.nome,
|
||||
descricao: args.descricao,
|
||||
nivel: args.nivel,
|
||||
roleId,
|
||||
criadoPor: args.criadoPorId,
|
||||
criadoEm: Date.now(),
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.criadoPorId,
|
||||
"criar",
|
||||
"perfis",
|
||||
JSON.stringify({ perfilId, nome: args.nome, nivel: args.nivel }),
|
||||
perfilId
|
||||
);
|
||||
|
||||
return { sucesso: true as const, perfilId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Editar perfil customizado (apenas TI_MASTER)
|
||||
*/
|
||||
export const editarPerfilCustomizado = mutation({
|
||||
args: {
|
||||
perfilId: v.id("perfisCustomizados"),
|
||||
nome: v.optional(v.string()),
|
||||
descricao: v.optional(v.string()),
|
||||
editadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const perfil = await ctx.db.get(args.perfilId);
|
||||
if (!perfil) {
|
||||
return { sucesso: false as const, erro: "Perfil não encontrado" };
|
||||
}
|
||||
|
||||
// Atualizar perfil
|
||||
const updates: any = {
|
||||
atualizadoEm: Date.now(),
|
||||
};
|
||||
|
||||
if (args.nome !== undefined) updates.nome = args.nome;
|
||||
if (args.descricao !== undefined) updates.descricao = args.descricao;
|
||||
|
||||
await ctx.db.patch(args.perfilId, updates);
|
||||
|
||||
// Atualizar role correspondente se nome mudou
|
||||
if (args.nome !== undefined) {
|
||||
await ctx.db.patch(perfil.roleId, {
|
||||
nome: args.nome.toLowerCase().replace(/\s+/g, "_"),
|
||||
});
|
||||
}
|
||||
|
||||
if (args.descricao !== undefined) {
|
||||
await ctx.db.patch(perfil.roleId, {
|
||||
descricao: args.descricao,
|
||||
});
|
||||
}
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.editadoPorId,
|
||||
"editar",
|
||||
"perfis",
|
||||
JSON.stringify(updates),
|
||||
args.perfilId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Excluir perfil customizado (apenas TI_MASTER)
|
||||
*/
|
||||
export const excluirPerfilCustomizado = mutation({
|
||||
args: {
|
||||
perfilId: v.id("perfisCustomizados"),
|
||||
excluidoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const perfil = await ctx.db.get(args.perfilId);
|
||||
if (!perfil) {
|
||||
return { sucesso: false as const, erro: "Perfil não encontrado" };
|
||||
}
|
||||
|
||||
// Verificar se existem usuários usando este perfil
|
||||
const usuarios = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
|
||||
.collect();
|
||||
|
||||
if (usuarios.length > 0) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Não é possível excluir. ${usuarios.length} usuário(s) ainda usa(m) este perfil.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Remover permissões associadas ao role
|
||||
const rolePermissoes = await ctx.db
|
||||
.query("rolePermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
|
||||
.collect();
|
||||
|
||||
for (const rp of rolePermissoes) {
|
||||
await ctx.db.delete(rp._id);
|
||||
}
|
||||
|
||||
// Remover permissões de menu
|
||||
const menuPermissoes = await ctx.db
|
||||
.query("menuPermissoes")
|
||||
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
|
||||
.collect();
|
||||
|
||||
for (const mp of menuPermissoes) {
|
||||
await ctx.db.delete(mp._id);
|
||||
}
|
||||
|
||||
// Excluir role
|
||||
await ctx.db.delete(perfil.roleId);
|
||||
|
||||
// Excluir perfil
|
||||
await ctx.db.delete(args.perfilId);
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.excluidoPorId,
|
||||
"excluir",
|
||||
"perfis",
|
||||
JSON.stringify({ perfilId: args.perfilId, nome: perfil.nome }),
|
||||
args.perfilId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Clonar perfil existente
|
||||
*/
|
||||
export const clonarPerfil = mutation({
|
||||
args: {
|
||||
perfilOrigemId: v.id("perfisCustomizados"),
|
||||
novoNome: v.string(),
|
||||
novaDescricao: v.string(),
|
||||
criadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), perfilId: v.id("perfisCustomizados") }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const perfilOrigem = await ctx.db.get(args.perfilOrigemId);
|
||||
if (!perfilOrigem) {
|
||||
return { sucesso: false as const, erro: "Perfil origem não encontrado" };
|
||||
}
|
||||
|
||||
// Criar novo perfil clonando o original
|
||||
const resultado = await criarPerfilCustomizado(ctx, {
|
||||
nome: args.novoNome,
|
||||
descricao: args.novaDescricao,
|
||||
nivel: perfilOrigem.nivel,
|
||||
clonarDeRoleId: perfilOrigem.roleId,
|
||||
criadoPorId: args.criadoPorId,
|
||||
});
|
||||
|
||||
return resultado;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ export const listar = query({
|
||||
descricao: v.string(),
|
||||
nivel: v.number(),
|
||||
setor: v.optional(v.string()),
|
||||
customizado: v.boolean(),
|
||||
editavel: v.optional(v.boolean()),
|
||||
criadoPor: v.optional(v.id("usuarios")),
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
|
||||
@@ -192,6 +192,13 @@ export default defineSchema({
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number(),
|
||||
|
||||
// Controle de Bloqueio e Segurança
|
||||
bloqueado: v.optional(v.boolean()),
|
||||
motivoBloqueio: v.optional(v.string()),
|
||||
dataBloqueio: v.optional(v.number()),
|
||||
tentativasLogin: v.optional(v.number()), // contador de tentativas falhas
|
||||
ultimaTentativaLogin: v.optional(v.number()), // timestamp da última tentativa
|
||||
|
||||
// Campos de Chat e Perfil
|
||||
avatar: v.optional(v.string()), // "avatar-1" até "avatar-15" ou storageId
|
||||
fotoPerfil: v.optional(v.id("_storage")),
|
||||
@@ -214,17 +221,22 @@ export default defineSchema({
|
||||
.index("by_email", ["email"])
|
||||
.index("by_role", ["roleId"])
|
||||
.index("by_ativo", ["ativo"])
|
||||
.index("by_status_presenca", ["statusPresenca"]),
|
||||
.index("by_status_presenca", ["statusPresenca"])
|
||||
.index("by_bloqueado", ["bloqueado"]),
|
||||
|
||||
roles: defineTable({
|
||||
nome: v.string(), // "admin", "ti", "usuario_avancado", "usuario"
|
||||
nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario"
|
||||
descricao: v.string(),
|
||||
nivel: v.number(), // 0 = admin, 1 = ti, 2 = usuario_avancado, 3 = usuario
|
||||
nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado
|
||||
setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc.
|
||||
customizado: v.optional(v.boolean()), // se é um perfil customizado criado por TI_MASTER
|
||||
criadoPor: v.optional(v.id("usuarios")), // usuário TI_MASTER que criou este perfil
|
||||
editavel: v.optional(v.boolean()), // se pode ser editado (false para roles fixas)
|
||||
})
|
||||
.index("by_nome", ["nome"])
|
||||
.index("by_nivel", ["nivel"])
|
||||
.index("by_setor", ["setor"]),
|
||||
.index("by_setor", ["setor"])
|
||||
.index("by_customizado", ["customizado"]),
|
||||
|
||||
permissoes: defineTable({
|
||||
nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc.
|
||||
@@ -300,6 +312,128 @@ export default defineSchema({
|
||||
.index("by_tipo", ["tipo"])
|
||||
.index("by_timestamp", ["timestamp"]),
|
||||
|
||||
// Logs de Login Detalhados
|
||||
logsLogin: defineTable({
|
||||
usuarioId: v.optional(v.id("usuarios")), // pode ser null se falha antes de identificar usuário
|
||||
matriculaOuEmail: v.string(), // tentativa de login
|
||||
sucesso: v.boolean(),
|
||||
motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente"
|
||||
ipAddress: v.optional(v.string()),
|
||||
userAgent: v.optional(v.string()),
|
||||
device: v.optional(v.string()),
|
||||
browser: v.optional(v.string()),
|
||||
sistema: v.optional(v.string()),
|
||||
timestamp: v.number(),
|
||||
})
|
||||
.index("by_usuario", ["usuarioId"])
|
||||
.index("by_sucesso", ["sucesso"])
|
||||
.index("by_timestamp", ["timestamp"])
|
||||
.index("by_ip", ["ipAddress"]),
|
||||
|
||||
// Logs de Atividades
|
||||
logsAtividades: defineTable({
|
||||
usuarioId: v.id("usuarios"),
|
||||
acao: v.string(), // "criar", "editar", "excluir", "bloquear", "desbloquear", etc.
|
||||
recurso: v.string(), // "funcionarios", "simbolos", "usuarios", "perfis", etc.
|
||||
recursoId: v.optional(v.string()), // ID do recurso afetado
|
||||
detalhes: v.optional(v.string()), // JSON com detalhes da ação
|
||||
timestamp: v.number(),
|
||||
})
|
||||
.index("by_usuario", ["usuarioId"])
|
||||
.index("by_acao", ["acao"])
|
||||
.index("by_recurso", ["recurso"])
|
||||
.index("by_timestamp", ["timestamp"])
|
||||
.index("by_recurso_id", ["recurso", "recursoId"]),
|
||||
|
||||
// Histórico de Bloqueios
|
||||
bloqueiosUsuarios: defineTable({
|
||||
usuarioId: v.id("usuarios"),
|
||||
motivo: v.string(),
|
||||
bloqueadoPor: v.id("usuarios"), // ID do TI_MASTER que bloqueou
|
||||
dataInicio: v.number(),
|
||||
dataFim: v.optional(v.number()), // quando foi desbloqueado
|
||||
desbloqueadoPor: v.optional(v.id("usuarios")),
|
||||
ativo: v.boolean(), // se é o bloqueio atual ativo
|
||||
})
|
||||
.index("by_usuario", ["usuarioId"])
|
||||
.index("by_bloqueado_por", ["bloqueadoPor"])
|
||||
.index("by_ativo", ["ativo"])
|
||||
.index("by_data_inicio", ["dataInicio"]),
|
||||
|
||||
// Perfis Customizados
|
||||
perfisCustomizados: defineTable({
|
||||
nome: v.string(),
|
||||
descricao: v.string(),
|
||||
nivel: v.number(), // >= 3
|
||||
roleId: v.id("roles"), // role correspondente criada
|
||||
criadoPor: v.id("usuarios"), // TI_MASTER que criou
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number(),
|
||||
})
|
||||
.index("by_nome", ["nome"])
|
||||
.index("by_nivel", ["nivel"])
|
||||
.index("by_criado_por", ["criadoPor"])
|
||||
.index("by_role", ["roleId"]),
|
||||
|
||||
// Templates de Mensagens
|
||||
templatesMensagens: defineTable({
|
||||
codigo: v.string(), // "USUARIO_BLOQUEADO", "SENHA_RESETADA", etc.
|
||||
nome: v.string(),
|
||||
tipo: v.union(
|
||||
v.literal("sistema"), // predefinido, não editável
|
||||
v.literal("customizado") // criado por TI_MASTER
|
||||
),
|
||||
titulo: v.string(),
|
||||
corpo: v.string(), // pode ter variáveis {{variavel}}
|
||||
variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.]
|
||||
criadoPor: v.optional(v.id("usuarios")),
|
||||
criadoEm: v.number(),
|
||||
})
|
||||
.index("by_codigo", ["codigo"])
|
||||
.index("by_tipo", ["tipo"])
|
||||
.index("by_criado_por", ["criadoPor"]),
|
||||
|
||||
// Configuração de Email/SMTP
|
||||
configuracaoEmail: defineTable({
|
||||
servidor: v.string(), // smtp.gmail.com
|
||||
porta: v.number(), // 587, 465, etc.
|
||||
usuario: v.string(),
|
||||
senhaHash: v.string(), // senha criptografada
|
||||
emailRemetente: v.string(),
|
||||
nomeRemetente: v.string(),
|
||||
usarSSL: v.boolean(),
|
||||
usarTLS: v.boolean(),
|
||||
ativo: v.boolean(),
|
||||
testadoEm: v.optional(v.number()),
|
||||
configuradoPor: v.id("usuarios"),
|
||||
atualizadoEm: v.number(),
|
||||
}).index("by_ativo", ["ativo"]),
|
||||
|
||||
// Fila de Emails
|
||||
notificacoesEmail: defineTable({
|
||||
destinatario: v.string(), // email
|
||||
destinatarioId: v.optional(v.id("usuarios")),
|
||||
assunto: v.string(),
|
||||
corpo: v.string(), // HTML ou texto
|
||||
templateId: v.optional(v.id("templatesMensagens")),
|
||||
status: v.union(
|
||||
v.literal("pendente"),
|
||||
v.literal("enviando"),
|
||||
v.literal("enviado"),
|
||||
v.literal("falha")
|
||||
),
|
||||
tentativas: v.number(),
|
||||
ultimaTentativa: v.optional(v.number()),
|
||||
erroDetalhes: v.optional(v.string()),
|
||||
enviadoPor: v.id("usuarios"),
|
||||
criadoEm: v.number(),
|
||||
enviadoEm: v.optional(v.number()),
|
||||
})
|
||||
.index("by_status", ["status"])
|
||||
.index("by_destinatario", ["destinatarioId"])
|
||||
.index("by_enviado_por", ["enviadoPor"])
|
||||
.index("by_criado_em", ["criadoEm"]),
|
||||
|
||||
configuracaoAcesso: defineTable({
|
||||
chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc.
|
||||
valor: v.string(),
|
||||
|
||||
@@ -191,52 +191,184 @@ export const seedDatabase = internalMutation({
|
||||
handler: async (ctx) => {
|
||||
console.log("🌱 Iniciando seed do banco de dados...");
|
||||
|
||||
// 1. Criar Roles
|
||||
// 1. Criar Roles (Perfis de Acesso)
|
||||
console.log("🔐 Criando roles...");
|
||||
// TI_MASTER - Nível 0 - Acesso total irrestrito
|
||||
const roleTIMaster = await ctx.db.insert("roles", {
|
||||
nome: "ti_master",
|
||||
descricao: "TI Master",
|
||||
nivel: 0,
|
||||
setor: "ti",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: ti_master (Nível 0 - Acesso Total)");
|
||||
|
||||
// ADMIN - Nível 2 - Permissões configuráveis
|
||||
const roleAdmin = await ctx.db.insert("roles", {
|
||||
nome: "admin",
|
||||
descricao: "Administrador do Sistema",
|
||||
nivel: 0,
|
||||
});
|
||||
console.log(" ✅ Role criada: admin");
|
||||
|
||||
const roleTI = await ctx.db.insert("roles", {
|
||||
nome: "ti",
|
||||
descricao: "Tecnologia da Informação",
|
||||
nivel: 1,
|
||||
setor: "ti",
|
||||
});
|
||||
console.log(" ✅ Role criada: ti");
|
||||
|
||||
const roleUsuarioAvancado = await ctx.db.insert("roles", {
|
||||
nome: "usuario_avancado",
|
||||
descricao: "Usuário Avançado",
|
||||
descricao: "Administrador Geral",
|
||||
nivel: 2,
|
||||
setor: "administrativo",
|
||||
customizado: false,
|
||||
editavel: true, // Permissões configuráveis
|
||||
});
|
||||
console.log(" ✅ Role criada: usuario_avancado");
|
||||
console.log(" ✅ Role criada: admin (Nível 2 - Configurável)");
|
||||
|
||||
// TI_USUARIO - Nível 2 - Suporte técnico
|
||||
const roleTIUsuario = await ctx.db.insert("roles", {
|
||||
nome: "ti_usuario",
|
||||
descricao: "TI Usuário",
|
||||
nivel: 2,
|
||||
setor: "ti",
|
||||
customizado: false,
|
||||
editavel: true,
|
||||
});
|
||||
console.log(" ✅ Role criada: ti_usuario (Nível 2 - Suporte)");
|
||||
|
||||
const roleRH = await ctx.db.insert("roles", {
|
||||
nome: "rh",
|
||||
descricao: "Recursos Humanos",
|
||||
nivel: 2,
|
||||
setor: "recursos_humanos",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: rh");
|
||||
|
||||
const roleFinanceiro = await ctx.db.insert("roles", {
|
||||
nome: "financeiro",
|
||||
descricao: "Financeiro",
|
||||
nivel: 2,
|
||||
setor: "financeiro",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: financeiro");
|
||||
|
||||
const roleControladoria = await ctx.db.insert("roles", {
|
||||
nome: "controladoria",
|
||||
descricao: "Controladoria",
|
||||
nivel: 2,
|
||||
setor: "controladoria",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: controladoria");
|
||||
|
||||
const roleLicitacoes = await ctx.db.insert("roles", {
|
||||
nome: "licitacoes",
|
||||
descricao: "Licitações",
|
||||
nivel: 2,
|
||||
setor: "licitacoes",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: licitacoes");
|
||||
|
||||
const roleCompras = await ctx.db.insert("roles", {
|
||||
nome: "compras",
|
||||
descricao: "Compras",
|
||||
nivel: 2,
|
||||
setor: "compras",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: compras");
|
||||
|
||||
const roleJuridico = await ctx.db.insert("roles", {
|
||||
nome: "juridico",
|
||||
descricao: "Jurídico",
|
||||
nivel: 2,
|
||||
setor: "juridico",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: juridico");
|
||||
|
||||
const roleComunicacao = await ctx.db.insert("roles", {
|
||||
nome: "comunicacao",
|
||||
descricao: "Comunicação",
|
||||
nivel: 2,
|
||||
setor: "comunicacao",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: comunicacao");
|
||||
|
||||
const roleProgramasEsportivos = await ctx.db.insert("roles", {
|
||||
nome: "programas_esportivos",
|
||||
descricao: "Programas Esportivos",
|
||||
nivel: 2,
|
||||
setor: "programas_esportivos",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: programas_esportivos");
|
||||
|
||||
const roleSecretariaExecutiva = await ctx.db.insert("roles", {
|
||||
nome: "secretaria_executiva",
|
||||
descricao: "Secretaria Executiva",
|
||||
nivel: 2,
|
||||
setor: "secretaria_executiva",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: secretaria_executiva");
|
||||
|
||||
const roleGestaoPessoas = await ctx.db.insert("roles", {
|
||||
nome: "gestao_pessoas",
|
||||
descricao: "Gestão de Pessoas",
|
||||
nivel: 2,
|
||||
setor: "gestao_pessoas",
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: gestao_pessoas");
|
||||
|
||||
const roleUsuario = await ctx.db.insert("roles", {
|
||||
nome: "usuario",
|
||||
descricao: "Usuário Comum",
|
||||
nivel: 3,
|
||||
nivel: 10,
|
||||
customizado: false,
|
||||
editavel: false,
|
||||
});
|
||||
console.log(" ✅ Role criada: usuario");
|
||||
|
||||
// 2. Criar usuário admin inicial
|
||||
console.log("👤 Criando usuário admin...");
|
||||
const senhaAdmin = await hashPassword("Admin@123");
|
||||
// 2. Criar usuários iniciais
|
||||
console.log("👤 Criando usuários iniciais...");
|
||||
|
||||
// TI Master
|
||||
const senhaTIMaster = await hashPassword("TI@123");
|
||||
await ctx.db.insert("usuarios", {
|
||||
matricula: "0000",
|
||||
matricula: "1000",
|
||||
senhaHash: senhaTIMaster,
|
||||
nome: "Gestor TI Master",
|
||||
email: "ti.master@sgse.pe.gov.br",
|
||||
setor: "ti",
|
||||
roleId: roleTIMaster as any,
|
||||
ativo: true,
|
||||
primeiroAcesso: false,
|
||||
criadoEm: Date.now(),
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
console.log(" ✅ TI Master criado (matrícula: 1000, senha: TI@123)");
|
||||
|
||||
// Admin (permissões configuráveis)
|
||||
const senhaAdmin = await hashPassword("Admin@123");
|
||||
const adminId = await ctx.db.insert("usuarios", {
|
||||
matricula: "2000",
|
||||
senhaHash: senhaAdmin,
|
||||
nome: "Administrador",
|
||||
nome: "Administrador Geral",
|
||||
email: "admin@sgse.pe.gov.br",
|
||||
setor: "administrativo",
|
||||
roleId: roleAdmin as any,
|
||||
ativo: true,
|
||||
primeiroAcesso: false,
|
||||
criadoEm: Date.now(),
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
console.log(" ✅ Usuário admin criado (matrícula: 0000, senha: Admin@123)");
|
||||
console.log(" ✅ Admin criado (matrícula: 2000, senha: Admin@123)");
|
||||
|
||||
// 3. Inserir símbolos
|
||||
console.log("📝 Inserindo símbolos...");
|
||||
@@ -323,10 +455,71 @@ export const seedDatabase = internalMutation({
|
||||
console.log(` ✅ Solicitação criada: ${solicitacao.nome}`);
|
||||
}
|
||||
|
||||
// 7. Criar templates de mensagens padrão
|
||||
console.log("📧 Criando templates de mensagens padrão...");
|
||||
const templatesPadrao = [
|
||||
{
|
||||
codigo: "USUARIO_BLOQUEADO",
|
||||
nome: "Usuário Bloqueado",
|
||||
titulo: "Sua conta foi bloqueada",
|
||||
corpo: "Sua conta no SGSE foi bloqueada.\\n\\nMotivo: {{motivo}}\\n\\nPara mais informações, entre em contato com a TI.",
|
||||
variaveis: ["motivo"],
|
||||
},
|
||||
{
|
||||
codigo: "USUARIO_DESBLOQUEADO",
|
||||
nome: "Usuário Desbloqueado",
|
||||
titulo: "Sua conta foi desbloqueada",
|
||||
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
|
||||
variaveis: [],
|
||||
},
|
||||
{
|
||||
codigo: "SENHA_RESETADA",
|
||||
nome: "Senha Resetada",
|
||||
titulo: "Sua senha foi resetada",
|
||||
corpo: "Sua senha foi resetada pela equipe de TI.\\n\\nNova senha temporária: {{senha}}\\n\\nPor favor, altere sua senha no próximo login.",
|
||||
variaveis: ["senha"],
|
||||
},
|
||||
{
|
||||
codigo: "PERMISSAO_ALTERADA",
|
||||
nome: "Permissão Alterada",
|
||||
titulo: "Suas permissões foram atualizadas",
|
||||
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\\n\\nPara verificar suas novas permissões, acesse o menu de perfil.",
|
||||
variaveis: [],
|
||||
},
|
||||
{
|
||||
codigo: "AVISO_GERAL",
|
||||
nome: "Aviso Geral",
|
||||
titulo: "{{titulo}}",
|
||||
corpo: "{{mensagem}}",
|
||||
variaveis: ["titulo", "mensagem"],
|
||||
},
|
||||
{
|
||||
codigo: "BEM_VINDO",
|
||||
nome: "Boas-vindas",
|
||||
titulo: "Bem-vindo ao SGSE",
|
||||
corpo: "Olá {{nome}},\\n\\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\\n\\nSuas credenciais de acesso:\\nMatrícula: {{matricula}}\\nSenha temporária: {{senha}}\\n\\nPor favor, altere sua senha no primeiro acesso.\\n\\nEquipe de TI",
|
||||
variaveis: ["nome", "matricula", "senha"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const template of templatesPadrao) {
|
||||
await ctx.db.insert("templatesMensagens", {
|
||||
codigo: template.codigo,
|
||||
nome: template.nome,
|
||||
tipo: "sistema" as const,
|
||||
titulo: template.titulo,
|
||||
corpo: template.corpo,
|
||||
variaveis: template.variaveis,
|
||||
criadoEm: Date.now(),
|
||||
});
|
||||
console.log(` ✅ Template criado: ${template.nome}`);
|
||||
}
|
||||
|
||||
console.log("✨ Seed concluído com sucesso!");
|
||||
console.log("");
|
||||
console.log("🔑 CREDENCIAIS DE ACESSO:");
|
||||
console.log(" Admin: matrícula 0000, senha Admin@123");
|
||||
console.log(" TI: matrícula 1000, senha TI@123");
|
||||
console.log(" Funcionários: usar matrícula, senha Mudar@123");
|
||||
return null;
|
||||
},
|
||||
|
||||
261
packages/backend/convex/templatesMensagens.ts
Normal file
261
packages/backend/convex/templatesMensagens.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { registrarAtividade } from "./logsAtividades";
|
||||
|
||||
/**
|
||||
* Listar todos os templates
|
||||
*/
|
||||
export const listarTemplates = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const templates = await ctx.db.query("templatesMensagens").collect();
|
||||
return templates;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obter template por código
|
||||
*/
|
||||
export const obterTemplatePorCodigo = query({
|
||||
args: {
|
||||
codigo: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const template = await ctx.db
|
||||
.query("templatesMensagens")
|
||||
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
|
||||
.first();
|
||||
|
||||
return template;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Criar template customizado (apenas TI_MASTER)
|
||||
*/
|
||||
export const criarTemplate = mutation({
|
||||
args: {
|
||||
codigo: v.string(),
|
||||
nome: v.string(),
|
||||
titulo: v.string(),
|
||||
corpo: v.string(),
|
||||
variaveis: v.optional(v.array(v.string())),
|
||||
criadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Verificar se código já existe
|
||||
const existente = await ctx.db
|
||||
.query("templatesMensagens")
|
||||
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
|
||||
.first();
|
||||
|
||||
if (existente) {
|
||||
return { sucesso: false as const, erro: "Código de template já existe" };
|
||||
}
|
||||
|
||||
// Criar template
|
||||
const templateId = await ctx.db.insert("templatesMensagens", {
|
||||
codigo: args.codigo,
|
||||
nome: args.nome,
|
||||
tipo: "customizado",
|
||||
titulo: args.titulo,
|
||||
corpo: args.corpo,
|
||||
variaveis: args.variaveis,
|
||||
criadoPor: args.criadoPorId,
|
||||
criadoEm: Date.now(),
|
||||
});
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.criadoPorId,
|
||||
"criar",
|
||||
"templates",
|
||||
JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
|
||||
templateId
|
||||
);
|
||||
|
||||
return { sucesso: true as const, templateId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
|
||||
*/
|
||||
export const editarTemplate = mutation({
|
||||
args: {
|
||||
templateId: v.id("templatesMensagens"),
|
||||
nome: v.optional(v.string()),
|
||||
titulo: v.optional(v.string()),
|
||||
corpo: v.optional(v.string()),
|
||||
variaveis: v.optional(v.array(v.string())),
|
||||
editadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const template = await ctx.db.get(args.templateId);
|
||||
if (!template) {
|
||||
return { sucesso: false as const, erro: "Template não encontrado" };
|
||||
}
|
||||
|
||||
// Não permite editar templates do sistema
|
||||
if (template.tipo === "sistema") {
|
||||
return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
|
||||
}
|
||||
|
||||
// Atualizar template
|
||||
const updates: any = {};
|
||||
if (args.nome !== undefined) updates.nome = args.nome;
|
||||
if (args.titulo !== undefined) updates.titulo = args.titulo;
|
||||
if (args.corpo !== undefined) updates.corpo = args.corpo;
|
||||
if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
|
||||
|
||||
await ctx.db.patch(args.templateId, updates);
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.editadoPorId,
|
||||
"editar",
|
||||
"templates",
|
||||
JSON.stringify(updates),
|
||||
args.templateId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
|
||||
*/
|
||||
export const excluirTemplate = mutation({
|
||||
args: {
|
||||
templateId: v.id("templatesMensagens"),
|
||||
excluidoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const template = await ctx.db.get(args.templateId);
|
||||
if (!template) {
|
||||
return { sucesso: false as const, erro: "Template não encontrado" };
|
||||
}
|
||||
|
||||
// Não permite excluir templates do sistema
|
||||
if (template.tipo === "sistema") {
|
||||
return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
|
||||
}
|
||||
|
||||
// Excluir template
|
||||
await ctx.db.delete(args.templateId);
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.excluidoPorId,
|
||||
"excluir",
|
||||
"templates",
|
||||
JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
|
||||
args.templateId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Renderizar template com variáveis
|
||||
*/
|
||||
export function renderizarTemplate(template: string, variaveis: Record<string, string>): string {
|
||||
let resultado = template;
|
||||
|
||||
for (const [chave, valor] of Object.entries(variaveis)) {
|
||||
const placeholder = `{{${chave}}}`;
|
||||
resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
|
||||
}
|
||||
|
||||
return resultado;
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar templates padrão do sistema (chamado no seed)
|
||||
*/
|
||||
export const criarTemplatesPadrao = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const templatesPadrao = [
|
||||
{
|
||||
codigo: "USUARIO_BLOQUEADO",
|
||||
nome: "Usuário Bloqueado",
|
||||
titulo: "Sua conta foi bloqueada",
|
||||
corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
|
||||
variaveis: ["motivo"],
|
||||
},
|
||||
{
|
||||
codigo: "USUARIO_DESBLOQUEADO",
|
||||
nome: "Usuário Desbloqueado",
|
||||
titulo: "Sua conta foi desbloqueada",
|
||||
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
|
||||
variaveis: [],
|
||||
},
|
||||
{
|
||||
codigo: "SENHA_RESETADA",
|
||||
nome: "Senha Resetada",
|
||||
titulo: "Sua senha foi resetada",
|
||||
corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
|
||||
variaveis: ["senha"],
|
||||
},
|
||||
{
|
||||
codigo: "PERMISSAO_ALTERADA",
|
||||
nome: "Permissão Alterada",
|
||||
titulo: "Suas permissões foram atualizadas",
|
||||
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
|
||||
variaveis: [],
|
||||
},
|
||||
{
|
||||
codigo: "AVISO_GERAL",
|
||||
nome: "Aviso Geral",
|
||||
titulo: "{{titulo}}",
|
||||
corpo: "{{mensagem}}",
|
||||
variaveis: ["titulo", "mensagem"],
|
||||
},
|
||||
{
|
||||
codigo: "BEM_VINDO",
|
||||
nome: "Boas-vindas",
|
||||
titulo: "Bem-vindo ao SGSE",
|
||||
corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
|
||||
variaveis: ["nome", "matricula", "senha"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const template of templatesPadrao) {
|
||||
// Verificar se já existe
|
||||
const existente = await ctx.db
|
||||
.query("templatesMensagens")
|
||||
.withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
|
||||
.first();
|
||||
|
||||
if (!existente) {
|
||||
await ctx.db.insert("templatesMensagens", {
|
||||
...template,
|
||||
tipo: "sistema",
|
||||
criadoEm: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { sucesso: true };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { hashPassword } from "./auth/utils";
|
||||
import { hashPassword, generateToken } from "./auth/utils";
|
||||
import { registrarAtividade } from "./logsAtividades";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
|
||||
/**
|
||||
* Criar novo usuário (apenas TI)
|
||||
@@ -76,6 +78,8 @@ export const listar = query({
|
||||
nome: v.string(),
|
||||
email: v.string(),
|
||||
ativo: v.boolean(),
|
||||
bloqueado: v.optional(v.boolean()),
|
||||
motivoBloqueio: v.optional(v.string()),
|
||||
primeiroAcesso: v.boolean(),
|
||||
ultimoAcesso: v.optional(v.number()),
|
||||
criadoEm: v.number(),
|
||||
@@ -141,6 +145,8 @@ export const listar = query({
|
||||
nome: usuario.nome,
|
||||
email: usuario.email,
|
||||
ativo: usuario.ativo,
|
||||
bloqueado: usuario.bloqueado,
|
||||
motivoBloqueio: usuario.motivoBloqueio,
|
||||
primeiroAcesso: usuario.primeiroAcesso,
|
||||
ultimoAcesso: usuario.ultimoAcesso,
|
||||
criadoEm: usuario.criadoEm,
|
||||
@@ -569,3 +575,362 @@ export const uploadFotoPerfil = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== GESTÃO AVANÇADA DE USUÁRIOS (TI_MASTER) ====================
|
||||
|
||||
/**
|
||||
* Bloquear usuário (apenas TI_MASTER)
|
||||
*/
|
||||
export const bloquearUsuario = mutation({
|
||||
args: {
|
||||
usuarioId: v.id("usuarios"),
|
||||
motivo: v.string(),
|
||||
bloqueadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await ctx.db.get(args.usuarioId);
|
||||
if (!usuario) {
|
||||
return { sucesso: false as const, erro: "Usuário não encontrado" };
|
||||
}
|
||||
|
||||
// Atualizar usuário como bloqueado
|
||||
await ctx.db.patch(args.usuarioId, {
|
||||
bloqueado: true,
|
||||
motivoBloqueio: args.motivo,
|
||||
dataBloqueio: Date.now(),
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
|
||||
// Registrar no histórico de bloqueios
|
||||
await ctx.db.insert("bloqueiosUsuarios", {
|
||||
usuarioId: args.usuarioId,
|
||||
motivo: args.motivo,
|
||||
bloqueadoPor: args.bloqueadoPorId,
|
||||
dataInicio: Date.now(),
|
||||
ativo: true,
|
||||
});
|
||||
|
||||
// Desativar todas as sessões ativas do usuário
|
||||
const sessoes = await ctx.db
|
||||
.query("sessoes")
|
||||
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
|
||||
.filter((q) => q.eq(q.field("ativo"), true))
|
||||
.collect();
|
||||
|
||||
for (const sessao of sessoes) {
|
||||
await ctx.db.patch(sessao._id, { ativo: false });
|
||||
}
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.bloqueadoPorId,
|
||||
"bloquear",
|
||||
"usuarios",
|
||||
JSON.stringify({ usuarioId: args.usuarioId, motivo: args.motivo }),
|
||||
args.usuarioId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Desbloquear usuário (apenas TI_MASTER)
|
||||
*/
|
||||
export const desbloquearUsuario = mutation({
|
||||
args: {
|
||||
usuarioId: v.id("usuarios"),
|
||||
desbloqueadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await ctx.db.get(args.usuarioId);
|
||||
if (!usuario) {
|
||||
return { sucesso: false as const, erro: "Usuário não encontrado" };
|
||||
}
|
||||
|
||||
// Atualizar usuário como desbloqueado
|
||||
await ctx.db.patch(args.usuarioId, {
|
||||
bloqueado: false,
|
||||
motivoBloqueio: undefined,
|
||||
dataBloqueio: undefined,
|
||||
tentativasLogin: 0,
|
||||
ultimaTentativaLogin: undefined,
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
|
||||
// Fechar bloqueios ativos
|
||||
const bloqueiosAtivos = await ctx.db
|
||||
.query("bloqueiosUsuarios")
|
||||
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
|
||||
.filter((q) => q.eq(q.field("ativo"), true))
|
||||
.collect();
|
||||
|
||||
for (const bloqueio of bloqueiosAtivos) {
|
||||
await ctx.db.patch(bloqueio._id, {
|
||||
ativo: false,
|
||||
dataFim: Date.now(),
|
||||
desbloqueadoPor: args.desbloqueadoPorId,
|
||||
});
|
||||
}
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.desbloqueadoPorId,
|
||||
"desbloquear",
|
||||
"usuarios",
|
||||
JSON.stringify({ usuarioId: args.usuarioId }),
|
||||
args.usuarioId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Resetar senha de usuário (apenas TI_MASTER)
|
||||
*/
|
||||
export const resetarSenhaUsuario = mutation({
|
||||
args: {
|
||||
usuarioId: v.id("usuarios"),
|
||||
resetadoPorId: v.id("usuarios"),
|
||||
novaSenhaTemporaria: v.optional(v.string()), // Se não fornecer, gera automática
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), senhaTemporaria: v.string() }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await ctx.db.get(args.usuarioId);
|
||||
if (!usuario) {
|
||||
return { sucesso: false as const, erro: "Usuário não encontrado" };
|
||||
}
|
||||
|
||||
// Gerar senha temporária se não foi fornecida
|
||||
const senhaTemporaria = args.novaSenhaTemporaria || gerarSenhaTemporaria();
|
||||
const senhaHash = await hashPassword(senhaTemporaria);
|
||||
|
||||
// Atualizar usuário
|
||||
await ctx.db.patch(args.usuarioId, {
|
||||
senhaHash,
|
||||
primeiroAcesso: true, // Força mudança de senha no próximo login
|
||||
tentativasLogin: 0,
|
||||
ultimaTentativaLogin: undefined,
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.resetadoPorId,
|
||||
"resetar_senha",
|
||||
"usuarios",
|
||||
JSON.stringify({ usuarioId: args.usuarioId }),
|
||||
args.usuarioId
|
||||
);
|
||||
|
||||
return { sucesso: true as const, senhaTemporaria };
|
||||
},
|
||||
});
|
||||
|
||||
// Helper para gerar senha temporária
|
||||
function gerarSenhaTemporaria(): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%";
|
||||
let senha = "";
|
||||
for (let i = 0; i < 12; i++) {
|
||||
senha += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return senha;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editar dados de usuário (apenas TI_MASTER)
|
||||
*/
|
||||
export const editarUsuario = mutation({
|
||||
args: {
|
||||
usuarioId: v.id("usuarios"),
|
||||
nome: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
roleId: v.optional(v.id("roles")),
|
||||
setor: v.optional(v.string()),
|
||||
editadoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await ctx.db.get(args.usuarioId);
|
||||
if (!usuario) {
|
||||
return { sucesso: false as const, erro: "Usuário não encontrado" };
|
||||
}
|
||||
|
||||
// Verificar se email já existe (se estiver mudando)
|
||||
if (args.email && args.email !== usuario.email) {
|
||||
const emailExistente = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_email", (q) => q.eq("email", args.email!))
|
||||
.first();
|
||||
|
||||
if (emailExistente) {
|
||||
return { sucesso: false as const, erro: "E-mail já cadastrado" };
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar campos fornecidos
|
||||
const updates: any = {
|
||||
atualizadoEm: Date.now(),
|
||||
};
|
||||
|
||||
if (args.nome !== undefined) updates.nome = args.nome;
|
||||
if (args.email !== undefined) updates.email = args.email;
|
||||
if (args.roleId !== undefined) updates.roleId = args.roleId;
|
||||
if (args.setor !== undefined) updates.setor = args.setor;
|
||||
|
||||
await ctx.db.patch(args.usuarioId, updates);
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.editadoPorId,
|
||||
"editar",
|
||||
"usuarios",
|
||||
JSON.stringify(updates),
|
||||
args.usuarioId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Desativar usuário logicamente (soft delete - apenas TI_MASTER)
|
||||
*/
|
||||
export const excluirUsuarioLogico = mutation({
|
||||
args: {
|
||||
usuarioId: v.id("usuarios"),
|
||||
excluidoPorId: v.id("usuarios"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await ctx.db.get(args.usuarioId);
|
||||
if (!usuario) {
|
||||
return { sucesso: false as const, erro: "Usuário não encontrado" };
|
||||
}
|
||||
|
||||
// Marcar como inativo
|
||||
await ctx.db.patch(args.usuarioId, {
|
||||
ativo: false,
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
|
||||
// Desativar todas as sessões
|
||||
const sessoes = await ctx.db
|
||||
.query("sessoes")
|
||||
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
|
||||
.filter((q) => q.eq(q.field("ativo"), true))
|
||||
.collect();
|
||||
|
||||
for (const sessao of sessoes) {
|
||||
await ctx.db.patch(sessao._id, { ativo: false });
|
||||
}
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.excluidoPorId,
|
||||
"excluir",
|
||||
"usuarios",
|
||||
JSON.stringify({ usuarioId: args.usuarioId }),
|
||||
args.usuarioId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Criar usuário completo com permissões (TI_MASTER)
|
||||
*/
|
||||
export const criarUsuarioCompleto = mutation({
|
||||
args: {
|
||||
matricula: v.string(),
|
||||
nome: v.string(),
|
||||
email: v.string(),
|
||||
roleId: v.id("roles"),
|
||||
setor: v.optional(v.string()),
|
||||
senhaInicial: v.optional(v.string()),
|
||||
criadoPorId: v.id("usuarios"),
|
||||
enviarEmailBoasVindas: v.optional(v.boolean()),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), usuarioId: v.id("usuarios"), senhaTemporaria: v.string() }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Verificar se matrícula já existe
|
||||
const existente = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
|
||||
.first();
|
||||
|
||||
if (existente) {
|
||||
return { sucesso: false as const, erro: "Matrícula já cadastrada" };
|
||||
}
|
||||
|
||||
// Verificar se email já existe
|
||||
const emailExistente = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_email", (q) => q.eq("email", args.email))
|
||||
.first();
|
||||
|
||||
if (emailExistente) {
|
||||
return { sucesso: false as const, erro: "E-mail já cadastrado" };
|
||||
}
|
||||
|
||||
// Gerar senha inicial se não fornecida
|
||||
const senhaTemporaria = args.senhaInicial || gerarSenhaTemporaria();
|
||||
const senhaHash = await hashPassword(senhaTemporaria);
|
||||
|
||||
// Criar usuário
|
||||
const usuarioId = await ctx.db.insert("usuarios", {
|
||||
matricula: args.matricula,
|
||||
senhaHash,
|
||||
nome: args.nome,
|
||||
email: args.email,
|
||||
roleId: args.roleId,
|
||||
setor: args.setor,
|
||||
ativo: true,
|
||||
primeiroAcesso: true,
|
||||
criadoEm: Date.now(),
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.criadoPorId,
|
||||
"criar",
|
||||
"usuarios",
|
||||
JSON.stringify({ usuarioId, matricula: args.matricula, nome: args.nome }),
|
||||
usuarioId
|
||||
);
|
||||
|
||||
// TODO: Se enviarEmailBoasVindas = true, enfileirar email
|
||||
// Isso será implementado quando criarmos o sistema de emails
|
||||
|
||||
return { sucesso: true as const, usuarioId, senhaTemporaria };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
101
packages/backend/convex/verificarMatriculas.ts
Normal file
101
packages/backend/convex/verificarMatriculas.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { internalMutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
/**
|
||||
* Verificar duplicatas de matrícula
|
||||
*/
|
||||
export const verificarDuplicatas = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
matricula: v.string(),
|
||||
count: v.number(),
|
||||
usuarios: v.array(
|
||||
v.object({
|
||||
_id: v.id("usuarios"),
|
||||
nome: v.string(),
|
||||
email: v.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
const usuarios = await ctx.db.query("usuarios").collect();
|
||||
|
||||
// Agrupar por matrícula
|
||||
const gruposPorMatricula = usuarios.reduce((acc, usuario) => {
|
||||
if (!acc[usuario.matricula]) {
|
||||
acc[usuario.matricula] = [];
|
||||
}
|
||||
acc[usuario.matricula].push({
|
||||
_id: usuario._id,
|
||||
nome: usuario.nome,
|
||||
email: usuario.email || "",
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<string, any[]>);
|
||||
|
||||
// Filtrar apenas duplicatas
|
||||
const duplicatas = Object.entries(gruposPorMatricula)
|
||||
.filter(([_, usuarios]) => usuarios.length > 1)
|
||||
.map(([matricula, usuarios]) => ({
|
||||
matricula,
|
||||
count: usuarios.length,
|
||||
usuarios,
|
||||
}));
|
||||
|
||||
return duplicatas;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Remover duplicatas mantendo apenas o mais recente
|
||||
*/
|
||||
export const removerDuplicatas = internalMutation({
|
||||
args: {},
|
||||
returns: v.object({
|
||||
removidos: v.number(),
|
||||
matriculas: v.array(v.string()),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const usuarios = await ctx.db.query("usuarios").collect();
|
||||
|
||||
// Agrupar por matrícula
|
||||
const gruposPorMatricula = usuarios.reduce((acc, usuario) => {
|
||||
if (!acc[usuario.matricula]) {
|
||||
acc[usuario.matricula] = [];
|
||||
}
|
||||
acc[usuario.matricula].push(usuario);
|
||||
return acc;
|
||||
}, {} as Record<string, any[]>);
|
||||
|
||||
let removidos = 0;
|
||||
const matriculasDuplicadas: string[] = [];
|
||||
|
||||
// Para cada grupo com duplicatas
|
||||
for (const [matricula, usuariosGrupo] of Object.entries(gruposPorMatricula)) {
|
||||
if (usuariosGrupo.length > 1) {
|
||||
matriculasDuplicadas.push(matricula);
|
||||
|
||||
// Ordenar por _creationTime (mais recente primeiro)
|
||||
usuariosGrupo.sort((a, b) => b._creationTime - a._creationTime);
|
||||
|
||||
// Manter o primeiro (mais recente) e remover os outros
|
||||
for (let i = 1; i < usuariosGrupo.length; i++) {
|
||||
await ctx.db.delete(usuariosGrupo[i]._id);
|
||||
removidos++;
|
||||
console.log(`🗑️ Removido usuário duplicado: ${usuariosGrupo[i].nome} (matrícula: ${matricula})`);
|
||||
}
|
||||
|
||||
console.log(`✅ Mantido usuário: ${usuariosGrupo[0].nome} (matrícula: ${matricula})`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
removidos,
|
||||
matriculas: matriculasDuplicadas,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user