Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-02 00:58:10 -03:00
38 changed files with 9633 additions and 3679 deletions

View File

@@ -61,7 +61,10 @@ import type * as templatesMensagens from "../templatesMensagens.js";
import type * as times from "../times.js";
import type * as todos from "../todos.js";
import type * as usuarios from "../usuarios.js";
import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js";
import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js";
import type * as utils_getClientIP from "../utils/getClientIP.js";
import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js";
import type * as verificarMatriculas from "../verificarMatriculas.js";
import type {
@@ -124,7 +127,10 @@ declare const fullApi: ApiFromModules<{
times: typeof times;
todos: typeof todos;
usuarios: typeof usuarios;
"utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper;
"utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper;
"utils/getClientIP": typeof utils_getClientIP;
"utils/scanEmailSenders": typeof utils_scanEmailSenders;
verificarMatriculas: typeof verificarMatriculas;
}>;

View File

@@ -358,20 +358,48 @@ export const criarSolicitacao = mutation({
.first();
if (gestorUsuario && funcionarioUsuario) {
// Enviar email ao gestor
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: gestorUsuario.email,
destinatarioId: gestorId,
assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`,
corpo: `<p>Olá ${gestorUsuario.nome},</p>
<p>O funcionário <strong>${funcionario.nome}</strong> solicitou uma ausência:</p>
<ul>
<li><strong>Período:</strong> ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${args.motivo}</li>
</ul>
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
enviadoPor: funcionarioUsuario._id,
});
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Enviar email ao gestor usando template (agendado via scheduler)
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: gestorUsuario.email,
destinatarioId: gestorId,
templateCodigo: "ausencia_solicitada",
variaveis: {
gestorNome: gestorUsuario.nome,
funcionarioNome: funcionario.nome,
dataInicio: new Date(args.dataInicio).toLocaleDateString("pt-BR"),
dataFim: new Date(args.dataFim).toLocaleDateString("pt-BR"),
motivo: args.motivo,
urlSistema,
},
enviadoPor: funcionarioUsuario._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 ausencia_solicitada, usando envio direto:",
error,
);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: gestorUsuario.email,
destinatarioId: gestorId,
assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`,
corpo: `<p>Olá ${gestorUsuario.nome},</p>
<p>O funcionário <strong>${funcionario.nome}</strong> solicitou uma ausência:</p>
<ul>
<li><strong>Período:</strong> ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${args.motivo}</li>
</ul>
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
enviadoPor: funcionarioUsuario._id,
});
}
// Criar ou obter conversa entre gestor e funcionário
const conversasExistentes = await ctx.db
@@ -475,19 +503,47 @@ export const aprovar = mutation({
const gestorUsuario = await ctx.db.get(args.gestorId);
if (gestorUsuario) {
// Enviar email ao funcionário
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Aprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
</ul>`,
enviadoPor: args.gestorId,
});
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Enviar email ao funcionário usando template (agendado via scheduler)
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
templateCodigo: "ausencia_aprovada",
variaveis: {
funcionarioNome: funcionarioUsuario.nome,
gestorNome: gestorUsuario.nome,
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR"),
dataFim: new Date(solicitacao.dataFim).toLocaleDateString("pt-BR"),
motivo: solicitacao.motivo,
urlSistema,
},
enviadoPor: args.gestorId,
});
} 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 ausencia_aprovada, usando envio direto:",
error,
);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Aprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
</ul>`,
enviadoPor: args.gestorId,
});
}
// Criar ou obter conversa
const conversasExistentes = await ctx.db
@@ -593,20 +649,49 @@ export const reprovar = mutation({
const gestorUsuario = await ctx.db.get(args.gestorId);
if (gestorUsuario) {
// Enviar email ao funcionário
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Reprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>reprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
</ul>`,
enviadoPor: args.gestorId,
});
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Enviar email ao funcionário usando template (agendado via scheduler)
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
templateCodigo: "ausencia_reprovada",
variaveis: {
funcionarioNome: funcionarioUsuario.nome,
gestorNome: gestorUsuario.nome,
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR"),
dataFim: new Date(solicitacao.dataFim).toLocaleDateString("pt-BR"),
motivo: solicitacao.motivo,
motivoReprovacao: args.motivoReprovacao,
urlSistema,
},
enviadoPor: args.gestorId,
});
} 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 ausencia_reprovada, usando envio direto:",
error,
);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Reprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>reprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
</ul>`,
enviadoPor: args.gestorId,
});
}
// Criar ou obter conversa
const conversasExistentes = await ctx.db

View File

@@ -121,15 +121,42 @@ async function registrarNotificacoes(
) {
const { ticket, titulo, mensagem, usuarioEvento } = params;
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Notificar solicitante
if (ticket.solicitanteEmail) {
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: ticket.solicitanteEmail,
destinatarioId: ticket.solicitanteId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
enviadoPor: usuarioEvento,
});
// Tentar usar template, senão usar envio direto
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: ticket.solicitanteEmail,
destinatarioId: ticket.solicitanteId,
templateCodigo: "chamado_atualizado",
variaveis: {
solicitante: ticket.solicitanteNome || "Usuário",
numeroTicket: ticket.numero,
mensagem: mensagem,
urlSistema,
},
enviadoPor: usuarioEvento,
});
} catch (error) {
// Fallback para envio direto
console.warn(
"Erro ao agendar envio de email com template chamado_atualizado para solicitante, usando envio direto:",
error,
);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: ticket.solicitanteEmail,
destinatarioId: ticket.solicitanteId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
enviadoPor: usuarioEvento,
});
}
}
await ctx.db.insert("notificacoes", {
@@ -143,17 +170,52 @@ async function registrarNotificacoes(
criadaEm: Date.now(),
});
// Se o ticket estiver associado a uma conversa, registrar também uma mensagem de chat
// Isso garante o "duplo canal": email + chat para notificações importantes.
if (ticket.conversaId) {
const conteudoChat = mensagem.length > 0 ? `${titulo}: ${mensagem}` : titulo;
await ctx.db.insert("mensagens", {
conversaId: ticket.conversaId,
remetenteId: usuarioEvento,
tipo: "texto",
conteudo: conteudoChat,
enviadaEm: Date.now(),
});
}
// Notificar responsável (se houver)
if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) {
const responsavel = await ctx.db.get(ticket.responsavelId);
if (responsavel?.email) {
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: responsavel.email,
destinatarioId: ticket.responsavelId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
enviadoPor: usuarioEvento,
});
// Tentar usar template, senão usar envio direto
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: responsavel.email,
destinatarioId: ticket.responsavelId,
templateCodigo: "chamado_atualizado",
variaveis: {
solicitante: ticket.solicitanteNome || "Usuário",
numeroTicket: ticket.numero,
mensagem: mensagem,
urlSistema,
},
enviadoPor: usuarioEvento,
});
} catch (error) {
// Fallback para envio direto
console.warn(
"Erro ao agendar envio de email com template chamado_atualizado para responsável, usando envio direto:",
error,
);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: responsavel.email,
destinatarioId: ticket.responsavelId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
enviadoPor: usuarioEvento,
});
}
}
await ctx.db.insert("notificacoes", {

View File

@@ -2,7 +2,6 @@ import { v } from "convex/values";
import { mutation, query, action, internalMutation } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades";
import { api, internal } from "./_generated/api";
import { encryptSMTPPassword } from "./auth/utils";
/**
* Obter configuração de Jitsi ativa
@@ -33,44 +32,6 @@ export const obterConfigJitsi = query({
},
});
/**
* Obter configuração completa de Jitsi (incluindo SSH, mas sem senha)
*/
export const obterConfigJitsiCompleta = query({
args: {
configId: v.id("configuracaoJitsi"),
},
handler: async (ctx, args) => {
const config = await ctx.db.get(args.configId);
if (!config) {
return null;
}
return {
_id: config._id,
domain: config.domain,
appId: config.appId,
roomPrefix: config.roomPrefix,
useHttps: config.useHttps,
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false,
ativo: config.ativo,
testadoEm: config.testadoEm,
atualizadoEm: config.atualizadoEm,
configuradoEm: config.configuradoEm,
// Configurações SSH (sem senha)
sshHost: config.sshHost,
sshPort: config.sshPort,
sshUsername: config.sshUsername,
sshPasswordHash: config.sshPasswordHash ? "********" : undefined, // Mascarar
sshKeyPath: config.sshKeyPath,
dockerComposePath: config.dockerComposePath,
jitsiConfigPath: config.jitsiConfigPath,
configuradoNoServidor: config.configuradoNoServidor ?? false,
configuradoNoServidorEm: config.configuradoNoServidorEm,
};
},
});
/**
* Salvar configuração de Jitsi (apenas TI_MASTER)
@@ -83,14 +44,6 @@ export const salvarConfigJitsi = mutation({
useHttps: v.boolean(),
acceptSelfSignedCert: v.boolean(),
configuradoPorId: v.id("usuarios"),
// Opcionais: configurações SSH/Docker
sshHost: v.optional(v.string()),
sshPort: v.optional(v.number()),
sshUsername: v.optional(v.string()),
sshPassword: v.optional(v.string()), // Senha nova (será criptografada)
sshKeyPath: v.optional(v.string()),
dockerComposePath: v.optional(v.string()),
jitsiConfigPath: v.optional(v.string()),
},
returns: v.union(
v.object({ sucesso: v.literal(true), configId: v.id("configuracaoJitsi") }),
@@ -121,12 +74,6 @@ export const salvarConfigJitsi = mutation({
};
}
// Buscar config ativa anterior para manter senha SSH se não fornecida
const configAtiva = await ctx.db
.query("configuracaoJitsi")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.first();
// Desativar config anterior
const configsAntigas = await ctx.db
.query("configuracaoJitsi")
@@ -137,16 +84,6 @@ export const salvarConfigJitsi = mutation({
await ctx.db.patch(config._id, { ativo: false });
}
// Determinar senha SSH: usar nova senha se fornecida, senão manter a atual
let sshPasswordHash: string | undefined = undefined;
if (args.sshPassword && args.sshPassword.trim().length > 0) {
// Nova senha fornecida, criptografar
sshPasswordHash = await encryptSMTPPassword(args.sshPassword);
} else if (configAtiva && configAtiva.sshPasswordHash) {
// Senha não fornecida, manter a atual (já criptografada)
sshPasswordHash = configAtiva.sshPasswordHash;
}
// Criar nova config
const configId = await ctx.db.insert("configuracaoJitsi", {
domain: args.domain.trim(),
@@ -157,14 +94,6 @@ export const salvarConfigJitsi = mutation({
ativo: true,
configuradoPor: args.configuradoPorId,
atualizadoEm: Date.now(),
// Configurações SSH/Docker
sshHost: args.sshHost?.trim() || undefined,
sshPort: args.sshPort || undefined,
sshUsername: args.sshUsername?.trim() || undefined,
sshPasswordHash: sshPasswordHash,
sshKeyPath: args.sshKeyPath?.trim() || undefined,
dockerComposePath: args.dockerComposePath?.trim() || undefined,
jitsiConfigPath: args.jitsiConfigPath?.trim() || undefined,
});
// Log de atividade

View File

@@ -1,7 +1,10 @@
import { v } from "convex/values";
import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server";
import { internal, api } from "./_generated/api";
import { renderizarTemplate } from "./templatesMensagens";
import {
renderizarTemplateEmailFromDoc,
type VariaveisTemplate,
} from "./templatesMensagens";
import type { Doc, Id } from "./_generated/dataModel";
// ========== INTERNAL QUERIES ==========
@@ -211,22 +214,24 @@ export const enviarEmailComTemplate = action({
}
// Renderizar template com variáveis
const variaveisTemplate = args.variaveis || {};
const variaveisTemplate: VariaveisTemplate = args.variaveis ?? {};
// Garantir que urlSistema sempre tenha protocolo se presente
if (variaveisTemplate.urlSistema && !variaveisTemplate.urlSistema.match(/^https?:\/\//i)) {
if (
typeof variaveisTemplate.urlSistema === "string" &&
!variaveisTemplate.urlSistema.match(/^https?:\/\//i)
) {
variaveisTemplate.urlSistema = `http://${variaveisTemplate.urlSistema}`;
}
const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate);
const emailRenderizado = renderizarTemplateEmailFromDoc(template, variaveisTemplate);
// Enfileirar email via mutation
const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: tituloRenderizado,
corpo: corpoRenderizado,
assunto: emailRenderizado.titulo,
corpo: emailRenderizado.html, // HTML completo com wrapper
templateId: template._id, // template._id sempre existe se template não é null
enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara,
@@ -397,18 +402,13 @@ export const buscarEmailsPorIds = query({
export const listarAgendamentosEmail = query({
args: {},
handler: async (ctx) => {
// Buscar todos os emails agendados (pendentes ou enviando)
// Buscar todos os emails agendados (pendentes, enviando ou já enviados que tinham agendamento)
const emailsAgendados = await ctx.db
.query("notificacoesEmail")
.filter((q) => {
const temAgendamento = q.neq(q.field("agendadaPara"), undefined);
const statusValido = q.or(
q.eq(q.field("status"), "pendente"),
q.eq(q.field("status"), "enviando")
);
return q.and(temAgendamento, statusValido);
// Apenas emails que têm agendadaPara definido
return q.neq(q.field("agendadaPara"), undefined);
})
.order("asc")
.collect();
// Enriquecer com informações de destinatário e template

View File

@@ -80,6 +80,18 @@ export const listarTodas = query({
todasFerias.map(async (ferias) => {
const funcionario = await ctx.db.get(ferias.funcionarioId);
// Buscar usuário do funcionário para obter fotoPerfilUrl
let fotoPerfilUrl: string | null = null;
if (funcionario) {
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
if (usuario?.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
}
}
// Buscar time do funcionário
const membroTime = await ctx.db
.query("timesMembros")
@@ -89,15 +101,34 @@ export const listarTodas = query({
.filter((q) => q.eq(q.field("ativo"), true))
.first();
let time = null;
let time: Doc<"times"> | null = null;
let gestor: { _id: Id<"usuarios">; nome: string } | null = null;
if (membroTime) {
time = await ctx.db.get(membroTime.timeId);
// Buscar gestor do time
if (time?.gestorId) {
const gestorUsuario = await ctx.db.get(time.gestorId);
if (gestorUsuario?.funcionarioId) {
// Buscar funcionário do gestor para obter o nome
const gestorFuncionario = await ctx.db.get(gestorUsuario.funcionarioId);
if (gestorFuncionario) {
gestor = {
_id: gestorUsuario._id,
nome: gestorFuncionario.nome,
};
}
}
}
}
return {
...ferias,
funcionario,
funcionario: funcionario ? {
...funcionario,
fotoPerfilUrl,
} : null,
time,
gestor,
};
})
);

View File

@@ -43,6 +43,13 @@ export async function registrarLogin(
motivoFalha?: string;
ipAddress?: string;
userAgent?: string;
latitudeGPS?: number;
longitudeGPS?: number;
precisaoGPS?: number;
enderecoGPS?: string;
cidadeGPS?: string;
estadoGPS?: string;
paisGPS?: string;
}
) {
// Extrair informações do userAgent
@@ -52,6 +59,9 @@ export async function registrarLogin(
// Validar e sanitizar IP antes de salvar
const ipAddressValidado = validarIP(dados.ipAddress);
// Nota: Geolocalização por IP removida porque fetch() não pode ser usado em mutations do Convex
// A localização GPS já é coletada no frontend e enviada diretamente
await ctx.db.insert("logsLogin", {
usuarioId: dados.usuarioId,
@@ -63,6 +73,21 @@ export async function registrarLogin(
device,
browser,
sistema,
// Informações de Localização por IP (removido - usar GPS do frontend)
latitude: undefined,
longitude: undefined,
cidade: undefined,
estado: undefined,
pais: undefined,
endereco: undefined,
// Informações de Localização (GPS do navegador)
latitudeGPS: dados.latitudeGPS,
longitudeGPS: dados.longitudeGPS,
precisaoGPS: dados.precisaoGPS,
enderecoGPS: dados.enderecoGPS,
cidadeGPS: dados.cidadeGPS,
estadoGPS: dados.estadoGPS,
paisGPS: dados.paisGPS,
timestamp: Date.now(),
});
@@ -280,6 +305,46 @@ function extrairSistema(userAgent: string): string {
return "Desconhecido";
}
/**
* Mutation pública para registrar tentativa de login
* Pode ser chamada do frontend após login bem-sucedido ou falho
*/
export const registrarTentativaLogin = mutation({
args: {
usuarioId: v.optional(v.id("usuarios")),
matriculaOuEmail: v.string(),
sucesso: v.boolean(),
motivoFalha: v.optional(v.string()),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
latitudeGPS: v.optional(v.number()),
longitudeGPS: v.optional(v.number()),
precisaoGPS: v.optional(v.number()),
enderecoGPS: v.optional(v.string()),
cidadeGPS: v.optional(v.string()),
estadoGPS: v.optional(v.string()),
paisGPS: v.optional(v.string()),
},
handler: async (ctx, args) => {
await registrarLogin(ctx, {
usuarioId: args.usuarioId,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: args.sucesso,
motivoFalha: args.motivoFalha,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
latitudeGPS: args.latitudeGPS,
longitudeGPS: args.longitudeGPS,
precisaoGPS: args.precisaoGPS,
enderecoGPS: args.enderecoGPS,
cidadeGPS: args.cidadeGPS,
estadoGPS: args.estadoGPS,
paisGPS: args.paisGPS,
});
return { success: true };
},
});
/**
* Lista histórico de logins de um usuário
*/
@@ -313,7 +378,29 @@ export const listarTodosLogins = query({
.order("desc")
.take(args.limite || 50);
return logs;
// Buscar informações dos usuários quando disponível
const logsComUsuarios = await Promise.all(
logs.map(async (log) => {
let usuarioNome: string | undefined = undefined;
let usuarioEmail: string | undefined = undefined;
if (log.usuarioId) {
const usuario = await ctx.db.get(log.usuarioId);
if (usuario) {
usuarioNome = usuario.nome;
usuarioEmail = usuario.email;
}
}
return {
...log,
usuarioNome,
usuarioEmail,
};
})
);
return logsComUsuarios;
},
});

View File

@@ -1,6 +1,6 @@
import { v } from 'convex/values';
import { mutation, query, internalMutation } from './_generated/server';
import { internal } from './_generated/api';
import { internal, api } from './_generated/api';
import { Id } from './_generated/dataModel';
import type { QueryCtx } from './_generated/server';
@@ -363,10 +363,41 @@ export const verificarAlertasInternal = internalMutation({
}
}
// TODO: Enviar email se configurado (integração com sistema de email)
// if (alerta.notifyByEmail) {
// await enviarEmailAlerta(alerta, metricValue);
// }
// Enviar email se configurado (usar template HTML padronizado)
if (alerta.notifyByEmail) {
// Buscar usuários administradores/TI para receber o alerta por email
const rolesAdminOuTi = await ctx.db
.query('roles')
.filter((q) => q.lte(q.field('nivel'), 1))
.collect();
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
const usuarios = await ctx.db.query('usuarios').collect();
const usuariosTI = usuarios.filter((u) => rolesPermitidas.has(u.roleId) && !!u.email);
for (const usuario of usuariosTI) {
const email = usuario.email;
if (!email) continue;
// Montar variáveis para template de alerta de sistema
const variaveisEmail = {
destinatarioNome: usuario.nome,
metricName: alerta.metricName,
metricValue: metricValue.toFixed(2),
threshold: alerta.threshold.toString()
};
// Importante: usar api.email.enviarEmailComTemplate (action pública),
// e não internal.email, para corresponder à tipagem gerada em ./_generated/api.
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: email,
destinatarioId: usuario._id,
templateCodigo: 'monitoramento_alerta_sistema',
variaveis: variaveisEmail,
enviadoPor: usuario._id
});
}
}
}
}

View File

@@ -759,24 +759,6 @@ 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'),
@@ -807,26 +789,6 @@ export default defineSchema({
.index('by_ativo', ['ativo'])
.index('by_data_inicio', ['dataInicio']),
// Perfis Customizados
// 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
@@ -843,30 +805,6 @@ export default defineSchema({
atualizadoEm: v.number()
}).index('by_ativo', ['ativo']),
// Configuração de Jitsi Meet
configuracaoJitsi: defineTable({
domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com")
appId: v.string(), // ID da aplicação Jitsi
roomPrefix: v.string(), // Prefixo para nomes de salas
useHttps: v.boolean(), // Usar HTTPS
acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento)
// Configurações SSH/Docker para configuração automática do servidor
sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local")
sshPort: v.optional(v.number()), // Porta SSH (padrão: 22)
sshUsername: v.optional(v.string()), // Usuário SSH
sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada)
sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha)
dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker")
jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg")
ativo: v.boolean(), // Configuração ativa
testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão
configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker
configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor
configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor
configuradoPor: v.id('usuarios'), // Usuário que configurou
atualizadoEm: v.number() // Timestamp de atualização
}).index('by_ativo', ['ativo']),
// Fila de Emails
notificacoesEmail: defineTable({
destinatario: v.string(), // email
@@ -1791,26 +1729,6 @@ export default defineSchema({
.index('by_registro', ['registroId'])
.index('by_data', ['criadoEm']),
// Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto
dispensasRegistro: defineTable({
funcionarioId: v.id('funcionarios'),
gestorId: v.id('usuarios'),
dataInicio: v.string(), // YYYY-MM-DD
horaInicio: v.number(),
minutoInicio: v.number(),
dataFim: v.string(), // YYYY-MM-DD
horaFim: v.number(),
minutoFim: v.number(),
motivo: v.string(),
isento: v.boolean(), // Se true, não expira (casos excepcionais)
ativo: v.boolean(),
criadoEm: v.number()
})
.index('by_funcionario', ['funcionarioId'])
.index('by_gestor', ['gestorId'])
.index('by_ativo', ['ativo'])
.index('by_data_inicio', ['dataInicio'])
.index('by_data_fim', ['dataFim']),
// Configurações Gerais
config: defineTable({
comprasSetorId: v.optional(v.id('setores')),
@@ -1881,5 +1799,104 @@ export default defineSchema({
})
.index('by_pedidoId', ['pedidoId'])
.index('by_usuarioId', ['usuarioId'])
.index('by_data', ['data'])
.index('by_data', ['data']),
// 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"
// Informações de Rede
ipAddress: v.optional(v.string()),
ipPublico: v.optional(v.string()),
ipLocal: v.optional(v.string()),
userAgent: v.optional(v.string()),
device: v.optional(v.string()),
browser: v.optional(v.string()),
sistema: v.optional(v.string()),
// Informações de Localização (por IP)
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
endereco: v.optional(v.string()),
cidade: v.optional(v.string()),
estado: v.optional(v.string()),
pais: v.optional(v.string()),
// Informações de Localização (GPS do navegador)
latitudeGPS: v.optional(v.number()),
longitudeGPS: v.optional(v.number()),
precisaoGPS: v.optional(v.number()),
enderecoGPS: v.optional(v.string()),
cidadeGPS: v.optional(v.string()),
estadoGPS: v.optional(v.string()),
paisGPS: v.optional(v.string()),
timestamp: v.number()
})
.index('by_usuario', ['usuarioId'])
.index('by_sucesso', ['sucesso'])
.index('by_timestamp', ['timestamp'])
.index('by_ip', ['ipAddress']),
// 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}}
htmlCorpo: v.optional(v.string()), // versão HTML do corpo (com wrapper)
variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.]
categoria: v.optional(v.union(v.literal('email'), v.literal('chat'), v.literal('ambos'))), // categoria do template
tags: v.optional(v.array(v.string())), // tags para organização
criadoPor: v.optional(v.id('usuarios')),
criadoEm: v.number()
})
.index('by_codigo', ['codigo'])
.index('by_tipo', ['tipo'])
.index('by_criado_por', ['criadoPor'])
.index('by_categoria', ['categoria']),
// Configuração de Jitsi Meet
configuracaoJitsi: defineTable({
domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com")
appId: v.string(), // ID da aplicação Jitsi
roomPrefix: v.string(), // Prefixo para nomes de salas
useHttps: v.boolean(), // Usar HTTPS
acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento)
ativo: v.boolean(), // Configuração ativa
testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão
configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker
configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor
configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor
configuradoPor: v.id('usuarios'), // Usuário que configurou
atualizadoEm: v.number(), // Timestamp de atualização
jitsiConfigPath: v.optional(v.string()), // Caminho da configuração do Jitsi no servidor (ex: "~/.jitsi-meet-cfg")
sshUsername: v.optional(v.string()), // Usuário SSH para acesso ao servidor
sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada)
sshPort: v.optional(v.number()) // Porta SSH (padrão: 22)
}).index('by_ativo', ['ativo']),
// Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto
dispensasRegistro: defineTable({
funcionarioId: v.id('funcionarios'),
gestorId: v.id('usuarios'),
dataInicio: v.string(), // YYYY-MM-DD
horaInicio: v.number(),
minutoInicio: v.number(),
dataFim: v.string(), // YYYY-MM-DD
horaFim: v.number(),
minutoFim: v.number(),
motivo: v.string(),
isento: v.boolean(), // Se true, não expira (casos excepcionais)
ativo: v.boolean(),
criadoEm: v.number()
})
.index('by_funcionario', ['funcionarioId'])
.index('by_gestor', ['gestorId'])
.index('by_ativo', ['ativo'])
.index('by_data_inicio', ['dataInicio'])
.index('by_data_fim', ['dataFim'])
});

View File

@@ -2,6 +2,7 @@ import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades";
import { Doc } from "./_generated/dataModel";
import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper";
/**
* Listar todos os templates
@@ -31,6 +32,19 @@ export const obterTemplatePorCodigo = query({
},
});
/**
* Obter template por ID
*/
export const obterTemplatePorId = query({
args: {
templateId: v.id("templatesMensagens"),
},
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
return template;
},
});
/**
* Criar template customizado (apenas TI_MASTER)
*/
@@ -40,7 +54,10 @@ export const criarTemplate = mutation({
nome: v.string(),
titulo: v.string(),
corpo: v.string(),
htmlCorpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))),
tags: v.optional(v.array(v.string())),
criadoPorId: v.id("usuarios"),
},
returns: v.union(
@@ -58,6 +75,18 @@ export const criarTemplate = mutation({
return { sucesso: false as const, erro: "Código de template já existe" };
}
// Gerar HTML se não fornecido
let htmlCorpo = args.htmlCorpo;
if (!htmlCorpo) {
// Se o corpo já for HTML, usar diretamente, senão converter
if (args.corpo.includes("<") && args.corpo.includes(">")) {
htmlCorpo = wrapEmailHTML(args.corpo, args.titulo);
} else {
const corpoHTML = textToHTML(args.corpo);
htmlCorpo = wrapEmailHTML(corpoHTML, args.titulo);
}
}
// Criar template
const templateId = await ctx.db.insert("templatesMensagens", {
codigo: args.codigo,
@@ -65,7 +94,10 @@ export const criarTemplate = mutation({
tipo: "customizado",
titulo: args.titulo,
corpo: args.corpo,
htmlCorpo,
variaveis: args.variaveis,
categoria: args.categoria || "email",
tags: args.tags,
criadoPor: args.criadoPorId,
criadoEm: Date.now(),
});
@@ -93,7 +125,10 @@ export const editarTemplate = mutation({
nome: v.optional(v.string()),
titulo: v.optional(v.string()),
corpo: v.optional(v.string()),
htmlCorpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))),
tags: v.optional(v.array(v.string())),
editadoPorId: v.id("usuarios"),
},
returns: v.union(
@@ -116,7 +151,21 @@ export const editarTemplate = mutation({
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.htmlCorpo !== undefined) {
updates.htmlCorpo = args.htmlCorpo;
} else if (args.corpo !== undefined) {
// Se corpo foi atualizado mas htmlCorpo não, regenerar HTML
const titulo = args.titulo || template.titulo;
if (args.corpo.includes("<") && args.corpo.includes(">")) {
updates.htmlCorpo = wrapEmailHTML(args.corpo, titulo);
} else {
const corpoHTML = textToHTML(args.corpo);
updates.htmlCorpo = wrapEmailHTML(corpoHTML, titulo);
}
}
if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
if (args.categoria !== undefined) updates.categoria = args.categoria;
if (args.tags !== undefined) updates.tags = args.tags;
await ctx.db.patch(args.templateId, updates);
@@ -188,6 +237,71 @@ export function renderizarTemplate(template: string, variaveis: Record<string, s
return resultado;
}
export type VariaveisTemplate = Record<string, string>;
export interface EmailRenderizado {
titulo: string;
html: string;
}
/**
* Renderizar template para EMAIL (HTML padronizado)
* - Usa `htmlCorpo` se existir, senão gera HTML a partir de `corpo` (texto ou HTML simples)
* - Sempre aplica o wrapper visual de email
*/
export function renderizarTemplateEmailFromDoc(
template: Doc<"templatesMensagens">,
variaveis: VariaveisTemplate,
): EmailRenderizado {
const variaveisTemplate: VariaveisTemplate = { ...variaveis };
const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
// Base para o corpo: se existir htmlCorpo usamos ele, senão usamos corpo
const baseCorpo = template.htmlCorpo ?? template.corpo ?? "";
const corpoRenderizado = renderizarTemplate(baseCorpo, variaveisTemplate);
let htmlFinal: string;
if (template.htmlCorpo) {
// htmlCorpo já é HTML completo de email (com ou sem wrapper) apenas aplica variáveis
htmlFinal = corpoRenderizado.includes("<html")
? corpoRenderizado
: wrapEmailHTML(corpoRenderizado, tituloRenderizado);
} else {
// corpo pode ser texto puro ou HTML simples sempre gera HTML padronizado
if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) {
htmlFinal = wrapEmailHTML(corpoRenderizado, tituloRenderizado);
} else {
const corpoHTML = textToHTML(corpoRenderizado);
htmlFinal = wrapEmailHTML(corpoHTML, tituloRenderizado);
}
}
return {
titulo: tituloRenderizado,
html: htmlFinal,
};
}
/**
* Renderizar template para CHAT (texto puro)
* - Usa sempre `corpo` como fonte
* - Remove quaisquer tags HTML residuais
*/
export function renderizarTemplateChatFromDoc(
template: Doc<"templatesMensagens">,
variaveis: VariaveisTemplate,
): string {
const corpoBase = template.corpo ?? "";
const textoComVariaveis = renderizarTemplate(corpoBase, variaveis);
// Garantir texto puro para o chat (sem tags HTML)
const textoPuro = textoComVariaveis.replace(/<[^>]*>/g, "");
return textoPuro;
}
/**
* Criar templates padrão do sistema (chamado no seed)
*/
@@ -396,6 +510,47 @@ export const criarTemplatesPadrao = mutation({
+ "</div></body></html>",
variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"],
},
{
codigo: "monitoramento_alerta_sistema",
nome: "Alerta de Sistema (Monitoramento)",
titulo: "⚠️ Alerta de Sistema: {{metricName}}",
corpo:
"Olá {{destinatarioNome}},\n\n" +
"A métrica {{metricName}} atingiu o valor {{metricValue}} (limite configurado: {{threshold}}).\n\n" +
"Recomenda-se verificar o painel de monitoramento do SGSE para detalhes adicionais e, se necessário, " +
"executar ações corretivas.\n\n" +
"Esta é uma notificação automática do sistema de monitoramento SGSE.",
variaveis: ["destinatarioNome", "metricName", "metricValue", "threshold"],
categoria: "email" as const,
tags: ["monitoramento", "alerta", "sistema", "ti"],
},
{
codigo: "ausencia_solicitada",
nome: "Ausência Solicitada",
titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}",
corpo: "Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.",
variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo", "urlSistema"],
categoria: "email" as const,
tags: ["ausencia", "solicitacao", "gestao"],
},
{
codigo: "ausencia_aprovada",
nome: "Ausência Aprovada",
titulo: "Solicitação de Ausência Aprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "urlSistema"],
categoria: "email" as const,
tags: ["ausencia", "aprovacao", "gestao"],
},
{
codigo: "ausencia_reprovada",
nome: "Ausência Reprovada",
titulo: "Solicitação de Ausência Reprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao", "urlSistema"],
categoria: "email" as const,
tags: ["ausencia", "reprovacao", "gestao"],
},
];
for (const template of templatesPadrao) {
@@ -418,4 +573,321 @@ export const criarTemplatesPadrao = mutation({
},
});
/**
* Atualizar HTML de um template
*/
export const atualizarTemplateHTML = mutation({
args: {
templateId: v.id("templatesMensagens"),
htmlCorpo: 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" };
}
await ctx.db.patch(args.templateId, {
htmlCorpo: args.htmlCorpo,
});
await registrarAtividade(
ctx,
args.editadoPorId,
"editar",
"templates",
JSON.stringify({ templateId: args.templateId, campo: "htmlCorpo" }),
args.templateId
);
return { sucesso: true as const };
},
});
/**
* Preview de template renderizado com variáveis de teste
*/
export const previewTemplate = query({
args: {
templateId: v.id("templatesMensagens"),
variaveisTeste: v.optional(v.record(v.string(), v.string())),
},
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return null;
}
// Variáveis padrão para teste
const variaveisPadrao: Record<string, string> = {
nome: "João Silva",
matricula: "12345",
senha: "Senha123!",
motivo: "Exemplo de motivo",
remetente: "Maria Santos",
mensagem: "Esta é uma mensagem de exemplo para preview do template.",
conversaId: "abc123",
urlSistema: getBaseUrl(),
solicitante: "João Silva",
numeroTicket: "TKT-2024-001",
prioridade: "Alta",
categoria: "Suporte Técnico",
responsavel: "Maria Santos",
descricao: "Exemplo de descrição de chamado",
destinario: "João Silva",
tipoPrazo: "resolução",
prazo: "24 horas",
status: "Em andamento",
rotaAcesso: "/ti/central-chamados",
titulo: "Título de Exemplo",
};
const variaveis = { ...variaveisPadrao, ...(args.variaveisTeste || {}) };
// Renderizar título e corpo
const tituloRenderizado = renderizarTemplate(template.titulo, variaveis);
const corpoRenderizado = renderizarTemplate(template.corpo, variaveis);
// Se tiver htmlCorpo, usar ele, senão gerar do corpo
let htmlFinal = template.htmlCorpo;
if (!htmlFinal) {
if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) {
htmlFinal = wrapEmailHTML(corpoRenderizado, tituloRenderizado);
} else {
const corpoHTML = textToHTML(corpoRenderizado);
htmlFinal = wrapEmailHTML(corpoHTML, tituloRenderizado);
}
} else {
htmlFinal = renderizarTemplate(htmlFinal, variaveis);
}
return {
titulo: tituloRenderizado,
corpo: corpoRenderizado,
html: htmlFinal,
variaveisUsadas: template.variaveis || [],
};
},
});
/**
* Função auxiliar para obter URL base
*/
function getBaseUrl(): string {
const url = process.env.FRONTEND_URL || "http://localhost:5173";
if (!url.match(/^https?:\/\//i)) {
return `http://${url}`;
}
return url;
}
/**
* Exportar templates (JSON)
*/
export const exportarTemplates = query({
args: {
templateIds: v.optional(v.array(v.id("templatesMensagens"))),
},
handler: async (ctx, args) => {
let templates;
if (args.templateIds && args.templateIds.length > 0) {
templates = await Promise.all(
args.templateIds.map((id) => ctx.db.get(id))
);
templates = templates.filter((t): t is Doc<"templatesMensagens"> => t !== null);
} else {
templates = await ctx.db.query("templatesMensagens").collect();
}
// Remover campos internos e retornar apenas dados exportáveis
return templates.map((t) => ({
codigo: t.codigo,
nome: t.nome,
tipo: t.tipo,
titulo: t.titulo,
corpo: t.corpo,
htmlCorpo: t.htmlCorpo,
variaveis: t.variaveis,
categoria: t.categoria,
tags: t.tags,
}));
},
});
/**
* Importar templates (JSON)
*/
export const importarTemplates = mutation({
args: {
templates: v.array(
v.object({
codigo: v.string(),
nome: v.string(),
tipo: v.optional(v.union(v.literal("sistema"), v.literal("customizado"))),
titulo: v.string(),
corpo: v.string(),
htmlCorpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))),
tags: v.optional(v.array(v.string())),
})
),
importadoPorId: v.id("usuarios"),
sobrescrever: v.optional(v.boolean()),
},
returns: v.object({
sucesso: v.boolean(),
importados: v.number(),
atualizados: v.number(),
erros: v.array(v.string()),
}),
handler: async (ctx, args) => {
let importados = 0;
let atualizados = 0;
const erros: string[] = [];
for (const templateData of args.templates) {
try {
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", templateData.codigo))
.first();
if (existente) {
if (args.sobrescrever && existente.tipo === "customizado") {
// Atualizar template existente
await ctx.db.patch(existente._id, {
nome: templateData.nome,
titulo: templateData.titulo,
corpo: templateData.corpo,
htmlCorpo: templateData.htmlCorpo,
variaveis: templateData.variaveis,
categoria: templateData.categoria,
tags: templateData.tags,
});
atualizados++;
} else {
erros.push(`Template ${templateData.codigo} já existe e sobrescrever está desabilitado`);
}
} else {
// Criar novo template
const tipo = templateData.tipo || "customizado";
// Gerar HTML se não fornecido
let htmlCorpo = templateData.htmlCorpo;
if (!htmlCorpo) {
if (templateData.corpo.includes("<") && templateData.corpo.includes(">")) {
htmlCorpo = wrapEmailHTML(templateData.corpo, templateData.titulo);
} else {
const corpoHTML = textToHTML(templateData.corpo);
htmlCorpo = wrapEmailHTML(corpoHTML, templateData.titulo);
}
}
await ctx.db.insert("templatesMensagens", {
codigo: templateData.codigo,
nome: templateData.nome,
tipo,
titulo: templateData.titulo,
corpo: templateData.corpo,
htmlCorpo,
variaveis: templateData.variaveis,
categoria: templateData.categoria || "email",
tags: templateData.tags,
criadoPor: args.importadoPorId,
criadoEm: Date.now(),
});
importados++;
}
} catch (error) {
const erroMsg = error instanceof Error ? error.message : String(error);
erros.push(`Erro ao importar ${templateData.codigo}: ${erroMsg}`);
}
}
await registrarAtividade(
ctx,
args.importadoPorId,
"importar",
"templates",
JSON.stringify({ importados, atualizados, erros: erros.length }),
undefined
);
return {
sucesso: erros.length === 0,
importados,
atualizados,
erros,
};
},
});
/**
* Duplicar template
*/
export const duplicarTemplate = mutation({
args: {
templateId: v.id("templatesMensagens"),
novoCodigo: v.string(),
novoNome: v.optional(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) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Verificar se novo código já existe
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.novoCodigo))
.first();
if (existente) {
return { sucesso: false as const, erro: "Código de template já existe" };
}
const templateId = await ctx.db.insert("templatesMensagens", {
codigo: args.novoCodigo,
nome: args.novoNome || `${template.nome} (Cópia)`,
tipo: "customizado",
titulo: template.titulo,
corpo: template.corpo,
htmlCorpo: template.htmlCorpo,
variaveis: template.variaveis,
categoria: template.categoria,
tags: template.tags,
criadoPor: args.criadoPorId,
criadoEm: Date.now(),
});
await registrarAtividade(
ctx,
args.criadoPorId,
"duplicar",
"templates",
JSON.stringify({ templateId, codigo: args.novoCodigo, originalId: args.templateId }),
templateId
);
return { sucesso: true as const, templateId };
},
});

View File

@@ -0,0 +1,46 @@
/**
* Wrapper para padronizar mensagens de chat do SGSE
*/
/**
* Formata mensagem de chat com prefixo padronizado quando necessário
* @param conteudo - Conteúdo da mensagem
* @param tipo - Tipo da mensagem (opcional)
* @returns Mensagem formatada
*/
export function wrapChatMessage(conteudo: string, tipo?: string): string {
// Se já tiver formatação especial, retornar como está
if (conteudo.includes('[SGSE]') || conteudo.includes('[Sistema]')) {
return conteudo;
}
// Para mensagens do sistema, adicionar prefixo
if (tipo === 'sistema' || tipo === 'notificacao') {
return `[SGSE] ${conteudo}`;
}
return conteudo;
}
/**
* Formata mensagem de chat com informações estruturadas
* @param titulo - Título da notificação
* @param conteudo - Conteúdo da mensagem
* @param acao - Ação sugerida (opcional)
* @returns Mensagem formatada
*/
export function formatChatNotification(
titulo: string,
conteudo: string,
acao?: string
): string {
let mensagem = `🔔 ${titulo}\n\n${conteudo}`;
if (acao) {
mensagem += `\n\n💡 ${acao}`;
}
return mensagem;
}

View File

@@ -0,0 +1,185 @@
/**
* Wrapper HTML para templates de email do SGSE
* Aplica estilo governamental profissional com logo e assinatura padronizada
*/
/**
* Obtém a URL base do sistema para uso em links de email
*/
function getBaseUrl(): string {
// Em produção, usar variável de ambiente
const url = process.env.FRONTEND_URL || "http://localhost:5173";
// Garantir que tenha protocolo
if (!url.match(/^https?:\/\//i)) {
return `http://${url}`;
}
return url;
}
/**
* Gera o HTML do header com logo do Governo de PE
*/
function generateHeader(): string {
const baseUrl = getBaseUrl();
return `
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #1a3a52; padding: 20px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="text-align: center; padding: 20px 0;">
<img src="${baseUrl}/logo_governo_PE.png" alt="Governo de Pernambuco" style="max-width: 200px; height: auto;" />
</td>
</tr>
</table>
</td>
</tr>
</table>
`;
}
/**
* Gera o HTML do footer com assinatura SGSE
*/
function generateFooter(): string {
const baseUrl = getBaseUrl();
const currentYear = new Date().getFullYear();
return `
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f5f5; border-top: 3px solid #1a3a52; margin-top: 30px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="padding: 30px 20px;">
<tr>
<td style="text-align: center; font-family: Arial, sans-serif; color: #333333; font-size: 14px; line-height: 1.6;">
<p style="margin: 0 0 10px 0; font-weight: bold; color: #1a3a52; font-size: 16px;">
SGSE - Sistema de Gerenciamento de Secretaria
</p>
<p style="margin: 0 0 10px 0; color: #666666;">
Secretaria de Esportes do Estado de Pernambuco
</p>
<p style="margin: 0 0 15px 0; color: #666666; font-size: 12px;">
Este é um email automático do sistema. Por favor, não responda diretamente a este email.
</p>
<hr style="border: none; border-top: 1px solid #dddddd; margin: 20px 0;" />
<p style="margin: 0; color: #999999; font-size: 11px;">
© ${currentYear} Secretaria de Esportes - Governo de Pernambuco. Todos os direitos reservados.
</p>
<p style="margin: 5px 0 0 0; color: #999999; font-size: 11px;">
<a href="${baseUrl}" style="color: #1a3a52; text-decoration: none;">Acessar Sistema</a> |
<a href="${baseUrl}/ti/notificacoes" style="color: #1a3a52; text-decoration: none;">Central de Notificações</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
`;
}
/**
* Envolve o conteúdo HTML do email com template profissional governamental
* @param conteudoHTML - Conteúdo HTML do corpo do email
* @param titulo - Título do email (usado no meta)
* @returns HTML completo do email pronto para envio
*/
export function wrapEmailHTML(conteudoHTML: string, titulo?: string): string {
// Se o conteúdo já estiver dentro de um wrapper completo, retornar como está
if (conteudoHTML.includes('<!DOCTYPE html>') || conteudoHTML.includes('<html')) {
return conteudoHTML;
}
// Garantir que o conteúdo tenha estrutura básica
let conteudoProcessado = conteudoHTML.trim();
// Se não tiver tags HTML básicas, envolver em parágrafo
if (!conteudoProcessado.match(/^<[a-z]/i)) {
conteudoProcessado = `<p style="margin: 0 0 15px 0;">${conteudoProcessado}</p>`;
}
const header = generateHeader();
const footer = generateFooter();
const emailTitle = titulo || "Notificação do SGSE";
return `
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>${emailTitle}</title>
<!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;">
<!-- Wrapper principal -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f5f5; padding: 20px 0;">
<tr>
<td align="center">
<!-- Container do conteúdo -->
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: hidden;">
<!-- Header -->
${header}
<!-- Corpo do email -->
<tr>
<td style="padding: 30px 20px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-family: Arial, sans-serif; color: #333333; font-size: 14px; line-height: 1.6;">
${conteudoProcessado}
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td>
${footer}
</td>
</tr>
</table>
<!-- Espaçamento inferior -->
<table width="600" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 20px 0; text-align: center; font-family: Arial, sans-serif; color: #999999; font-size: 11px;">
<p style="margin: 0;">Se você não solicitou este email, pode ignorá-lo com segurança.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
/**
* Converte texto plano em HTML básico
* @param texto - Texto plano
* @returns HTML formatado
*/
export function textToHTML(texto: string): string {
return texto
.split('\n')
.map(linha => {
const linhaTrim = linha.trim();
if (!linhaTrim) return '<br />';
// Detectar links
const linkRegex = /(https?:\/\/[^\s]+)/g;
const linhaComLinks = linhaTrim.replace(linkRegex, '<a href="$1" style="color: #1a3a52; text-decoration: underline;">$1</a>');
return `<p style="margin: 0 0 15px 0;">${linhaComLinks}</p>`;
})
.join('');
}

View File

@@ -0,0 +1,189 @@
/**
* Scanner automático de envios de email e mensagens no código
* Identifica todos os locais onde emails são enviados para gerar templates
*/
import { Doc } from "../_generated/dataModel";
export interface EmailSendLocation {
arquivo: string;
funcao: string;
tipo: "enfileirarEmail" | "enviarEmailComTemplate" | "enviarMensagem" | "html_inline";
linha?: number;
contexto?: string;
assunto?: string;
corpo?: string;
templateCodigo?: string;
variaveis?: string[];
}
/**
* Lista de locais conhecidos onde emails são enviados
* Este é um mapeamento manual baseado na análise do código
*/
export const LOCAIS_ENVIO_EMAIL: EmailSendLocation[] = [
// Chamados
{
arquivo: "packages/backend/convex/chamados.ts",
funcao: "registrarNotificacoes",
tipo: "enfileirarEmail",
contexto: "Notificação ao solicitante quando chamado é criado/atualizado",
assunto: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
variaveis: ["numeroTicket", "titulo", "mensagem"],
},
{
arquivo: "packages/backend/convex/chamados.ts",
funcao: "registrarNotificacoes",
tipo: "enfileirarEmail",
contexto: "Notificação ao responsável quando chamado é atualizado",
assunto: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
variaveis: ["numeroTicket", "titulo", "mensagem"],
},
// Ausências
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "solicitar",
tipo: "enfileirarEmail",
contexto: "Notificação ao gestor quando funcionário solicita ausência",
assunto: "Nova Solicitação de Ausência - {{funcionarioNome}}",
corpo: "Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.",
variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"],
},
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "aprovar",
tipo: "enfileirarEmail",
contexto: "Notificação ao funcionário quando ausência é aprovada",
assunto: "Solicitação de Ausência Aprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"],
},
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "reprovar",
tipo: "enfileirarEmail",
contexto: "Notificação ao funcionário quando ausência é reprovada",
assunto: "Solicitação de Ausência Reprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"],
},
// Chat
{
arquivo: "packages/backend/convex/chat.ts",
funcao: "enviarMensagem",
tipo: "enviarEmailComTemplate",
contexto: "Email quando usuário recebe nova mensagem no chat (usuário offline)",
templateCodigo: "chat_mensagem",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
{
arquivo: "packages/backend/convex/chat.ts",
funcao: "enviarMensagem",
tipo: "enviarEmailComTemplate",
contexto: "Email quando usuário é mencionado no chat (usuário offline)",
templateCodigo: "chat_mencao",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
// Painel de Notificações
{
arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte",
funcao: "enviarNotificacao",
tipo: "enfileirarEmail",
contexto: "Envio manual de notificação via painel de TI",
assunto: "Notificação do Sistema",
corpo: "{{mensagemPersonalizada}}",
variaveis: ["mensagemPersonalizada"],
},
{
arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte",
funcao: "enviarNotificacao",
tipo: "enviarEmailComTemplate",
contexto: "Envio manual de notificação usando template via painel de TI",
templateCodigo: "{{templateCodigo}}",
variaveis: ["nome", "matricula"],
},
];
/**
* Sugestões de templates baseadas nos locais de envio encontrados
*/
export interface TemplateSuggestion {
codigo: string;
nome: string;
titulo: string;
corpo: string;
categoria: "email" | "chat" | "ambos";
variaveis: string[];
tags: string[];
origem: string;
}
/**
* Gerar sugestões de templates baseadas nos locais de envio
*/
export function gerarSugestoesTemplates(): TemplateSuggestion[] {
const sugestoes: TemplateSuggestion[] = [];
// Template para ausência solicitada
sugestoes.push({
codigo: "ausencia_solicitada",
nome: "Ausência Solicitada",
titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}",
corpo: "Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.",
categoria: "email",
variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"],
tags: ["ausencia", "solicitacao", "gestao"],
origem: "ausencias.ts - solicitar",
});
// Template para ausência aprovada
sugestoes.push({
codigo: "ausencia_aprovada",
nome: "Ausência Aprovada",
titulo: "Solicitação de Ausência Aprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>",
categoria: "email",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"],
tags: ["ausencia", "aprovacao", "gestao"],
origem: "ausencias.ts - aprovar",
});
// Template para ausência reprovada
sugestoes.push({
codigo: "ausencia_reprovada",
nome: "Ausência Reprovada",
titulo: "Solicitação de Ausência Reprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>",
categoria: "email",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"],
tags: ["ausencia", "reprovacao", "gestao"],
origem: "ausencias.ts - reprovar",
});
// Template genérico para notificações de chamados
sugestoes.push({
codigo: "chamado_notificacao",
nome: "Notificação de Chamado",
titulo: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
categoria: "email",
variaveis: ["numeroTicket", "titulo", "mensagem"],
tags: ["chamado", "notificacao", "suporte"],
origem: "chamados.ts - registrarNotificacoes",
});
return sugestoes;
}
/**
* Obter todos os locais de envio de email
*/
export function obterLocaisEnvio(): EmailSendLocation[] {
return LOCAIS_ENVIO_EMAIL;
}