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

This commit is contained in:
2025-12-11 10:08:12 -03:00
194 changed files with 30374 additions and 10247 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
* @module
*/
import { anyApi, componentsGeneric } from 'convex/server';
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.

View File

@@ -9,13 +9,13 @@
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames
} from 'convex/server';
import type { GenericId } from 'convex/values';
import schema from '../schema.js';
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
@@ -27,7 +27,10 @@ export type TableNames = TableNamesInDataModel<DataModel>;
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<DataModel, TableName>;
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
@@ -42,7 +45,8 @@ export type Doc<TableName extends TableNames> = DocumentByName<DataModel, TableN
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> = GenericId<TableName>;
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.

View File

@@ -9,17 +9,17 @@
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter
} from 'convex/server';
import type { DataModel } from './dataModel.js';
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
@@ -29,7 +29,7 @@ import type { DataModel } from './dataModel.js';
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, 'public'>;
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
@@ -39,7 +39,7 @@ export declare const query: QueryBuilder<DataModel, 'public'>;
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
@@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, 'public'>;
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
@@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder<DataModel, 'public'>;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
@@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, 'public'>;
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
@@ -80,7 +80,7 @@ export declare const action: ActionBuilder<DataModel, 'public'>;
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, 'internal'>;
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.

View File

@@ -9,14 +9,14 @@
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric
} from 'convex/server';
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.

View File

@@ -1,10 +1,10 @@
'use node';
import { v } from 'convex/values';
import nodemailer from 'nodemailer';
import { internal } from '../_generated/api';
import { action } from '../_generated/server';
import { v } from 'convex/values';
import { internal } from '../_generated/api';
import { decryptSMTPPasswordNode } from './utils/nodeCrypto';
import nodemailer from 'nodemailer';
export const enviar = action({
args: {

View File

@@ -1,8 +1,8 @@
'use node';
import { action } from '../_generated/server';
import { v } from 'convex/values';
import { internal } from '../_generated/api';
import { action } from '../_generated/server';
/**
* Extrair preview de link (metadados Open Graph) - função auxiliar

View File

@@ -1,8 +1,8 @@
'use node';
import { action } from '../_generated/server';
import { v } from 'convex/values';
import { internal } from '../_generated/api';
import { action } from '../_generated/server';
/**
* Enviar push notification usando Web Push API
@@ -29,10 +29,7 @@ export const enviarPush = action({
});
if (!subscription || !subscription.ativo) {
return {
sucesso: false,
erro: 'Subscription não encontrada ou inativa'
};
return { sucesso: false, erro: 'Subscription não encontrada ou inativa' };
}
// Web Push requer VAPID keys (deve estar em variáveis de ambiente)
@@ -45,10 +42,7 @@ export const enviarPush = action({
interface WebPushType {
setVapidDetails: (subject: string, publicKey: string, privateKey: string) => void;
sendNotification: (
subscription: {
endpoint: string;
keys: { p256dh: string; auth: string };
},
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string | Buffer
) => Promise<void>;
}

View File

@@ -1,9 +1,10 @@
'use node';
import { v } from 'convex/values';
// Importar nodemailer de forma estática para evitar problemas com caminhos no Windows
import nodemailer from 'nodemailer';
import { action } from '../_generated/server';
import { v } from 'convex/values';
// Importar nodemailer de forma estática para evitar problemas com caminhos no Windows
import nodemailer from 'nodemailer';
export const testarConexao = action({
args: {

View File

@@ -3,6 +3,7 @@ import type { Id } from './_generated/dataModel';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { mutation, query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
import { internal } from './_generated/api';
import { registrarAtividade } from './logsAtividades';
// ========== HELPERS ==========
@@ -26,6 +27,118 @@ function calcularDias(dataInicio: string, dataFim: string): number {
return diffDays;
}
/**
* Helper para recalcular banco de horas em um período
*/
async function recalcularBancoHorasPeriodo(
ctx: MutationCtx,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string
): Promise<void> {
// Gerar todas as datas do período
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(dataFim);
const datas: string[] = [];
const dataAtual = new Date(dataInicioObj);
while (dataAtual <= dataFimObj) {
const ano = dataAtual.getFullYear();
const mes = String(dataAtual.getMonth() + 1).padStart(2, '0');
const dia = String(dataAtual.getDate()).padStart(2, '0');
datas.push(`${ano}-${mes}-${dia}`);
dataAtual.setDate(dataAtual.getDate() + 1);
}
// Recalcular para cada data usando a mutation interna (agendar para execução assíncrona)
for (let i = 0; i < datas.length; i++) {
await ctx.scheduler.runAfter(i * 100, internal.pontos.recalcularBancoHorasData, {
funcionarioId,
data: datas[i]!
});
}
}
/**
* Helper para verificar se um funcionário tem licença ou atestado ativo
* Retorna true se há algum registro ativo (data atual entre dataInicio e dataFim)
*/
export async function verificarLicencaAtiva(
ctx: QueryCtx | MutationCtx,
funcionarioId: Id<'funcionarios'>,
dataAtual?: Date
): Promise<boolean> {
// Normalizar data atual para comparar apenas a parte da data (sem hora)
// Usar timezone local para evitar problemas de conversão
const hoje = dataAtual || new Date();
const hojeStr = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}-${String(hoje.getDate()).padStart(2, '0')}`; // Formato: "YYYY-MM-DD"
console.log(
`[verificarLicencaAtiva] Verificando funcionário ${funcionarioId}, data atual: ${hojeStr}`
);
// Buscar atestados e licenças do funcionário
const [atestados, licencas] = await Promise.all([
ctx.db
.query('atestados')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.collect(),
ctx.db
.query('licencas')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.collect()
]);
console.log(
`[verificarLicencaAtiva] Encontrados ${atestados.length} atestados e ${licencas.length} licenças`
);
// Verificar se há algum atestado ativo
for (const atestado of atestados) {
// Normalizar datas para formato "YYYY-MM-DD" (pode vir como "YYYY-MM-DD" ou "YYYY-MM-DDTHH:mm:ss")
const inicioStr = atestado.dataInicio.includes('T')
? atestado.dataInicio.split('T')[0]
: atestado.dataInicio.substring(0, 10);
const fimStr = atestado.dataFim.includes('T')
? atestado.dataFim.split('T')[0]
: atestado.dataFim.substring(0, 10);
console.log(
`[verificarLicencaAtiva] Atestado: ${inicioStr} a ${fimStr}, hoje: ${hojeStr}, ativo: ${hojeStr >= inicioStr && hojeStr <= fimStr}`
);
// Comparar strings de data diretamente (formato ISO permite comparação lexicográfica)
if (hojeStr >= inicioStr && hojeStr <= fimStr) {
console.log(`[verificarLicencaAtiva] ✅ Atestado ativo encontrado!`);
return true;
}
}
// Verificar se há alguma licença ativa
for (const licenca of licencas) {
// Normalizar datas para formato "YYYY-MM-DD" (pode vir como "YYYY-MM-DD" ou "YYYY-MM-DDTHH:mm:ss")
const inicioStr = licenca.dataInicio.includes('T')
? licenca.dataInicio.split('T')[0]
: licenca.dataInicio.substring(0, 10);
const fimStr = licenca.dataFim.includes('T')
? licenca.dataFim.split('T')[0]
: licenca.dataFim.substring(0, 10);
console.log(
`[verificarLicencaAtiva] Licença: ${inicioStr} a ${fimStr}, hoje: ${hojeStr}, ativa: ${hojeStr >= inicioStr && hojeStr <= fimStr}`
);
// Comparar strings de data diretamente (formato ISO permite comparação lexicográfica)
if (hojeStr >= inicioStr && hojeStr <= fimStr) {
console.log(`[verificarLicencaAtiva] ✅ Licença ativa encontrada!`);
return true;
}
}
console.log(`[verificarLicencaAtiva] ❌ Nenhuma licença/atestado ativo encontrado`);
return false;
}
// ========== QUERIES ==========
/**
@@ -45,9 +158,23 @@ export const listarTodos = query({
try {
const funcionario = await ctx.db.get(a.funcionarioId);
const criadoPor = await ctx.db.get(a.criadoPor);
// Buscar foto do perfil do funcionário através do usuário associado
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);
}
}
return {
...a,
funcionario,
fotoPerfilUrl,
criadoPorNome: criadoPor?.nome || 'Sistema',
dias: calcularDias(a.dataInicio, a.dataFim),
status: new Date(a.dataFim) >= new Date() ? 'ativo' : 'finalizado'
@@ -57,6 +184,7 @@ export const listarTodos = query({
return {
...a,
funcionario: null,
fotoPerfilUrl: null,
criadoPorNome: 'Sistema',
dias: calcularDias(a.dataInicio, a.dataFim),
status: new Date(a.dataFim) >= new Date() ? 'ativo' : 'finalizado'
@@ -73,9 +201,23 @@ export const listarTodos = query({
const licencaOriginal = l.licencaOriginalId
? await ctx.db.get(l.licencaOriginalId)
: null;
// Buscar foto do perfil do funcionário através do usuário associado
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);
}
}
return {
...l,
funcionario,
fotoPerfilUrl,
criadoPorNome: criadoPor?.nome || 'Sistema',
licencaOriginal,
dias: calcularDias(l.dataInicio, l.dataFim),
@@ -86,6 +228,7 @@ export const listarTodos = query({
return {
...l,
funcionario: null,
fotoPerfilUrl: null,
criadoPorNome: 'Sistema',
licencaOriginal: null,
dias: calcularDias(l.dataInicio, l.dataFim),
@@ -175,6 +318,19 @@ export const listarPorPeriodo = query({
}
});
/**
* Verificar se o funcionário atual tem licença/atestado ativo
*/
export const verificarStatusLicenca = query({
args: {
funcionarioId: v.id('funcionarios')
},
returns: v.boolean(),
handler: async (ctx, args) => {
return await verificarLicencaAtiva(ctx, args.funcionarioId);
}
});
/**
* Obter dados para gráficos
*/
@@ -747,6 +903,18 @@ export const criarAtestadoMedico = mutation({
atestadoId
);
// Recalcular banco de horas para todas as datas do período do atestado
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
// Atualizar status do funcionário imediatamente
console.log(
`[criarAtestadoMedico] Atualizando status do funcionário ${args.funcionarioId} após criar atestado`
);
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: args.funcionarioId
});
console.log(`[criarAtestadoMedico] Status atualizado com sucesso`);
return atestadoId;
}
});
@@ -792,6 +960,18 @@ export const criarDeclaracaoComparecimento = mutation({
atestadoId
);
// Recalcular banco de horas para todas as datas do período da declaração
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
// Atualizar status do funcionário imediatamente
console.log(
`[criarDeclaracaoComparecimento] Atualizando status do funcionário ${args.funcionarioId} após criar declaração`
);
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: args.funcionarioId
});
console.log(`[criarDeclaracaoComparecimento] Status atualizado com sucesso`);
return atestadoId;
}
});
@@ -845,6 +1025,18 @@ export const criarLicencaMaternidade = mutation({
licencaId
);
// Recalcular banco de horas para todas as datas do período da licença
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
// Atualizar status do funcionário imediatamente
console.log(
`[criarLicencaMaternidade] Atualizando status do funcionário ${args.funcionarioId} após criar licença maternidade`
);
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: args.funcionarioId
});
console.log(`[criarLicencaMaternidade] Status atualizado com sucesso`);
return licencaId;
}
});
@@ -891,6 +1083,18 @@ export const criarLicencaPaternidade = mutation({
licencaId
);
// Recalcular banco de horas para todas as datas do período da licença
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
// Atualizar status do funcionário imediatamente
console.log(
`[criarLicencaPaternidade] Atualizando status do funcionário ${args.funcionarioId} após criar licença paternidade`
);
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: args.funcionarioId
});
console.log(`[criarLicencaPaternidade] Status atualizado com sucesso`);
return licencaId;
}
});
@@ -947,6 +1151,11 @@ export const prorrogarLicencaMaternidade = mutation({
prorrogacaoId
);
// Atualizar status do funcionário imediatamente
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: licencaOriginal.funcionarioId
});
return prorrogacaoId;
}
});
@@ -966,6 +1175,13 @@ export const excluirAtestado = mutation({
const atestado = await ctx.db.get(args.id);
if (!atestado) throw new Error('Atestado não encontrado');
// IMPORTANTE: Salvar o período exato do atestado ANTES de excluir
// para recalcular o banco de horas apenas para esse período específico
const funcionarioId = atestado.funcionarioId;
const dataInicio = atestado.dataInicio; // Data início do atestado
const dataFim = atestado.dataFim; // Data fim do atestado
// Excluir o registro do banco de dados
await ctx.db.delete(args.id);
await registrarAtividade(
@@ -977,6 +1193,15 @@ export const excluirAtestado = mutation({
args.id
);
// Recalcular banco de horas APENAS para o período específico do atestado excluído
// Isso garante que os dias do atestado sejam removidos corretamente dos registros de ponto
await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim);
// Atualizar status do funcionário imediatamente
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId
});
return null;
}
});
@@ -996,6 +1221,13 @@ export const excluirLicenca = mutation({
const licenca = await ctx.db.get(args.id);
if (!licenca) throw new Error('Licença não encontrada');
// IMPORTANTE: Salvar o período exato da licença ANTES de excluir
// para recalcular o banco de horas apenas para esse período específico
const funcionarioId = licenca.funcionarioId;
const dataInicio = licenca.dataInicio; // Data início da licença
const dataFim = licenca.dataFim; // Data fim da licença
// Excluir o registro do banco de dados
await ctx.db.delete(args.id);
await registrarAtividade(
@@ -1007,6 +1239,15 @@ export const excluirLicenca = mutation({
args.id
);
// Recalcular banco de horas APENAS para o período específico da licença excluída
// Isso garante que os dias da licença sejam removidos corretamente dos registros de ponto
await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim);
// Atualizar status do funcionário imediatamente
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId
});
return null;
}
});

View File

@@ -1,8 +1,11 @@
import { v } from 'convex/values';
import { api, internal } from './_generated/api';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { mutation, query } from './_generated/server';
import type { QueryCtx, MutationCtx } from './_generated/server';
import { api } from './_generated/api';
import { internal } from './_generated/api';
import { Id, Doc } from './_generated/dataModel';
import { parseLocalDate, formatarDataBR } from './utils/datas';
import { getCurrentUserFunction } from './auth';
// Query: Listar todas as solicitações (para RH)
export const listarTodas = query({
@@ -26,9 +29,26 @@ export const listarTodas = query({
time = await ctx.db.get(membroTime.timeId);
}
// 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);
}
}
return {
...s,
funcionario,
funcionario: funcionario
? {
...funcionario,
fotoPerfilUrl
}
: null,
time
};
})
@@ -40,7 +60,10 @@ export const listarTodas = query({
// Query: Listar solicitações do funcionário
export const listarMinhasSolicitacoes = query({
args: { funcionarioId: v.id('funcionarios') },
args: {
funcionarioId: v.id('funcionarios'),
_refresh: v.optional(v.number()) // Parâmetro para forçar atualização no frontend
},
handler: async (ctx, args) => {
const solicitacoes = await ctx.db
.query('solicitacoesAusencias')
@@ -90,7 +113,7 @@ export const listarSolicitacoesSubordinados = query({
const solicitacoes: Array<
Doc<'solicitacoesAusencias'> & {
funcionario: Doc<'funcionarios'> | null;
funcionario: (Doc<'funcionarios'> & { fotoPerfilUrl: string | null }) | null;
time: Doc<'times'> | null;
}
> = [];
@@ -112,9 +135,27 @@ export const listarSolicitacoesSubordinados = query({
// Adicionar info do funcionário
for (const s of solic) {
const funcionario = await ctx.db.get(s.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);
}
}
solicitacoes.push({
...s,
funcionario,
funcionario: funcionario
? {
...funcionario,
fotoPerfilUrl
}
: null,
time
});
}
@@ -133,6 +174,19 @@ export const obterDetalhes = query({
if (!solicitacao) return null;
const funcionario = await ctx.db.get(solicitacao.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);
}
}
let gestor = null;
if (solicitacao.gestorId) {
gestor = await ctx.db.get(solicitacao.gestorId);
@@ -150,11 +204,31 @@ export const obterDetalhes = query({
time = await ctx.db.get(membroTime.timeId);
}
// Enriquecer histórico com nomes dos usuários
let historicoComUsuarios = solicitacao.historicoAlteracoes;
if (historicoComUsuarios && historicoComUsuarios.length > 0) {
historicoComUsuarios = await Promise.all(
historicoComUsuarios.map(async (hist) => {
const usuario = await ctx.db.get(hist.usuarioId);
return {
...hist,
usuarioNome: usuario?.nome || 'Usuário Desconhecido'
};
})
);
}
return {
...solicitacao,
funcionario,
funcionario: funcionario
? {
...funcionario,
fotoPerfilUrl
}
: null,
gestor,
time
time,
historicoAlteracoes: historicoComUsuarios
};
}
});
@@ -211,6 +285,36 @@ export const contarPendentesGestor = query({
}
});
// Helper: Recalcular banco de horas em um período
async function recalcularBancoHorasPeriodo(
ctx: MutationCtx,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string
): Promise<void> {
// Gerar todas as datas do período
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(dataFim);
const datas: string[] = [];
const dataAtual = new Date(dataInicioObj);
while (dataAtual <= dataFimObj) {
const ano = dataAtual.getFullYear();
const mes = String(dataAtual.getMonth() + 1).padStart(2, '0');
const dia = String(dataAtual.getDate()).padStart(2, '0');
datas.push(`${ano}-${mes}-${dia}`);
dataAtual.setDate(dataAtual.getDate() + 1);
}
// Recalcular para cada data usando a mutation interna (agendar para execução assíncrona)
for (let i = 0; i < datas.length; i++) {
await ctx.scheduler.runAfter(i * 100, internal.pontos.recalcularBancoHorasData, {
funcionarioId,
data: datas[i]!
});
}
}
// Helper: Verificar se há sobreposição de datas
function verificarSobreposicao(
inicio1: string,
@@ -218,10 +322,10 @@ function verificarSobreposicao(
inicio2: string,
fim2: string
): boolean {
const d1Inicio = new Date(inicio1);
const d1Fim = new Date(fim1);
const d2Inicio = new Date(inicio2);
const d2Fim = new Date(fim2);
const d1Inicio = parseLocalDate(inicio1);
const d1Fim = parseLocalDate(fim1);
const d2Inicio = parseLocalDate(inicio2);
const d2Fim = parseLocalDate(fim2);
return d1Inicio <= d2Fim && d2Inicio <= d1Fim;
}
@@ -260,12 +364,16 @@ export const criarSolicitacao = mutation({
throw new Error('O motivo deve ter no mínimo 10 caracteres');
}
const dataInicio = new Date(args.dataInicio);
const dataFim = new Date(args.dataFim);
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
const dataInicio = parseLocalDate(args.dataInicio);
const dataFim = parseLocalDate(args.dataFim);
if (dataInicio < hoje) {
// Criar data de hoje em UTC para comparação
const hoje = new Date();
const hojeUTC = new Date(
Date.UTC(hoje.getUTCFullYear(), hoje.getUTCMonth(), hoje.getUTCDate(), 0, 0, 0, 0)
);
if (dataInicio < hojeUTC) {
throw new Error('A data de início não pode ser no passado');
}
@@ -278,6 +386,17 @@ export const criarSolicitacao = mutation({
throw new Error('Funcionário não encontrado');
}
// Buscar usuário que está criando (pode não ser o próprio funcionário)
const usuarioCriador = await getCurrentUserFunction(ctx);
const usuarioFuncionario = await ctx.db
.query('usuarios')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
.first();
// Usar o usuário autenticado se disponível, senão usar o usuário do funcionário ou gestor
const usuarioIdParaHistorico =
usuarioCriador?._id || usuarioFuncionario?._id || funcionario.gestorId;
// Verificar sobreposição com outras solicitações aprovadas ou pendentes
const solicitacoesExistentes = await ctx.db
.query('solicitacoesAusencias')
@@ -292,6 +411,17 @@ export const criarSolicitacao = mutation({
}
}
// Criar histórico inicial
const historicoInicial = usuarioIdParaHistorico
? [
{
data: Date.now(),
usuarioId: usuarioIdParaHistorico,
acao: 'Solicitação criada'
}
]
: [];
// Criar solicitação
const solicitacaoId = await ctx.db.insert('solicitacoesAusencias', {
funcionarioId: args.funcionarioId,
@@ -299,7 +429,8 @@ export const criarSolicitacao = mutation({
dataFim: args.dataFim,
motivo: args.motivo.trim(),
status: 'aguardando_aprovacao',
criadoEm: Date.now()
criadoEm: Date.now(),
historicoAlteracoes: historicoInicial
});
// Encontrar gestor do funcionário
@@ -312,7 +443,7 @@ export const criarSolicitacao = mutation({
solicitacaoAusenciaId: solicitacaoId,
tipo: 'nova_solicitacao',
lida: false,
mensagem: `${funcionario.nome} solicitou uma ausência de ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}`
mensagem: `${funcionario.nome} solicitou uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}`
});
// Buscar usuário do gestor para enviar email e chat
@@ -338,8 +469,8 @@ export const criarSolicitacao = mutation({
variaveis: {
gestorNome: gestorUsuario.nome,
funcionarioNome: funcionario.nome,
dataInicio: new Date(args.dataInicio).toLocaleDateString('pt-BR'),
dataFim: new Date(args.dataFim).toLocaleDateString('pt-BR'),
dataInicio: formatarDataBR(args.dataInicio),
dataFim: formatarDataBR(args.dataFim),
motivo: args.motivo,
urlSistema
},
@@ -358,7 +489,7 @@ export const criarSolicitacao = mutation({
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>Período:</strong> ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}</li>
<li><strong>Motivo:</strong> ${args.motivo}</li>
</ul>
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
@@ -398,7 +529,7 @@ export const criarSolicitacao = mutation({
conversaId,
remetenteId: funcionarioUsuario._id,
tipo: 'texto',
conteudo: `Solicitei uma ausência de ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}. Motivo: ${args.motivo}`,
conteudo: `Solicitei uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}. Motivo: ${args.motivo}`,
enviadaEm: Date.now()
});
}
@@ -440,11 +571,23 @@ export const aprovar = mutation({
throw new Error('Funcionário não encontrado');
}
// Buscar nome do gestor para o histórico
const gestorUsuario = await ctx.db.get(args.gestorId);
const nomeGestor = gestorUsuario?.nome || 'Gestor';
// Atualizar solicitação
await ctx.db.patch(args.solicitacaoId, {
status: 'aprovado',
gestorId: args.gestorId,
dataAprovacao: Date.now()
dataAprovacao: Date.now(),
historicoAlteracoes: [
...(solicitacao.historicoAlteracoes || []),
{
data: Date.now(),
usuarioId: args.gestorId,
acao: `Aprovado por ${nomeGestor}`
}
]
});
// Buscar usuário do funcionário
@@ -460,7 +603,7 @@ export const aprovar = mutation({
solicitacaoAusenciaId: args.solicitacaoId,
tipo: 'aprovado',
lida: false,
mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} foi aprovada!`
mensagem: `Sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)} foi aprovada!`
});
const gestorUsuario = await ctx.db.get(args.gestorId);
@@ -481,8 +624,8 @@ export const aprovar = mutation({
variaveis: {
funcionarioNome: funcionarioUsuario.nome,
gestorNome: gestorUsuario.nome,
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'),
dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'),
dataInicio: formatarDataBR(solicitacao.dataInicio),
dataFim: formatarDataBR(solicitacao.dataFim),
motivo: solicitacao.motivo,
urlSistema
},
@@ -501,7 +644,7 @@ export const aprovar = mutation({
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>Período:</strong> ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
</ul>`,
enviadoPor: args.gestorId
@@ -540,12 +683,20 @@ export const aprovar = mutation({
conversaId,
remetenteId: args.gestorId,
tipo: 'texto',
conteudo: `Aprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}.`,
conteudo: `Aprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}.`,
enviadaEm: Date.now()
});
}
}
// Recalcular banco de horas para todas as datas do período da ausência aprovada
await recalcularBancoHorasPeriodo(
ctx,
solicitacao.funcionarioId,
solicitacao.dataInicio,
solicitacao.dataFim
);
return null;
}
});
@@ -583,12 +734,24 @@ export const reprovar = mutation({
throw new Error('Funcionário não encontrado');
}
// Buscar nome do gestor para o histórico
const gestorUsuario = await ctx.db.get(args.gestorId);
const nomeGestor = gestorUsuario?.nome || 'Gestor';
// Atualizar solicitação
await ctx.db.patch(args.solicitacaoId, {
status: 'reprovado',
gestorId: args.gestorId,
dataReprovacao: Date.now(),
motivoReprovacao: args.motivoReprovacao.trim()
motivoReprovacao: args.motivoReprovacao.trim(),
historicoAlteracoes: [
...(solicitacao.historicoAlteracoes || []),
{
data: Date.now(),
usuarioId: args.gestorId,
acao: `Reprovado por ${nomeGestor}: ${args.motivoReprovacao.trim()}`
}
]
});
// Buscar usuário do funcionário
@@ -604,7 +767,7 @@ export const reprovar = mutation({
solicitacaoAusenciaId: args.solicitacaoId,
tipo: 'reprovado',
lida: false,
mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} foi reprovada. Motivo: ${args.motivoReprovacao}`
mensagem: `Sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)} foi reprovada. Motivo: ${args.motivoReprovacao}`
});
const gestorUsuario = await ctx.db.get(args.gestorId);
@@ -625,8 +788,8 @@ export const reprovar = mutation({
variaveis: {
funcionarioNome: funcionarioUsuario.nome,
gestorNome: gestorUsuario.nome,
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'),
dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'),
dataInicio: formatarDataBR(solicitacao.dataInicio),
dataFim: formatarDataBR(solicitacao.dataFim),
motivo: solicitacao.motivo,
motivoReprovacao: args.motivoReprovacao,
urlSistema
@@ -646,7 +809,7 @@ export const reprovar = mutation({
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>Período:</strong> ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
</ul>`,
@@ -686,7 +849,7 @@ export const reprovar = mutation({
conversaId,
remetenteId: args.gestorId,
tipo: 'texto',
conteudo: `Reprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}. Motivo: ${args.motivoReprovacao}`,
conteudo: `Reprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}. Motivo: ${args.motivoReprovacao}`,
enviadaEm: Date.now()
});
}

View File

@@ -4,12 +4,13 @@ import { authComponent, updatePassword } from './auth';
/**
* Alterar senha do usuário autenticado
* Token é opcional - autenticação é feita via contexto do Convex
*/
export const alterarSenha = mutation({
args: {
token: v.string(), // Token não é usado, mas mantido para compatibilidade
senhaAtual: v.string(),
novaSenha: v.string()
novaSenha: v.string(),
token: v.optional(v.string()) // Token opcional - não é usado, mas mantido para compatibilidade
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
@@ -43,14 +44,12 @@ export const alterarSenha = mutation({
return {
sucesso: true as const
};
} catch (error: any) {
} catch (error: unknown) {
// Capturar erros específicos do Better Auth
let mensagemErro = 'Erro ao alterar senha';
if (error?.message) {
if (error instanceof Error && 'message' in error) {
mensagemErro = error.message;
} else if (typeof error === 'string') {
mensagemErro = error;
}
// Mensagens de erro mais amigáveis

View File

@@ -5,7 +5,10 @@ import { components } from './_generated/api';
import type { DataModel } from './_generated/dataModel';
import { type MutationCtx, type QueryCtx, query } from './_generated/server';
const siteUrl = process.env.SITE_URL!;
// Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173';
console.log('siteUrl:', siteUrl);
// The component client has methods needed for integrating Convex with Better Auth,
// as well as helper methods for general use.
@@ -21,6 +24,7 @@ export const createAuth = (
logger: {
disabled: optionsOnly
},
trustedOrigins: ['https://vite.kilder.dev'],
baseURL: siteUrl,
database: authComponent.adapter(ctx),
// Configure simple, non-verified email/password to get started
@@ -40,32 +44,41 @@ export const createAuth = (
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
const authUser = await authComponent.safeGetAuthUser(ctx);
if (!authUser) {
try {
const authUser = await authComponent.safeGetAuthUser(ctx);
if (!authUser) {
return;
}
const user = await ctx.db
.query('usuarios')
.withIndex('authId', (q) => q.eq('authId', authUser._id))
.unique();
if (!user) {
return;
}
// Buscar foto de perfil e role em paralelo para melhor performance
const [fotoPerfilUrl, role] = await Promise.all([
user.fotoPerfil
? ctx.storage.getUrl(user.fotoPerfil).catch(() => null)
: Promise.resolve(null),
user.roleId
? ctx.db
.query('roles')
.withIndex('by_id', (q) => q.eq('_id', user.roleId))
.unique()
.catch(() => null)
: Promise.resolve(null)
]);
return { ...user, role: role || null, fotoPerfilUrl };
} catch (error) {
// Log do erro mas não falhar completamente - retornar null para permitir retry
console.error('Erro ao buscar usuário atual:', error);
return;
}
const user = await ctx.db
.query('usuarios')
.withIndex('authId', (q) => q.eq('authId', authUser._id))
.unique();
if (!user) {
return;
}
const fotoPerfilUrl = user.fotoPerfil ? await ctx.storage.getUrl(user.fotoPerfil) : null;
if (!user.roleId) {
return { ...user, role: null, fotoPerfilUrl };
}
const role = await ctx.db
.query('roles')
.withIndex('by_id', (q) => q.eq('_id', user.roleId))
.unique();
return { ...user, role, fotoPerfilUrl };
}
});

View File

@@ -1,9 +1,9 @@
import { v } from 'convex/values';
import { api } from './_generated/api';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { mutation, query } from './_generated/server';
import type { MutationCtx } from './_generated/server';
import { api } from './_generated/api';
import { getCurrentUserFunction } from './auth';
import type { Doc, Id } from './_generated/dataModel';
const ticketStatusValidator = v.union(
v.literal('aberto'),
@@ -122,7 +122,7 @@ async function registrarNotificacoes(
const { ticket, titulo, mensagem, usuarioEvento } = params;
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
let urlSistema = process.env.SITE_URL || 'http://localhost:5173';
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
@@ -393,7 +393,7 @@ export const listarChamadosTI = query({
// Enriquecer tickets com nome do responsável
const ticketsEnriquecidos = await Promise.all(
filtrados.map(async (ticket) => {
let responsavelNome: string | undefined;
let responsavelNome: string | undefined = undefined;
if (ticket.responsavelId) {
const responsavel = await ctx.db.get(ticket.responsavelId);
responsavelNome = responsavel?.nome;

View File

@@ -261,7 +261,11 @@ export const enviarMensagem = mutation({
arquivoTipo: v.optional(v.string()),
mencoes: v.optional(v.array(v.id('usuarios'))),
respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()) // ✅ NOVO: Permite criar notificação para si mesmo
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
// Campos para criptografia E2E
criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
@@ -281,6 +285,59 @@ export const enviarMensagem = mutation({
});
}
// Validar tamanho da mensagem
const MAX_MENSAGEM_LENGTH = 5000;
if (args.conteudo.length > MAX_MENSAGEM_LENGTH) {
throw new Error(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`);
}
// Validação de tipo de arquivo (se houver arquivo)
if (args.arquivoTipo) {
const TIPOS_PERMITIDOS = [
// Imagens
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documentos
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv',
// Arquivos
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
];
if (!TIPOS_PERMITIDOS.includes(args.arquivoTipo)) {
throw new Error('Tipo de arquivo não permitido');
}
// Validar tamanho de arquivo (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024;
if (args.arquivoTamanho && args.arquivoTamanho > MAX_FILE_SIZE) {
throw new Error(`Arquivo muito grande. O tamanho máximo é ${MAX_FILE_SIZE / 1024 / 1024}MB.`);
}
// Validar nome do arquivo (sanitizar)
if (args.arquivoNome) {
const nomeSanitizado = args.arquivoNome.replace(/[^a-zA-Z0-9._-]/g, '_');
if (nomeSanitizado !== args.arquivoNome) {
// Se o nome foi alterado, usar o sanitizado
args.arquivoNome = nomeSanitizado;
}
}
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) throw new Error('Conversa não encontrada');
@@ -289,7 +346,8 @@ export const enviarMensagem = mutation({
}
// Normalizar conteúdo para busca (remover acentos, lowercase)
const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
// Se a mensagem estiver criptografada, não criar índice de busca (conteudoBusca será undefined)
const conteudoBusca = args.criptografado ? undefined : normalizarTextoParaBusca(args.conteudo);
// Verificar se é resposta a outra mensagem
if (args.respostaPara) {
@@ -320,7 +378,11 @@ export const enviarMensagem = mutation({
mencoes: args.mencoes,
respostaPara: args.respostaPara,
enviadaEm: Date.now(),
lidaPor: [] // Inicializar como array vazio
lidaPor: [], // Inicializar como array vazio
// Campos de criptografia E2E
criptografado: args.criptografado ?? false,
iv: args.iv,
keyId: args.keyId
});
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
@@ -343,8 +405,12 @@ export const enviarMensagem = mutation({
}
// Atualizar última mensagem da conversa
// Se a mensagem estiver criptografada, usar placeholder
const ultimaMensagemTexto = args.criptografado
? '🔒 Mensagem criptografada'
: args.conteudo.substring(0, 100);
await ctx.db.patch(args.conversaId, {
ultimaMensagem: args.conteudo.substring(0, 100),
ultimaMensagem: ultimaMensagemTexto,
ultimaMensagemTimestamp: Date.now(),
ultimaMensagemRemetenteId: usuarioAtual._id // Guardar ID do remetente da última mensagem
});
@@ -793,7 +859,11 @@ export const marcarNotificacaoLida = mutation({
if (!usuarioAtual) throw new Error('Não autenticado');
const notificacao = await ctx.db.get(args.notificacaoId);
if (!notificacao) throw new Error('Notificação não encontrada');
// Se a notificação não existe (já foi deletada), retornar sucesso silenciosamente
// Isso evita erros quando múltiplas tentativas são feitas ou quando a notificação já foi removida
if (!notificacao) {
return true;
}
// SEGURANÇA: Verificar se a notificação pertence ao usuário atual
if (notificacao.usuarioId !== usuarioAtual._id) {
@@ -808,6 +878,11 @@ export const marcarNotificacaoLida = mutation({
}
}
// Se já está marcada como lida, retornar sucesso sem fazer nada
if (notificacao.lida) {
return true;
}
await ctx.db.patch(args.notificacaoId, { lida: true });
return true;
}
@@ -890,7 +965,10 @@ export const limparNotificacoesNaoLidas = mutation({
export const editarMensagem = mutation({
args: {
mensagemId: v.id('mensagens'),
novoConteudo: v.string()
novoConteudo: v.string(),
// Campos para criptografia E2E (opcionais, apenas se a mensagem for criptografada)
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
@@ -947,15 +1025,32 @@ export const editarMensagem = mutation({
};
}
// Normalizar conteúdo para busca
const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
// Normalizar conteúdo para busca (se não estiver criptografado)
// Se a mensagem original estava criptografada, manter criptografado
const estaCriptografada = mensagem.criptografado ?? false;
const conteudoBusca = estaCriptografada ? undefined : normalizarTextoParaBusca(args.novoConteudo);
// Atualizar mensagem
await ctx.db.patch(args.mensagemId, {
// Se a mensagem estava criptografada e novos campos de criptografia foram fornecidos, atualizar
const updateData: {
conteudo: string;
conteudoBusca?: string;
editadaEm: number;
iv?: string;
keyId?: string;
} = {
conteudo: args.novoConteudo.trim(),
conteudoBusca,
editadaEm: Date.now()
});
};
// Se a mensagem estava criptografada e novos campos foram fornecidos, atualizar
if (estaCriptografada && (args.iv || args.keyId)) {
if (args.iv) updateData.iv = args.iv;
if (args.keyId) updateData.keyId = args.keyId;
}
await ctx.db.patch(args.mensagemId, updateData);
return { sucesso: true };
}
@@ -1148,18 +1243,39 @@ export const adicionarParticipanteSala = mutation({
participantes: novosParticipantes
});
// Verificar se a conversa tem criptografia E2E ativa
const chaveE2E = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
// Criar notificação para o novo participante
const agora = Date.now();
await ctx.db.insert('notificacoes', {
usuarioId: args.participanteId,
tipo: 'adicionado_grupo',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Adicionado a sala de reunião',
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}`,
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}${chaveE2E ? '. Esta conversa usa criptografia E2E - você receberá a chave automaticamente.' : ''}`,
lida: false,
criadaEm: Date.now()
criadaEm: agora
});
// Se a conversa tem E2E ativo, notificar sobre a chave
if (chaveE2E) {
await ctx.db.insert('notificacoes', {
usuarioId: args.participanteId,
tipo: 'alerta_seguranca',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Chave de criptografia E2E disponível',
descricao: `Esta conversa usa criptografia end-to-end. A chave foi compartilhada com você automaticamente.`,
lida: false,
criadaEm: agora
});
}
return { sucesso: true };
}
});
@@ -1764,28 +1880,42 @@ export const listarConversas = query({
export const obterMensagens = query({
args: {
conversaId: v.id('conversas'),
limit: v.optional(v.number())
limit: v.optional(v.number()),
cursor: v.optional(v.id('mensagens')) // ID da última mensagem carregada (para paginação)
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
if (!usuarioAtual) return { mensagens: [], hasMore: false };
// Verificar se usuário pertence à conversa (SEGURANÇA CRÍTICA)
const conversa = await ctx.db.get(args.conversaId);
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
return [];
return { mensagens: [], hasMore: false };
}
// Buscar mensagens (excluir agendadas)
const mensagens = await ctx.db
const limit = args.limit || 50;
let query = ctx.db
.query('mensagens')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
.order('desc')
.take(args.limit || 50);
.order('desc');
// Se há cursor, buscar mensagens anteriores a ele
if (args.cursor) {
const cursorMsg = await ctx.db.get(args.cursor);
if (cursorMsg && cursorMsg.conversaId === args.conversaId) {
// Buscar mensagens anteriores à mensagem do cursor
query = query.filter((q) => q.lt(q.field('_creationTime'), cursorMsg._creationTime));
}
}
// Buscar uma mensagem a mais para verificar se há mais mensagens
const mensagens = await query.take(limit + 1);
const hasMore = mensagens.length > limit;
const mensagensParaRetornar = hasMore ? mensagens.slice(0, limit) : mensagens;
// Filtrar mensagens agendadas e garantir que são da conversa correta
// SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas
const mensagensFiltradas = mensagens.filter((m) => {
const mensagensFiltradas = mensagensParaRetornar.filter((m) => {
// Excluir agendadas
if (m.agendadaPara) return false;
@@ -1842,7 +1972,15 @@ export const obterMensagens = query({
);
// Filtrar nulls (caso alguma mensagem tenha sido rejeitada por segurança)
return mensagensEnriquecidas.filter((m) => m !== null).reverse();
const mensagensFinais = mensagensEnriquecidas.filter((m) => m !== null).reverse();
return {
mensagens: mensagensFinais,
hasMore,
nextCursor: hasMore && mensagensFinais.length > 0
? mensagensFinais[mensagensFinais.length - 1]._id
: null
};
}
});
@@ -2480,3 +2618,144 @@ export const limparIndicadoresDigitacao = internalMutation({
return indicadoresAntigos.length;
}
});
// ========== CRIPTOGRAFIA E2E ==========
/**
* Compartilha uma chave de criptografia E2E para uma conversa
* A chave deve ser criptografada antes de ser enviada (usando chave pública do servidor ou outro método)
*/
export const compartilharChaveCriptografia = mutation({
args: {
conversaId: v.id('conversas'),
chaveCompartilhada: v.string(), // Chave criptografada (base64)
keyId: v.string() // Identificador único da chave
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
throw new Error('Não autenticado');
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
throw new Error('Conversa não encontrada');
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
throw new Error('Você não pertence a esta conversa');
}
// Desativar chaves antigas da conversa
const chavesAntigas = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.collect();
for (const chaveAntiga of chavesAntigas) {
await ctx.db.patch(chaveAntiga._id, { ativo: false });
}
// Criar nova chave
await ctx.db.insert('chavesCriptografia', {
conversaId: args.conversaId,
chaveCompartilhada: args.chaveCompartilhada,
keyId: args.keyId,
criadoPor: usuarioAtual._id,
criadoEm: Date.now(),
ativo: true
});
// Criar notificações para outros participantes sobre a ativação/regeneração da chave
const agora = Date.now();
for (const participanteId of conversa.participantes) {
if (participanteId !== usuarioAtual._id) {
await ctx.db.insert('notificacoes', {
usuarioId: participanteId,
tipo: 'alerta_seguranca',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Criptografia E2E atualizada',
descricao: `${usuarioAtual.nome} ${chavesAntigas.length > 0 ? 'regenerou' : 'ativou'} a criptografia end-to-end nesta conversa. Suas mensagens futuras serão criptografadas.`,
lida: false,
criadaEm: agora
});
}
}
return { sucesso: true };
}
});
/**
* Obtém a chave de criptografia ativa para uma conversa
*/
export const obterChaveCriptografia = query({
args: {
conversaId: v.id('conversas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return null;
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return null;
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
return null;
}
// Buscar chave ativa
const chave = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
if (!chave) {
return null;
}
return {
chaveCompartilhada: chave.chaveCompartilhada,
keyId: chave.keyId,
criadoPor: chave.criadoPor,
criadoEm: chave.criadoEm
};
}
});
/**
* Verifica se uma conversa tem criptografia E2E habilitada
*/
export const verificarCriptografiaE2E = query({
args: {
conversaId: v.id('conversas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return false;
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return false;
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
return false;
}
// Verificar se existe chave ativa
const chave = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
return chave !== null;
}
});

View File

@@ -1,8 +1,8 @@
import { v } from 'convex/values';
import { api, internal } from './_generated/api';
import { action, internalMutation, mutation, query } from './_generated/server';
import { mutation, query, action, internalMutation } from './_generated/server';
import { encryptSMTPPassword } from './auth/utils';
import { registrarAtividade } from './logsAtividades';
import { api, internal } from './_generated/api';
/**
* Obter configuração de email ativa (senha mascarada)
@@ -65,10 +65,7 @@ export const salvarConfigEmail = mutation({
// Validar porta
if (args.porta < 1 || args.porta > 65535) {
return {
sucesso: false as const,
erro: 'Porta deve ser um número entre 1 e 65535'
};
return { sucesso: false as const, erro: 'Porta deve ser um número entre 1 e 65535' };
}
// Buscar config ativa anterior para manter senha se não fornecida
@@ -87,10 +84,7 @@ export const salvarConfigEmail = mutation({
senhaHash = configAtiva.senhaHash;
} else {
// Sem senha e sem config existente - erro
return {
sucesso: false as const,
erro: 'Senha é obrigatória para nova configuração'
};
return { sucesso: false as const, erro: 'Senha é obrigatória para nova configuração' };
}
// Desativar config anterior
@@ -167,17 +161,11 @@ export const testarConexaoSMTP = action({
handler: async (ctx, args): Promise<{ sucesso: true } | { sucesso: false; erro: string }> => {
// Validações básicas
if (!args.servidor || args.servidor.trim().length === 0) {
return {
sucesso: false as const,
erro: 'Servidor SMTP não pode estar vazio'
};
return { sucesso: false as const, erro: 'Servidor SMTP não pode estar vazio' };
}
if (!args.porta || args.porta < 1 || args.porta > 65535) {
return {
sucesso: false as const,
erro: 'Porta inválida. Deve ser entre 1 e 65535'
};
return { sucesso: false as const, erro: 'Porta inválida. Deve ser entre 1 e 65535' };
}
if (!args.usuario || args.usuario.trim().length === 0) {

View File

@@ -1,7 +1,7 @@
import { v } from 'convex/values';
import { api, internal } from './_generated/api';
import { action, internalMutation, mutation, query } from './_generated/server';
import { mutation, query, action, internalMutation } from './_generated/server';
import { registrarAtividade } from './logsAtividades';
import { api, internal } from './_generated/api';
/**
* Obter configuração de Jitsi ativa

View File

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

View File

@@ -50,4 +50,12 @@ crons.interval(
{}
);
// Verificar e enviar notificações de alertas de banco de horas diariamente às 8h
crons.interval(
'verificar-alertas-banco-horas',
{ hours: 24 },
internal.pontos.enviarNotificacoesAlertasBancoHoras,
{}
);
export default crons;

View File

@@ -1,5 +1,5 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { query, mutation } from './_generated/server';
export const listarPorFuncionario = query({
args: {

View File

@@ -1,5 +1,5 @@
import { v } from 'convex/values';
import { query } from './_generated/server';
import { v } from 'convex/values';
// Obter estatísticas gerais do sistema
export const getStats = query({

View File

@@ -1,6 +1,6 @@
import { v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
import { Id } from './_generated/dataModel';
// Mutation para fazer upload de arquivo e obter o storage ID
export const generateUploadUrl = mutation({

View File

@@ -1,8 +1,8 @@
import { v } from 'convex/values';
import { api, internal } from './_generated/api';
import type { Doc, Id } from './_generated/dataModel';
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server';
import { mutation, query, internalMutation, internalQuery, action } from './_generated/server';
import { internal, api } from './_generated/api';
import { renderizarTemplateEmailFromDoc, type VariaveisTemplate } from './templatesMensagens';
import type { Doc, Id } from './_generated/dataModel';
// ========== INTERNAL QUERIES ==========

View File

@@ -1,8 +1,8 @@
import { v } from 'convex/values';
import { internal } from './_generated/api';
import type { Id } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
import { internal } from './_generated/api';
import { getCurrentUserFunction } from './auth';
import type { Id } from './_generated/dataModel';
export const list = query({
args: {},

View File

@@ -0,0 +1,429 @@
import {
action,
internalMutation,
internalAction,
internalQuery,
query
} from './_generated/server';
import { v } from 'convex/values';
import { api, internal } from './_generated/api';
import type { Id, Doc } from './_generated/dataModel';
import { getCurrentUserFunction } from './auth';
/**
* Action pública para registrar erro do servidor e notificar equipe técnica
* Esta função será chamada pelo handleError do SvelteKit
*/
export const registrarErroServidor = action({
args: {
statusCode: v.number(),
mensagem: v.string(),
stack: v.optional(v.string()),
url: v.optional(v.string()),
method: v.optional(v.string()),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
usuarioId: v.optional(v.id('usuarios'))
},
handler: async (ctx, args) => {
// Registrar erro no banco
// Anotação explícita de tipo para evitar problemas de tipagem circular ao
// chamar uma função registrada neste mesmo módulo (ver docs do Convex).
const erroId: Id<'errosServidor'> = await ctx.runMutation(internal.errosServidor.inserirErro, {
statusCode: args.statusCode,
mensagem: args.mensagem,
stack: args.stack,
url: args.url,
method: args.method,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
usuarioId: args.usuarioId
});
// Notificar equipe técnica (assíncrono)
ctx.scheduler
.runAfter(0, internal.errosServidor.notificarEquipeTecnica, {
erroId
})
.catch((error) => {
console.error('Erro ao agendar notificação de erro:', error);
});
return { sucesso: true, erroId };
}
});
/**
* Mutation interna para inserir erro no banco
*/
export const inserirErro = internalMutation({
args: {
statusCode: v.number(),
mensagem: v.string(),
stack: v.optional(v.string()),
url: v.optional(v.string()),
method: v.optional(v.string()),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
usuarioId: v.optional(v.id('usuarios'))
},
handler: async (ctx, args) => {
const erroId = await ctx.db.insert('errosServidor', {
statusCode: args.statusCode,
mensagem: args.mensagem,
stack: args.stack,
url: args.url,
method: args.method,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
usuarioId: args.usuarioId,
notificado: false,
criadoEm: Date.now()
});
return erroId;
}
});
/**
* Action interna para notificar equipe técnica sobre erro do servidor
*/
export const notificarEquipeTecnica = internalAction({
args: {
erroId: v.id('errosServidor')
},
handler: async (ctx, args) => {
// Buscar detalhes do erro
const erro = await ctx.runQuery(internal.errosServidor.obterErroPorId, {
erroId: args.erroId
});
if (!erro) {
console.error('Erro não encontrado:', args.erroId);
return;
}
// Buscar usuários da equipe técnica (roles com nível <= 1)
const rolesAdminOuTi: Doc<'roles'>[] = await ctx.runQuery(
internal.errosServidor.obterRolesTI,
{}
);
if (rolesAdminOuTi.length === 0) {
console.warn('Nenhuma role de TI encontrada para notificação de erro');
return;
}
const rolesPermitidas = new Set(rolesAdminOuTi.map((r: Doc<'roles'>) => r._id));
const usuarios: Doc<'usuarios'>[] = await ctx.runQuery(internal.errosServidor.obterUsuariosTI, {
rolesPermitidas: Array.from(rolesPermitidas)
});
if (usuarios.length === 0) {
console.warn('Nenhum usuário de TI encontrado para notificação de erro');
return;
}
// Preparar informações do erro para notificação
const urlFormatada = erro.url || 'N/A';
const metodoFormatado = erro.method || 'N/A';
const stackFormatado = erro.stack
? erro.stack.substring(0, 500) + (erro.stack.length > 500 ? '...' : '')
: 'N/A';
// Notificar via chat (notificações internas)
for (const usuario of usuarios) {
await ctx.runMutation(internal.errosServidor.criarNotificacaoChat, {
usuarioId: usuario._id,
statusCode: erro.statusCode,
mensagem: erro.mensagem,
url: urlFormatada,
method: metodoFormatado
});
}
// Notificar via email (apenas para usuários com email)
const usuariosComEmail = usuarios.filter((u: Doc<'usuarios'>) => u.email);
for (const usuario of usuariosComEmail) {
try {
// Determinar código do template baseado no status code
const templateCodigo = erro.statusCode === 404 ? 'ERRO_SERVIDOR_404' : 'ERRO_SERVIDOR_500';
// Verificar se existe template de erro do servidor
const templateExiste = await ctx.runQuery(api.templatesMensagens.obterTemplatePorCodigo, {
codigo: templateCodigo
});
if (templateExiste) {
// Usar template personalizado
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: usuario.email!,
destinatarioId: usuario._id,
templateCodigo,
variaveis: {
destinatarioNome: usuario.nome,
statusCode: erro.statusCode.toString(),
mensagem: erro.mensagem,
url: urlFormatada,
method: metodoFormatado,
stack: stackFormatado,
timestamp: new Date(erro.criadoEm).toLocaleString('pt-BR')
},
enviadoPor: usuario._id // Usar o próprio usuário como remetente
});
} else {
// Template não existe, criar email simples com HTML básico
const assunto =
erro.statusCode === 404
? `⚠️ Erro 404 - Página não encontrada: ${urlFormatada.substring(0, 50)}`
: `🚨 Erro ${erro.statusCode} no Servidor - ${urlFormatada.substring(0, 50)}`;
const corpo = `<html><body>
<h2>${erro.statusCode === 404 ? 'Página Não Encontrada (404)' : 'Erro do Servidor Detectado'}</h2>
<p><strong>Código:</strong> ${erro.statusCode}</p>
<p><strong>Mensagem:</strong> ${erro.mensagem}</p>
<p><strong>URL:</strong> ${urlFormatada}</p>
<p><strong>Método:</strong> ${metodoFormatado}</p>
<p><strong>Data/Hora:</strong> ${new Date(erro.criadoEm).toLocaleString('pt-BR')}</p>
${erro.stack && erro.statusCode !== 404 ? `<p><strong>Stack Trace:</strong><br><pre style="white-space: pre-wrap; word-wrap: break-word;">${stackFormatado}</pre></p>` : ''}
</body></html>`;
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: usuario.email!,
destinatarioId: usuario._id,
assunto,
corpo,
enviadoPor: usuario._id
});
}
} catch (error) {
console.error(`Erro ao enviar email de notificação para ${usuario.email}:`, error);
}
}
// Marcar erro como notificado
await ctx.runMutation(internal.errosServidor.marcarComoNotificado, {
erroId: args.erroId
});
}
});
/**
* Query interna para obter erro por ID
*/
export const obterErroPorId = internalQuery({
args: {
erroId: v.id('errosServidor')
},
handler: async (ctx, args) => {
return await ctx.db.get(args.erroId);
}
});
/**
* Query interna para obter roles de TI
*/
export const obterRolesTI = internalQuery({
args: {},
handler: async (ctx) => {
return await ctx.db
.query('roles')
.filter((q) => q.lte(q.field('admin'), true))
.collect();
}
});
/**
* Query interna para obter usuários de TI
*/
export const obterUsuariosTI = internalQuery({
args: {
rolesPermitidas: v.array(v.id('roles'))
},
handler: async (ctx, args) => {
const usuarios = await ctx.db.query('usuarios').collect();
return usuarios.filter((u) => args.rolesPermitidas.includes(u.roleId));
}
});
/**
* Mutation interna para criar notificação no chat
*/
export const criarNotificacaoChat = internalMutation({
args: {
usuarioId: v.id('usuarios'),
statusCode: v.number(),
mensagem: v.string(),
url: v.string(),
method: v.string()
},
handler: async (ctx, args) => {
const tituloNotificacao =
args.statusCode === 404
? `⚠️ Erro 404 - Página não encontrada`
: `🚨 Erro ${args.statusCode} no Servidor`;
const descricaoNotificacao =
args.statusCode === 404
? `Página não encontrada: ${args.url} (${args.method})`
: `Erro detectado em ${args.url} (${args.method}): ${args.mensagem.substring(0, 100)}`;
await ctx.db.insert('notificacoes', {
usuarioId: args.usuarioId,
tipo: 'nova_mensagem',
titulo: tituloNotificacao,
descricao: descricaoNotificacao,
lida: false,
criadaEm: Date.now()
});
}
});
/**
* Mutation interna para marcar erro como notificado
*/
export const marcarComoNotificado = internalMutation({
args: {
erroId: v.id('errosServidor')
},
handler: async (ctx, args) => {
await ctx.db.patch(args.erroId, {
notificado: true,
notificadoEm: Date.now()
});
}
});
/**
* Query pública para listar erros do servidor (apenas para TI)
*/
export const listarErros = query({
args: {
limite: v.optional(v.number()),
statusCode: v.optional(v.number()),
notificado: v.optional(v.boolean()),
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number())
},
handler: async (ctx, args) => {
// Verificar se usuário tem permissão (nível <= 1)
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Não autenticado');
}
const role = await ctx.db.get(usuario.roleId);
if (!role || !role.admin) {
throw new Error('Acesso negado. Apenas usuários de TI podem visualizar erros do servidor.');
}
// Construir query com filtros
let erros;
if (args.statusCode !== undefined) {
erros = await ctx.db
.query('errosServidor')
.withIndex('by_status_code', (q) => q.eq('statusCode', args.statusCode!))
.collect();
} else {
erros = await ctx.db.query('errosServidor').withIndex('by_criado_em').collect();
}
// Aplicar filtros adicionais que não são índices
if (args.notificado !== undefined) {
erros = erros.filter((e) => e.notificado === args.notificado);
}
if (args.dataInicio !== undefined) {
erros = erros.filter((e) => e.criadoEm >= args.dataInicio!);
}
if (args.dataFim !== undefined) {
erros = erros.filter((e) => e.criadoEm <= args.dataFim!);
}
// Ordenar por data (mais recentes primeiro)
erros.sort((a, b) => b.criadoEm - a.criadoEm);
// Aplicar limite
const limite = args.limite || 100;
erros = erros.slice(0, limite);
// Buscar informações do usuário se houver usuarioId
const errosComUsuario = await Promise.all(
erros.map(async (erro) => {
let usuarioNome = null;
if (erro.usuarioId) {
const usuarioErro = await ctx.db.get(erro.usuarioId);
usuarioNome = usuarioErro?.nome || null;
}
return {
...erro,
usuarioNome
};
})
);
return errosComUsuario;
}
});
/**
* Query pública para obter estatísticas de erros
*/
export const obterEstatisticasErros = query({
args: {
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number())
},
handler: async (ctx, args) => {
// Verificar se usuário tem permissão (nível <= 1)
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Não autenticado');
}
const role = await ctx.db.get(usuario.roleId);
if (!role || !role.admin) {
throw new Error(
'Acesso negado. Apenas usuários de TI podem visualizar estatísticas de erros.'
);
}
// Buscar todos os erros no período
let erros = await ctx.db.query('errosServidor').withIndex('by_criado_em').collect();
// Aplicar filtros de data
if (args.dataInicio !== undefined) {
erros = erros.filter((e) => e.criadoEm >= args.dataInicio!);
}
if (args.dataFim !== undefined) {
erros = erros.filter((e) => e.criadoEm <= args.dataFim!);
}
// Calcular estatísticas
const total = erros.length;
const porStatus = erros.reduce(
(acc, erro) => {
const status = erro.statusCode.toString();
acc[status] = (acc[status] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const notificados = erros.filter((e) => e.notificado).length;
const naoNotificados = total - notificados;
// Erros mais recentes (últimas 24 horas)
const agora = Date.now();
const ultimas24h = erros.filter((e) => agora - e.criadoEm <= 24 * 60 * 60 * 1000).length;
return {
total,
porStatus,
notificados,
naoNotificados,
ultimas24h
};
}
});

View File

@@ -1,7 +1,11 @@
import { v } from 'convex/values';
import { mutation, query, internalMutation } from './_generated/server';
import { internal } from './_generated/api';
import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, mutation, query } from './_generated/server';
import { Id, Doc } from './_generated/dataModel';
import { verificarLicencaAtiva } from './atestadosLicencas';
import { getCurrentUserFunction } from './auth';
import { formatarDataBR } from './utils/datas';
import { api } from './_generated/api';
// Validador para períodos
const periodoValidator = v.object({
@@ -46,7 +50,7 @@ function agruparPorSolicitacao(registros: Array<Doc<'ferias'>>): Array<{
grupos.get(chave)!.push(registro);
}
return Array.from(grupos.entries()).map(([_, periodos]) => {
return Array.from(grupos.entries()).map(([, periodos]) => {
// Ordenar por data de criação para manter ordem
periodos.sort((a, b) => a._creationTime - b._creationTime);
@@ -137,7 +141,10 @@ export const listarTodas = query({
// Query: Listar solicitações do funcionário - períodos individuais
export const listarMinhasSolicitacoes = query({
args: { funcionarioId: v.id('funcionarios') },
args: {
funcionarioId: v.id('funcionarios'),
_refresh: v.optional(v.number()) // Parâmetro para forçar atualização no frontend
},
handler: async (ctx, args) => {
const todasFerias = await ctx.db
.query('ferias')
@@ -205,6 +212,18 @@ export const listarSolicitacoesSubordinados = 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')
@@ -219,7 +238,12 @@ export const listarSolicitacoesSubordinados = query({
return {
...ferias,
funcionario,
funcionario: funcionario
? {
...funcionario,
fotoPerfilUrl
}
: null,
time
};
})
@@ -240,6 +264,19 @@ export const obterDetalhes = query({
if (!ferias) return null;
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);
}
}
let gestor = null;
if (ferias.gestorId) {
gestor = await ctx.db.get(ferias.gestorId);
@@ -257,11 +294,31 @@ export const obterDetalhes = query({
time = await ctx.db.get(membroTime.timeId);
}
// Enriquecer histórico com nomes dos usuários
let historicoComUsuarios = ferias.historicoAlteracoes;
if (historicoComUsuarios && historicoComUsuarios.length > 0) {
historicoComUsuarios = await Promise.all(
historicoComUsuarios.map(async (hist) => {
const usuario = await ctx.db.get(hist.usuarioId);
return {
...hist,
usuarioNome: usuario?.nome || 'Usuário Desconhecido'
};
})
);
}
return {
...ferias,
funcionario,
funcionario: funcionario
? {
...funcionario,
fotoPerfilUrl
}
: null,
gestor,
time
time,
historicoAlteracoes: historicoComUsuarios
};
}
});
@@ -351,6 +408,10 @@ export const aprovar = mutation({
const funcionario = await ctx.db.get(registro.funcionarioId);
// Buscar nome do gestor para o histórico
const gestorUsuario = await ctx.db.get(args.gestorId);
const nomeGestor = gestorUsuario?.nome || 'Gestor';
// Atualizar o registro
await ctx.db.patch(registro._id, {
status: 'aprovado',
@@ -361,7 +422,7 @@ export const aprovar = mutation({
{
data: Date.now(),
usuarioId: args.gestorId,
acao: 'Aprovado'
acao: `Aprovado por ${nomeGestor}`
}
]
});
@@ -374,13 +435,58 @@ export const aprovar = mutation({
.first();
if (usuario) {
// Criar notificação in-app para funcionário
await ctx.db.insert('notificacoesFerias', {
destinatarioId: usuario._id,
feriasId: registro._id,
tipo: 'aprovado',
lida: false,
mensagem: `Período de férias de ${registro.diasFerias} dias foi aprovado!`
mensagem: `Período de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi aprovado por ${nomeGestor}!`
});
// Enviar email ao funcionário usando template (agendado via scheduler)
if (gestorUsuario) {
// Obter URL do sistema
let urlSistema = process.env.SITE_URL || 'http://localhost:5173';
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: usuario.email,
destinatarioId: usuario._id,
templateCodigo: 'ferias_aprovada',
variaveis: {
funcionarioNome: usuario.nome,
gestorNome: gestorUsuario.nome,
dataInicio: formatarDataBR(registro.dataInicio),
dataFim: formatarDataBR(registro.dataFim),
diasFerias: registro.diasFerias.toString(),
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 ferias_aprovada, usando envio direto:',
error
);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: usuario.email,
destinatarioId: usuario._id,
assunto: 'Solicitação de Férias Aprovada',
corpo: `<p>Olá ${usuario.nome},</p>
<p>Sua solicitação de férias foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)}</li>
<li><strong>Dias:</strong> ${registro.diasFerias} dias</li>
</ul>`,
enviadoPor: args.gestorId
});
}
}
}
}
@@ -494,6 +600,10 @@ export const ajustarEAprovar = mutation({
]
});
// Buscar nome do gestor
const gestorUsuario = await ctx.db.get(args.gestorId);
const nomeGestor = gestorUsuario?.nome || 'Gestor';
// Notificar funcionário
if (funcionario) {
const usuario = await ctx.db
@@ -502,13 +612,58 @@ export const ajustarEAprovar = mutation({
.first();
if (usuario) {
// Criar notificação in-app para funcionário
await ctx.db.insert('notificacoesFerias', {
destinatarioId: usuario._id,
feriasId: registroAntigo._id,
tipo: 'data_ajustada',
lida: false,
mensagem: `Período de férias foi aprovado com ajuste de datas: ${args.novaDataInicio} a ${args.novaDataFim}`
mensagem: `Período de férias foi aprovado com ajuste de datas: ${formatarDataBR(args.novaDataInicio)} a ${formatarDataBR(args.novaDataFim)} (${novosDias} dias) por ${nomeGestor}`
});
// Enviar email ao funcionário usando template (agendado via scheduler)
if (gestorUsuario) {
// Obter URL do sistema
let urlSistema = process.env.SITE_URL || 'http://localhost:5173';
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: usuario.email,
destinatarioId: usuario._id,
templateCodigo: 'ferias_aprovada',
variaveis: {
funcionarioNome: usuario.nome,
gestorNome: gestorUsuario.nome,
dataInicio: formatarDataBR(args.novaDataInicio),
dataFim: formatarDataBR(args.novaDataFim),
diasFerias: novosDias.toString(),
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 ferias_aprovada, usando envio direto:',
error
);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: usuario.email,
destinatarioId: usuario._id,
assunto: 'Solicitação de Férias Aprovada (com Ajuste de Datas)',
corpo: `<p>Olá ${usuario.nome},</p>
<p>Sua solicitação de férias foi <strong>aprovada com ajuste de datas</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${formatarDataBR(args.novaDataInicio)} até ${formatarDataBR(args.novaDataFim)}</li>
<li><strong>Dias:</strong> ${novosDias} dias</li>
</ul>`,
enviadoPor: args.gestorId
});
}
}
}
}
@@ -646,6 +801,124 @@ export const atualizarStatus = mutation({
await ctx.db.patch(registro._id, updateData);
}
// Recalcular imediatamente o status de férias/licença do funcionário
// para refletir o cancelamento (ou outra mudança) sem depender apenas do cron diário
try {
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: registro.funcionarioId
});
} catch (error) {
console.error(
'[ferias.atualizarStatus] Erro ao atualizar statusFerias do funcionário:',
error
);
}
// Se o status foi alterado para Cancelado_RH, notificar o funcionário
if (args.novoStatus === 'Cancelado_RH') {
const funcionario = await ctx.db.get(registro.funcionarioId);
if (funcionario) {
const funcionarioUsuario = await ctx.db
.query('usuarios')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
.first();
if (funcionarioUsuario) {
// Buscar usuário do RH que está cancelando
const usuarioRH = await ctx.db.get(args.usuarioId);
const nomeRH = usuarioRH?.nome || 'Recursos Humanos';
// Criar notificação in-app para funcionário
await ctx.db.insert('notificacoesFerias', {
destinatarioId: funcionarioUsuario._id,
feriasId: registro._id,
tipo: 'cancelado',
lida: false,
mensagem: `Sua solicitação de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi cancelada pelo setor de Recursos Humanos.`
});
// Obter URL do sistema
let urlSistema = process.env.SITE_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: 'ferias_cancelada_rh',
variaveis: {
funcionarioNome: funcionarioUsuario.nome,
dataInicio: formatarDataBR(registro.dataInicio),
dataFim: formatarDataBR(registro.dataFim),
diasFerias: registro.diasFerias.toString(),
urlSistema
},
enviadoPor: args.usuarioId
});
} 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 ferias_cancelada_rh, usando envio direto:',
error
);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: 'Solicitação de Férias Cancelada',
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de férias foi <strong>cancelada</strong> pelo setor de Recursos Humanos:</p>
<ul>
<li><strong>Período:</strong> ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)}</li>
<li><strong>Dias:</strong> ${registro.diasFerias} dias</li>
</ul>
<p>Para mais informações, entre em contato com o setor de Recursos Humanos.</p>`,
enviadoPor: args.usuarioId
});
}
// Criar ou obter conversa entre RH e funcioná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(args.usuarioId) &&
conversa.participantes.includes(funcionarioUsuario._id)
) {
conversaId = conversa._id;
break;
}
}
if (!conversaId) {
conversaId = await ctx.db.insert('conversas', {
tipo: 'individual',
participantes: [args.usuarioId, funcionarioUsuario._id],
criadoPor: args.usuarioId,
criadoEm: Date.now()
});
}
// Criar mensagem de chat (texto simples)
await ctx.db.insert('mensagens', {
conversaId,
remetenteId: args.usuarioId,
tipo: 'texto',
conteudo: `Sua solicitação de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi cancelada pelo setor de Recursos Humanos. Para mais informações, entre em contato conosco.`,
enviadaEm: Date.now()
});
}
}
}
return null;
}
});
@@ -762,7 +1035,15 @@ export const atualizarStatusTodosFuncionarios = internalMutation({
}
}
const novoStatus = emFerias ? 'em_ferias' : 'ativo';
// Determinar o status: férias tem prioridade sobre licença
let novoStatus: 'ativo' | 'em_ferias' | 'em_licenca';
if (emFerias) {
novoStatus = 'em_ferias';
} else {
// Se não está em férias, verificar se está em licença
const emLicenca = await verificarLicencaAtiva(ctx, func._id, hoje);
novoStatus = emLicenca ? 'em_licenca' : 'ativo';
}
if (func.statusFerias !== novoStatus) {
await ctx.db.patch(func._id, { statusFerias: novoStatus });
@@ -772,3 +1053,146 @@ export const atualizarStatusTodosFuncionarios = internalMutation({
return null;
}
});
// Internal Mutation: Atualizar status de um funcionário específico
export const atualizarStatusFuncionario = internalMutation({
args: {
funcionarioId: v.id('funcionarios')
},
returns: v.null(),
handler: async (ctx, args) => {
const func = await ctx.db.get(args.funcionarioId);
if (!func) return null;
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
// Buscar todos os registros de férias que podem estar em férias
const feriasAprovadas = await ctx.db
.query('ferias')
.withIndex('by_funcionario_and_status', (q) =>
q.eq('funcionarioId', func._id).eq('status', 'aprovado')
)
.collect();
const feriasAjustadas = await ctx.db
.query('ferias')
.withIndex('by_funcionario_and_status', (q) =>
q.eq('funcionarioId', func._id).eq('status', 'data_ajustada_aprovada')
)
.collect();
const feriasEmFerias = await ctx.db
.query('ferias')
.withIndex('by_funcionario_and_status', (q) =>
q.eq('funcionarioId', func._id).eq('status', 'EmFérias')
)
.collect();
const idsAprovados = new Set(feriasAprovadas.map((f) => f._id));
const idsAjustados = new Set(feriasAjustadas.map((f) => f._id));
const statusAnteriorPorId = new Map<Id<'ferias'>, 'aprovado' | 'data_ajustada_aprovada'>();
for (const ferias of feriasEmFerias) {
if (ferias.historicoAlteracoes && ferias.historicoAlteracoes.length > 0) {
const historico = ferias.historicoAlteracoes;
for (let i = historico.length - 1; i >= 0; i--) {
const entrada = historico[i];
if (entrada.acao.includes('Aprovado') || entrada.acao.includes('aprovado')) {
statusAnteriorPorId.set(ferias._id, 'aprovado');
break;
} else if (entrada.acao.includes('Data ajustada') || entrada.acao.includes('ajustada')) {
statusAnteriorPorId.set(ferias._id, 'data_ajustada_aprovada');
break;
}
}
}
if (!statusAnteriorPorId.has(ferias._id)) {
statusAnteriorPorId.set(ferias._id, 'aprovado');
}
}
const todasFerias = [...feriasAprovadas, ...feriasAjustadas, ...feriasEmFerias];
let emFerias = false;
for (const ferias of todasFerias) {
const inicio = new Date(ferias.dataInicio);
const fim = new Date(ferias.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(23, 59, 59, 999);
if (hoje >= inicio && hoje <= fim) {
emFerias = true;
if (ferias.status !== 'EmFérias') {
await ctx.db.patch(ferias._id, {
status: 'EmFérias'
});
}
} else {
if (ferias.status === 'EmFérias') {
let statusAnterior: 'aprovado' | 'data_ajustada_aprovada';
if (idsAprovados.has(ferias._id)) {
statusAnterior = 'aprovado';
} else if (idsAjustados.has(ferias._id)) {
statusAnterior = 'data_ajustada_aprovada';
} else {
statusAnterior = statusAnteriorPorId.get(ferias._id) || 'aprovado';
}
await ctx.db.patch(ferias._id, {
status: statusAnterior
});
}
}
}
// Determinar o status: férias tem prioridade sobre licença
let novoStatus: 'ativo' | 'em_ferias' | 'em_licenca';
if (emFerias) {
novoStatus = 'em_ferias';
console.log(`[atualizarStatusFuncionario] Funcionário ${func._id} está em férias`);
} else {
// Se não está em férias, verificar se está em licença
console.log(
`[atualizarStatusFuncionario] Verificando licença ativa para funcionário ${func._id}, data: ${hoje.toISOString()}`
);
const emLicenca = await verificarLicencaAtiva(ctx, func._id, hoje);
novoStatus = emLicenca ? 'em_licenca' : 'ativo';
console.log(
`[atualizarStatusFuncionario] Funcionário ${func._id}: emLicenca=${emLicenca}, statusAtual=${func.statusFerias}, novoStatus=${novoStatus}`
);
}
if (func.statusFerias !== novoStatus) {
console.log(
`[atualizarStatusFuncionario] ⚠️ ATUALIZANDO status de "${func.statusFerias}" para "${novoStatus}"`
);
await ctx.db.patch(func._id, { statusFerias: novoStatus });
console.log(`[atualizarStatusFuncionario] ✅ Status atualizado com sucesso!`);
} else {
console.log(`[atualizarStatusFuncionario] Status já está correto: ${novoStatus}`);
}
return null;
}
});
// Mutation pública para atualizar status do funcionário atual (útil para debug/teste)
export const atualizarMeuStatus = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario || !usuario.funcionarioId) {
throw new Error('Usuário não encontrado ou não possui funcionário associado');
}
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: usuario.funcionarioId
});
return null;
}
});

View File

@@ -1,7 +1,7 @@
import { httpRouter } from 'convex/server';
import { api } from './_generated/api';
import { httpAction } from './_generated/server';
import { authComponent, createAuth } from './auth';
import { httpAction } from './_generated/server';
import { api } from './_generated/api';
import { getClientIP } from './utils/getClientIP';
const http = httpRouter();

File diff suppressed because it is too large Load Diff

View File

@@ -81,11 +81,11 @@ export const listar = query({
// Buscar informações dos usuários
const resultado = [];
for (const log of logs) {
let usuario;
let usuario = undefined;
if (log.usuarioId) {
const user = await ctx.db.get(log.usuarioId);
if (user) {
let matricula: string | undefined;
let matricula: string | undefined = undefined;
if (user.funcionarioId) {
const funcionario = await ctx.db.get(user.funcionarioId);
matricula = funcionario?.matricula;

View File

@@ -1,6 +1,6 @@
import { v } from 'convex/values';
import { Doc, type Id } from './_generated/dataModel';
import { type MutationCtx, mutation, QueryCtx, query } from './_generated/server';
import { mutation, query, MutationCtx } from './_generated/server';
import { Id } from './_generated/dataModel';
/**
* Helper para registrar tentativas de login
@@ -382,8 +382,8 @@ export const listarTodosLogins = query({
// Buscar informações dos usuários quando disponível
const logsComUsuarios = await Promise.all(
logs.map(async (log) => {
let usuarioNome: string | undefined;
let usuarioEmail: string | undefined;
let usuarioNome: string | undefined = undefined;
let usuarioEmail: string | undefined = undefined;
if (log.usuarioId) {
const usuario = await ctx.db.get(log.usuarioId);

View File

@@ -145,6 +145,217 @@ export const listarAlertas = query({
}
});
/**
* Verificar configuração do sistema de alertas (diagnóstico)
*/
export const verificarConfiguracaoAlertas = query({
args: {
_refresh: v.optional(v.number()) // Parâmetro ignorado, usado apenas para forçar refresh no frontend
},
returns: v.object({
templateExiste: v.boolean(),
templateInfo: v.union(
v.object({
_id: v.id('templatesMensagens'),
codigo: v.string(),
nome: v.string(),
htmlCorpo: v.optional(v.string())
}),
v.null()
),
todosTemplatesCodigos: v.optional(v.array(v.string())), // Para debug
roleTiMasterExiste: v.boolean(),
usuariosTiMaster: v.array(
v.object({
_id: v.id('usuarios'),
nome: v.string(),
email: v.optional(v.string()),
temEmail: v.boolean()
})
),
configSmtpAtiva: v.boolean(),
configSmtpInfo: v.union(
v.object({
_id: v.id('configuracaoEmail'),
servidor: v.string(),
porta: v.number(),
emailRemetente: v.string(),
ativo: v.boolean()
}),
v.null()
),
emailsPendentes: v.number(),
emailsFalha: v.number(),
alertasAtivos: v.number(),
alertasComEmail: v.number()
}),
handler: async (ctx) => {
try {
// 1. Verificar template
let template = null;
try {
// Tentar buscar usando índice
template = await ctx.db
.query('templatesMensagens')
.withIndex('by_codigo', (q) => q.eq('codigo', 'monitoramento_alerta_sistema'))
.first();
// Se não encontrou com índice, tentar busca direta
if (!template) {
const todosTemplates = await ctx.db.query('templatesMensagens').collect();
template =
todosTemplates.find(
(t) => t.codigo?.toLowerCase() === 'monitoramento_alerta_sistema'.toLowerCase()
) || null;
}
if (template) {
console.log('✅ Template encontrado:', template.codigo, template.nome);
} else {
console.warn('⚠️ Template monitoramento_alerta_sistema não encontrado no banco');
}
} catch (error) {
console.error('❌ Erro ao buscar template:', error);
// Tentar busca alternativa sem índice
try {
const todosTemplates = await ctx.db.query('templatesMensagens').collect();
template =
todosTemplates.find(
(t) => t.codigo?.toLowerCase() === 'monitoramento_alerta_sistema'.toLowerCase()
) || null;
} catch (fallbackError) {
console.error('❌ Erro na busca alternativa:', fallbackError);
}
}
// 2. Verificar role TI_MASTER
let roleTiMaster = null;
try {
roleTiMaster = await ctx.db
.query('roles')
.withIndex('by_nome', (q) => q.eq('nome', 'ti_master'))
.first();
} catch (error) {
console.warn('Erro ao buscar role TI_MASTER:', error);
}
// 3. Verificar usuários TI_MASTER
let usuariosTiMaster: Array<{
_id: Id<'usuarios'>;
nome: string;
email?: string;
temEmail: boolean;
}> = [];
if (roleTiMaster) {
try {
const usuarios = await ctx.db
.query('usuarios')
.withIndex('by_role', (q) => q.eq('roleId', roleTiMaster!._id))
.collect();
usuariosTiMaster = usuarios.map((u) => ({
_id: u._id,
nome: u.nome,
email: u.email,
temEmail: !!u.email
}));
} catch (error) {
console.warn('Erro ao buscar usuários TI_MASTER:', error);
}
}
// 4. Verificar configuração SMTP
let configSmtp = null;
try {
configSmtp = await ctx.db
.query('configuracaoEmail')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
} catch (error) {
console.warn('Erro ao buscar configuração SMTP:', error);
}
// 5. Listar todos os templates para debug (opcional)
let todosTemplatesCodigos: string[] = [];
try {
const todosTemplates = await ctx.db.query('templatesMensagens').collect();
todosTemplatesCodigos = todosTemplates.map((t) => t.codigo || '').filter(Boolean);
console.log('📋 Templates encontrados no banco:', todosTemplatesCodigos);
} catch (error) {
console.warn('Erro ao listar templates para debug:', error);
}
// 6. Verificar fila de emails
let emailsPendentes = 0;
let emailsFalha = 0;
try {
const todosEmails = await ctx.db.query('notificacoesEmail').collect();
emailsPendentes = todosEmails.filter((e) => e.status === 'pendente').length;
emailsFalha = todosEmails.filter((e) => e.status === 'falha').length;
} catch (error) {
console.warn('Erro ao buscar emails:', error);
}
// 7. Verificar alertas
let alertasAtivos = 0;
let alertasComEmail = 0;
try {
const todosAlertas = await ctx.db.query('alertConfigurations').collect();
alertasAtivos = todosAlertas.filter((a) => a.enabled).length;
alertasComEmail = todosAlertas.filter((a) => a.enabled && a.notifyByEmail).length;
} catch (error) {
console.warn('Erro ao buscar alertas:', error);
}
return {
templateExiste: !!template,
templateInfo: template
? {
_id: template._id,
codigo: template.codigo,
nome: template.nome,
htmlCorpo: template.htmlCorpo
}
: null,
todosTemplatesCodigos: todosTemplatesCodigos.length > 0 ? todosTemplatesCodigos : undefined,
roleTiMasterExiste: !!roleTiMaster,
usuariosTiMaster,
configSmtpAtiva: !!configSmtp,
configSmtpInfo: configSmtp
? {
_id: configSmtp._id,
servidor: configSmtp.servidor,
porta: configSmtp.porta,
emailRemetente: configSmtp.emailRemetente,
ativo: configSmtp.ativo
}
: null,
emailsPendentes,
emailsFalha,
alertasAtivos,
alertasComEmail
};
} catch (error) {
console.error('Erro ao verificar configuração de alertas:', error);
// Retornar valores padrão em caso de erro
return {
templateExiste: false,
templateInfo: null,
todosTemplatesCodigos: undefined,
roleTiMasterExiste: false,
usuariosTiMaster: [],
configSmtpAtiva: false,
configSmtpInfo: null,
emailsPendentes: 0,
emailsFalha: 0,
alertasAtivos: 0,
alertasComEmail: 0
};
}
}
});
/**
* Obter métricas com filtros
*/
@@ -346,56 +557,94 @@ export const verificarAlertasInternal = internalMutation({
.filter((q) => q.eq(q.field('admin'), true))
.collect();
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
if (!rolesAdminOuTi) {
console.warn('Role TI_MASTER não encontrada. Notificações de chat não serão enviadas.');
} else {
// Buscar usuários com role TI_MASTER
const usuarios = await ctx.db
.query('usuarios')
.withIndex('by_role', (q) => q.eq('roleId', rolesAdminOuTi[0]._id))
.collect();
const usuarios = await ctx.db.query('usuarios').collect();
const usuariosTI = usuarios.filter((u) => rolesPermitidas.has(u.roleId));
for (const usuario of usuariosTI) {
await ctx.db.insert('notificacoes', {
usuarioId: usuario._id,
tipo: 'nova_mensagem',
titulo: `⚠️ Alerta de Sistema: ${alerta.metricName}`,
descricao: `Métrica ${alerta.metricName} está em ${metricValue.toFixed(2)}% (limite: ${alerta.threshold}%)`,
lida: false,
criadaEm: Date.now()
});
for (const usuario of usuarios) {
await ctx.db.insert('notificacoes', {
usuarioId: usuario._id,
tipo: 'nova_mensagem',
titulo: `⚠️ Alerta de Sistema: ${alerta.metricName}`,
descricao: `Métrica ${alerta.metricName} está em ${metricValue.toFixed(2)}% (limite: ${alerta.threshold}%)`,
lida: false,
criadaEm: Date.now()
});
}
}
}
// 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
// Buscar apenas a role TI_MASTER
const roleTiMaster = await ctx.db
.query('roles')
.filter((q) => q.eq(q.field('admin'), true))
.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);
if (!roleTiMaster) {
console.warn(
'⚠️ [Monitoramento] Role TI_MASTER não encontrada. Emails de alerta não serão enviados.'
);
} else {
// Buscar usuários com role TI_MASTER que possuem email
const usuarios = await ctx.db
.query('usuarios')
.withIndex('by_role', (q) => q.eq('roleId', roleTiMaster[0]._id))
.collect();
for (const usuario of usuariosTI) {
const email = usuario.email;
if (!email) continue;
if (usuarios.length === 0) {
console.warn(
'⚠️ [Monitoramento] Nenhum usuário TI_MASTER encontrado para receber alertas por email.'
);
} else {
// Usar o createdBy do alerta como enviadoPor (quem criou o alerta)
const enviadoPorId = alerta.createdBy;
// 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()
};
for (const usuario of usuarios) {
const email = usuario.email;
if (!email) {
console.warn(
`⚠️ [Monitoramento] Usuário ${usuario._id} (TI_MASTER) não possui email cadastrado.`
);
continue;
}
// 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
});
// 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()
};
try {
// 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: enviadoPorId // ✅ CORRIGIDO: usar createdBy do alerta
});
console.log(
`✅ [Monitoramento] Email de alerta agendado para ${email} (${usuario.nome})`
);
} catch (error) {
console.error(
`❌ [Monitoramento] Erro ao agendar email de alerta para ${email}:`,
error
);
// Continuar tentando enviar para outros usuários mesmo se um falhar
}
}
}
}
}
}
@@ -612,49 +861,62 @@ export const getStatusSistema = query({
ultimaAtualizacao: v.number()
}),
handler: async (ctx) => {
// Última métrica, se existir
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
try {
// Últimatrica, se existir
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
// Usuários online: usar métrica se disponível, senão derivar de usuários
let usuariosOnline = 0;
if (ultimaMetrica?.usuariosOnline !== undefined) {
usuariosOnline = ultimaMetrica.usuariosOnline;
} else {
const usuarios = await ctx.db.query('usuarios').collect();
usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length;
// Usuários online: usar métrica se disponível, senão derivar de usuários
let usuariosOnline = 0;
if (ultimaMetrica?.usuariosOnline !== undefined) {
usuariosOnline = ultimaMetrica.usuariosOnline;
} else {
const usuarios = await ctx.db.query('usuarios').collect();
usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length;
}
// Total de registros (estimativa baseada em tabelas principais)
const [usuarios, funcionarios, simbolos, alertas, metricas] = await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const totalRegistros =
usuarios.length + funcionarios.length + simbolos.length + alertas.length + metricas.length;
// Métricas de performance com fallbacks seguros
const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0;
const cpuUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.cpuUsage ?? 0) * 100) / 100)
);
const memoriaUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.memoryUsage ?? 0) * 100) / 100)
);
const ultimaAtualizacao = ultimaMetrica?.timestamp ?? Date.now();
return {
usuariosOnline,
totalRegistros,
tempoMedioResposta,
cpuUsada,
memoriaUsada,
ultimaAtualizacao
};
} catch (error) {
console.error('Erro em getStatusSistema:', error);
// Retornar valores padrão em caso de erro
return {
usuariosOnline: 0,
totalRegistros: 0,
tempoMedioResposta: 0,
cpuUsada: 0,
memoriaUsada: 0,
ultimaAtualizacao: Date.now()
};
}
// Total de registros (estimativa baseada em tabelas principais)
const [usuarios, funcionarios, simbolos, alertas, metricas] = await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const totalRegistros =
usuarios.length + funcionarios.length + simbolos.length + alertas.length + metricas.length;
// Métricas de performance com fallbacks seguros
const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0;
const cpuUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.cpuUsage ?? 0) * 100) / 100)
);
const memoriaUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.memoryUsage ?? 0) * 100) / 100)
);
const ultimaAtualizacao = ultimaMetrica?.timestamp ?? Date.now();
return {
usuariosOnline,
totalRegistros,
tempoMedioResposta,
cpuUsada,
memoriaUsada,
ultimaAtualizacao
};
}
});
@@ -673,60 +935,66 @@ export const getAtividadeBancoDados = query({
)
}),
handler: async (ctx) => {
const agora = Date.now();
const haUmMinuto = agora - 60 * 1000;
try {
const agora = Date.now();
const haUmMinuto = agora - 60 * 1000;
// Buscar atividades reais do sistema
const atividadesRecentes = await ctx.db
.query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Buscar atividades reais do sistema
const atividadesRecentes = await ctx.db
.query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Buscar métricas também (para mensagens se houver)
const metricasRecentes = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Buscar métricas também (para mensagens se houver)
const metricasRecentes = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Bucketizar em 30 pontos (~2s cada) para visualização
const numBuckets = 30;
const bucketSizeMs = Math.ceil(60_000 / numBuckets);
const historico: Array<{ entradas: number; saidas: number }> = [];
// Bucketizar em 30 pontos (~2s cada) para visualização
const numBuckets = 30;
const bucketSizeMs = Math.ceil(60_000 / numBuckets);
const historico: Array<{ entradas: number; saidas: number }> = [];
for (let i = 0; i < numBuckets; i++) {
const inicio = haUmMinuto + i * bucketSizeMs;
const fim = inicio + bucketSizeMs;
for (let i = 0; i < numBuckets; i++) {
const inicio = haUmMinuto + i * bucketSizeMs;
const fim = inicio + bucketSizeMs;
// Contar atividades de criação/inserção (entradas)
const atividadesBucket = atividadesRecentes.filter(
(a) => a.timestamp >= inicio && a.timestamp < fim
);
const entradasAtividades = atividadesBucket.filter(
(a) => a.acao === 'criar' || a.acao === 'inserir' || a.acao === 'cadastrar'
).length;
// Contar atividades de criação/inserção (entradas)
const atividadesBucket = atividadesRecentes.filter(
(a) => a.timestamp >= inicio && a.timestamp < fim
);
const entradasAtividades = atividadesBucket.filter(
(a) => a.acao === 'criar' || a.acao === 'inserir' || a.acao === 'cadastrar'
).length;
// Contar atividades de exclusão/remoção (saídas)
const saidasAtividades = atividadesBucket.filter(
(a) => a.acao === 'excluir' || a.acao === 'remover' || a.acao === 'deletar'
).length;
// Contar atividades de exclusão/remoção (saídas)
const saidasAtividades = atividadesBucket.filter(
(a) => a.acao === 'excluir' || a.acao === 'remover' || a.acao === 'deletar'
).length;
// Usar mensagensPorMinuto como adicional se disponível
const bucketMetricas = metricasRecentes.filter(
(m) => m.timestamp >= inicio && m.timestamp < fim
);
const somaMensagens =
bucketMetricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0) || 0;
// Usar mensagensPorMinuto como adicional se disponível
const bucketMetricas = metricasRecentes.filter(
(m) => m.timestamp >= inicio && m.timestamp < fim
);
const somaMensagens =
bucketMetricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0) || 0;
// Combinar atividades reais com métricas de mensagens
const entradas = Math.max(0, Math.round(entradasAtividades + somaMensagens * 0.3));
const saidas = Math.max(0, Math.round(saidasAtividades + somaMensagens * 0.2));
// Combinar atividades reais com métricas de mensagens
const entradas = Math.max(0, Math.round(entradasAtividades + somaMensagens * 0.3));
const saidas = Math.max(0, Math.round(saidasAtividades + somaMensagens * 0.2));
historico.push({ entradas, saidas });
historico.push({ entradas, saidas });
}
return { historico };
} catch (error) {
console.error('Erro em getAtividadeBancoDados:', error);
// Retornar histórico vazio em caso de erro
return { historico: Array(30).fill({ entradas: 0, saidas: 0 }) };
}
return { historico };
}
});
@@ -742,55 +1010,61 @@ export const getDistribuicaoRequisicoes = query({
escritas: v.number()
}),
handler: async (ctx) => {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
try {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
// Buscar atividades reais do sistema
const atividades = await ctx.db
.query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.collect();
// Buscar atividades reais do sistema
const atividades = await ctx.db
.query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.collect();
// Buscar métricas também
const metricas = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.order('desc')
.take(100);
// Buscar métricas também
const metricas = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.order('desc')
.take(100);
// Contar operações de leitura (consultas, visualizações)
const leituras = atividades.filter(
(a) =>
a.acao === 'consultar' ||
a.acao === 'visualizar' ||
a.acao === 'listar' ||
a.acao === 'buscar'
).length;
// Contar operações de leitura (consultas, visualizações)
const leituras = atividades.filter(
(a) =>
a.acao === 'consultar' ||
a.acao === 'visualizar' ||
a.acao === 'listar' ||
a.acao === 'buscar'
).length;
// Contar operações de escrita (criar, editar, excluir)
const escritas = atividades.filter(
(a) =>
a.acao === 'criar' ||
a.acao === 'editar' ||
a.acao === 'excluir' ||
a.acao === 'inserir' ||
a.acao === 'atualizar' ||
a.acao === 'deletar' ||
a.acao === 'cadastrar' ||
a.acao === 'remover'
).length;
// Contar operações de escrita (criar, editar, excluir)
const escritas = atividades.filter(
(a) =>
a.acao === 'criar' ||
a.acao === 'editar' ||
a.acao === 'excluir' ||
a.acao === 'inserir' ||
a.acao === 'atualizar' ||
a.acao === 'deletar' ||
a.acao === 'cadastrar' ||
a.acao === 'remover'
).length;
// Adicionar estimativa baseada em mensagens se disponível
const totalMensagens = Math.max(
0,
Math.round(metricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0))
);
// Adicionar estimativa baseada em mensagens se disponível
const totalMensagens = Math.max(
0,
Math.round(metricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0))
);
// Queries são leituras + parte das mensagens (como consultas de chat)
const queries = leituras + Math.round(totalMensagens * 0.5);
// Queries são leituras + parte das mensagens (como consultas de chat)
const queries = leituras + Math.round(totalMensagens * 0.5);
// Mutations são escritas + parte das mensagens (como envio de mensagens)
const mutations = escritas + Math.round(totalMensagens * 0.3);
// Mutations são escritas + parte das mensagens (como envio de mensagens)
const mutations = escritas + Math.round(totalMensagens * 0.3);
return { queries, mutations, leituras, escritas };
return { queries, mutations, leituras, escritas };
} catch (error) {
console.error('Erro em getDistribuicaoRequisicoes:', error);
// Retornar valores padrão em caso de erro
return { queries: 0, mutations: 0, leituras: 0, escritas: 0 };
}
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { QueryCtx } from './_generated/server';
import { query } from './_generated/server';
import { Id } from './_generated/dataModel';
import type { QueryCtx } from './_generated/server';
/**
* SISTEMA DE CÁLCULO DE SALDO DE FÉRIAS

View File

@@ -20,6 +20,7 @@ import { setoresTables } from './tables/setores';
import { systemTables } from './tables/system';
import { ticketsTables } from './tables/tickets';
import { timesTables } from './tables/times';
import { lgpdTables } from './tables/lgpdTables';
export default defineSchema({
...setoresTables,
@@ -42,5 +43,6 @@ export default defineSchema({
...pontoTables,
...pedidosTables,
...objetosTables,
...atasTables
...atasTables,
...lgpdTables
});

View File

@@ -1,9 +1,8 @@
import { RateLimiter, SECOND } from '@convex-dev/rate-limiter';
import { v } from 'convex/values';
import { components, internal } from './_generated/api';
import { internalMutation, mutation, MutationCtx, query, QueryCtx } from './_generated/server';
import { internal, api, components } from './_generated/api';
import type { Id } from './_generated/dataModel';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import type {
AtaqueCiberneticoTipo,
SeveridadeSeguranca,
@@ -1435,6 +1434,7 @@ export const listarAlertConfigs = query({
severidadeMin: severidadeValidator,
tiposAtaque: v.optional(v.array(ataqueValidator)),
reenvioMin: v.number(),
templateCodigo: v.optional(v.string()),
criadoEm: v.number(),
atualizadoEm: v.number()
})
@@ -1455,6 +1455,7 @@ export const listarAlertConfigs = query({
severidadeMin: r.severidadeMin,
tiposAtaque: r.tiposAtaque,
reenvioMin: r.reenvioMin,
templateCodigo: r.templateCodigo,
criadoEm: r.criadoEm,
atualizadoEm: r.atualizadoEm
}));
@@ -1471,6 +1472,7 @@ export const salvarAlertConfig = mutation({
severidadeMin: severidadeValidator,
tiposAtaque: v.optional(v.array(ataqueValidator)),
reenvioMin: v.number(),
templateCodigo: v.optional(v.string()), // Template a ser usado
criadoPor: v.id('usuarios')
},
returns: v.object({ _id: v.id('alertConfigs') }),
@@ -1485,6 +1487,7 @@ export const salvarAlertConfig = mutation({
severidadeMin: args.severidadeMin,
tiposAtaque: args.tiposAtaque,
reenvioMin: args.reenvioMin,
templateCodigo: args.templateCodigo,
atualizadoEm: agora
});
return { _id: args.configId };
@@ -1497,6 +1500,7 @@ export const salvarAlertConfig = mutation({
severidadeMin: args.severidadeMin,
tiposAtaque: args.tiposAtaque,
reenvioMin: args.reenvioMin,
templateCodigo: args.templateCodigo ?? 'incidente_critico', // Padrão
criadoPor: args.criadoPor,
criadoEm: agora,
atualizadoEm: agora
@@ -1522,6 +1526,197 @@ export const dispararAlertasInternos = internalMutation({
const evento = await ctx.db.get(args.eventoId);
if (!evento) return null;
// Buscar todas as configurações de alerta ativas
const alertConfigs = await ctx.db.query('alertConfigs').collect();
// Obter URL do sistema
let urlSistema = process.env.SITE_URL || 'http://localhost:5173';
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Formatar data/hora
const dataHora = new Date(evento.timestamp).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Mapear severidade para texto legível
const severityLabels: Record<SeveridadeSeguranca, string> = {
informativo: 'Informativo',
baixo: 'Baixo',
moderado: 'Moderado',
alto: 'Alto',
critico: 'Crítico'
};
// Mapear tipo de ataque para texto legível
const attackLabels: Record<string, string> = {
phishing: 'Phishing',
malware: 'Malware',
ransomware: 'Ransomware',
brute_force: 'Brute Force',
credential_stuffing: 'Credential Stuffing',
sql_injection: 'SQL Injection',
xss: 'XSS',
path_traversal: 'Path Traversal',
command_injection: 'Command Injection',
nosql_injection: 'NoSQL Injection',
xxe: 'XXE',
man_in_the_middle: 'MITM',
ddos: 'DDoS',
engenharia_social: 'Engenharia Social',
cve_exploit: 'Exploração de CVE',
apt: 'APT',
zero_day: 'Zero-Day',
supply_chain: 'Supply Chain',
fileless_malware: 'Fileless Malware',
polymorphic_malware: 'Polymorphic',
ransomware_lateral: 'Ransomware Lateral',
deepfake_phishing: 'Deepfake Phishing',
adversarial_ai: 'Ataque IA',
side_channel: 'Side-Channel',
firmware_bootloader: 'Firmware/Bootloader',
bec: 'BEC',
botnet: 'Botnet',
ot_ics: 'OT/ICS',
quantum_attack: 'Quantum'
};
const tipoAtaqueLabel = attackLabels[evento.tipoAtaque] || evento.tipoAtaque.replace(/_/g, ' ');
const severidadeLabel = severityLabels[evento.severidade] || evento.severidade;
// Função auxiliar para verificar se a severidade atende ao mínimo
const severidadeAtende = (
severidade: SeveridadeSeguranca,
min: SeveridadeSeguranca
): boolean => {
const ordem: SeveridadeSeguranca[] = ['informativo', 'baixo', 'moderado', 'alto', 'critico'];
return ordem.indexOf(severidade) >= ordem.indexOf(min);
};
// Processar cada configuração de alerta
for (const config of alertConfigs) {
// Verificar se a severidade atende ao mínimo
if (!severidadeAtende(evento.severidade, config.severidadeMin)) {
continue;
}
// Verificar se o tipo de ataque está na lista (se especificado)
if (config.tiposAtaque && config.tiposAtaque.length > 0) {
if (!config.tiposAtaque.includes(evento.tipoAtaque)) {
continue;
}
}
// Buscar usuário sistema para enviar emails (ou usar o primeiro usuário TI)
const rolesTi = await ctx.db
.query('roles')
.filter((q) => q.eq(q.field('admin'), true))
.collect();
let usuarioSistema: Id<'usuarios'> | undefined;
if (rolesTi) {
const usuarioTi = await ctx.db
.query('usuarios')
.withIndex('by_role', (q) => q.eq('roleId', rolesTi[0]._id))
.first();
if (usuarioTi) {
usuarioSistema = usuarioTi._id;
}
}
if (!usuarioSistema) {
console.error('❌ Não foi possível encontrar usuário sistema para enviar alertas');
continue;
}
// Preparar variáveis do template
const variaveisTemplate = {
destinatarioNome: '', // Será preenchido por destinatário
tipoAtaque: tipoAtaqueLabel,
severidade: severidadeLabel,
descricao: evento.descricao,
origemIp: evento.origemIp || 'N/A',
dataHora,
urlSistema
};
// ENVIAR EMAILS
if (config.canais.email && config.emails.length > 0) {
const templateCodigo = config.templateCodigo || 'incidente_critico';
for (const emailDestinatario of config.emails) {
// Buscar usuário pelo email
const usuarioDestinatario = await ctx.db
.query('usuarios')
.filter((q) => q.eq(q.field('email'), emailDestinatario))
.first();
if (usuarioDestinatario) {
variaveisTemplate.destinatarioNome = usuarioDestinatario.nome;
// Enviar email usando template
ctx.scheduler
.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: emailDestinatario,
destinatarioId: usuarioDestinatario._id,
templateCodigo,
variaveis: variaveisTemplate,
enviadoPor: usuarioSistema
})
.catch((error) => {
console.error(`Erro ao agendar email de alerta para ${emailDestinatario}:`, error);
});
}
}
}
// ENVIAR CHAT
if (config.canais.chat && config.chatUsers.length > 0) {
const templateCodigo = config.templateCodigo || 'incidente_critico';
// Buscar template para chat
const template = await ctx.runQuery(api.templatesMensagens.obterTemplatePorCodigo, {
codigo: templateCodigo
});
if (template) {
// Importar função de renderização
const { renderizarTemplateChatFromDoc } = await import('./templatesMensagens');
for (const chatUserEmail of config.chatUsers) {
// Buscar usuário pelo email
const usuarioDestinatario = await ctx.db
.query('usuarios')
.filter((q) => q.eq(q.field('email'), chatUserEmail))
.first();
if (usuarioDestinatario && usuarioSistema) {
variaveisTemplate.destinatarioNome = usuarioDestinatario.nome;
// Renderizar mensagem do template
const mensagemChat = renderizarTemplateChatFromDoc(template, variaveisTemplate);
// Usar função interna para criar conversa e enviar mensagem
ctx.scheduler
.runAfter(0, internal.security.enviarMensagemChatSistema, {
usuarioSistemaId: usuarioSistema,
usuarioDestinatarioId: usuarioDestinatario._id,
mensagem: mensagemChat
})
.catch((error) => {
console.error(`Erro ao agendar mensagem de chat para ${chatUserEmail}:`, error);
});
}
}
}
}
}
// Manter notificação padrão para usuários TI (compatibilidade)
const rolesTi = await ctx.db
.query('roles')
.filter((q) => q.eq(q.field('admin'), true))
@@ -1546,7 +1741,7 @@ export const dispararAlertasInternos = internalMutation({
conversaId: undefined,
mensagemId: undefined,
remetenteId: undefined,
titulo: `🚨 ${evento.severidade.toUpperCase()} - ${evento.tipoAtaque.replace(/_/g, ' ')}`,
titulo: `🚨 ${evento.severidade.toUpperCase()} - ${tipoAtaqueLabel}`,
descricao: evento.descricao,
lida: false,
criadaEm: Date.now()
@@ -1557,6 +1752,183 @@ export const dispararAlertasInternos = internalMutation({
}
});
/**
* Função interna para enviar mensagem de chat do sistema
*/
export const enviarMensagemChatSistema = internalMutation({
args: {
usuarioSistemaId: v.id('usuarios'),
usuarioDestinatarioId: v.id('usuarios'),
mensagem: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
// Buscar ou criar conversa individual entre sistema e destinatá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(args.usuarioSistemaId) &&
conversa.participantes.includes(args.usuarioDestinatarioId)
) {
conversaId = conversa._id;
break;
}
}
if (!conversaId) {
// Criar nova conversa
conversaId = await ctx.db.insert('conversas', {
tipo: 'individual',
participantes: [args.usuarioSistemaId, args.usuarioDestinatarioId],
criadoPor: args.usuarioSistemaId,
criadoEm: Date.now()
});
}
// Criar mensagem
const mensagemId = await ctx.db.insert('mensagens', {
conversaId,
remetenteId: args.usuarioSistemaId,
conteudo: args.mensagem,
conteudoBusca: args.mensagem.toLowerCase(),
tipo: 'texto',
enviadaEm: Date.now()
});
// Atualizar última mensagem da conversa
await ctx.db.patch(conversaId, {
ultimaMensagem: args.mensagem.substring(0, 100),
ultimaMensagemTimestamp: Date.now(),
ultimaMensagemRemetenteId: args.usuarioSistemaId
});
// Criar notificação para destinatário
await ctx.db.insert('notificacoes', {
usuarioId: args.usuarioDestinatarioId,
tipo: 'nova_mensagem',
conversaId,
mensagemId,
remetenteId: args.usuarioSistemaId,
titulo: '🚨 Alerta de Segurança',
descricao: args.mensagem.substring(0, 100),
lida: false,
criadaEm: Date.now()
});
return null;
}
});
/**
* Notificar quando rate limit é excedido
*/
export const notificarRateLimitExcedido = internalMutation({
args: {
configId: v.id('rateLimitConfig'),
tipo: v.union(
v.literal('ip'),
v.literal('usuario'),
v.literal('endpoint'),
v.literal('global')
),
identificador: v.string(),
endpoint: v.string(),
acaoExcedido: v.union(v.literal('bloquear'), v.literal('throttle'), v.literal('alertar')),
limite: v.number(),
janelaSegundos: v.number()
},
returns: v.null(),
handler: async (ctx, args) => {
const config = await ctx.db.get(args.configId);
if (!config) return null;
// Buscar usuários TI para notificar
const rolesTi = await ctx.db
.query('roles')
.filter((q) => q.eq(q.field('admin'), true))
.collect();
const usuariosNotificados: Id<'usuarios'>[] = [];
for (const role of rolesTi) {
const membros = await ctx.db
.query('usuarios')
.withIndex('by_role', (q) => q.eq('roleId', role._id))
.collect();
for (const usuario of membros) {
usuariosNotificados.push(usuario._id);
}
}
// Criar notificações para usuários TI
const tipoAcao =
args.acaoExcedido === 'bloquear'
? 'bloqueado'
: args.acaoExcedido === 'alertar'
? 'alertado'
: 'throttled';
const emoji = args.acaoExcedido === 'bloquear' ? '🚫' : '⚠️';
const titulo = `${emoji} Rate Limit ${tipoAcao === 'bloqueado' ? 'Bloqueado' : tipoAcao === 'alertado' ? 'Alertado' : 'Throttled'}`;
const descricao = `${args.tipo.toUpperCase()}: ${args.identificador} excedeu o limite de ${args.limite} requisições em ${args.janelaSegundos}s no endpoint ${args.endpoint}`;
for (const usuarioId of usuariosNotificados) {
await ctx.db.insert('notificacoes', {
usuarioId,
tipo: 'alerta_seguranca',
conversaId: undefined,
mensagemId: undefined,
remetenteId: undefined,
titulo,
descricao,
lida: false,
criadaEm: Date.now()
});
}
// Criar evento de segurança se foi bloqueado
if (args.acaoExcedido === 'bloquear') {
// Determinar tipo de ataque baseado no contexto
let tipoAtaque: AtaqueCiberneticoTipo = 'brute_force';
if (args.tipo === 'ip') {
tipoAtaque = 'ddos';
} else if (args.tipo === 'usuario') {
tipoAtaque = 'brute_force';
}
// Criar evento de segurança
const eventoId = await ctx.db.insert('securityEvents', {
referencia: `rate_limit_${args.tipo}_${args.identificador}_${Date.now()}`,
timestamp: Date.now(),
tipoAtaque,
severidade: 'alto',
status: 'detectado',
descricao: `Rate limit bloqueado: ${args.identificador} excedeu ${args.limite} requisições em ${args.janelaSegundos}s`,
origemIp: args.tipo === 'ip' ? args.identificador : undefined,
tags: ['rate_limit', 'bloqueio_automatico'],
atualizadoEm: Date.now()
});
// Disparar alertas se configurado
ctx.scheduler
.runAfter(0, internal.security.dispararAlertasInternos, {
eventoId
})
.catch((error) => {
console.error('Erro ao agendar alertas de rate limit:', error);
});
}
return null;
}
});
export const expirarBloqueiosIpAutomaticos = internalMutation({
args: {},
returns: v.null(),
@@ -1692,6 +2064,24 @@ async function aplicarRateLimit(
if (!result.ok) {
const retryAfter = result.retryAfter ?? periodo;
// Criar notificações e eventos quando rate limit é excedido
// Usar scheduler para não bloquear a requisição
if ('runMutation' in ctx) {
ctx.scheduler
.runAfter(0, internal.security.notificarRateLimitExcedido, {
configId: config._id,
tipo,
identificador,
endpoint: endpoint ?? 'default',
acaoExcedido: config.acaoExcedido,
limite: config.limite,
janelaSegundos: config.janelaSegundos
})
.catch((error) => {
console.error('Erro ao agendar notificação de rate limit:', error);
});
}
if (config.acaoExcedido === 'bloquear') {
return {
permitido: false,

View File

@@ -18,7 +18,16 @@ export const ausenciasTables = {
dataReprovacao: v.optional(v.number()),
motivoReprovacao: v.optional(v.string()),
observacao: v.optional(v.string()),
criadoEm: v.number()
criadoEm: v.number(),
historicoAlteracoes: v.optional(
v.array(
v.object({
data: v.number(),
usuarioId: v.id('usuarios'),
acao: v.string()
})
)
)
})
.index('by_funcionario', ['funcionarioId'])
.index('by_status', ['status'])

View File

@@ -23,12 +23,16 @@ export const chatTables = {
conversaId: v.id('conversas'),
remetenteId: v.id('usuarios'),
tipo: v.union(v.literal('texto'), v.literal('arquivo'), v.literal('imagem')),
conteudo: v.string(), // texto ou nome do arquivo
conteudoBusca: v.optional(v.string()), // versão normalizada para busca
conteudo: v.string(), // texto ou nome do arquivo (pode ser criptografado)
conteudoBusca: v.optional(v.string()), // versão normalizada para busca (não criptografada se mensagem for E2E)
arquivoId: v.optional(v.id('_storage')),
arquivoNome: v.optional(v.string()),
arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()),
// Campos para criptografia E2E
criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
keyId: v.optional(v.string()), // Identificador da chave usada para criptografar
linkPreview: v.optional(
v.object({
url: v.string(),
@@ -169,5 +173,17 @@ export const chatTables = {
atualizadoEm: v.number()
})
.index('by_usuario_conversa', ['usuarioId', 'conversaId'])
.index('by_conversa', ['conversaId'])
.index('by_conversa', ['conversaId']),
// Chaves de Criptografia E2E por Conversa
chavesCriptografia: defineTable({
conversaId: v.id('conversas'),
chaveCompartilhada: v.string(), // Chave criptografada compartilhada (base64)
keyId: v.string(), // Identificador único da chave
criadoPor: v.id('usuarios'), // Usuário que criou/compartilhou a chave
criadoEm: v.number(),
ativo: v.boolean() // Se a chave está ativa (permite rotação de chaves)
})
.index('by_conversa', ['conversaId', 'ativo'])
.index('by_key_id', ['keyId'])
};

View File

@@ -45,7 +45,8 @@ export const feriasTables = {
v.literal('nova_solicitacao'),
v.literal('aprovado'),
v.literal('reprovado'),
v.literal('data_ajustada')
v.literal('data_ajustada'),
v.literal('cancelado')
),
lida: v.boolean(),
mensagem: v.string()

View File

@@ -35,7 +35,9 @@ export const funcionariosTables = {
simboloId: v.id('simbolos'),
simboloTipo: simboloTipo,
gestorId: v.optional(v.id('usuarios')),
statusFerias: v.optional(v.union(v.literal('ativo'), v.literal('em_ferias'))),
statusFerias: v.optional(
v.union(v.literal('ativo'), v.literal('em_ferias'), v.literal('em_licenca'))
),
// Regime de trabalho (para cálculo correto de férias)
regimeTrabalho: v.optional(

View File

@@ -0,0 +1,98 @@
import { defineTable } from 'convex/server';
import { v } from 'convex/values';
export const lgpdTables = {
// ========== LGPD - Lei Geral de Proteção de Dados ==========
// Solicitações de direitos LGPD
solicitacoesLGPD: defineTable({
tipo: v.union(
v.literal('acesso'),
v.literal('correcao'),
v.literal('exclusao'),
v.literal('portabilidade'),
v.literal('revogacao_consentimento'),
v.literal('informacao_compartilhamento')
),
usuarioId: v.id('usuarios'),
funcionarioId: v.optional(v.id('funcionarios')),
status: v.union(
v.literal('pendente'),
v.literal('em_analise'),
v.literal('concluida'),
v.literal('rejeitada'),
v.literal('cancelada')
),
dadosSolicitados: v.optional(v.string()), // JSON com detalhes da solicitação
resposta: v.optional(v.string()), // Resposta da solicitação
arquivoResposta: v.optional(v.id('_storage')), // Arquivo gerado (ex: exportação de dados)
respondidoPor: v.optional(v.id('usuarios')),
respondidoEm: v.optional(v.number()),
criadoEm: v.number(),
prazoResposta: v.number(), // Prazo legal (15 dias) - timestamp
observacoes: v.optional(v.string())
})
.index('by_usuario', ['usuarioId'])
.index('by_status', ['status'])
.index('by_tipo', ['tipo'])
.index('by_prazo', ['prazoResposta'])
.index('by_funcionario', ['funcionarioId']),
// Consentimentos dos usuários
consentimentos: defineTable({
usuarioId: v.id('usuarios'),
tipo: v.union(
v.literal('termo_uso'),
v.literal('politica_privacidade'),
v.literal('comunicacoes'),
v.literal('compartilhamento_dados')
),
aceito: v.boolean(),
versao: v.string(), // Versão do documento aceito (ex: "1.0")
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
aceitoEm: v.number(),
revogadoEm: v.optional(v.number()),
revogadoPor: v.optional(v.id('usuarios')) // Se revogado pelo próprio usuário ou por TI
})
.index('by_usuario', ['usuarioId'])
.index('by_tipo', ['tipo'])
.index('by_usuario_tipo', ['usuarioId', 'tipo'])
.index('by_versao', ['versao']),
// Registro de Operações de Tratamento (ROT)
registrosTratamento: defineTable({
finalidade: v.string(), // Finalidade do tratamento
baseLegal: v.string(), // Base legal (ex: "Art. 7º, II - Execução de políticas públicas")
categoriasDados: v.array(v.string()), // ["dados_identificacao", "dados_contato", "dados_profissionais"]
categoriasTitulares: v.array(v.string()), // ["funcionarios", "servidores", "colaboradores"]
medidasSeguranca: v.array(v.string()), // ["criptografia", "controle_acesso", "logs_auditoria"]
prazoRetencao: v.number(), // em dias
compartilhamentoTerceiros: v.boolean(),
terceiros: v.optional(v.array(v.string())), // Lista de terceiros com quem compartilha
responsavel: v.id('usuarios'), // Responsável pelo tratamento
criadoEm: v.number(),
atualizadoEm: v.number(),
ativo: v.boolean(),
descricao: v.optional(v.string()) // Descrição detalhada
})
.index('by_finalidade', ['finalidade'])
.index('by_ativo', ['ativo'])
.index('by_responsavel', ['responsavel']),
// Configurações LGPD
configuracaoLGPD: defineTable({
encarregadoNome: v.optional(v.string()),
encarregadoEmail: v.optional(v.string()),
encarregadoTelefone: v.optional(v.string()),
encarregadoHorarioAtendimento: v.optional(v.string()), // Ex: "Segunda a Sexta, das 8h às 17h"
prazoRespostaPadrao: v.number(), // em dias (padrão: 15)
diasAlertaVencimento: v.number(), // dias antes do prazo para alertar (padrão: 3)
termoObrigatorio: v.boolean(), // Se o termo de consentimento é obrigatório
versaoTermoAtual: v.string(), // Versão atual do termo (ex: "1.0")
politicaRetencao: v.optional(v.string()), // JSON com política de retenção por tipo de dado
ativo: v.boolean(),
atualizadoPor: v.id('usuarios'),
atualizadoEm: v.number()
}).index('by_ativo', ['ativo'])
};

View File

@@ -204,11 +204,48 @@ export const pontoTables = {
horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos)
saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit)
registrosPontoIds: v.array(v.id('registrosPonto')), // IDs dos registros do dia
// Novos campos para sistema avançado
ajustesIds: v.optional(v.array(v.id('ajustesBancoHoras'))), // IDs dos ajustes aplicados no dia
motivoAbono: v.optional(v.string()), // Motivo do abono (atestado, licença, ausência, etc.)
tipoDia: v.optional(
v.union(
v.literal('normal'),
v.literal('atestado'),
v.literal('licenca'),
v.literal('ausencia'),
v.literal('abonado'),
v.literal('descontado')
)
), // Tipo do dia
inconsistenciasIds: v.optional(v.array(v.id('inconsistenciasBancoHoras'))), // IDs de inconsistências detectadas
calculadoEm: v.number()
})
.index('by_funcionario_data', ['funcionarioId', 'data'])
.index('by_funcionario', ['funcionarioId'])
.index('by_data', ['data']),
.index('by_data', ['data'])
.index('by_tipo_dia', ['tipoDia']),
// Banco de Horas Mensal - Agregação mensal do banco de horas
bancoHorasMensal: defineTable({
funcionarioId: v.id('funcionarios'),
mes: v.string(), // YYYY-MM (ex: "2024-01")
saldoInicialMinutos: v.number(), // Saldo acumulado do mês anterior (pode ser negativo)
saldoFinalMinutos: v.number(), // Saldo acumulado ao final do mês
saldoMesMinutos: v.number(), // Saldo apenas do mês atual (sem acumular)
diasTrabalhados: v.number(), // Quantidade de dias com registros no mês
horasExtras: v.number(), // Total de minutos positivos do mês
horasDeficit: v.number(), // Total de minutos negativos do mês (valor absoluto)
// Novos campos para sistema avançado
totalAjustes: v.optional(v.number()), // Total de ajustes aplicados no mês (em minutos)
totalAbonos: v.optional(v.number()), // Total de abonos no mês (em minutos)
totalDescontos: v.optional(v.number()), // Total de descontos no mês (em minutos)
inconsistenciasResolvidas: v.optional(v.number()), // Quantidade de inconsistências resolvidas
calculadoEm: v.number(),
atualizadoEm: v.number()
})
.index('by_funcionario_mes', ['funcionarioId', 'mes'])
.index('by_funcionario', ['funcionarioId'])
.index('by_mes', ['mes']),
// Homologações de Ponto - Edições e ajustes realizados pelo gestor
homologacoesPonto: defineTable({
@@ -262,5 +299,119 @@ export const pontoTables = {
.index('by_gestor', ['gestorId'])
.index('by_ativo', ['ativo'])
.index('by_data_inicio', ['dataInicio'])
.index('by_data_fim', ['dataFim'])
.index('by_data_fim', ['dataFim']),
// Configuração de Banco de Horas - Configurações gerais do sistema
configuracaoBancoHoras: defineTable({
// Limites de saldo
limiteSaldoPositivoMinutos: v.optional(v.number()), // Limite máximo de saldo positivo (em minutos)
limiteSaldoNegativoMinutos: v.optional(v.number()), // Limite máximo de saldo negativo (em minutos)
// Regras de cálculo
considerarAjustesAutomaticos: v.optional(v.boolean()), // Se deve considerar ajustes automáticos (atestados, licenças, ausências)
// Periodicidade de verificação
periodicidadeVerificacao: v.optional(
v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal'))
),
// Metadados
atualizadoPor: v.id('usuarios'),
atualizadoEm: v.number()
}).index('by_ativo', ['atualizadoEm']),
// Alertas de Banco de Horas - Configuração de alertas por tipo
alertasBancoHoras: defineTable({
tipoAlerta: v.union(
v.literal('saldo_negativo'),
v.literal('saldo_negativo_critico'),
v.literal('inconsistencia_detectada'),
v.literal('dias_sem_registro'),
v.literal('limite_saldo_excedido')
),
// Periodicidade
periodicidade: v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')),
// Canais de envio
enviarEmail: v.boolean(),
enviarChat: v.boolean(),
// Destinatários específicos (opcional - se vazio, envia para gestor padrão)
destinatariosEmail: v.optional(v.array(v.id('usuarios'))), // IDs de usuários que receberão email
destinatariosChat: v.optional(v.array(v.id('usuarios'))), // IDs de usuários que receberão chat
// Thresholds e limites
threshold: v.optional(v.number()), // Valor limite para disparar alerta
limiteMinutos: v.optional(v.number()), // Limite em minutos (para saldo negativo)
// Status
ativo: v.boolean(),
// Metadados
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
atualizadoPor: v.optional(v.id('usuarios')),
atualizadoEm: v.optional(v.number())
})
.index('by_tipo', ['tipoAlerta'])
.index('by_ativo', ['ativo'])
.index('by_tipo_ativo', ['tipoAlerta', 'ativo']),
// Ajustes de Banco de Horas - Registro de ajustes manuais e automáticos
ajustesBancoHoras: defineTable({
funcionarioId: v.id('funcionarios'),
tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')),
// Motivo vinculado
motivoTipo: v.optional(
v.union(
v.literal('atestado'),
v.literal('licenca'),
v.literal('ausencia'),
v.literal('manual')
)
),
motivoId: v.optional(v.string()), // ID do atestado, licença, ausência ou null para manual
motivoDescricao: v.optional(v.string()), // Descrição do motivo
// Valor do ajuste
valorMinutos: v.number(), // Valor em minutos (positivo para abonar, negativo para descontar)
// Data de aplicação
dataAplicacao: v.string(), // YYYY-MM-DD
// Gestor responsável (null se automático)
gestorId: v.optional(v.id('usuarios')),
// Observações
observacoes: v.optional(v.string()),
// Status
aplicado: v.boolean(), // Se já foi aplicado ao banco de horas
// Metadados
criadoEm: v.number(),
aplicadoEm: v.optional(v.number())
})
.index('by_funcionario', ['funcionarioId'])
.index('by_data_aplicacao', ['dataAplicacao'])
.index('by_funcionario_data', ['funcionarioId', 'dataAplicacao'])
.index('by_tipo', ['tipo'])
.index('by_aplicado', ['aplicado'])
.index('by_gestor', ['gestorId']),
// Inconsistências de Banco de Horas - Registro de inconsistências detectadas
inconsistenciasBancoHoras: defineTable({
funcionarioId: v.id('funcionarios'),
tipo: v.union(
v.literal('ponto_com_atestado'),
v.literal('ponto_com_licenca'),
v.literal('ponto_com_ausencia'),
v.literal('registro_duplicado'),
v.literal('sequencia_invalida'),
v.literal('saldo_inconsistente')
),
descricao: v.string(), // Descrição detalhada da inconsistência
dataDetectada: v.string(), // YYYY-MM-DD
dataInconsistencia: v.string(), // YYYY-MM-DD (data do dia com inconsistência)
// Status
status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')),
// Resolução
resolucao: v.optional(v.string()), // Descrição da resolução
resolvidoPor: v.optional(v.id('usuarios')), // Usuário que resolveu
resolvidoEm: v.optional(v.number()), // Timestamp da resolução
// Metadados
criadoEm: v.number()
})
.index('by_funcionario', ['funcionarioId'])
.index('by_status', ['status'])
.index('by_funcionario_status', ['funcionarioId', 'status'])
.index('by_data_detectada', ['dataDetectada'])
.index('by_tipo', ['tipo'])
.index('by_data_inconsistencia', ['dataInconsistencia'])
};

View File

@@ -164,6 +164,7 @@ export const systemTables = {
severidadeMin: severidadeSeguranca,
tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)),
reenvioMin: v.number(),
templateCodigo: v.optional(v.string()), // Template a ser usado para email/chat
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
atualizadoEm: v.number()
@@ -222,5 +223,24 @@ export const systemTables = {
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'])
}).index('by_ativo', ['ativo']),
// Logs de Erros do Servidor (500, etc)
errosServidor: defineTable({
statusCode: v.number(), // Código HTTP do erro (500, 502, etc)
mensagem: v.string(), // Mensagem do erro
stack: v.optional(v.string()), // Stack trace do erro
url: v.optional(v.string()), // URL onde ocorreu o erro
method: v.optional(v.string()), // Método HTTP (GET, POST, etc)
ipAddress: v.optional(v.string()), // IP do cliente
userAgent: v.optional(v.string()), // User agent do navegador
usuarioId: v.optional(v.id('usuarios')), // Usuário autenticado (se houver)
notificado: v.boolean(), // Se a equipe técnica já foi notificada
notificadoEm: v.optional(v.number()), // Timestamp da notificação
criadoEm: v.number() // Timestamp do erro
})
.index('by_status_code', ['statusCode'])
.index('by_notificado', ['notificado'])
.index('by_criado_em', ['criadoEm'])
.index('by_usuario', ['usuarioId'])
};

View File

@@ -1,8 +1,8 @@
import { v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
import { registrarAtividade } from './logsAtividades';
import { textToHTML, wrapEmailHTML } from './utils/emailTemplateWrapper';
import { Doc } from './_generated/dataModel';
import { wrapEmailHTML, textToHTML } from './utils/emailTemplateWrapper';
/**
* Listar todos os templates
@@ -61,10 +61,7 @@ export const criarTemplate = mutation({
criadoPorId: v.id('usuarios')
},
returns: v.union(
v.object({
sucesso: v.literal(true),
templateId: v.id('templatesMensagens')
}),
v.object({ sucesso: v.literal(true), templateId: v.id('templatesMensagens') }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
@@ -146,10 +143,7 @@ export const editarTemplate = mutation({
// 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'
};
return { sucesso: false as const, erro: 'Templates do sistema não podem ser editados' };
}
// Atualizar template
@@ -209,10 +203,7 @@ export const excluirTemplate = mutation({
// Não permite excluir templates do sistema
if (template.tipo === 'sistema') {
return {
sucesso: false as const,
erro: 'Templates do sistema não podem ser excluídos'
};
return { sucesso: false as const, erro: 'Templates do sistema não podem ser excluídos' };
}
// Excluir template
@@ -361,8 +352,27 @@ export const criarTemplatesPadrao = mutation({
nome: 'Boas-vindas',
titulo: 'Bem-vindo ao SGSE',
corpo:
'Olá {{nome}},\n\nSeja bem-vindo ao SGSE - Sistema de Gerenciamento de Secretaria!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI',
variaveis: ['nome', 'matricula', 'senha']
"<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>" +
"<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>" +
"<h2 style='color: #2563EB;'>Bem-vindo ao SGSE</h2>" +
'<p>Olá <strong>{{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> {{email}}</li>' +
'{{credenciaisAdicionais}}' +
'<li><strong>Senha temporária:</strong> {{senha}}</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>" +
'</div></body></html>',
variaveis: ['nome', 'email', 'credenciaisAdicionais', 'senha', 'urlSistema'],
categoria: 'email' as const,
tags: ['boas_vindas', 'cadastro', 'credenciais']
},
{
codigo: 'chat_mensagem',
@@ -554,10 +564,138 @@ export const criarTemplatesPadrao = mutation({
'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.',
htmlCorpo:
'<div style="max-width: 600px; margin: 0 auto; padding: 20px;">' +
'<div style="background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%); border-radius: 8px; padding: 20px; margin-bottom: 25px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">' +
'<h2 style="color: #FFFFFF; margin: 0 0 10px 0; font-size: 24px; font-weight: bold;">⚠️ Alerta de Sistema</h2>' +
'<p style="color: #FFFFFF; margin: 0; font-size: 16px; font-weight: 500;">Métrica: <strong>{{metricName}}</strong></p>' +
'</div>' +
'<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Olá <strong>{{destinatarioNome}}</strong>,</p>' +
'<div style="background-color: #FFF3CD; border-left: 4px solid #FFC107; padding: 15px; border-radius: 4px; margin: 20px 0;">' +
'<p style="margin: 0 0 10px 0; color: #856404; font-weight: bold; font-size: 14px;">📊 Detalhes do Alerta:</p>' +
'<ul style="margin: 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;">' +
'<li><strong>Métrica:</strong> {{metricName}}</li>' +
'<li><strong>Valor Atual:</strong> <span style="color: #DC3545; font-weight: bold;">{{metricValue}}</span></li>' +
'<li><strong>Limite Configurado:</strong> {{threshold}}</li>' +
'</ul>' +
'</div>' +
'<p style="color: #333333; font-size: 14px; line-height: 1.6; margin: 20px 0;">' +
'Recomenda-se verificar o <strong>painel de monitoramento do SGSE</strong> para detalhes adicionais e, se necessário, executar ações corretivas.' +
'</p>' +
'<div style="background-color: #E7F3FF; border-left: 4px solid #0052A5; padding: 15px; border-radius: 4px; margin: 20px 0;">' +
'<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.6;">' +
'<strong>💡 Dica:</strong> Acesse o painel de monitoramento para visualizar gráficos e histórico detalhado desta métrica.' +
'</p>' +
'</div>' +
'<p style="color: #666666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #E0E0E0;">' +
'Esta é uma notificação automática do sistema de monitoramento SGSE.' +
'</p>' +
'</div>',
variaveis: ['destinatarioNome', 'metricName', 'metricValue', 'threshold'],
categoria: 'email' as const,
tags: ['monitoramento', 'alerta', 'sistema', 'ti']
},
// ===================== LGPD =====================
{
codigo: 'lgpd_solicitacao_criada',
nome: 'LGPD - Solicitação Criada',
titulo: 'Recebemos sua solicitação LGPD ({{tipoSolicitacaoLabel}})',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Recebemos sua solicitação LGPD do tipo "{{tipoSolicitacaoLabel}}".\n\n' +
'Prazo estimado para resposta: até {{prazoResposta}}.\n\n' +
'Você pode acompanhar o andamento acessando: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'tipoSolicitacaoLabel', 'prazoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'solicitacao', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_acesso',
nome: 'LGPD - Resposta Acesso',
titulo: 'Resposta à sua solicitação LGPD - Acesso aos Dados',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Acesso aos Dados foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'acesso', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_correcao',
nome: 'LGPD - Resposta Correção',
titulo: 'Resposta à sua solicitação LGPD - Correção de Dados',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Correção de Dados foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'correcao', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_exclusao',
nome: 'LGPD - Resposta Exclusão',
titulo: 'Resposta à sua solicitação LGPD - Exclusão de Dados',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Exclusão de Dados foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'exclusao', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_portabilidade',
nome: 'LGPD - Resposta Portabilidade',
titulo: 'Resposta à sua solicitação LGPD - Portabilidade dos Dados',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Portabilidade dos Dados foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Caso tenha recebido um arquivo anexo, ele contém os dados em formato portável.\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'portabilidade', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_revogacao_consentimento',
nome: 'LGPD - Resposta Revogação de Consentimento',
titulo: 'Confirmação de Revogação de Consentimento',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Revogação de Consentimento foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Todos os consentimentos ativos associados à sua conta foram marcados como revogados a partir desta data.\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'revogacao_consentimento', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_informacao_compartilhamento',
nome: 'LGPD - Resposta Informação sobre Compartilhamento',
titulo: 'Resposta à sua solicitação LGPD - Informação sobre Compartilhamento',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Informação sobre Compartilhamento foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'informacao_compartilhamento', 'dados_pessoais']
},
{
codigo: 'ausencia_solicitada',
nome: 'Ausência Solicitada',
@@ -609,6 +747,264 @@ export const criarTemplatesPadrao = mutation({
],
categoria: 'email' as const,
tags: ['ausencia', 'reprovacao', 'gestao']
},
{
codigo: 'ferias_aprovada',
nome: 'Férias Aprovada',
titulo: 'Solicitação de Férias Aprovada',
corpo:
'Olá {{funcionarioNome}},\n\nSua solicitação de férias foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Dias:</strong> {{diasFerias}} dias</li></ul>',
variaveis: [
'funcionarioNome',
'gestorNome',
'dataInicio',
'dataFim',
'diasFerias',
'urlSistema'
],
categoria: 'email' as const,
tags: ['ferias', 'aprovacao', 'gestao']
},
{
codigo: 'ferias_cancelada_rh',
nome: 'Férias Cancelada pelo RH',
titulo: 'Solicitação de Férias Cancelada',
corpo:
'Olá {{funcionarioNome}},\n\nSua solicitação de férias foi <strong>cancelada</strong> pelo setor de Recursos Humanos:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Dias:</strong> {{diasFerias}} dias</li></ul>\n\nPara mais informações, entre em contato com o setor de Recursos Humanos.',
variaveis: ['funcionarioNome', 'dataInicio', 'dataFim', 'diasFerias', 'urlSistema'],
categoria: 'email' as const,
tags: ['ferias', 'cancelamento', 'recursos_humanos']
},
// ===================== ALERTAS DE SEGURANÇA CIBERNÉTICA =====================
{
codigo: 'incidente_critico',
nome: 'Incidente Crítico - Ação Imediata',
titulo: '🚨 ALERTA CRÍTICO: {{tipoAtaque}}',
corpo:
"<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>" +
"<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>" +
"<h2 style='color: #DC2626;'>🚨 ALERTA CRÍTICO DE SEGURANÇA</h2>" +
'<p>Olá <strong>{{destinatarioNome}}</strong>,</p>' +
'<p>Um <strong>incidente crítico de segurança</strong> foi detectado no sistema:</p>' +
"<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>" +
"<p style='margin: 0;'><strong>Tipo de Ataque:</strong> {{tipoAtaque}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Severidade:</strong> <span style='color: #DC2626; font-weight: bold;'>{{severidade}}</span></p>" +
"<p style='margin: 5px 0 0 0;'><strong>Descrição:</strong> {{descricao}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>IP de Origem:</strong> {{origemIp}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Data/Hora:</strong> {{dataHora}}</p>" +
'</div>' +
"<p style='color: #DC2626; font-weight: bold;'>⚠️ AÇÃO IMEDIATA NECESSÁRIA</p>" +
"<p style='margin-top: 30px;'>" +
"<a href='{{urlSistema}}/ti/cybersecurity' " +
"style='background-color: #DC2626; color: white; padding: 12px 24px; " +
"text-decoration: none; border-radius: 6px; display: inline-block;'>" +
'Ver Detalhes do Incidente' +
'</a>' +
'</p>' +
"<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>" +
'SGSE - Sistema de Gerenciamento de Secretaria - Equipe de Segurança' +
'</p>' +
'</div></body></html>',
variaveis: [
'destinatarioNome',
'tipoAtaque',
'severidade',
'descricao',
'origemIp',
'dataHora',
'urlSistema'
],
categoria: 'email' as const,
tags: ['seguranca', 'alerta', 'critico', 'cybersecurity']
},
{
codigo: 'bloqueio_automatico',
nome: 'Bloqueio Automático',
titulo: '🔒 Bloqueio Automático: {{tipoAtaque}}',
corpo:
"<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>" +
"<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>" +
"<h2 style='color: #F59E0B;'>🔒 Bloqueio Automático Aplicado</h2>" +
'<p>Olá <strong>{{destinatarioNome}}</strong>,</p>' +
'<p>O sistema aplicou um <strong>bloqueio automático</strong> devido a uma tentativa de ataque detectada:</p>' +
"<div style='background-color: #FFFBEB; border-left: 4px solid #F59E0B; padding: 15px; border-radius: 8px; margin: 20px 0;'>" +
"<p style='margin: 0;'><strong>Tipo de Ataque:</strong> {{tipoAtaque}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>IP Bloqueado:</strong> {{origemIp}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Descrição:</strong> {{descricao}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Data/Hora:</strong> {{dataHora}}</p>" +
'</div>' +
"<p style='margin-top: 30px;'>" +
"<a href='{{urlSistema}}/ti/cybersecurity' " +
"style='background-color: #F59E0B; color: white; padding: 12px 24px; " +
"text-decoration: none; border-radius: 6px; display: inline-block;'>" +
'Ver Detalhes do Bloqueio' +
'</a>' +
'</p>' +
"<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>" +
'SGSE - Sistema de Gerenciamento de Secretaria - Equipe de Segurança' +
'</p>' +
'</div></body></html>',
variaveis: [
'destinatarioNome',
'tipoAtaque',
'origemIp',
'descricao',
'dataHora',
'urlSistema'
],
categoria: 'email' as const,
tags: ['seguranca', 'bloqueio', 'automatico', 'cybersecurity']
},
{
codigo: 'sumario_30min',
nome: 'Sumário 30 Min',
titulo: '📊 Sumário de Segurança - Últimos 30 minutos',
corpo:
"<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>" +
"<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>" +
"<h2 style='color: #2563EB;'>📊 Sumário de Segurança</h2>" +
'<p>Olá <strong>{{destinatarioNome}}</strong>,</p>' +
'<p>Resumo dos eventos de segurança dos últimos 30 minutos:</p>' +
"<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>" +
"<p style='margin: 0;'><strong>Total de Eventos:</strong> {{totalEventos}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Eventos Críticos:</strong> {{eventosCriticos}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Eventos Altos:</strong> {{eventosAltos}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>IPs Bloqueados:</strong> {{ipsBloqueados}}</p>" +
'</div>' +
"<p style='margin-top: 30px;'>" +
"<a href='{{urlSistema}}/ti/cybersecurity' " +
"style='background-color: #2563EB; color: white; padding: 12px 24px; " +
"text-decoration: none; border-radius: 6px; display: inline-block;'>" +
'Ver Relatório Completo' +
'</a>' +
'</p>' +
"<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>" +
'SGSE - Sistema de Gerenciamento de Secretaria - Equipe de Segurança' +
'</p>' +
'</div></body></html>',
variaveis: [
'destinatarioNome',
'totalEventos',
'eventosCriticos',
'eventosAltos',
'ipsBloqueados',
'urlSistema'
],
categoria: 'email' as const,
tags: ['seguranca', 'sumario', 'relatorio', 'cybersecurity']
},
{
codigo: 'anormalidade',
nome: 'Anomalia Detectada',
titulo: '⚠️ Anomalia Detectada: {{tipoAtaque}}',
corpo:
"<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>" +
"<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>" +
"<h2 style='color: #F59E0B;'>⚠️ Anomalia Detectada</h2>" +
'<p>Olá <strong>{{destinatarioNome}}</strong>,</p>' +
'<p>O sistema detectou uma <strong>anomalia de segurança</strong> que requer atenção:</p>' +
"<div style='background-color: #FFFBEB; border-left: 4px solid #F59E0B; padding: 15px; border-radius: 8px; margin: 20px 0;'>" +
"<p style='margin: 0;'><strong>Tipo de Ataque:</strong> {{tipoAtaque}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Severidade:</strong> {{severidade}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Descrição:</strong> {{descricao}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>IP de Origem:</strong> {{origemIp}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Data/Hora:</strong> {{dataHora}}</p>" +
'</div>' +
"<p style='margin-top: 30px;'>" +
"<a href='{{urlSistema}}/ti/cybersecurity' " +
"style='background-color: #F59E0B; color: white; padding: 12px 24px; " +
"text-decoration: none; border-radius: 6px; display: inline-block;'>" +
'Ver Detalhes da Anomalia' +
'</a>' +
'</p>' +
"<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>" +
'SGSE - Sistema de Gerenciamento de Secretaria - Equipe de Segurança' +
'</p>' +
'</div></body></html>',
variaveis: [
'destinatarioNome',
'tipoAtaque',
'severidade',
'descricao',
'origemIp',
'dataHora',
'urlSistema'
],
categoria: 'email' as const,
tags: ['seguranca', 'anomalia', 'alerta', 'cybersecurity']
},
// ===================== NOTIFICAÇÕES DE ERROS DO SERVIDOR =====================
{
codigo: 'ERRO_SERVIDOR_404',
nome: 'Erro 404 - Página Não Encontrada',
titulo: '⚠️ Erro 404 - Página não encontrada',
corpo:
"<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>" +
"<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>" +
"<h2 style='color: #F59E0B;'>⚠️ Erro 404 - Página Não Encontrada</h2>" +
'<p>Olá <strong>{{destinatarioNome}}</strong>,</p>' +
'<p>O sistema detectou uma tentativa de acesso a uma página que não existe:</p>' +
"<div style='background-color: #FFFBEB; border-left: 4px solid #F59E0B; padding: 15px; border-radius: 8px; margin: 20px 0;'>" +
"<p style='margin: 0;'><strong>URL:</strong> {{url}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Método HTTP:</strong> {{method}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Mensagem:</strong> {{mensagem}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Data/Hora:</strong> {{timestamp}}</p>" +
'</div>' +
"<p style='color: #6B7280; font-size: 14px; margin-top: 20px;'>" +
'<strong>Possíveis causas:</strong><br>' +
'• Link quebrado ou desatualizado<br>' +
'• URL digitada incorretamente<br>' +
'• Página movida ou removida<br>' +
'• Tentativa de acesso a recurso inexistente' +
'</p>' +
"<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>" +
'SGSE - Sistema de Gerenciamento de Secretaria - Equipe de TI' +
'</p>' +
'</div></body></html>',
variaveis: ['destinatarioNome', 'url', 'method', 'mensagem', 'timestamp'],
categoria: 'email' as const,
tags: ['erro', '404', 'servidor', 'notificacao', 'ti']
},
{
codigo: 'ERRO_SERVIDOR_500',
nome: 'Erro 500 - Erro Interno do Servidor',
titulo: '🚨 Erro 500 - Erro Interno do Servidor',
corpo:
"<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>" +
"<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>" +
"<h2 style='color: #DC2626;'>🚨 Erro 500 - Erro Interno do Servidor</h2>" +
'<p>Olá <strong>{{destinatarioNome}}</strong>,</p>' +
'<p>O sistema detectou um <strong>erro interno do servidor</strong> que requer atenção imediata:</p>' +
"<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>" +
"<p style='margin: 0;'><strong>Código HTTP:</strong> {{statusCode}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>URL:</strong> {{url}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Método HTTP:</strong> {{method}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Mensagem:</strong> {{mensagem}}</p>" +
"<p style='margin: 5px 0 0 0;'><strong>Data/Hora:</strong> {{timestamp}}</p>" +
'</div>' +
"<div style='background-color: #F9FAFB; border: 1px solid #E5E7EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>" +
"<p style='margin: 0; font-size: 12px; color: #6B7280;'><strong>Stack Trace:</strong></p>" +
"<pre style='margin: 10px 0 0 0; padding: 10px; background-color: #FFFFFF; border: 1px solid #E5E7EB; border-radius: 4px; font-size: 11px; white-space: pre-wrap; word-wrap: break-word; overflow-x: auto;'>{{stack}}</pre>" +
'</div>' +
"<p style='color: #DC2626; font-weight: bold; margin-top: 20px;'>" +
'⚠️ AÇÃO IMEDIATA NECESSÁRIA' +
'</p>' +
"<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>" +
'SGSE - Sistema de Gerenciamento de Secretaria - Equipe de TI<br>' +
'Este é um alerta automático do sistema de monitoramento de erros.' +
'</p>' +
'</div></body></html>',
variaveis: [
'destinatarioNome',
'statusCode',
'url',
'method',
'mensagem',
'stack',
'timestamp'
],
categoria: 'email' as const,
tags: ['erro', '500', 'servidor', 'critico', 'notificacao', 'ti']
}
];
@@ -653,10 +1049,7 @@ export const atualizarTemplateHTML = mutation({
// 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'
};
return { sucesso: false as const, erro: 'Templates do sistema não podem ser editados' };
}
await ctx.db.patch(args.templateId, {
@@ -746,7 +1139,7 @@ export const previewTemplate = query({
* Função auxiliar para obter URL base
*/
function getBaseUrl(): string {
const url = process.env.FRONTEND_URL || 'http://localhost:5173';
const url = process.env.SITE_URL || 'http://localhost:5173';
if (!url.match(/^https?:\/\//i)) {
return `http://${url}`;
}
@@ -907,10 +1300,7 @@ export const duplicarTemplate = mutation({
criadoPorId: v.id('usuarios')
},
returns: v.union(
v.object({
sucesso: v.literal(true),
templateId: v.id('templatesMensagens')
}),
v.object({ sucesso: v.literal(true), templateId: v.id('templatesMensagens') }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
@@ -948,11 +1338,7 @@ export const duplicarTemplate = mutation({
args.criadoPorId,
'duplicar',
'templates',
JSON.stringify({
templateId,
codigo: args.novoCodigo,
originalId: args.templateId
}),
JSON.stringify({ templateId, codigo: args.novoCodigo, originalId: args.templateId }),
templateId
);

View File

@@ -163,9 +163,22 @@ export const listarSubordinadosDoGestorAtual = query({
for (const rel of membrosRelacoes) {
const funcionario = await ctx.db.get(rel.funcionarioId);
if (funcionario) {
// Buscar foto do perfil do funcionário através do usuário associado
let fotoPerfilUrl: string | null = null;
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);
}
membros.push({
relacaoId: rel._id,
funcionario,
funcionario: {
...funcionario,
fotoPerfilUrl
},
dataEntrada: rel.dataEntrada
});
}

View File

@@ -4,6 +4,7 @@ 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)
@@ -123,6 +124,116 @@ export const criar = mutation({
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 };
}
});
@@ -827,48 +938,108 @@ export const desbloquearUsuario = mutation({
/**
* 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" };
// }
export const resetarSenhaUsuario = mutation({
args: {
usuarioId: v.id('usuarios'),
resetadoPorId: v.id('usuarios'),
novaSenhaTemporaria: v.optional(v.string()) // Se não fornecer, gera automática
},
returns: v.union(
v.object({ sucesso: v.literal(true), senhaTemporaria: v.string() }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario) {
return { sucesso: false as const, erro: 'Usuário não encontrado' };
}
// // Gerar senha temporária se não foi fornecida
// const senhaTemporaria = args.novaSenhaTemporaria || gerarSenhaTemporaria();
// const senhaHash = await hashPassword(senhaTemporaria);
// 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' };
}
// // Atualizar usuário
// await ctx.db.patch(args.usuarioId, {
// senhaHash,
// primeiroAcesso: true, // Força mudança de senha no próximo login
// tentativasLogin: 0,
// ultimaTentativaLogin: undefined,
// atualizadoEm: Date.now(),
// });
// Buscar a role do usuário
if (!resetadoPor.roleId) {
return { sucesso: false as const, erro: 'Usuário não possui role definida' };
}
// // Log de atividade
// await registrarAtividade(
// ctx,
// args.resetadoPorId,
// "resetar_senha",
// "usuarios",
// JSON.stringify({ usuarioId: args.usuarioId }),
// args.usuarioId
// );
const role = await ctx.db.get(resetadoPor.roleId);
if (!role) {
return { sucesso: false as const, erro: 'Role do usuário não encontrada' };
}
// return { sucesso: true as const, senhaTemporaria };
// },
// });
// 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 {

View File

@@ -0,0 +1,65 @@
/**
* Utilitários para manipulação de datas no backend
* Resolve problemas de timezone ao trabalhar com datas no formato YYYY-MM-DD
*/
/**
* Converte uma string de data no formato YYYY-MM-DD para um objeto Date local
* No ambiente Convex, as datas são tratadas como UTC, então precisamos garantir
* que a data seja interpretada corretamente.
*
* @param dateString - String no formato YYYY-MM-DD
* @returns Date objeto representando a data
*
* @example
* parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024
*/
export function parseLocalDate(dateString: string): Date {
if (!dateString || typeof dateString !== 'string') {
throw new Error('dateString deve ser uma string válida');
}
// Validar formato YYYY-MM-DD
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(dateString)) {
throw new Error('dateString deve estar no formato YYYY-MM-DD');
}
// Extrair ano, mês e dia
const [year, month, day] = dateString.split('-').map(Number);
// No Convex, criar a data usando UTC para evitar problemas de timezone
// Usamos UTC para garantir consistência, mas mantemos a data correta
const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
// Validar se a data é válida
if (isNaN(date.getTime())) {
throw new Error(`Data inválida: ${dateString}`);
}
return date;
}
/**
* Formata uma data para o formato brasileiro (DD/MM/YYYY)
*
* @param date - Date objeto ou string no formato YYYY-MM-DD
* @returns String formatada no formato DD/MM/YYYY
*/
export function formatarDataBR(date: Date | string): string {
let dateObj: Date;
if (typeof date === 'string') {
dateObj = parseLocalDate(date);
} else {
dateObj = date;
}
// Usar UTC para garantir consistência
const day = dateObj.getUTCDate().toString().padStart(2, '0');
const month = (dateObj.getUTCMonth() + 1).toString().padStart(2, '0');
const year = dateObj.getUTCFullYear();
return `${day}/${month}/${year}`;
}

View File

@@ -8,7 +8,7 @@
*/
function getBaseUrl(): string {
// Em produção, usar variável de ambiente
const url = process.env.FRONTEND_URL || 'http://localhost:5173';
const url = process.env.SITE_URL || 'http://localhost:5173';
// Garantir que tenha protocolo
if (!url.match(/^https?:\/\//i)) {
return `http://${url}`;
@@ -18,17 +18,20 @@ function getBaseUrl(): string {
/**
* Gera o HTML do header com logo do Governo de PE
* Usa URL estática do SvelteKit para servir a logo
*/
function generateHeader(): string {
const baseUrl = getBaseUrl();
// URL da logo na pasta static do SvelteKit
const logoUrl = `${baseUrl}/logo_governo_PE.png`;
return `
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #1a3a52; padding: 20px 0;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #0052A5; 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;" />
<img src="${logoUrl}" alt="Governo de Pernambuco" style="max-width: 200px; height: auto; display: block; margin: 0 auto;" />
</td>
</tr>
</table>
@@ -46,13 +49,13 @@ function generateFooter(): string {
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;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f5f5; border-top: 3px solid #0052A5; 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;">
<p style="margin: 0 0 10px 0; font-weight: bold; color: #0052A5; font-size: 16px;">
SGSE - Sistema de Gerenciamento de Secretaria
</p>
<p style="margin: 0 0 10px 0; color: #666666;">
@@ -66,8 +69,8 @@ function generateFooter(): string {
© ${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>
<a href="${baseUrl}" style="color: #0052A5; text-decoration: none;">Acessar Sistema</a> |
<a href="${baseUrl}/ti/notificacoes" style="color: #0052A5; text-decoration: none;">Central de Notificações</a>
</p>
</td>
</tr>
@@ -178,7 +181,7 @@ export function textToHTML(texto: string): string {
const linkRegex = /(https?:\/\/[^\s]+)/g;
const linhaComLinks = linhaTrim.replace(
linkRegex,
'<a href="$1" style="color: #1a3a52; text-decoration: underline;">$1</a>'
'<a href="$1" style="color: #0052A5; text-decoration: underline;">$1</a>'
);
return `<p style="margin: 0 0 15px 0;">${linhaComLinks}</p>`;
})

View File

@@ -1,6 +1,6 @@
import { v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, query } from './_generated/server';
import { v } from 'convex/values';
import { Id, Doc } from './_generated/dataModel';
/**
* Verificar duplicatas de matrícula (agora busca do funcionário associado)
@@ -30,7 +30,7 @@ export const verificarDuplicatas = query({
> = {};
for (const usuario of usuarios) {
let matricula: string | undefined;
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
@@ -77,7 +77,7 @@ export const removerDuplicatas = internalMutation({
const gruposPorMatricula: Record<string, Doc<'usuarios'>[]> = {};
for (const usuario of usuarios) {
let matricula: string | undefined;
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;