Merge remote-tracking branch 'origin' into feat-pedidos
This commit is contained in:
4476
packages/backend/convex/_generated/api.d.ts
vendored
4476
packages/backend/convex/_generated/api.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
34
packages/backend/convex/_generated/server.d.ts
vendored
34
packages/backend/convex/_generated/server.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
429
packages/backend/convex/errosServidor.ts
Normal file
429
packages/backend/convex/errosServidor.ts
Normal 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
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
1278
packages/backend/convex/lgpd.ts
Normal file
1278
packages/backend/convex/lgpd.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
// Última métrica, 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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'])
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
98
packages/backend/convex/tables/lgpdTables.ts
Normal file
98
packages/backend/convex/tables/lgpdTables.ts
Normal 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'])
|
||||
};
|
||||
@@ -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'])
|
||||
};
|
||||
|
||||
@@ -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'])
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
65
packages/backend/convex/utils/datas.ts
Normal file
65
packages/backend/convex/utils/datas.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user