From c6c88f85a77c76766702bd2e624d7e003fcae226 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 03:26:34 -0300 Subject: [PATCH] feat: enhance login process with IP capture and improved error handling - Implemented an internal mutation for login that captures the user's IP address and user agent for better security and tracking. - Enhanced the HTTP login endpoint to extract and log client IP, improving the overall authentication process. - Added validation for IP addresses to ensure only valid formats are recorded, enhancing data integrity. - Updated the login mutation to handle rate limiting and user status checks more effectively, providing clearer feedback on login attempts. --- .../lib/components/FuncionarioSelect.svelte | 189 ++ apps/web/src/lib/components/Sidebar.svelte | 3 +- apps/web/src/lib/utils/browserInfo.ts | 144 +- .../atestados-licencas/+page.svelte | 1557 ++++++++++++++++- packages/backend/convex/_generated/api.d.ts | 4 + packages/backend/convex/atestadosLicencas.ts | 1048 +++++++++++ packages/backend/convex/autenticacao.ts | 278 ++- packages/backend/convex/http.ts | 155 +- packages/backend/convex/logsLogin.ts | 34 +- packages/backend/convex/schema.ts | 38 +- packages/backend/convex/utils/getClientIP.ts | 151 ++ 11 files changed, 3531 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/lib/components/FuncionarioSelect.svelte create mode 100644 packages/backend/convex/atestadosLicencas.ts create mode 100644 packages/backend/convex/utils/getClientIP.ts diff --git a/apps/web/src/lib/components/FuncionarioSelect.svelte b/apps/web/src/lib/components/FuncionarioSelect.svelte new file mode 100644 index 0000000..544dcd4 --- /dev/null +++ b/apps/web/src/lib/components/FuncionarioSelect.svelte @@ -0,0 +1,189 @@ + + +
+ + +
+ + + {#if value} + + {:else} +
+ + + +
+ {/if} + + {#if mostrarDropdown && funcionariosFiltrados.length > 0} +
+ {#each funcionariosFiltrados as funcionario} + + {/each} +
+ {/if} + + {#if mostrarDropdown && busca && funcionariosFiltrados.length === 0} +
+ Nenhum funcionário encontrado +
+ {/if} +
+ + {#if funcionarioSelecionado} +
+ Selecionado: {funcionarioSelecionado.nome} + {#if funcionarioSelecionado.matricula} + - {funcionarioSelecionado.matricula} + {/if} +
+ {/if} +
diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 8ec8bdd..7ef45b5 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -101,7 +101,8 @@ carregandoLogin = true; try { - // Capturar informações do navegador + // Usar mutation normal com WebRTC para capturar IP + // getBrowserInfo() tenta obter o IP local via WebRTC const browserInfo = await getBrowserInfo(); const resultado = await convex.mutation(api.autenticacao.login, { diff --git a/apps/web/src/lib/utils/browserInfo.ts b/apps/web/src/lib/utils/browserInfo.ts index 8030aae..86fe840 100644 --- a/apps/web/src/lib/utils/browserInfo.ts +++ b/apps/web/src/lib/utils/browserInfo.ts @@ -14,7 +14,64 @@ export function getUserAgent(): string { } /** - * Tenta obter o IP local usando WebRTC + * Valida se uma string tem formato de IP válido + */ +function isValidIPFormat(ip: string): boolean { + if (!ip || ip.length < 7) return false; // IP mínimo: "1.1.1.1" = 7 chars + + // Validar IPv4 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (ipv4Regex.test(ip)) { + const parts = ip.split('.'); + return parts.length === 4 && parts.every(part => { + const num = parseInt(part, 10); + return !isNaN(num) && num >= 0 && num <= 255; + }); + } + + // Validar IPv6 básico (formato simplificado) + const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,6}$|^[0-9a-fA-F]{0,4}::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,5}$/; + if (ipv6Regex.test(ip)) { + return true; + } + + return false; +} + +/** + * Verifica se um IP é local/privado + */ +function isLocalIP(ip: string): boolean { + // IPs locais/privados + return ( + ip.startsWith('127.') || + ip.startsWith('192.168.') || + ip.startsWith('10.') || + ip.startsWith('172.16.') || + ip.startsWith('172.17.') || + ip.startsWith('172.18.') || + ip.startsWith('172.19.') || + ip.startsWith('172.20.') || + ip.startsWith('172.21.') || + ip.startsWith('172.22.') || + ip.startsWith('172.23.') || + ip.startsWith('172.24.') || + ip.startsWith('172.25.') || + ip.startsWith('172.26.') || + ip.startsWith('172.27.') || + ip.startsWith('172.28.') || + ip.startsWith('172.29.') || + ip.startsWith('172.30.') || + ip.startsWith('172.31.') || + ip.startsWith('169.254.') || // Link-local + ip === '::1' || + ip.startsWith('fe80:') // IPv6 link-local + ); +} + +/** + * Tenta obter o IP usando WebRTC + * Prioriza IP público, mas retorna IP local se não encontrar * Esta função não usa API externa, mas pode falhar em alguns navegadores * Retorna undefined se não conseguir obter */ @@ -32,31 +89,87 @@ export async function getLocalIP(): Promise { }); let resolved = false; + let foundIPs: string[] = []; + let publicIP: string | undefined = undefined; + let localIP: string | undefined = undefined; + const timeout = setTimeout(() => { if (!resolved) { resolved = true; pc.close(); - resolve(undefined); + // Priorizar IP público, mas retornar local se não houver + resolve(publicIP || localIP || undefined); } - }, 3000); + }, 5000); // Aumentar timeout para 5 segundos pc.onicecandidate = (event) => { if (event.candidate && !resolved) { const candidate = event.candidate.candidate; - // Regex para extrair IP local (IPv4) - const ipMatch = candidate.match(/([0-9]{1,3}(\.[0-9]{1,3}){3})/); - if (ipMatch && ipMatch[1]) { - const ip = ipMatch[1]; - // Verificar se não é IP localhost (127.0.0.1 ou ::1) - if (!ip.startsWith('127.') && !ip.startsWith('::1')) { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - pc.close(); - resolve(ip); + + // Regex mais rigorosa para IPv4 - deve ser um IP completo e válido + // Formato: X.X.X.X onde X é 0-255 + const ipv4Match = candidate.match(/\b([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\b/); + + // Regex para IPv6 - mais específica + const ipv6Match = candidate.match(/\b([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){2,7}|::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,6}|[0-9a-fA-F]{1,4}::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,5})\b/); + + let ip: string | undefined = undefined; + + if (ipv4Match && ipv4Match[1]) { + const candidateIP = ipv4Match[1]; + // Validar se cada octeto está entre 0-255 + const parts = candidateIP.split('.'); + if (parts.length === 4 && parts.every(part => { + const num = parseInt(part, 10); + return !isNaN(num) && num >= 0 && num <= 255; + })) { + ip = candidateIP; + } + } else if (ipv6Match && ipv6Match[1]) { + // Validar formato básico de IPv6 + const candidateIP = ipv6Match[1]; + if (candidateIP.includes(':') && candidateIP.length >= 3) { + ip = candidateIP; + } + } + + // Validar se o IP é válido antes de processar + if (ip && isValidIPFormat(ip) && !foundIPs.includes(ip)) { + foundIPs.push(ip); + + // Ignorar localhost + if (ip.startsWith('127.') || ip === '::1') { + return; + } + + // Separar IPs públicos e locais + if (isLocalIP(ip)) { + if (!localIP) { + localIP = ip; + } + } else { + // IP público encontrado! + if (!publicIP) { + publicIP = ip; + // Se encontrou IP público, podemos resolver mais cedo + if (!resolved) { + resolved = true; + clearTimeout(timeout); + pc.close(); + resolve(publicIP); + } } } } + } else if (event.candidate === null) { + // No more candidates + if (!resolved) { + resolved = true; + clearTimeout(timeout); + pc.close(); + // Retornar IP público se encontrou, senão local + resolve(publicIP || localIP || undefined); + } } }; @@ -69,10 +182,11 @@ export async function getLocalIP(): Promise { resolved = true; clearTimeout(timeout); pc.close(); - resolve(undefined); + resolve(publicIP || localIP || undefined); } }); } catch (error) { + console.warn("Erro ao obter IP via WebRTC:", error); resolve(undefined); } }); diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte index 9b7d77c..65b5f26 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte @@ -1,91 +1,1562 @@ + + Atestados & Licenças - Recursos Humanos + +
-
+
- - + +

Atestados & Licenças

-

Registro de atestados médicos e licenças

+

+ Registro de atestados médicos e licenças +

-
- -
- - - -
-

Módulo em Desenvolvimento

-
Esta funcionalidade está em desenvolvimento e estará disponível em breve.
-
+ +
+ + + + +
- -
-
+ + {#if abaAtiva === "dashboard"} + + + {#if statsQuery?.data} +
+
+
+ + + +
+
Atestados Ativos
+
+ {statsQuery.data.totalAtestadosAtivos} +
+
+ +
+
+ + + +
+
Licenças Ativas
+
+ {statsQuery.data.totalLicencasAtivas} +
+
+ +
+
+ + + +
+
Afastados Hoje
+
+ {statsQuery.data.funcionariosAfastadosHoje} +
+
+ +
+
+ + + +
+
Dias no Mês
+
+ {statsQuery.data.totalDiasAfastamentoMes} +
+
+
+ {/if} + + +
-

Registrar Atestado

-

Cadastre atestados médicos

-
- +

Filtros

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
-
+ +
-

Registrar Licença

-

Cadastre licenças e afastamentos

-
- +

Calendário de Afastamentos

+
+ + + + O calendário interativo completo será implementado em breve. Por + enquanto, visualize os eventos na tabela abaixo.
-
-
-

Histórico

-

Consulte histórico de atestados e licenças

-
- -
-
-
+ + {#if graficosQuery?.data} + {@const dados = graficosQuery.data.totalDiasPorTipo} + {@const maxDias = Math.max(...dados.map((d) => d.dias), 1)} + {@const chartWidth = 800} + {@const chartHeight = 350} + {@const padding = { top: 20, right: 40, bottom: 80, left: 70 }} + {@const barWidth = (chartWidth - padding.left - padding.right) / dados.length - 10} + {@const innerHeight = chartHeight - padding.top - padding.bottom} + {@const tendencias = graficosQuery.data.tendenciasMensais} + {@const tipos = ["atestado_medico", "declaracao_comparecimento", "maternidade", "paternidade", "ferias"]} + {@const cores = ["#ef4444", "#f97316", "#ec4899", "#3b82f6", "#10b981"]} + {@const nomes = ["Atestado Médico", "Declaração", "Maternidade", "Paternidade", "Férias"]} + {@const maxValor = Math.max( + ...tendencias.flatMap((t) => + tipos.map((tipo) => t[tipo as keyof typeof t] as number) + ), + 1 + )} + {@const chartWidth2 = 900} + {@const chartHeight2 = 400} + {@const padding2 = { top: 20, right: 40, bottom: 80, left: 70 }} + {@const innerWidth = chartWidth2 - padding2.left - padding2.right} + {@const innerHeight2 = chartHeight2 - padding2.top - padding2.bottom} + +
+
+

Total de Dias por Tipo

+
+ + + {#each [0, 1, 2, 3, 4, 5] as t} + {@const val = Math.round((maxDias / 5) * t)} + {@const y = chartHeight - padding.bottom - (val / maxDias) * innerHeight} + + + {val} + + {/each} + + + + + + + {#each dados as item, i} + {@const x = padding.left + i * (barWidth + 10) + 5} + {@const height = (item.dias / maxDias) * innerHeight} + {@const y = chartHeight - padding.bottom - height} + {@const colors = ["#ef4444", "#f97316", "#ec4899", "#3b82f6", "#10b981"]} + + + + + + + + + + + + + + {#if item.dias > 0} + + {item.dias} + + {/if} + + + +
+ + {item.tipo} + +
+
+ {/each} +
+
+
+
+ + +
+
+

Tendências Mensais (Últimos 6 Meses)

+
+ + + {#each [0, 1, 2, 3, 4, 5] as t} + {@const val = Math.round((maxValor / 5) * t)} + {@const y = chartHeight2 - padding2.bottom - (val / maxValor) * innerHeight2} + + + {val} + + {/each} + + + + + + + {#each tipos as tipo, tipoIdx} + {@const cor = cores[tipoIdx]} + + + + + + + + + + {@const pontos = tendencias.map((t, i) => { + const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth; + const valor = t[tipo as keyof typeof t] as number; + const y = chartHeight2 - padding2.bottom - (valor / maxValor) * innerHeight2; + return { x, y, valor }; + })} + + + {#if pontos.length > 0} + {@const pathArea = `M ${pontos[0].x} ${chartHeight2 - padding2.bottom} ` + pontos.map(p => `L ${p.x} ${p.y}`).join(' ') + ` L ${pontos[pontos.length - 1].x} ${chartHeight2 - padding2.bottom} Z`} + + {/if} + + + {#if pontos.length > 1} + `${p.x},${p.y}`).join(' ')} + fill="none" + stroke={cor} + stroke-width="3" + stroke-linecap="round" + stroke-linejoin="round" + /> + {/if} + + + {#each pontos as ponto, pontoIdx} + + + {nomes[tipoIdx]}: {ponto.valor} dias em {tendencias[pontoIdx]?.mes || ""} + {/each} + {/each} + + + {#each tendencias as t, i} + {@const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth} + +
+ + {t.mes} + +
+
+ {/each} +
+ + +
+ {#each tipos as tipo, idx} +
+
+ {nomes[idx]} +
+ {/each} +
+
+
+
+ + +
+
+

Funcionários Atualmente Afastados

+ {#if graficosQuery.data.funcionariosAfastados.length > 0} +
+ + + + + + + + + + + {#each graficosQuery.data.funcionariosAfastados as item} + + + + + + + {/each} + +
FuncionárioTipoData InícioData Fim
{item.funcionarioNome} + + {item.tipo === "atestado_medico" + ? "Atestado Médico" + : item.tipo === "declaracao_comparecimento" + ? "Declaração" + : item.tipo === "maternidade" + ? "Licença Maternidade" + : item.tipo === "paternidade" + ? "Licença Paternidade" + : item.tipo} + + {formatarData(item.dataInicio)}{formatarData(item.dataFim)}
+
+ {:else} +
+ Nenhum funcionário afastado no momento +
+ {/if} +
+
+ {/if} + +
-

Estatísticas

-

Visualize estatísticas e relatórios

-
- +

Registros

+
+ + + + + + + + + + + + + + {#each registrosFiltrados.atestados as atestado} + + + + + + + + + + {/each} + {#each registrosFiltrados.licencas as licenca} + + + + + + + + + + {/each} + +
FuncionárioTipoData InícioData FimDiasStatusAções
{atestado.funcionario?.nome || "-"} + + {atestado.tipo === "atestado_medico" + ? "Atestado Médico" + : "Declaração"} + + {formatarData(atestado.dataInicio)}{formatarData(atestado.dataFim)}{atestado.dias} + + {atestado.status === "ativo" ? "Ativo" : "Finalizado"} + + +
+ {#if atestado.documentoId} + + {/if} + +
+
{licenca.funcionario?.nome || "-"} + + Licença{" "} + {licenca.tipo === "maternidade" + ? "Maternidade" + : "Paternidade"} + {licenca.ehProrrogacao ? " (Prorrogação)" : ""} + + {formatarData(licenca.dataInicio)}{formatarData(licenca.dataFim)}{licenca.dias} + + {licenca.status === "ativo" ? "Ativo" : "Finalizado"} + + +
+ {#if licenca.documentoId} + + {/if} + +
+
+ {#if registrosFiltrados.atestados.length === 0 && registrosFiltrados.licencas.length === 0} +
+ Nenhum registro encontrado +
+ {/if}
-
+ {:else if abaAtiva === "atestado"} + +
+
+

Registrar Atestado Médico

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ { + atestadoMedico.documentoId = await handleDocumentoUpload( + file + ); + }} + onRemove={async () => { + atestadoMedico.documentoId = undefined; + }} + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ {:else if abaAtiva === "declaracao"} + +
+
+

Registrar Declaração de Comparecimento

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ { + declaracao.documentoId = await handleDocumentoUpload(file); + }} + onRemove={async () => { + declaracao.documentoId = undefined; + }} + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ {:else if abaAtiva === "maternidade"} + +
+
+

Registrar Licença Maternidade

+ +
+ + +
+ + +
+ +
+ + + +
+ +
+ +
+ + {#if licencaMaternidade.ehProrrogacao} +
+ + +
+ {/if} + +
+ { + licencaMaternidade.documentoId = await handleDocumentoUpload( + file + ); + }} + onRemove={async () => { + licencaMaternidade.documentoId = undefined; + }} + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ {:else if abaAtiva === "paternidade"} + +
+
+

Registrar Licença Paternidade

+ +
+ + +
+ + +
+ +
+ + + +
+ +
+ { + licencaPaternidade.documentoId = await handleDocumentoUpload( + file + ); + }} + onRemove={async () => { + licencaPaternidade.documentoId = undefined; + }} + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ {/if}
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index b3d1c17..a0b2e5f 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as actions_email from "../actions/email.js"; import type * as actions_smtp from "../actions/smtp.js"; +import type * as atestadosLicencas from "../atestadosLicencas.js"; import type * as autenticacao from "../autenticacao.js"; import type * as auth_utils from "../auth/utils.js"; import type * as chat from "../chat.js"; @@ -38,6 +39,7 @@ import type * as templatesMensagens from "../templatesMensagens.js"; import type * as times from "../times.js"; import type * as todos from "../todos.js"; import type * as usuarios from "../usuarios.js"; +import type * as utils_getClientIP from "../utils/getClientIP.js"; import type * as verificarMatriculas from "../verificarMatriculas.js"; import type { @@ -57,6 +59,7 @@ import type { declare const fullApi: ApiFromModules<{ "actions/email": typeof actions_email; "actions/smtp": typeof actions_smtp; + atestadosLicencas: typeof atestadosLicencas; autenticacao: typeof autenticacao; "auth/utils": typeof auth_utils; chat: typeof chat; @@ -85,6 +88,7 @@ declare const fullApi: ApiFromModules<{ times: typeof times; todos: typeof todos; usuarios: typeof usuarios; + "utils/getClientIP": typeof utils_getClientIP; verificarMatriculas: typeof verificarMatriculas; }>; declare const fullApiWithMounts: typeof fullApi; diff --git a/packages/backend/convex/atestadosLicencas.ts b/packages/backend/convex/atestadosLicencas.ts new file mode 100644 index 0000000..c326445 --- /dev/null +++ b/packages/backend/convex/atestadosLicencas.ts @@ -0,0 +1,1048 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { Id, Doc } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; +import { registrarAtividade } from "./logsAtividades"; + +// ========== HELPERS ========== + +/** + * Helper function para obter usuário autenticado + */ +async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { + const identity = await ctx.auth.getUserIdentity(); + let usuarioAtual = null; + + if (identity && identity.email) { + usuarioAtual = await ctx.db + .query("usuarios") + .withIndex("by_email", (q) => q.eq("email", identity.email!)) + .first(); + } + + if (!usuarioAtual) { + const sessaoAtiva = await ctx.db + .query("sessoes") + .filter((q) => q.eq(q.field("ativo"), true)) + .order("desc") + .first(); + + if (sessaoAtiva) { + usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); + } + } + + return usuarioAtual; +} + +/** + * Helper para calcular dias entre duas datas + */ +function calcularDias(dataInicio: string, dataFim: string): number { + const inicio = new Date(dataInicio); + const fim = new Date(dataFim); + const diffTime = Math.abs(fim.getTime() - inicio.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + return diffDays; +} + +// ========== QUERIES ========== + +/** + * Listar todos os atestados e licenças com detalhes do funcionário + */ +export const listarTodos = query({ + args: {}, + handler: async (ctx) => { + try { + const [atestados, licencas] = await Promise.all([ + ctx.db.query("atestados").collect(), + ctx.db.query("licencas").collect(), + ]); + + const atestadosComDetalhes = await Promise.all( + atestados.map(async (a) => { + try { + const funcionario = await ctx.db.get(a.funcionarioId); + const criadoPor = await ctx.db.get(a.criadoPor); + return { + ...a, + funcionario, + criadoPorNome: criadoPor?.nome || "Sistema", + dias: calcularDias(a.dataInicio, a.dataFim), + status: new Date(a.dataFim) >= new Date() ? "ativo" : "finalizado", + }; + } catch (error) { + console.error("Erro ao buscar detalhes do atestado:", error); + return { + ...a, + funcionario: null, + criadoPorNome: "Sistema", + dias: calcularDias(a.dataInicio, a.dataFim), + status: new Date(a.dataFim) >= new Date() ? "ativo" : "finalizado", + }; + } + }) + ); + + const licencasComDetalhes = await Promise.all( + licencas.map(async (l) => { + try { + const funcionario = await ctx.db.get(l.funcionarioId); + const criadoPor = await ctx.db.get(l.criadoPor); + const licencaOriginal = l.licencaOriginalId + ? await ctx.db.get(l.licencaOriginalId) + : null; + return { + ...l, + funcionario, + criadoPorNome: criadoPor?.nome || "Sistema", + licencaOriginal, + dias: calcularDias(l.dataInicio, l.dataFim), + status: new Date(l.dataFim) >= new Date() ? "ativo" : "finalizado", + }; + } catch (error) { + console.error("Erro ao buscar detalhes da licença:", error); + return { + ...l, + funcionario: null, + criadoPorNome: "Sistema", + licencaOriginal: null, + dias: calcularDias(l.dataInicio, l.dataFim), + status: new Date(l.dataFim) >= new Date() ? "ativo" : "finalizado", + }; + } + }) + ); + + return { + atestados: atestadosComDetalhes.sort( + (a, b) => b._creationTime - a._creationTime + ), + licencas: licencasComDetalhes.sort( + (a, b) => b._creationTime - a._creationTime + ), + }; + } catch (error) { + console.error("Erro em listarTodos:", error); + return { + atestados: [], + licencas: [], + }; + } + }, +}); + +/** + * Listar por funcionário específico + */ +export const listarPorFuncionario = query({ + args: { funcionarioId: v.id("funcionarios") }, + handler: async (ctx, args) => { + const [atestados, licencas] = await Promise.all([ + ctx.db + .query("atestados") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", args.funcionarioId) + ) + .collect(), + ctx.db + .query("licencas") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", args.funcionarioId) + ) + .collect(), + ]); + + return { + atestados: atestados.sort((a, b) => b._creationTime - a._creationTime), + licencas: licencas.sort((a, b) => b._creationTime - a._creationTime), + }; + }, +}); + +/** + * Listar por período + */ +export const listarPorPeriodo = query({ + args: { + dataInicio: v.string(), + dataFim: v.string(), + }, + handler: async (ctx, args) => { + const dataInicioObj = new Date(args.dataInicio); + const dataFimObj = new Date(args.dataFim); + + const atestados = await ctx.db.query("atestados").collect(); + const licencas = await ctx.db.query("licencas").collect(); + + const atestadosFiltrados = atestados.filter((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return ( + (inicio >= dataInicioObj && inicio <= dataFimObj) || + (fim >= dataInicioObj && fim <= dataFimObj) || + (inicio <= dataInicioObj && fim >= dataFimObj) + ); + }); + + const licencasFiltradas = licencas.filter((l) => { + const inicio = new Date(l.dataInicio); + const fim = new Date(l.dataFim); + return ( + (inicio >= dataInicioObj && inicio <= dataFimObj) || + (fim >= dataInicioObj && fim <= dataFimObj) || + (inicio <= dataInicioObj && fim >= dataFimObj) + ); + }); + + return { + atestados: atestadosFiltrados, + licencas: licencasFiltradas, + }; + }, +}); + +/** + * Obter dados para gráficos + */ +export const obterDadosGraficos = query({ + args: { + periodo: v.optional(v.number()), // dias (padrão: 30) + }, + handler: async (ctx, args) => { + try { + const dias = args.periodo || 30; + const dataLimite = Date.now() - dias * 24 * 60 * 60 * 1000; + + const [atestados, licencas] = await Promise.all([ + ctx.db.query("atestados").collect(), + ctx.db.query("licencas").collect(), + ]); + + // Filtrar por período + const atestadosFiltrados = atestados.filter( + (a) => new Date(a.criadoEm) >= new Date(dataLimite) + ); + const licencasFiltradas = licencas.filter( + (l) => new Date(l.criadoEm) >= new Date(dataLimite) + ); + + // 1. Total de dias por tipo (para gráfico de barras) + const totalDiasPorTipo: Record = { + atestado_medico: 0, + declaracao_comparecimento: 0, + maternidade: 0, + paternidade: 0, + ferias: 0, + }; + + atestadosFiltrados.forEach((a) => { + const dias = calcularDias(a.dataInicio, a.dataFim); + if (a.tipo === "atestado_medico") { + totalDiasPorTipo.atestado_medico += dias; + } else { + totalDiasPorTipo.declaracao_comparecimento += dias; + } + }); + + licencasFiltradas.forEach((l) => { + const dias = calcularDias(l.dataInicio, l.dataFim); + if (l.tipo === "maternidade") { + totalDiasPorTipo.maternidade += dias; + } else { + totalDiasPorTipo.paternidade += dias; + } + }); + + // Buscar férias do período + try { + const solicitacoesFerias = await ctx.db + .query("solicitacoesFerias") + .filter((q) => + q.or( + q.eq(q.field("status"), "aprovado"), + q.eq(q.field("status"), "data_ajustada_aprovada") + ) + ) + .collect(); + + solicitacoesFerias.forEach((s) => { + if (s.periodos && Array.isArray(s.periodos)) { + s.periodos.forEach((p: { dataInicio: string; dataFim: string }) => { + const dias = calcularDias(p.dataInicio, p.dataFim); + totalDiasPorTipo.ferias += dias; + }); + } + }); + } catch (error) { + console.error("Erro ao buscar férias para gráfico:", error); + } + + // 2. Tendências mensais (últimos 6 meses) + const meses: Record< + string, + { + atestado_medico: number; + declaracao_comparecimento: number; + maternidade: number; + paternidade: number; + ferias: number; + } + > = {}; + + const hoje = new Date(); + for (let i = 5; i >= 0; i--) { + const mesData = new Date(hoje.getFullYear(), hoje.getMonth() - i, 1); + const mesKey = mesData.toLocaleDateString("pt-BR", { + month: "short", + year: "numeric", + }); + meses[mesKey] = { + atestado_medico: 0, + declaracao_comparecimento: 0, + maternidade: 0, + paternidade: 0, + ferias: 0, + }; + } + + // Processar atestados para tendências mensais (usar todos, não apenas filtrados) + atestados.forEach((item) => { + try { + const mesData = new Date(item.criadoEm); + const mesKey = mesData.toLocaleDateString("pt-BR", { + month: "short", + year: "numeric", + }); + + if (meses[mesKey]) { + const dias = calcularDias(item.dataInicio, item.dataFim); + if (item.tipo === "atestado_medico") { + meses[mesKey].atestado_medico += dias; + } else if (item.tipo === "declaracao_comparecimento") { + meses[mesKey].declaracao_comparecimento += dias; + } + } + } catch (error) { + console.error("Erro ao processar atestado para tendências:", error); + } + }); + + // Processar licenças para tendências mensais (usar todas, não apenas filtradas) + licencas.forEach((item) => { + try { + const mesData = new Date(item.criadoEm); + const mesKey = mesData.toLocaleDateString("pt-BR", { + month: "short", + year: "numeric", + }); + + if (meses[mesKey]) { + const dias = calcularDias(item.dataInicio, item.dataFim); + if (item.tipo === "maternidade") { + meses[mesKey].maternidade += dias; + } else if (item.tipo === "paternidade") { + meses[mesKey].paternidade += dias; + } + } + } catch (error) { + console.error("Erro ao processar licença para tendências:", error); + } + }); + + // 3. Funcionários atualmente afastados + const hojeStr = new Date().toISOString().split("T")[0]; + const funcionariosAfastados: Array<{ + funcionarioId: Id<"funcionarios">; + funcionarioNome: string; + tipo: string; + dataInicio: string; + dataFim: string; + }> = []; + + // Processar atestados (verificar funcionários atualmente afastados) + atestadosFiltrados.forEach((item) => { + try { + const inicio = new Date(item.dataInicio); + const fim = new Date(item.dataFim); + const hoje = new Date(hojeStr); + + if (hoje >= inicio && hoje <= fim) { + funcionariosAfastados.push({ + funcionarioId: item.funcionarioId, + funcionarioNome: "Carregando...", + tipo: item.tipo, + dataInicio: item.dataInicio, + dataFim: item.dataFim, + }); + } + } catch (error) { + console.error("Erro ao processar atestado:", error); + } + }); + + // Processar licenças (verificar funcionários atualmente afastados) + licencasFiltradas.forEach((item) => { + try { + const inicio = new Date(item.dataInicio); + const fim = new Date(item.dataFim); + const hoje = new Date(hojeStr); + + if (hoje >= inicio && hoje <= fim) { + funcionariosAfastados.push({ + funcionarioId: item.funcionarioId, + funcionarioNome: "Carregando...", + tipo: item.tipo, + dataInicio: item.dataInicio, + dataFim: item.dataFim, + }); + } + } catch (error) { + console.error("Erro ao processar licença:", error); + } + }); + + // Buscar nomes dos funcionários + const funcionariosAfastadosComNomes = await Promise.all( + funcionariosAfastados.map(async (item) => { + try { + const funcionario = await ctx.db.get(item.funcionarioId); + return { + ...item, + funcionarioNome: funcionario?.nome || "Desconhecido", + }; + } catch (error) { + console.error("Erro ao buscar funcionário:", error); + return { + ...item, + funcionarioNome: "Desconhecido", + }; + } + }) + ); + + return { + totalDiasPorTipo: [ + { tipo: "Atestado Médico", dias: totalDiasPorTipo.atestado_medico }, + { + tipo: "Declaração", + dias: totalDiasPorTipo.declaracao_comparecimento, + }, + { tipo: "Licença Maternidade", dias: totalDiasPorTipo.maternidade }, + { tipo: "Licença Paternidade", dias: totalDiasPorTipo.paternidade }, + { tipo: "Férias", dias: totalDiasPorTipo.ferias }, + ], + tendenciasMensais: Object.entries(meses).map(([mes, dados]) => ({ + mes, + ...dados, + })), + funcionariosAfastados: funcionariosAfastadosComNomes, + }; + } catch (error) { + console.error("Erro em obterDadosGraficos:", error); + // Retornar dados vazios em caso de erro para não quebrar a página + return { + totalDiasPorTipo: [ + { tipo: "Atestado Médico", dias: 0 }, + { tipo: "Declaração", dias: 0 }, + { tipo: "Licença Maternidade", dias: 0 }, + { tipo: "Licença Paternidade", dias: 0 }, + { tipo: "Férias", dias: 0 }, + ], + tendenciasMensais: [], + funcionariosAfastados: [], + }; + } + }, +}); + +/** + * Obter estatísticas para dashboard + */ +export const obterEstatisticas = query({ + args: {}, + handler: async (ctx) => { + const hoje = new Date(); + hoje.setHours(0, 0, 0, 0); + const inicioMes = new Date(hoje.getFullYear(), hoje.getMonth(), 1); + const fimMes = new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0); + + const [atestados, licencas] = await Promise.all([ + ctx.db.query("atestados").collect(), + ctx.db.query("licencas").collect(), + ]); + + // Atestados ativos + const atestadosAtivos = atestados.filter( + (a) => new Date(a.dataFim) >= hoje + ); + + // Licenças ativas + const licencasAtivas = licencas.filter( + (l) => new Date(l.dataFim) >= hoje + ); + + // Funcionários afastados hoje + const funcionariosAfastadosHoje = new Set(); + [...atestados, ...licencas].forEach((item) => { + const inicio = new Date(item.dataInicio); + const fim = new Date(item.dataFim); + if (hoje >= inicio && hoje <= fim) { + funcionariosAfastadosHoje.add(item.funcionarioId); + } + }); + + // Total de dias no mês + let totalDiasMes = 0; + [...atestados, ...licencas].forEach((item) => { + const inicio = new Date(item.dataInicio); + const fim = new Date(item.dataFim); + if ( + (inicio >= inicioMes && inicio <= fimMes) || + (fim >= inicioMes && fim <= fimMes) || + (inicio <= inicioMes && fim >= fimMes) + ) { + const dias = calcularDias(item.dataInicio, item.dataFim); + totalDiasMes += dias; + } + }); + + return { + totalAtestadosAtivos: atestadosAtivos.length, + totalLicencasAtivas: licencasAtivas.length, + funcionariosAfastadosHoje: funcionariosAfastadosHoje.size, + totalDiasAfastamentoMes: totalDiasMes, + }; + }, +}); + +/** + * Obter eventos formatados para calendário + */ +export const obterEventosCalendario = query({ + args: { + dataInicio: v.optional(v.string()), + dataFim: v.optional(v.string()), + tipoFiltro: v.optional( + v.union( + v.literal("todos"), + v.literal("atestado_medico"), + v.literal("declaracao_comparecimento"), + v.literal("maternidade"), + v.literal("paternidade"), + v.literal("ferias") + ) + ), + }, + handler: async (ctx, args) => { + const eventos: Array<{ + id: string; + title: string; + start: string; + end: string; + color: string; + tipo: string; + funcionarioNome: string; + funcionarioId: string; + }> = []; + + try { + // Buscar atestados + if ( + !args.tipoFiltro || + args.tipoFiltro === "todos" || + args.tipoFiltro === "atestado_medico" || + args.tipoFiltro === "declaracao_comparecimento" + ) { + try { + const atestados = await ctx.db.query("atestados").collect(); + for (const atestado of atestados) { + try { + if ( + args.tipoFiltro && + args.tipoFiltro !== "todos" && + atestado.tipo !== args.tipoFiltro + ) { + continue; + } + + const funcionario = await ctx.db.get(atestado.funcionarioId); + if (!funcionario) continue; + + if (!atestado.dataInicio || !atestado.dataFim) continue; + + const cor = + atestado.tipo === "atestado_medico" + ? "#ef4444" + : "#f97316"; // vermelho ou laranja + + eventos.push({ + id: `atestado-${atestado._id}`, + title: `${funcionario.nome} - ${ + atestado.tipo === "atestado_medico" + ? "Atestado Médico" + : "Declaração" + }`, + start: atestado.dataInicio, + end: atestado.dataFim, + color: cor, + tipo: atestado.tipo, + funcionarioNome: funcionario.nome, + funcionarioId: funcionario._id, + }); + } catch (error) { + console.error(`Erro ao processar atestado ${atestado._id}:`, error); + continue; + } + } + } catch (error) { + console.error("Erro ao buscar atestados:", error); + } + } + + // Buscar licenças + if ( + !args.tipoFiltro || + args.tipoFiltro === "todos" || + args.tipoFiltro === "maternidade" || + args.tipoFiltro === "paternidade" + ) { + try { + const licencas = await ctx.db.query("licencas").collect(); + for (const licenca of licencas) { + try { + if ( + args.tipoFiltro && + args.tipoFiltro !== "todos" && + licenca.tipo !== args.tipoFiltro + ) { + continue; + } + + const funcionario = await ctx.db.get(licenca.funcionarioId); + if (!funcionario) continue; + + if (!licenca.dataInicio || !licenca.dataFim) continue; + + const cor = + licenca.tipo === "maternidade" + ? "#ec4899" + : "#3b82f6"; // rosa ou azul + + eventos.push({ + id: `licenca-${licenca._id}`, + title: `${funcionario.nome} - Licença ${ + licenca.tipo === "maternidade" ? "Maternidade" : "Paternidade" + }`, + start: licenca.dataInicio, + end: licenca.dataFim, + color: cor, + tipo: licenca.tipo, + funcionarioNome: funcionario.nome, + funcionarioId: funcionario._id, + }); + } catch (error) { + console.error(`Erro ao processar licença ${licenca._id}:`, error); + continue; + } + } + } catch (error) { + console.error("Erro ao buscar licenças:", error); + } + } + } catch (error) { + console.error("Erro geral em obterEventosCalendario:", error); + return eventos; // Retorna eventos já coletados mesmo se houver erro + } + + // Integrar com férias (se não estiver filtrando por tipo específico) + if (!args.tipoFiltro || args.tipoFiltro === "todos" || args.tipoFiltro === "ferias") { + try { + // Buscar solicitações de férias aprovadas + const solicitacoesFerias = await ctx.db + .query("solicitacoesFerias") + .filter((q) => + q.or( + q.eq(q.field("status"), "aprovado"), + q.eq(q.field("status"), "data_ajustada_aprovada") + ) + ) + .collect(); + + for (const solicitacao of solicitacoesFerias) { + try { + const funcionario = await ctx.db.get(solicitacao.funcionarioId); + if (!funcionario) continue; + + // Verificar se periodos existe e é um array + if (!solicitacao.periodos || !Array.isArray(solicitacao.periodos)) { + continue; + } + + for (const periodo of solicitacao.periodos) { + if (!periodo.dataInicio || !periodo.dataFim) continue; + + eventos.push({ + id: `ferias-${solicitacao._id}-${periodo.dataInicio}`, + title: `${funcionario.nome} - Férias`, + start: periodo.dataInicio, + end: periodo.dataFim, + color: "#10b981", // verde + tipo: "ferias", + funcionarioNome: funcionario.nome, + funcionarioId: funcionario._id, + }); + } + } catch (error) { + console.error(`Erro ao processar solicitação de férias ${solicitacao._id}:`, error); + continue; + } + } + } catch (error) { + console.error("Erro ao buscar solicitações de férias:", error); + // Continua mesmo se houver erro ao buscar férias + } + } + + // Filtrar por período se fornecido + if (args.dataInicio && args.dataFim) { + const inicio = new Date(args.dataInicio); + const fim = new Date(args.dataFim); + return eventos.filter((e) => { + const eventStart = new Date(e.start); + const eventEnd = new Date(e.end); + return ( + (eventStart >= inicio && eventStart <= fim) || + (eventEnd >= inicio && eventEnd <= fim) || + (eventStart <= inicio && eventEnd >= fim) + ); + }); + } + + return eventos; + }, +}); + +// ========== MUTATIONS ========== + +/** + * Gerar URL para upload de documentos + */ +export const generateUploadUrl = mutation({ + args: {}, + returns: v.string(), + handler: async (ctx) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + return await ctx.storage.generateUploadUrl(); + }, +}); + +/** + * Criar atestado médico + */ +export const criarAtestadoMedico = mutation({ + args: { + funcionarioId: v.id("funcionarios"), + dataInicio: v.string(), + dataFim: v.string(), + cid: v.string(), + observacoes: v.optional(v.string()), + documentoId: v.optional(v.id("_storage")), + }, + returns: v.id("atestados"), + handler: async (ctx, args) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + // Validar datas + if (new Date(args.dataFim) < new Date(args.dataInicio)) { + throw new Error("Data fim deve ser maior ou igual à data início"); + } + + const atestadoId = await ctx.db.insert("atestados", { + funcionarioId: args.funcionarioId, + tipo: "atestado_medico", + dataInicio: args.dataInicio, + dataFim: args.dataFim, + cid: args.cid, + observacoes: args.observacoes, + documentoId: args.documentoId, + criadoPor: usuario._id, + criadoEm: Date.now(), + }); + + await registrarAtividade( + ctx, + usuario._id, + "criar", + "atestados", + `Atestado médico criado para funcionário ${args.funcionarioId}`, + atestadoId + ); + + return atestadoId; + }, +}); + +/** + * Criar declaração de comparecimento + */ +export const criarDeclaracaoComparecimento = mutation({ + args: { + funcionarioId: v.id("funcionarios"), + dataInicio: v.string(), + dataFim: v.string(), + observacoes: v.optional(v.string()), + documentoId: v.optional(v.id("_storage")), + }, + returns: v.id("atestados"), + handler: async (ctx, args) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + // Validar datas + if (new Date(args.dataFim) < new Date(args.dataInicio)) { + throw new Error("Data fim deve ser maior ou igual à data início"); + } + + const atestadoId = await ctx.db.insert("atestados", { + funcionarioId: args.funcionarioId, + tipo: "declaracao_comparecimento", + dataInicio: args.dataInicio, + dataFim: args.dataFim, + observacoes: args.observacoes, + documentoId: args.documentoId, + criadoPor: usuario._id, + criadoEm: Date.now(), + }); + + await registrarAtividade( + ctx, + usuario._id, + "criar", + "atestados", + `Declaração de comparecimento criada para funcionário ${args.funcionarioId}`, + atestadoId + ); + + return atestadoId; + }, +}); + +/** + * Criar licença maternidade + */ +export const criarLicencaMaternidade = mutation({ + args: { + funcionarioId: v.id("funcionarios"), + dataInicio: v.string(), + dataFim: v.string(), + observacoes: v.optional(v.string()), + documentoId: v.optional(v.id("_storage")), + licencaOriginalId: v.optional(v.id("licencas")), + }, + returns: v.id("licencas"), + handler: async (ctx, args) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + // Validar datas + if (new Date(args.dataFim) < new Date(args.dataInicio)) { + throw new Error("Data fim deve ser maior ou igual à data início"); + } + + const ehProrrogacao = !!args.licencaOriginalId; + if (ehProrrogacao && !args.licencaOriginalId) { + throw new Error("Licença original é obrigatória para prorrogação"); + } + + const licencaId = await ctx.db.insert("licencas", { + funcionarioId: args.funcionarioId, + tipo: "maternidade", + dataInicio: args.dataInicio, + dataFim: args.dataFim, + observacoes: args.observacoes, + documentoId: args.documentoId, + licencaOriginalId: args.licencaOriginalId, + ehProrrogacao, + criadoPor: usuario._id, + criadoEm: Date.now(), + }); + + await registrarAtividade( + ctx, + usuario._id, + "criar", + "licencas", + `Licença maternidade criada para funcionário ${args.funcionarioId}${ehProrrogacao ? " (prorrogação)" : ""}`, + licencaId + ); + + return licencaId; + }, +}); + +/** + * Criar licença paternidade + */ +export const criarLicencaPaternidade = mutation({ + args: { + funcionarioId: v.id("funcionarios"), + dataInicio: v.string(), + dataFim: v.string(), + observacoes: v.optional(v.string()), + documentoId: v.optional(v.id("_storage")), + }, + returns: v.id("licencas"), + handler: async (ctx, args) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + // Validar datas + if (new Date(args.dataFim) < new Date(args.dataInicio)) { + throw new Error("Data fim deve ser maior ou igual à data início"); + } + + const licencaId = await ctx.db.insert("licencas", { + funcionarioId: args.funcionarioId, + tipo: "paternidade", + dataInicio: args.dataInicio, + dataFim: args.dataFim, + observacoes: args.observacoes, + documentoId: args.documentoId, + ehProrrogacao: false, + criadoPor: usuario._id, + criadoEm: Date.now(), + }); + + await registrarAtividade( + ctx, + usuario._id, + "criar", + "licencas", + `Licença paternidade criada para funcionário ${args.funcionarioId}`, + licencaId + ); + + return licencaId; + }, +}); + +/** + * Prorrogar licença maternidade + */ +export const prorrogarLicencaMaternidade = mutation({ + args: { + licencaOriginalId: v.id("licencas"), + dataInicio: v.string(), + dataFim: v.string(), + observacoes: v.optional(v.string()), + documentoId: v.optional(v.id("_storage")), + }, + returns: v.id("licencas"), + handler: async (ctx, args) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + const licencaOriginal = await ctx.db.get(args.licencaOriginalId); + if (!licencaOriginal) { + throw new Error("Licença original não encontrada"); + } + + if (licencaOriginal.tipo !== "maternidade") { + throw new Error("Apenas licenças de maternidade podem ser prorrogadas"); + } + + // Validar datas + if (new Date(args.dataFim) < new Date(args.dataInicio)) { + throw new Error("Data fim deve ser maior ou igual à data início"); + } + + const prorrogacaoId = await ctx.db.insert("licencas", { + funcionarioId: licencaOriginal.funcionarioId, + tipo: "maternidade", + dataInicio: args.dataInicio, + dataFim: args.dataFim, + observacoes: args.observacoes, + documentoId: args.documentoId, + licencaOriginalId: args.licencaOriginalId, + ehProrrogacao: true, + criadoPor: usuario._id, + criadoEm: Date.now(), + }); + + await registrarAtividade( + ctx, + usuario._id, + "criar", + "licencas", + `Prorrogação de licença maternidade criada para funcionário ${licencaOriginal.funcionarioId}`, + prorrogacaoId + ); + + return prorrogacaoId; + }, +}); + +/** + * Excluir atestado + */ +export const excluirAtestado = mutation({ + args: { + id: v.id("atestados"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + const atestado = await ctx.db.get(args.id); + if (!atestado) throw new Error("Atestado não encontrado"); + + await ctx.db.delete(args.id); + + await registrarAtividade( + ctx, + usuario._id, + "excluir", + "atestados", + `Atestado excluído: ${args.id}`, + args.id + ); + + return null; + }, +}); + +/** + * Excluir licença + */ +export const excluirLicenca = mutation({ + args: { + id: v.id("licencas"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + const licenca = await ctx.db.get(args.id); + if (!licenca) throw new Error("Licença não encontrada"); + + await ctx.db.delete(args.id); + + await registrarAtividade( + ctx, + usuario._id, + "excluir", + "licencas", + `Licença excluída: ${args.id}`, + args.id + ); + + return null; + }, +}); diff --git a/packages/backend/convex/autenticacao.ts b/packages/backend/convex/autenticacao.ts index dc6757d..43dc379 100644 --- a/packages/backend/convex/autenticacao.ts +++ b/packages/backend/convex/autenticacao.ts @@ -1,5 +1,5 @@ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { mutation, query, internalMutation } from "./_generated/server"; import { hashPassword, verifyPassword, @@ -9,7 +9,7 @@ import { } from "./auth/utils"; import { registrarLogin } from "./logsLogin"; import { Id, Doc } from "./_generated/dataModel"; -import type { QueryCtx } from "./_generated/server"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; /** * Helper para verificar se usuário está bloqueado @@ -315,6 +315,280 @@ export const login = mutation({ }, }); +/** + * Mutation interna para login via HTTP (com IP extraído do request) + * Usada pelo endpoint HTTP /api/login + */ +export const loginComIP = internalMutation({ + args: { + matriculaOuEmail: v.string(), + senha: v.string(), + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + }, + returns: v.union( + v.object({ + sucesso: v.literal(true), + token: v.string(), + usuario: v.object({ + _id: v.id("usuarios"), + matricula: v.string(), + nome: v.string(), + email: v.string(), + funcionarioId: v.optional(v.id("funcionarios")), + role: v.object({ + _id: v.id("roles"), + nome: v.string(), + nivel: v.number(), + setor: v.optional(v.string()), + }), + primeiroAcesso: v.boolean(), + }), + }), + v.object({ + sucesso: v.literal(false), + erro: v.string(), + }) + ), + handler: async (ctx, args) => { + // Reutilizar a mesma lógica da mutation pública + // Verificar rate limiting por IP + if (args.ipAddress) { + const ipBloqueado = await verificarRateLimitIP(ctx, args.ipAddress); + if (ipBloqueado) { + await registrarLogin(ctx, { + matriculaOuEmail: args.matriculaOuEmail, + sucesso: false, + motivoFalha: "rate_limit_excedido", + ipAddress: args.ipAddress, + userAgent: args.userAgent, + }); + + return { + sucesso: false as const, + erro: "Muitas tentativas de login. Tente novamente em 15 minutos.", + }; + } + } + + // Determinar se é email ou matrícula + const isEmail = args.matriculaOuEmail.includes("@"); + + // Buscar usuário + let usuario: Doc<"usuarios"> | null = null; + if (isEmail) { + usuario = await ctx.db + .query("usuarios") + .withIndex("by_email", (q) => q.eq("email", args.matriculaOuEmail)) + .first(); + } else { + const funcionario: Doc<"funcionarios"> | null = await ctx.db.query("funcionarios").withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail)).first(); + if (funcionario) { + usuario = await ctx.db + .query("usuarios") + .withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id)) + .first(); + } + } + + if (!usuario) { + await registrarLogin(ctx, { + matriculaOuEmail: args.matriculaOuEmail, + sucesso: false, + motivoFalha: "usuario_inexistente", + ipAddress: args.ipAddress, + userAgent: args.userAgent, + }); + + return { + sucesso: false as const, + erro: "Credenciais incorretas.", + }; + } + + // Verificar se usuário está bloqueado + if ( + usuario.bloqueado || + (await verificarBloqueioUsuario(ctx, usuario._id)) + ) { + await registrarLogin(ctx, { + usuarioId: usuario._id, + matriculaOuEmail: args.matriculaOuEmail, + sucesso: false, + motivoFalha: "usuario_bloqueado", + ipAddress: args.ipAddress, + userAgent: args.userAgent, + }); + + return { + sucesso: false as const, + erro: "Usuário bloqueado. Entre em contato com o TI.", + }; + } + + // Verificar se usuário está ativo + if (!usuario.ativo) { + await registrarLogin(ctx, { + usuarioId: usuario._id, + matriculaOuEmail: args.matriculaOuEmail, + sucesso: false, + motivoFalha: "usuario_inativo", + ipAddress: args.ipAddress, + userAgent: args.userAgent, + }); + + return { + sucesso: false as const, + erro: "Usuário inativo. Entre em contato com o TI.", + }; + } + + // Verificar tentativas de login (bloqueio temporário) + const tentativasRecentes = usuario.tentativasLogin || 0; + const ultimaTentativa = usuario.ultimaTentativaLogin || 0; + const tempoDecorrido = Date.now() - ultimaTentativa; + const TEMPO_BLOQUEIO = 30 * 60 * 1000; // 30 minutos + + // Se tentou 5 vezes e ainda não passou o tempo de bloqueio + if (tentativasRecentes >= 5 && tempoDecorrido < TEMPO_BLOQUEIO) { + await registrarLogin(ctx, { + usuarioId: usuario._id, + matriculaOuEmail: args.matriculaOuEmail, + sucesso: false, + motivoFalha: "bloqueio_temporario", + ipAddress: args.ipAddress, + userAgent: args.userAgent, + }); + + const minutosRestantes = Math.ceil( + (TEMPO_BLOQUEIO - tempoDecorrido) / 60000 + ); + return { + sucesso: false as const, + erro: `Conta temporariamente bloqueada. Tente novamente em ${minutosRestantes} minutos.`, + }; + } + + // Resetar tentativas se passou o tempo de bloqueio + if (tempoDecorrido > TEMPO_BLOQUEIO) { + await ctx.db.patch(usuario._id, { + tentativasLogin: 0, + ultimaTentativaLogin: Date.now(), + }); + } + + // Verificar senha + const senhaValida = await verifyPassword(args.senha, usuario.senhaHash); + + if (!senhaValida) { + // Incrementar tentativas + const novasTentativas = + tempoDecorrido > TEMPO_BLOQUEIO ? 1 : tentativasRecentes + 1; + + await ctx.db.patch(usuario._id, { + tentativasLogin: novasTentativas, + ultimaTentativaLogin: Date.now(), + }); + + await registrarLogin(ctx, { + usuarioId: usuario._id, + matriculaOuEmail: args.matriculaOuEmail, + sucesso: false, + motivoFalha: "senha_incorreta", + ipAddress: args.ipAddress, + userAgent: args.userAgent, + }); + + const tentativasRestantes = 5 - novasTentativas; + if (tentativasRestantes > 0) { + return { + sucesso: false as const, + erro: `Credenciais incorretas. ${tentativasRestantes} tentativas restantes.`, + }; + } else { + return { + sucesso: false as const, + erro: "Conta bloqueada por 30 minutos devido a múltiplas tentativas falhas.", + }; + } + } + + // Login bem-sucedido! Resetar tentativas + await ctx.db.patch(usuario._id, { + tentativasLogin: 0, + ultimaTentativaLogin: undefined, + }); + + // Buscar role do usuário + const role: Doc<"roles"> | null = await ctx.db.get(usuario.roleId); + if (!role) { + return { + sucesso: false as const, + erro: "Erro ao carregar permissões do usuário.", + }; + } + + // Gerar token de sessão + const token = generateToken(); + const agora = Date.now(); + const expiraEm = agora + 8 * 60 * 60 * 1000; // 8 horas + + // Criar sessão + await ctx.db.insert("sessoes", { + usuarioId: usuario._id, + token, + ipAddress: args.ipAddress, + userAgent: args.userAgent, + criadoEm: agora, + expiraEm, + ativo: true, + }); + + // Atualizar último acesso + await ctx.db.patch(usuario._id, { + ultimoAcesso: agora, + atualizadoEm: agora, + }); + + // Log de login bem-sucedido + await registrarLogin(ctx, { + usuarioId: usuario._id, + matriculaOuEmail: args.matriculaOuEmail, + sucesso: true, + ipAddress: args.ipAddress, + userAgent: args.userAgent, + }); + + await ctx.db.insert("logsAcesso", { + usuarioId: usuario._id, + tipo: "login", + ipAddress: args.ipAddress, + userAgent: args.userAgent, + detalhes: "Login realizado com sucesso", + timestamp: agora, + }); + + return { + sucesso: true as const, + token, + usuario: { + _id: usuario._id, + matricula: usuario.matricula, + nome: usuario.nome, + email: usuario.email, + funcionarioId: usuario.funcionarioId, + role: { + _id: role._id, + nome: role.nome, + nivel: role.nivel, + setor: role.setor, + }, + primeiroAcesso: usuario.primeiroAcesso, + }, + }; + }, +}); + /** * Logout do usuário */ diff --git a/packages/backend/convex/http.ts b/packages/backend/convex/http.ts index 185d69c..a4cf87c 100644 --- a/packages/backend/convex/http.ts +++ b/packages/backend/convex/http.ts @@ -1,5 +1,150 @@ -import { httpRouter } from "convex/server"; - -const http = httpRouter(); - -export default http; +import { httpRouter } from "convex/server"; +import { httpAction } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { getClientIP } from "./utils/getClientIP"; +import { v } from "convex/values"; + +const http = httpRouter(); + +/** + * Endpoint de teste para debug - retorna todos os headers disponíveis + * GET /api/debug/headers + */ +http.route({ + path: "/api/debug/headers", + method: "GET", + handler: httpAction(async (ctx, request) => { + const headers: Record = {}; + request.headers.forEach((value, key) => { + headers[key] = value; + }); + + const ip = getClientIP(request); + + return new Response( + JSON.stringify({ + headers, + extractedIP: ip, + url: request.url, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }), +}); + +/** + * Endpoint HTTP para login que captura automaticamente o IP do cliente + * POST /api/login + * Body: { matriculaOuEmail: string, senha: string } + */ +http.route({ + path: "/api/login", + method: "POST", + handler: httpAction(async (ctx, request) => { + try { + // Debug: Log todos os headers disponíveis + console.log("=== DEBUG: Headers HTTP ==="); + const headersEntries: string[] = []; + request.headers.forEach((value, key) => { + headersEntries.push(`${key}: ${value}`); + }); + console.log("Headers:", headersEntries.join(", ")); + console.log("Request URL:", request.url); + + // Extrair IP do cliente do request + let clientIP = getClientIP(request); + console.log("IP extraído:", clientIP); + + // Se não encontrou IP, tentar obter do URL ou usar valor padrão + if (!clientIP) { + try { + const url = new URL(request.url); + // Tentar pegar do query param se disponível + const ipParam = url.searchParams.get("client_ip"); + if (ipParam && /^(\d{1,3}\.){3}\d{1,3}$/.test(ipParam)) { + clientIP = ipParam; + console.log("IP obtido do query param:", clientIP); + } else { + // Se ainda não tiver IP, usar um identificador baseado no timestamp + // Isso pelo menos diferencia requisições + console.warn("IP não encontrado nos headers. Usando fallback."); + clientIP = undefined; // Deixar como undefined para registrar como não disponível + } + } catch { + console.warn("Erro ao processar URL para IP"); + } + } + + // Extrair User-Agent + const userAgent = request.headers.get("user-agent") || undefined; + + // Ler body da requisição + const body = await request.json(); + + if (!body.matriculaOuEmail || !body.senha) { + return new Response( + JSON.stringify({ + sucesso: false, + erro: "Matrícula/Email e senha são obrigatórios", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // Chamar a mutation de login interna com IP e userAgent + const resultado = await ctx.runMutation(internal.autenticacao.loginComIP, { + matriculaOuEmail: body.matriculaOuEmail, + senha: body.senha, + ipAddress: clientIP, + userAgent: userAgent, + }); + + return new Response(JSON.stringify(resultado), { + status: 200, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); + } catch (error) { + return new Response( + JSON.stringify({ + sucesso: false, + erro: error instanceof Error ? error.message : "Erro ao processar login", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } + }), +}); + +/** + * Endpoint OPTIONS para CORS preflight + */ +http.route({ + path: "/api/login", + method: "OPTIONS", + handler: httpAction(async () => { + return new Response(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); + }), +}); + +export default http; diff --git a/packages/backend/convex/logsLogin.ts b/packages/backend/convex/logsLogin.ts index 27de8ac..96259d4 100644 --- a/packages/backend/convex/logsLogin.ts +++ b/packages/backend/convex/logsLogin.ts @@ -5,6 +5,35 @@ import { Doc, Id } from "./_generated/dataModel"; /** * Helper para registrar tentativas de login */ +/** + * Valida se uma string é um IP válido + */ +function validarIP(ip: string | undefined): string | undefined { + if (!ip || ip.length < 7) return undefined; // IP mínimo: "1.1.1.1" = 7 chars + + // Validar IPv4 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (ipv4Regex.test(ip)) { + const parts = ip.split('.'); + if (parts.length === 4 && parts.every(part => { + const num = parseInt(part, 10); + return !isNaN(num) && num >= 0 && num <= 255; + })) { + return ip; + } + } + + // Validar IPv6 básico + const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,6}$|^[0-9a-fA-F]{0,4}::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,5}$/; + if (ipv6Regex.test(ip)) { + return ip; + } + + // IP inválido - não salvar + console.warn(`IP inválido detectado e ignorado: "${ip}"`); + return undefined; +} + export async function registrarLogin( ctx: MutationCtx, dados: { @@ -21,12 +50,15 @@ export async function registrarLogin( const browser = dados.userAgent ? extrairBrowser(dados.userAgent) : undefined; const sistema = dados.userAgent ? extrairSistema(dados.userAgent) : undefined; + // Validar e sanitizar IP antes de salvar + const ipAddressValidado = validarIP(dados.ipAddress); + await ctx.db.insert("logsLogin", { usuarioId: dados.usuarioId, matriculaOuEmail: dados.matriculaOuEmail, sucesso: dados.sucesso, motivoFalha: dados.motivoFalha, - ipAddress: dados.ipAddress, + ipAddress: ipAddressValidado, userAgent: dados.userAgent, device, browser, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index fb337c9..49bf0ad 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -151,11 +151,43 @@ export default defineSchema({ atestados: defineTable({ funcionarioId: v.id("funcionarios"), + tipo: v.union( + v.literal("atestado_medico"), + v.literal("declaracao_comparecimento") + ), dataInicio: v.string(), dataFim: v.string(), - cid: v.string(), - descricao: v.string(), - }), + cid: v.optional(v.string()), // Apenas para atestado médico + observacoes: v.optional(v.string()), + documentoId: v.optional(v.id("_storage")), + criadoPor: v.id("usuarios"), + criadoEm: v.number(), + }) + .index("by_funcionario", ["funcionarioId"]) + .index("by_tipo", ["tipo"]) + .index("by_data_inicio", ["dataInicio"]) + .index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]), + + licencas: defineTable({ + funcionarioId: v.id("funcionarios"), + tipo: v.union( + v.literal("maternidade"), + v.literal("paternidade") + ), + dataInicio: v.string(), + dataFim: v.string(), + documentoId: v.optional(v.id("_storage")), + observacoes: v.optional(v.string()), + licencaOriginalId: v.optional(v.id("licencas")), // Para prorrogações + ehProrrogacao: v.boolean(), + criadoPor: v.id("usuarios"), + criadoEm: v.number(), + }) + .index("by_funcionario", ["funcionarioId"]) + .index("by_tipo", ["tipo"]) + .index("by_data_inicio", ["dataInicio"]) + .index("by_licenca_original", ["licencaOriginalId"]) + .index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]), solicitacoesFerias: defineTable({ funcionarioId: v.id("funcionarios"), diff --git a/packages/backend/convex/utils/getClientIP.ts b/packages/backend/convex/utils/getClientIP.ts new file mode 100644 index 0000000..7063688 --- /dev/null +++ b/packages/backend/convex/utils/getClientIP.ts @@ -0,0 +1,151 @@ +/** + * Função utilitária para extrair o IP do cliente de um Request HTTP + * Sem usar APIs externas - usa apenas headers HTTP + */ + +/** + * Extrai o IP do cliente de um Request HTTP + * Considera headers como X-Forwarded-For, X-Real-IP, etc. + */ +export function getClientIP(request: Request): string | undefined { + // Headers que podem conter o IP do cliente (case-insensitive) + const getHeader = (name: string): string | null => { + // Tentar diferentes variações de case + const variations = [ + name, + name.toLowerCase(), + name.toUpperCase(), + name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(), + ]; + + for (const variation of variations) { + const value = request.headers.get(variation); + if (value) return value; + } + + // As variações de case já cobrem a maioria dos casos + // Se não encontrou, retorna null + return null; + }; + + const forwardedFor = getHeader("x-forwarded-for"); + const realIP = getHeader("x-real-ip"); + const cfConnectingIP = getHeader("cf-connecting-ip"); // Cloudflare + const trueClientIP = getHeader("true-client-ip"); // Cloudflare Enterprise + const xClientIP = getHeader("x-client-ip"); + const forwarded = getHeader("forwarded"); + const remoteAddr = getHeader("remote-addr"); + + // Log para debug + console.log("Procurando IP nos headers:", { + "x-forwarded-for": forwardedFor, + "x-real-ip": realIP, + "cf-connecting-ip": cfConnectingIP, + "true-client-ip": trueClientIP, + "x-client-ip": xClientIP, + "forwarded": forwarded, + "remote-addr": remoteAddr, + }); + + // Prioridade: X-Forwarded-For pode conter múltiplos IPs (proxy chain) + // O primeiro IP é geralmente o IP original do cliente + if (forwardedFor) { + const ips = forwardedFor.split(",").map((ip) => ip.trim()); + // Pegar o primeiro IP válido + for (const ip of ips) { + if (isValidIP(ip)) { + console.log("IP encontrado em X-Forwarded-For:", ip); + return ip; + } + } + } + + // Forwarded header (RFC 7239) + if (forwarded) { + // Formato: for=192.0.2.60;proto=http;by=203.0.113.43 + const forMatch = forwarded.match(/for=([^;,\s]+)/i); + if (forMatch && forMatch[1]) { + const ip = forMatch[1].replace(/^\[|\]$/g, ''); // Remove brackets de IPv6 + if (isValidIP(ip)) { + console.log("IP encontrado em Forwarded:", ip); + return ip; + } + } + } + + // Outros headers com IP único + if (realIP && isValidIP(realIP)) { + console.log("IP encontrado em X-Real-IP:", realIP); + return realIP; + } + + if (cfConnectingIP && isValidIP(cfConnectingIP)) { + console.log("IP encontrado em CF-Connecting-IP:", cfConnectingIP); + return cfConnectingIP; + } + + if (trueClientIP && isValidIP(trueClientIP)) { + console.log("IP encontrado em True-Client-IP:", trueClientIP); + return trueClientIP; + } + + if (xClientIP && isValidIP(xClientIP)) { + console.log("IP encontrado em X-Client-IP:", xClientIP); + return xClientIP; + } + + if (remoteAddr && isValidIP(remoteAddr)) { + console.log("IP encontrado em Remote-Addr:", remoteAddr); + return remoteAddr; + } + + // Tentar extrair do URL (último recurso) + try { + const url = new URL(request.url); + // Se o servidor estiver configurado para passar IP via query param + const ipFromQuery = url.searchParams.get("ip"); + if (ipFromQuery && isValidIP(ipFromQuery)) { + console.log("IP encontrado em query param:", ipFromQuery); + return ipFromQuery; + } + } catch { + // Ignorar erro de parsing do URL + } + + console.log("Nenhum IP válido encontrado nos headers"); + return undefined; +} + +/** + * Valida se uma string é um endereço IP válido (IPv4 ou IPv6) + */ +function isValidIP(ip: string): boolean { + if (!ip || ip.length === 0) { + return false; + } + + // Validar IPv4 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (ipv4Regex.test(ip)) { + const parts = ip.split("."); + return parts.every((part) => { + const num = parseInt(part, 10); + return num >= 0 && num <= 255; + }); + } + + // Validar IPv6 (formato simplificado) + const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; + if (ipv6Regex.test(ip)) { + return true; + } + + // Validar IPv6 comprimido (com ::) + const ipv6CompressedRegex = /^([0-9a-fA-F]{0,4}:)*::([0-9a-fA-F]{0,4}:)*[0-9a-fA-F]{0,4}$/; + if (ipv6CompressedRegex.test(ip)) { + return true; + } + + return false; +} +