1203 lines
32 KiB
TypeScript
1203 lines
32 KiB
TypeScript
import { v } from 'convex/values';
|
|
import type { Doc, Id } from './_generated/dataModel';
|
|
import type { QueryCtx } from './_generated/server';
|
|
import { mutation, query } from './_generated/server';
|
|
import { createAuthUser, getCurrentUserFunction } from './auth';
|
|
import { registrarAtividade } from './logsAtividades';
|
|
import { api } from './_generated/api';
|
|
|
|
/**
|
|
* Helper para obter a matrícula do usuário (do funcionário se houver)
|
|
*/
|
|
async function obterMatriculaUsuario(
|
|
ctx: QueryCtx,
|
|
usuario: Doc<'usuarios'>
|
|
): Promise<string | undefined> {
|
|
if (usuario.funcionarioId) {
|
|
const funcionario = await ctx.db.get(usuario.funcionarioId);
|
|
return funcionario?.matricula;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Associar funcionário a um usuário
|
|
*/
|
|
export const associarFuncionario = mutation({
|
|
args: {
|
|
usuarioId: v.id('usuarios'),
|
|
funcionarioId: v.id('funcionarios')
|
|
},
|
|
returns: v.object({ sucesso: v.boolean() }),
|
|
handler: async (ctx, args) => {
|
|
// Verificar se o funcionário existe
|
|
const funcionario = await ctx.db.get(args.funcionarioId);
|
|
if (!funcionario) {
|
|
throw new Error('Funcionário não encontrado');
|
|
}
|
|
|
|
// Verificar se o funcionário já está associado a outro usuário
|
|
const usuarioExistente = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.first();
|
|
|
|
if (usuarioExistente && usuarioExistente._id !== args.usuarioId) {
|
|
const matricula = await obterMatriculaUsuario(ctx, usuarioExistente);
|
|
throw new Error(
|
|
`Este funcionário já está associado ao usuário: ${
|
|
usuarioExistente.nome
|
|
}${matricula ? ` (${matricula})` : ''}`
|
|
);
|
|
}
|
|
|
|
// Associar funcionário ao usuário
|
|
await ctx.db.patch(args.usuarioId, {
|
|
funcionarioId: args.funcionarioId
|
|
});
|
|
|
|
return { sucesso: true };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Desassociar funcionário de um usuário
|
|
*/
|
|
export const desassociarFuncionario = mutation({
|
|
args: {
|
|
usuarioId: v.id('usuarios')
|
|
},
|
|
returns: v.object({ sucesso: v.boolean() }),
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.usuarioId, {
|
|
funcionarioId: undefined
|
|
});
|
|
|
|
return { sucesso: true };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Criar novo usuário (apenas TI)
|
|
*/
|
|
export const criar = mutation({
|
|
args: {
|
|
nome: v.string(),
|
|
email: v.string(),
|
|
roleId: v.id('roles'),
|
|
funcionarioId: v.optional(v.id('funcionarios')),
|
|
senhaInicial: v.string()
|
|
},
|
|
returns: v.union(
|
|
v.object({ sucesso: v.literal(true), usuarioId: v.id('usuarios') }),
|
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
|
),
|
|
handler: async (ctx, args) => {
|
|
// 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' };
|
|
}
|
|
|
|
const senhaTemporaria = args.senhaInicial;
|
|
|
|
const authUserId = await createAuthUser(ctx, {
|
|
nome: args.nome,
|
|
email: args.email,
|
|
password: senhaTemporaria
|
|
});
|
|
|
|
// Criar usuário
|
|
const usuarioId = await ctx.db.insert('usuarios', {
|
|
authId: authUserId,
|
|
nome: args.nome,
|
|
email: args.email,
|
|
funcionarioId: args.funcionarioId,
|
|
roleId: args.roleId,
|
|
ativo: true,
|
|
primeiroAcesso: true,
|
|
criadoEm: Date.now(),
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
// Obter usuário que está criando (para enviar email e chat)
|
|
const usuarioCriador = await getCurrentUserFunction(ctx);
|
|
if (!usuarioCriador) {
|
|
// Se não conseguir obter o criador, retornar sucesso mesmo assim
|
|
return { sucesso: true as const, usuarioId };
|
|
}
|
|
|
|
// Buscar funcionário para obter matrícula se houver
|
|
let matricula = '';
|
|
if (args.funcionarioId) {
|
|
const funcionario = await ctx.db.get(args.funcionarioId);
|
|
if (funcionario?.matricula) {
|
|
matricula = funcionario.matricula;
|
|
}
|
|
}
|
|
|
|
// Preparar credenciais adicionais (matrícula se houver)
|
|
const credenciaisAdicionais = matricula
|
|
? `<li><strong>Matrícula:</strong> ${matricula}</li>`
|
|
: '';
|
|
|
|
// Obter URL do sistema
|
|
let urlSistema = process.env.SITE_URL || 'http://localhost:5173';
|
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
|
urlSistema = `http://${urlSistema}`;
|
|
}
|
|
|
|
// Enviar email de boas-vindas usando template (agendado via scheduler)
|
|
try {
|
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
destinatario: args.email,
|
|
destinatarioId: usuarioId,
|
|
templateCodigo: 'BEM_VINDO',
|
|
variaveis: {
|
|
nome: args.nome,
|
|
email: args.email,
|
|
credenciaisAdicionais,
|
|
senha: senhaTemporaria,
|
|
urlSistema
|
|
},
|
|
enviadoPor: usuarioCriador._id
|
|
});
|
|
} catch (error) {
|
|
// Fallback para envio direto se houver erro ao agendar ou processar o template
|
|
console.warn(
|
|
'Erro ao agendar envio de email com template BEM_VINDO, usando envio direto:',
|
|
error
|
|
);
|
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
|
destinatario: args.email,
|
|
destinatarioId: usuarioId,
|
|
assunto: 'Bem-vindo ao SGSE',
|
|
corpo: `<p>Olá <strong>${args.nome}</strong>,</p>
|
|
<p>Seja bem-vindo ao <strong>SGSE - Sistema de Gerenciamento de Secretaria</strong>!</p>
|
|
<p>Seu cadastro foi realizado com sucesso.</p>
|
|
<div style='background-color: #F3F4F6; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>
|
|
<p style='margin: 0 0 10px 0;'><strong>Suas credenciais de acesso:</strong></p>
|
|
<ul style='margin: 0; padding-left: 20px;'>
|
|
<li><strong>E-mail:</strong> ${args.email}</li>
|
|
${credenciaisAdicionais}
|
|
<li><strong>Senha temporária:</strong> ${senhaTemporaria}</li>
|
|
</ul>
|
|
</div>
|
|
<p><strong>⚠️ Importante:</strong> Por favor, altere sua senha no primeiro acesso ao sistema.</p>
|
|
<p>Acesse o sistema através do link: <a href='${urlSistema}' style='color: #2563EB;'>${urlSistema}</a></p>
|
|
<p style='margin-top: 30px; color: #6B7280; font-size: 14px;'>Equipe de TI - Secretaria de Esportes</p>`,
|
|
enviadoPor: usuarioCriador._id
|
|
});
|
|
}
|
|
|
|
// Criar ou obter conversa entre criador e novo usuário
|
|
const conversasExistentes = await ctx.db
|
|
.query('conversas')
|
|
.filter((q) => q.eq(q.field('tipo'), 'individual'))
|
|
.collect();
|
|
|
|
let conversaId: Id<'conversas'> | null = null;
|
|
for (const conversa of conversasExistentes) {
|
|
if (
|
|
conversa.participantes.length === 2 &&
|
|
conversa.participantes.includes(usuarioCriador._id) &&
|
|
conversa.participantes.includes(usuarioId)
|
|
) {
|
|
conversaId = conversa._id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!conversaId) {
|
|
conversaId = await ctx.db.insert('conversas', {
|
|
tipo: 'individual',
|
|
participantes: [usuarioCriador._id, usuarioId],
|
|
criadoPor: usuarioCriador._id,
|
|
criadoEm: Date.now()
|
|
});
|
|
}
|
|
|
|
// Criar mensagem de chat (texto simples)
|
|
const mensagemChat = matricula
|
|
? `Bem-vindo ao SGSE! Seu cadastro foi realizado com sucesso. Suas credenciais de acesso: E-mail: ${args.email}, Matrícula: ${matricula}, Senha temporária: ${senhaTemporaria}. Por favor, altere sua senha no primeiro acesso.`
|
|
: `Bem-vindo ao SGSE! Seu cadastro foi realizado com sucesso. Suas credenciais de acesso: E-mail: ${args.email}, Senha temporária: ${senhaTemporaria}. Por favor, altere sua senha no primeiro acesso.`;
|
|
|
|
await ctx.db.insert('mensagens', {
|
|
conversaId,
|
|
remetenteId: usuarioCriador._id,
|
|
tipo: 'texto',
|
|
conteudo: mensagemChat,
|
|
enviadaEm: Date.now()
|
|
});
|
|
|
|
return { sucesso: true as const, usuarioId };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Listar todos os usuários com filtros
|
|
*/
|
|
export const listar = query({
|
|
args: {
|
|
setor: v.optional(v.string()),
|
|
matricula: v.optional(v.string()),
|
|
ativo: v.optional(v.boolean())
|
|
},
|
|
handler: async (ctx, args) => {
|
|
let usuarios = await ctx.db.query('usuarios').collect();
|
|
|
|
// Filtrar por matrícula (buscar no funcionário)
|
|
if (args.matricula) {
|
|
const usuariosComMatricula = await Promise.all(
|
|
usuarios.map(async (u) => {
|
|
const matricula = await obterMatriculaUsuario(ctx, u);
|
|
return { usuario: u, matricula };
|
|
})
|
|
);
|
|
usuarios = usuariosComMatricula
|
|
.filter(({ matricula }) => matricula?.includes(args.matricula!))
|
|
.map(({ usuario }) => usuario);
|
|
}
|
|
|
|
// Filtrar por ativo
|
|
if (args.ativo !== undefined) {
|
|
usuarios = usuarios.filter((u) => u.ativo === args.ativo);
|
|
}
|
|
|
|
// Buscar roles e funcionários
|
|
const resultado = [];
|
|
const usuariosSemRole: Array<{
|
|
nome: string;
|
|
matricula: string;
|
|
roleId: Id<'roles'>;
|
|
}> = [];
|
|
|
|
for (const usuario of usuarios) {
|
|
try {
|
|
const role = await ctx.db.get(usuario.roleId);
|
|
|
|
// Se a role não existe, criar uma role de erro mas ainda incluir o usuário
|
|
if (!role) {
|
|
const matricula = await obterMatriculaUsuario(ctx, usuario);
|
|
usuariosSemRole.push({
|
|
nome: usuario.nome,
|
|
matricula: matricula || 'N/A',
|
|
roleId: usuario.roleId
|
|
});
|
|
|
|
// Filtrar por setor - se filtro está ativo e role não existe, pular
|
|
if (args.setor) {
|
|
continue;
|
|
}
|
|
|
|
// Incluir usuário com role de erro
|
|
let funcionario;
|
|
if (usuario.funcionarioId) {
|
|
try {
|
|
const func = await ctx.db.get(usuario.funcionarioId);
|
|
if (func) {
|
|
funcionario = {
|
|
_id: func._id,
|
|
nome: func.nome,
|
|
matricula: func.matricula,
|
|
descricaoCargo: func.descricaoCargo,
|
|
simboloTipo: func.simboloTipo
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario);
|
|
|
|
// Criar role de erro (sem _creationTime pois a role não existe)
|
|
resultado.push({
|
|
_id: usuario._id,
|
|
matricula: matriculaUsuario,
|
|
nome: usuario.nome,
|
|
email: usuario.email,
|
|
ativo: usuario.ativo,
|
|
bloqueado: usuario.bloqueado,
|
|
motivoBloqueio: usuario.motivoBloqueio,
|
|
primeiroAcesso: usuario.primeiroAcesso,
|
|
ultimoAcesso: usuario.ultimoAcesso,
|
|
criadoEm: usuario.criadoEm,
|
|
role: {
|
|
_id: usuario.roleId,
|
|
descricao: 'Perfil não encontrado' as const,
|
|
nome: 'erro_role_ausente' as const,
|
|
admin: false as const,
|
|
erro: true as const
|
|
},
|
|
funcionario,
|
|
avisos: [
|
|
{
|
|
tipo: 'erro' as const,
|
|
mensagem: `Perfil de acesso (ID: ${usuario.roleId}) não encontrado. Este usuário precisa ter seu perfil reatribuído.`
|
|
}
|
|
]
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Buscar funcionário associado
|
|
let funcionario;
|
|
if (usuario.funcionarioId) {
|
|
try {
|
|
const func = await ctx.db.get(usuario.funcionarioId);
|
|
if (func) {
|
|
funcionario = {
|
|
_id: func._id,
|
|
nome: func.nome,
|
|
matricula: func.matricula,
|
|
descricaoCargo: func.descricaoCargo,
|
|
simboloTipo: func.simboloTipo
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
const roleObj = {
|
|
_id: role._id,
|
|
descricao: role.descricao,
|
|
nome: role.nome,
|
|
admin: role.admin ?? false
|
|
};
|
|
|
|
const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario);
|
|
|
|
resultado.push({
|
|
_id: usuario._id,
|
|
matricula: matriculaUsuario,
|
|
nome: usuario.nome,
|
|
email: usuario.email,
|
|
ativo: usuario.ativo,
|
|
bloqueado: usuario.bloqueado,
|
|
motivoBloqueio: usuario.motivoBloqueio,
|
|
primeiroAcesso: usuario.primeiroAcesso,
|
|
ultimoAcesso: usuario.ultimoAcesso,
|
|
criadoEm: usuario.criadoEm,
|
|
role: roleObj,
|
|
funcionario
|
|
});
|
|
} catch (error) {
|
|
console.error(`Erro ao processar usuário ${usuario._id}:`, error);
|
|
// Continua processando outros usuários mesmo se houver erro em um
|
|
}
|
|
}
|
|
|
|
// Log de usuários sem role para depuração
|
|
if (usuariosSemRole.length > 0) {
|
|
console.warn(
|
|
`⚠️ Encontrados ${usuariosSemRole.length} usuário(s) com perfil ausente:`,
|
|
usuariosSemRole.map(
|
|
(u) =>
|
|
`${u.nome}${u.matricula !== 'N/A' ? ` (${u.matricula})` : ''} - RoleID: ${u.roleId}`
|
|
)
|
|
);
|
|
}
|
|
|
|
return resultado;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Ativar/Desativar usuário
|
|
*/
|
|
export const alterarStatus = mutation({
|
|
args: {
|
|
usuarioId: v.id('usuarios'),
|
|
ativo: v.boolean()
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.usuarioId, {
|
|
ativo: args.ativo,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
// Se desativar, desativar todas as sessões
|
|
if (!args.ativo) {
|
|
const sessoes = await ctx.db
|
|
.query('sessoes')
|
|
.withIndex('by_usuario', (q) => q.eq('usuarioId', args.usuarioId))
|
|
.collect();
|
|
|
|
for (const sessao of sessoes) {
|
|
await ctx.db.patch(sessao._id, { ativo: false });
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Resetar senha do usuário
|
|
*/
|
|
// export const resetarSenha = mutation({
|
|
// args: {
|
|
// usuarioId: v.id("usuarios"),
|
|
// novaSenha: v.string(),
|
|
// },
|
|
// returns: v.null(),
|
|
// handler: async (ctx, args) => {
|
|
// const senhaHash = await hashPassword(args.novaSenha);
|
|
|
|
// await ctx.db.patch(args.usuarioId, {
|
|
// senhaHash,
|
|
// primeiroAcesso: true,
|
|
// atualizadoEm: Date.now(),
|
|
// });
|
|
|
|
// // Desativar todas as sessões
|
|
// const sessoes = await ctx.db
|
|
// .query("sessoes")
|
|
// .withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
|
|
// .collect();
|
|
|
|
// for (const sessao of sessoes) {
|
|
// await ctx.db.patch(sessao._id, { ativo: false });
|
|
// }
|
|
|
|
// return null;
|
|
// },
|
|
// });
|
|
|
|
/**
|
|
* Excluir usuário
|
|
*/
|
|
export const excluir = mutation({
|
|
args: {
|
|
usuarioId: v.id('usuarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
// Excluir sessões
|
|
const sessoes = await ctx.db
|
|
.query('sessoes')
|
|
.withIndex('by_usuario', (q) => q.eq('usuarioId', args.usuarioId))
|
|
.collect();
|
|
|
|
for (const sessao of sessoes) {
|
|
await ctx.db.delete(sessao._id);
|
|
}
|
|
|
|
// Excluir usuário
|
|
await ctx.db.delete(args.usuarioId);
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Ativar usuário
|
|
*/
|
|
export const ativar = mutation({
|
|
args: {
|
|
id: v.id('usuarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.id, {
|
|
ativo: true,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Desativar usuário
|
|
*/
|
|
export const desativar = mutation({
|
|
args: {
|
|
id: v.id('usuarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.id, {
|
|
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.id))
|
|
.collect();
|
|
|
|
for (const sessao of sessoes) {
|
|
await ctx.db.patch(sessao._id, { ativo: false });
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Alterar role de um usuário
|
|
*/
|
|
export const alterarRole = mutation({
|
|
args: {
|
|
usuarioId: v.id('usuarios'),
|
|
novaRoleId: v.id('roles')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
// Verificar se a role existe
|
|
const role = await ctx.db.get(args.novaRoleId);
|
|
if (!role) {
|
|
throw new Error('Role não encontrada');
|
|
}
|
|
|
|
// Atualizar usuário
|
|
await ctx.db.patch(args.usuarioId, {
|
|
roleId: args.novaRoleId,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Atualizar perfil do usuário (foto, setor, status, preferências)
|
|
*/
|
|
export const atualizarPerfil = mutation({
|
|
args: {
|
|
fotoPerfil: v.optional(v.id('_storage')),
|
|
avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear)
|
|
setor: v.optional(v.string()),
|
|
statusMensagem: v.optional(v.string()),
|
|
statusPresenca: v.optional(
|
|
v.union(
|
|
v.literal('online'),
|
|
v.literal('offline'),
|
|
v.literal('ausente'),
|
|
v.literal('externo'),
|
|
v.literal('em_reuniao')
|
|
)
|
|
),
|
|
notificacoesAtivadas: v.optional(v.boolean()),
|
|
somNotificacao: v.optional(v.boolean()),
|
|
temaPreferido: v.optional(v.string())
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const usuarioAtual = await getCurrentUserFunction(ctx);
|
|
if (!usuarioAtual) throw new Error('Usuário não encontrado');
|
|
|
|
// Validar statusMensagem (max 100 chars)
|
|
if (args.statusMensagem && args.statusMensagem.length > 100) {
|
|
throw new Error('Mensagem de status deve ter no máximo 100 caracteres');
|
|
}
|
|
|
|
// Atualizar apenas os campos fornecidos
|
|
const updates: Partial<Doc<'usuarios'>> & { atualizadoEm: number } = {
|
|
atualizadoEm: Date.now()
|
|
};
|
|
|
|
if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil;
|
|
if (args.avatar !== undefined) updates.avatar = args.avatar;
|
|
if (args.statusMensagem !== undefined) updates.statusMensagem = args.statusMensagem;
|
|
if (args.statusPresenca !== undefined) {
|
|
updates.statusPresenca = args.statusPresenca;
|
|
updates.ultimaAtividade = Date.now();
|
|
}
|
|
if (args.notificacoesAtivadas !== undefined)
|
|
updates.notificacoesAtivadas = args.notificacoesAtivadas;
|
|
if (args.somNotificacao !== undefined) updates.somNotificacao = args.somNotificacao;
|
|
if (args.temaPreferido !== undefined) updates.temaPreferido = args.temaPreferido;
|
|
|
|
await ctx.db.patch(usuarioAtual._id, updates);
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Atualizar tema preferido do usuário
|
|
*/
|
|
export const atualizarTema = mutation({
|
|
args: {
|
|
temaPreferido: v.string()
|
|
},
|
|
returns: v.object({ sucesso: v.boolean() }),
|
|
handler: async (ctx, args) => {
|
|
const usuarioAtual = await getCurrentUserFunction(ctx);
|
|
if (!usuarioAtual) {
|
|
throw new Error('Usuário não encontrado');
|
|
}
|
|
|
|
await ctx.db.patch(usuarioAtual._id, {
|
|
temaPreferido: args.temaPreferido,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
return { sucesso: true };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Obter perfil do usuário atual
|
|
*/
|
|
export const obterPerfil = query({
|
|
args: {},
|
|
returns: v.union(
|
|
v.object({
|
|
_id: v.id('usuarios'),
|
|
nome: v.string(),
|
|
email: v.string(),
|
|
matricula: v.optional(v.string()),
|
|
funcionarioId: v.optional(v.id('funcionarios')),
|
|
fotoPerfil: v.optional(v.id('_storage')),
|
|
fotoPerfilUrl: v.union(v.string(), v.null()),
|
|
avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear)
|
|
setor: v.optional(v.string()),
|
|
statusMensagem: v.optional(v.string()),
|
|
statusPresenca: v.optional(
|
|
v.union(
|
|
v.literal('online'),
|
|
v.literal('offline'),
|
|
v.literal('ausente'),
|
|
v.literal('externo'),
|
|
v.literal('em_reuniao')
|
|
)
|
|
),
|
|
notificacoesAtivadas: v.boolean(),
|
|
somNotificacao: v.boolean()
|
|
}),
|
|
v.null()
|
|
),
|
|
handler: async (ctx) => {
|
|
const usuarioAutenticado = await getCurrentUserFunction(ctx);
|
|
if (!usuarioAutenticado) {
|
|
return null;
|
|
}
|
|
|
|
const usuarioAtual = usuarioAutenticado;
|
|
|
|
// Buscar fotoPerfil URL se existir
|
|
let fotoPerfilUrl = null;
|
|
if (usuarioAtual.fotoPerfil) {
|
|
fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtual.fotoPerfil);
|
|
}
|
|
|
|
const matricula = await obterMatriculaUsuario(ctx, usuarioAtual);
|
|
|
|
return {
|
|
_id: usuarioAtual._id,
|
|
nome: usuarioAtual.nome,
|
|
email: usuarioAtual.email,
|
|
matricula: matricula || undefined,
|
|
funcionarioId: usuarioAtual.funcionarioId,
|
|
fotoPerfil: usuarioAtual.fotoPerfil,
|
|
fotoPerfilUrl,
|
|
avatar: usuarioAtual.avatar,
|
|
statusMensagem: usuarioAtual.statusMensagem,
|
|
statusPresenca: usuarioAtual.statusPresenca,
|
|
notificacoesAtivadas: usuarioAtual.notificacoesAtivadas ?? true,
|
|
somNotificacao: usuarioAtual.somNotificacao ?? true
|
|
};
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Listar todos usuários para o chat (com foto e status)
|
|
*/
|
|
export const listarParaChat = query({
|
|
args: {},
|
|
returns: v.array(
|
|
v.object({
|
|
_id: v.id('usuarios'),
|
|
nome: v.string(),
|
|
email: v.string(),
|
|
matricula: v.optional(v.string()),
|
|
fotoPerfil: v.optional(v.id('_storage')),
|
|
fotoPerfilUrl: v.union(v.string(), v.null()),
|
|
avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear)
|
|
statusPresenca: v.optional(
|
|
v.union(
|
|
v.literal('online'),
|
|
v.literal('offline'),
|
|
v.literal('ausente'),
|
|
v.literal('externo'),
|
|
v.literal('em_reuniao')
|
|
)
|
|
),
|
|
statusMensagem: v.optional(v.string()),
|
|
ultimaAtividade: v.optional(v.number())
|
|
})
|
|
),
|
|
handler: async (ctx) => {
|
|
// Obter usuário autenticado usando função helper compartilhada
|
|
const usuarioAtual = await getCurrentUserFunction(ctx);
|
|
if (!usuarioAtual) {
|
|
return [];
|
|
}
|
|
|
|
// Buscar todos os usuários ativos
|
|
const usuarios = await ctx.db
|
|
.query('usuarios')
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.collect();
|
|
|
|
// Filtrar o usuário atual da lista apenas se conseguimos identificá-lo com certeza
|
|
// Se não conseguimos identificar (usuarioAtual é null), retornar todos
|
|
// O frontend fará um filtro adicional usando obterPerfil como camada de segurança
|
|
const usuariosFiltrados = usuarioAtual
|
|
? usuarios.filter((u) => u._id !== usuarioAtual._id)
|
|
: usuarios;
|
|
|
|
// Buscar foto de perfil URL para cada usuário
|
|
const usuariosComFoto = await Promise.all(
|
|
usuariosFiltrados.map(async (usuario) => {
|
|
let fotoPerfilUrl = null;
|
|
if (usuario.fotoPerfil) {
|
|
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
|
|
}
|
|
|
|
const matricula = await obterMatriculaUsuario(ctx, usuario);
|
|
|
|
return {
|
|
_id: usuario._id,
|
|
nome: usuario.nome,
|
|
email: usuario.email,
|
|
matricula: matricula || undefined,
|
|
fotoPerfil: usuario.fotoPerfil,
|
|
fotoPerfilUrl,
|
|
statusPresenca: usuario.statusPresenca || 'offline',
|
|
statusMensagem: usuario.statusMensagem,
|
|
ultimaAtividade: usuario.ultimaAtividade
|
|
};
|
|
})
|
|
);
|
|
|
|
return usuariosComFoto;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Gera URL para upload de foto de perfil
|
|
*/
|
|
export const uploadFotoPerfil = mutation({
|
|
args: {},
|
|
returns: v.string(),
|
|
handler: async (ctx) => {
|
|
const usuarioAtual = await getCurrentUserFunction(ctx);
|
|
if (!usuarioAtual) throw new Error('Usuário não autenticado');
|
|
|
|
return await ctx.storage.generateUploadUrl();
|
|
}
|
|
});
|
|
|
|
// ==================== 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 usuarioAtual = await getCurrentUserFunction(ctx);
|
|
if (!usuarioAtual) {
|
|
return { sucesso: false as const, erro: 'Usuário não autenticado' };
|
|
}
|
|
|
|
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 usuarioAtual = await getCurrentUserFunction(ctx);
|
|
if (!usuarioAtual) {
|
|
return { sucesso: false as const, erro: 'Usuário não autenticado' };
|
|
}
|
|
|
|
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' };
|
|
}
|
|
|
|
// Verificar permissão (apenas TI_MASTER)
|
|
const resetadoPor = await ctx.db.get(args.resetadoPorId);
|
|
if (!resetadoPor) {
|
|
return { sucesso: false as const, erro: 'Usuário que está resetando não encontrado' };
|
|
}
|
|
|
|
// Buscar a role do usuário
|
|
if (!resetadoPor.roleId) {
|
|
return { sucesso: false as const, erro: 'Usuário não possui role definida' };
|
|
}
|
|
|
|
const role = await ctx.db.get(resetadoPor.roleId);
|
|
if (!role) {
|
|
return { sucesso: false as const, erro: 'Role do usuário não encontrada' };
|
|
}
|
|
|
|
// Permitir TI_MASTER, TI_USUARIO e ADMIN
|
|
const rolesPermitidas = ['ti_master', 'ti_usuario', 'admin'];
|
|
if (!rolesPermitidas.includes(role.nome)) {
|
|
return {
|
|
sucesso: false as const,
|
|
erro: 'Apenas usuários de TI ou administradores podem resetar senhas'
|
|
};
|
|
}
|
|
|
|
// Gerar senha temporária se não foi fornecida
|
|
const senhaTemporaria = args.novaSenhaTemporaria || gerarSenhaTemporaria();
|
|
|
|
try {
|
|
// Nota: Better Auth gerencia senhas através do sistema de autenticação.
|
|
// A senha não é armazenada diretamente na tabela usuarios.
|
|
// Para resetar a senha, seria necessário usar a API do Better Auth,
|
|
// mas isso requer uma implementação adicional.
|
|
// Por enquanto, atualizamos apenas os campos do usuário que podemos modificar.
|
|
|
|
// Atualizar usuário (sem senhaHash, pois não existe no schema)
|
|
await ctx.db.patch(args.usuarioId, {
|
|
primeiroAcesso: true, // Força mudança de senha no próximo login
|
|
tentativasLogin: 0,
|
|
ultimaTentativaLogin: undefined,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
// Desativar todas as sessões ativas
|
|
const sessoes = await ctx.db
|
|
.query('sessoes')
|
|
.withIndex('by_usuario', (q) => q.eq('usuarioId', args.usuarioId))
|
|
.collect();
|
|
|
|
for (const sessao of sessoes) {
|
|
await ctx.db.patch(sessao._id, { ativo: false });
|
|
}
|
|
|
|
// Enviar email com a nova senha usando template
|
|
try {
|
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
destinatario: usuario.email,
|
|
destinatarioId: args.usuarioId,
|
|
templateCodigo: 'SENHA_RESETADA',
|
|
variaveis: {
|
|
senha: senhaTemporaria
|
|
},
|
|
enviadoPor: args.resetadoPorId
|
|
});
|
|
} catch (emailError) {
|
|
console.error('Erro ao agendar envio de email:', emailError);
|
|
// Não falhar a mutation se o email falhar, apenas logar o erro
|
|
}
|
|
|
|
// Log de atividade
|
|
await registrarAtividade(
|
|
ctx,
|
|
args.resetadoPorId,
|
|
'resetar_senha',
|
|
'usuarios',
|
|
JSON.stringify({ usuarioId: args.usuarioId }),
|
|
args.usuarioId
|
|
);
|
|
|
|
return { sucesso: true as const, senhaTemporaria };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { sucesso: false as const, erro: `Erro ao resetar senha: ${errorMessage}` };
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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 usuarioAtual = await getCurrentUserFunction(ctx);
|
|
if (!usuarioAtual) {
|
|
return { sucesso: false as const, erro: 'Usuário não autenticado' };
|
|
}
|
|
|
|
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: Partial<Doc<'usuarios'>> & { atualizadoEm: number } = {
|
|
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;
|
|
|
|
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 };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Criar/Promover usuário Admin Master (TI_MASTER - nível 0)
|
|
*/
|
|
export const criarAdminMaster = mutation({
|
|
args: {
|
|
nome: v.string(),
|
|
email: v.string(),
|
|
senha: v.optional(v.string())
|
|
},
|
|
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) => {
|
|
// Garantir que a role TI_MASTER exista (nível 0)
|
|
let roleTIMaster = await ctx.db
|
|
.query('roles')
|
|
.withIndex('by_nome', (q) => q.eq('nome', 'ti_master'))
|
|
.first();
|
|
|
|
if (!roleTIMaster) {
|
|
const roleId = await ctx.db.insert('roles', {
|
|
nome: 'ti_master',
|
|
descricao: 'TI Master',
|
|
admin: true
|
|
});
|
|
roleTIMaster = await ctx.db.get(roleId);
|
|
}
|
|
|
|
if (!roleTIMaster) {
|
|
return {
|
|
sucesso: false as const,
|
|
erro: 'Falha ao garantir role TI Master'
|
|
};
|
|
}
|
|
|
|
const senhaTemporaria = args.senha || gerarSenhaTemporaria();
|
|
|
|
const authUserId = await createAuthUser(ctx, {
|
|
nome: args.nome,
|
|
email: args.email,
|
|
password: senhaTemporaria
|
|
});
|
|
|
|
// Verificar se email já existe
|
|
const existentePorEmail = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_email', (q) => q.eq('email', args.email))
|
|
.first();
|
|
if (existentePorEmail) {
|
|
// Promove usuário existente por email
|
|
await ctx.db.patch(existentePorEmail._id, {
|
|
nome: args.nome,
|
|
roleId: roleTIMaster._id,
|
|
ativo: true,
|
|
primeiroAcesso: true,
|
|
atualizadoEm: Date.now(),
|
|
authId: authUserId
|
|
});
|
|
return {
|
|
sucesso: true as const,
|
|
usuarioId: existentePorEmail._id,
|
|
senhaTemporaria
|
|
};
|
|
}
|
|
|
|
// Criar novo usuário TI Master
|
|
const usuarioId = await ctx.db.insert('usuarios', {
|
|
authId: authUserId,
|
|
nome: args.nome,
|
|
email: args.email,
|
|
roleId: roleTIMaster._id,
|
|
ativo: true,
|
|
primeiroAcesso: true,
|
|
criadoEm: Date.now(),
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
return { sucesso: true as const, usuarioId, senhaTemporaria };
|
|
}
|
|
});
|