diff --git a/.cursor/rules/typescript_rules.mdc b/.cursor/rules/typescript_rules.mdc new file mode 100644 index 0000000..2eafacb --- /dev/null +++ b/.cursor/rules/typescript_rules.mdc @@ -0,0 +1,107 @@ +--- +description: Guidelines for TypeScript usage, including type safety rules and Convex query typing +globs: **/*.ts,**/*.tsx,**/*.svelte +--- + +# TypeScript Guidelines + +## Type Safety Rules + +### Avoid `any` Type +- **NEVER** use the `any` type in production code +- The only exception is in test files (files matching `*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`) +- Instead of `any`, use: + - Proper type definitions + - `unknown` for truly unknown types (with type guards) + - Generic types (``) when appropriate + - Union types when multiple types are possible + - `Record` for objects with unknown structure + +### Examples + +**❌ Bad:** +```typescript +function processData(data: any) { + return data.value; +} +``` + +**✅ Good:** +```typescript +function processData(data: { value: string }) { + return data.value; +} + +// Or with generics +function processData(data: T) { + return data.value; +} + +// Or with unknown and type guards +function processData(data: unknown) { + if (typeof data === 'object' && data !== null && 'value' in data) { + return (data as { value: string }).value; + } + throw new Error('Invalid data'); +} +``` + +**✅ Exception (tests only):** +```typescript +// test.ts or *.spec.ts +it('should handle any input', () => { + const input: any = getMockData(); + expect(process(input)).toBeDefined(); +}); +``` + +## Convex Query Typing + +### Frontend Query Usage +- **DO NOT** create manual type definitions for Convex query results in the frontend +- Convex queries already return properly typed results based on their `returns` validator +- The TypeScript types are automatically inferred from the query's return validator +- Simply use the query result directly - TypeScript will infer the correct type + +### Examples + +**❌ Bad:** +```typescript +// Don't manually type the result +type UserListResult = Array<{ + _id: Id<"users">; + name: string; +}>; + +const users: UserListResult = useQuery(api.users.list); +``` + +**✅ Good:** +```typescript +// Let TypeScript infer the type from the query +const users = useQuery(api.users.list); +// TypeScript automatically knows the type based on the query's returns validator + +// You can still use it with type inference +if (users !== undefined) { + users.forEach(user => { + // TypeScript knows user._id is Id<"users"> and user.name is string + console.log(user.name); + }); +} +``` + +**✅ Good (with explicit type if needed for clarity):** +```typescript +// Only if you need to export or explicitly annotate for documentation +import type { FunctionReturnType } from "convex/server"; +import type { api } from "./convex/_generated/api"; + +type UserListResult = FunctionReturnType; +const users = useQuery(api.users.list); +``` + +### Best Practices +- Trust Convex's type inference - it's based on your schema and validators +- If you need type annotations, use `FunctionReturnType` from Convex's type utilities +- Only create manual types if you're doing complex transformations that need intermediate types diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index 18964c8..668b21d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 25.0.0 +nodejs 22.21.1 diff --git a/README.md b/README.md index 9e865c6..553ae3d 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,8 @@ npx convex dev ### **Banco vazio:** ```powershell cd packages\backend -npx convex run seed:clearDatabase -npx convex run seed:seedDatabase +npx convex run seed:limparBanco +npx convex run seed:popularBanco ``` **Mais soluções:** Veja `TESTAR_SISTEMA_COMPLETO.md` seção "Problemas Comuns" diff --git a/apps/web/package.json b/apps/web/package.json index 7edd571..d5ac91d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,7 @@ "svelte": "^5.38.1", "svelte-check": "^4.3.1", "tailwindcss": "^4.1.12", - "typescript": "^5.9.2", + "typescript": "catalog:", "vite": "^7.1.2" }, "dependencies": { @@ -38,7 +38,7 @@ "@sgse-app/backend": "*", "@tanstack/svelte-form": "^1.19.2", "@types/papaparse": "^5.3.14", - "convex": "^1.28.0", + "convex": "catalog:", "convex-svelte": "^0.0.11", "date-fns": "^4.1.0", "emoji-picker-element": "^1.27.0", diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 5e25b7b..7d3fb0f 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -17,4 +17,59 @@ .btn-error { @apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-error bg-base-100 hover:bg-error/60 active:bg-error text-error hover:text-white active:text-white transition-colors; +} + +:where(.card, .card-hover) { + position: relative; + overflow: hidden; + transform: translateY(0); + transition: transform 220ms ease, box-shadow 220ms ease; +} + +:where(.card, .card-hover)::before { + content: ""; + position: absolute; + inset: -2px; + border-radius: 1.15rem; + box-shadow: + 0 0 0 1px rgba(15, 23, 42, 0.04), + 0 14px 32px -22px rgba(15, 23, 42, 0.45), + 0 6px 18px -16px rgba(102, 126, 234, 0.35); + opacity: 0.55; + transition: opacity 220ms ease, transform 220ms ease; + pointer-events: none; + z-index: 0; +} + +:where(.card, .card-hover)::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 1rem; + background: linear-gradient(135deg, rgba(102, 126, 234, 0.12), rgba(118, 75, 162, 0.12)); + opacity: 0; + transform: scale(0.96); + transition: opacity 220ms ease, transform 220ms ease; + pointer-events: none; + z-index: 1; +} + +:where(.card, .card-hover):hover { + transform: translateY(-6px); + box-shadow: 0 20px 45px -20px rgba(15, 23, 42, 0.35); +} + +:where(.card, .card-hover):hover::before { + opacity: 0.9; + transform: scale(1); +} + +:where(.card, .card-hover):hover::after { + opacity: 1; + transform: scale(1); +} + +:where(.card, .card-hover) > * { + position: relative; + z-index: 2; } \ No newline at end of file diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..6de9cf0 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,7 @@ +import { createAuthClient } from "better-auth/client"; +import { convexClient } from "@convex-dev/better-auth/client/plugins"; + +export const authClient = createAuthClient({ + baseURL: "http://localhost:5173", + plugins: [convexClient()], +}); diff --git a/apps/web/src/lib/components/AprovarFerias.svelte b/apps/web/src/lib/components/AprovarFerias.svelte index 18f45e3..f15afe9 100644 --- a/apps/web/src/lib/components/AprovarFerias.svelte +++ b/apps/web/src/lib/components/AprovarFerias.svelte @@ -1,6 +1,7 @@ + +
+
+ +
+

Calendário de Afastamentos

+ + +
+ Filtrar: +
+ + + + + + +
+
+
+ + +
+
+
+ Atestado Médico +
+
+
+ Declaração +
+
+
+ Licença Maternidade +
+
+
+ Licença Paternidade +
+
+
+ Férias +
+
+ + +
+
+
+ + + {#if showModal && eventoSelecionado} + + +
(showModal = false)} + role="dialog" + aria-modal="true" + > + +
e.stopPropagation()} + > + +
+
+
+

+ {eventoSelecionado.funcionarioNome} +

+

+ {getTipoNome(eventoSelecionado.tipo)} +

+
+ +
+
+ + +
+
+ + + +
+

Data Início

+

{formatarData(eventoSelecionado.start)}

+
+
+ +
+ + + +
+

Data Fim

+

{formatarData(eventoSelecionado.end)}

+
+
+ +
+ + + +
+

Duração

+

+ {(() => { + const inicio = new Date(eventoSelecionado.start); + const fim = new Date(eventoSelecionado.end); + const diffTime = Math.abs(fim.getTime() - inicio.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + return `${diffDays} ${diffDays === 1 ? "dia" : "dias"}`; + })()} +

+
+
+
+ + +
+ +
+
+
+ {/if} +
+
+ + diff --git a/apps/web/src/lib/components/ErrorModal.svelte b/apps/web/src/lib/components/ErrorModal.svelte new file mode 100644 index 0000000..9684a28 --- /dev/null +++ b/apps/web/src/lib/components/ErrorModal.svelte @@ -0,0 +1,54 @@ + + +{#if open} + +{/if} + diff --git a/apps/web/src/lib/components/FileUpload.svelte b/apps/web/src/lib/components/FileUpload.svelte index f6d62c0..60e9e77 100644 --- a/apps/web/src/lib/components/FileUpload.svelte +++ b/apps/web/src/lib/components/FileUpload.svelte @@ -1,274 +1,279 @@ - - -
- - - - - {#if value || fileName} -
- -
- {#if previewUrl} - Preview - {:else if fileType === "application/pdf" || fileName.endsWith(".pdf")} -
- - - -
- {:else} -
- - - -
- {/if} -
- - -
-

{fileName || "Arquivo anexado"}

-

- {#if uploading} - Carregando... - {:else} - Enviado com sucesso - {/if} -

-
- - -
- {#if fileUrl} - - {/if} - - -
-
- {:else} - - {/if} - - {#if error} -
- {error} -
- {/if} -
- + + +
+ + + + + {#if value || fileName} +
+ +
+ {#if previewUrl} + Preview + {:else if fileType === "application/pdf" || fileName.endsWith(".pdf")} +
+ + + +
+ {:else} +
+ + + +
+ {/if} +
+ + +
+

{fileName || "Arquivo anexado"}

+

+ {#if uploading} + Carregando... + {:else} + Enviado com sucesso + {/if} +

+
+ + +
+ {#if fileUrl} + + {/if} + + +
+
+ {:else} + + {/if} + + {#if error} +
+ {error} +
+ {/if} +
+ 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 7c1b6aa..7ef45b5 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -10,6 +10,7 @@ import NotificationBell from "$lib/components/chat/NotificationBell.svelte"; import ChatWidget from "$lib/components/chat/ChatWidget.svelte"; import PresenceManager from "$lib/components/chat/PresenceManager.svelte"; + import { getBrowserInfo } from "$lib/utils/browserInfo"; let { children }: { children: Snippet } = $props(); @@ -100,9 +101,15 @@ carregandoLogin = true; try { + // 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, { matriculaOuEmail: matricula.trim(), senha: senha, + userAgent: browserInfo.userAgent || undefined, + ipAddress: browserInfo.ipAddress, }); if (resultado.sucesso) { diff --git a/apps/web/src/lib/components/chat/NotificationBell.svelte b/apps/web/src/lib/components/chat/NotificationBell.svelte index b86c35c..b7056b4 100644 --- a/apps/web/src/lib/components/chat/NotificationBell.svelte +++ b/apps/web/src/lib/components/chat/NotificationBell.svelte @@ -5,18 +5,27 @@ import { formatDistanceToNow } from "date-fns"; import { ptBR } from "date-fns/locale"; import { onMount } from "svelte"; + import { authStore } from "$lib/stores/auth.svelte"; // Queries e Client const client = useConvexClient(); - const notificacoesQuery = useQuery(api.chat.obterNotificacoes, { apenasPendentes: true }); + const notificacoesQuery = useQuery(api.chat.obterNotificacoes, { + apenasPendentes: true, + }); const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {}); let dropdownOpen = $state(false); let notificacoesFerias = $state([]); // Helpers para obter valores das queries - const count = $derived((typeof countQuery === 'number' ? countQuery : countQuery?.data) ?? 0); - const notificacoes = $derived((Array.isArray(notificacoesQuery) ? notificacoesQuery : notificacoesQuery?.data) ?? []); + const count = $derived( + (typeof countQuery === "number" ? countQuery : countQuery?.data) ?? 0 + ); + const notificacoes = $derived( + (Array.isArray(notificacoesQuery) + ? notificacoesQuery + : notificacoesQuery?.data) ?? [] + ); // Atualizar contador no store $effect(() => { @@ -27,11 +36,15 @@ // Buscar notificações de férias async function buscarNotificacoesFerias() { try { - const usuarioStore = await import("$lib/stores/auth.svelte").then(m => m.authStore); + const usuarioStore = authStore; + if (usuarioStore.usuario?._id) { - const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, { - usuarioId: usuarioStore.usuario._id as any, - }); + const notifsFerias = await client.query( + api.ferias.obterNotificacoesNaoLidas, + { + usuarioId: usuarioStore.usuario._id as any, + } + ); notificacoesFerias = notifsFerias || []; } } catch (e) { @@ -61,19 +74,25 @@ await client.mutation(api.chat.marcarTodasNotificacoesLidas, {}); // Marcar todas as notificações de férias como lidas for (const notif of notificacoesFerias) { - await client.mutation(api.ferias.marcarComoLida, { notificacaoId: notif._id }); + await client.mutation(api.ferias.marcarComoLida, { + notificacaoId: notif._id, + }); } dropdownOpen = false; await buscarNotificacoesFerias(); } async function handleClickNotificacao(notificacaoId: string) { - await client.mutation(api.chat.marcarNotificacaoLida, { notificacaoId: notificacaoId as any }); + await client.mutation(api.chat.marcarNotificacaoLida, { + notificacaoId: notificacaoId as any, + }); dropdownOpen = false; } async function handleClickNotificacaoFerias(notificacaoId: string) { - await client.mutation(api.ferias.marcarComoLida, { notificacaoId: notificacaoId as any }); + await client.mutation(api.ferias.marcarComoLida, { + notificacaoId: notificacaoId as any, + }); await buscarNotificacoesFerias(); dropdownOpen = false; // Redirecionar para a página de férias @@ -98,43 +117,6 @@ }); - - + + {:else if abaAtiva === "aprovar-ferias"}

- - + + Solicitações da Equipe -
{solicitacoesSubordinados.length}
+
+ {solicitacoesSubordinados.length} +

{#if solicitacoesSubordinados.length === 0}
- - + + - Nenhuma solicitação pendente no momento. + Nenhuma solicitação pendente no momento.
{:else}
@@ -733,20 +1283,36 @@ {#each solicitacoesSubordinados as solicitacao} -
{solicitacao.funcionario?.nome}
+
+ {solicitacao.funcionario?.nome} +
{#if solicitacao.time} -
+
{solicitacao.time.nome}
{/if} {solicitacao.anoReferencia} - {solicitacao.periodos.length} - {solicitacao.periodos.reduce((acc: number, p: any) => acc + p.diasCorridos, 0)} + {solicitacao.periodos.length} + {solicitacao.periodos.reduce( + (acc: number, p: any) => acc + p.diasCorridos, + 0 + )} -
+
{getStatusTexto(solicitacao.status)}
@@ -755,10 +1321,22 @@ @@ -766,11 +1344,28 @@ @@ -785,7 +1380,7 @@
{/if} -
+
{#if solicitacaoSelecionada} @@ -796,12 +1391,16 @@ solicitacao={solicitacaoSelecionada} gestorId={authStore.usuario._id} onSucesso={recarregar} - onCancelar={() => solicitacaoSelecionada = null} + onCancelar={() => (solicitacaoSelecionada = null)} /> {/if}
{/if} @@ -810,21 +1409,39 @@ {#if mostrarModalFoto} {/if} + + + + +{#if mostrarWizard && funcionario} + + + + + + +{/if} 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..a3ff1ad 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,1657 @@ + + 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.
-
+ +
+ + + + +
- -
-
-
-

Registrar Atestado

-

Cadastre atestados médicos

-
- + + {#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 Licença

-

Cadastre licenças e afastamentos

-
- + +
+
+

Filtros

+
+
+
-
-
-
-
-

Histórico

-

Consulte histórico de atestados e licenças

-
- +
+
-
-
-
-
-

Estatísticas

-

Visualize estatísticas e relatórios

-
- +
+ +
+ +
+ +
+ +
+
+ + + {#if eventosQuery?.data} + + {/if} + + + {#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} + + +
+
+

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

+ +
+ + +
+
+ Data Início * +
+ +
+ +
+
+ Data Fim * +
+ +
+ +
+
+ CID * +
+ +
+ +
+ { + atestadoMedico.documentoId = await handleDocumentoUpload( + file + ); + }} + onRemove={async () => { + atestadoMedico.documentoId = undefined; + }} + /> +
+ +
+
+ Observações +
+ +
+
+ +
+ + +
+
+
+ {:else if abaAtiva === "declaracao"} + +
+
+

Registrar Declaração de Comparecimento

+ +
+ + +
+
+ Data Início * +
+ +
+ +
+
+ Data Fim * +
+ +
+ +
+ { + declaracao.documentoId = await handleDocumentoUpload(file); + }} + onRemove={async () => { + declaracao.documentoId = undefined; + }} + /> +
+ +
+
+ Observações +
+ +
+
+ +
+ + +
+
+
+ {:else if abaAtiva === "maternidade"} + +
+
+

Registrar Licença Maternidade

+ +
+ + +
+
+ Data Início * +
+ +
+ +
+
+ Data Fim * +
+ +
+ Calculado automaticamente (120 dias) +
+
+ +
+ +
+ + {#if licencaMaternidade.ehProrrogacao} +
+
+ Licença Original * +
+ +
+ {/if} + +
+ { + licencaMaternidade.documentoId = await handleDocumentoUpload( + file + ); + }} + onRemove={async () => { + licencaMaternidade.documentoId = undefined; + }} + /> +
+ +
+
+ Observações +
+ +
+
+ +
+ + +
+
+
+ {:else if abaAtiva === "paternidade"} + +
+
+

Registrar Licença Paternidade

+ +
+ + +
+
+ Data Início * +
+ +
+ +
+
+ Data Fim * +
+ +
+ Calculado automaticamente (20 dias) +
+
+ +
+ { + licencaPaternidade.documentoId = await handleDocumentoUpload( + file + ); + }} + onRemove={async () => { + licencaPaternidade.documentoId = undefined; + }} + /> +
+ +
+
+ Observações +
+ +
+
+ +
+ + +
+
+
+ {/if}
+ + { + erroModal.aberto = false; + }} +/> + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte index 8b95711..73a9dd1 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte @@ -3,49 +3,30 @@ import { useQuery } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; - // Buscar todas as solicitações (RH vê tudo) + // Buscar TODAS as solicitações de férias (Dashboard RH) const todasSolicitacoesQuery = useQuery(api.ferias.listarTodas, {}); - const todosFuncionariosQuery = useQuery(api.funcionarios.getAll, {}); let filtroStatus = $state("todos"); - let filtroTime = $state("todos"); - let filtroBusca = $state(""); const solicitacoes = $derived(todasSolicitacoesQuery?.data || []); - const funcionarios = $derived(todosFuncionariosQuery?.data || []); // Filtrar solicitações const solicitacoesFiltradas = $derived( solicitacoes.filter((s: any) => { // Filtro de status if (filtroStatus !== "todos" && s.status !== filtroStatus) return false; - - // Filtro de time - if (filtroTime !== "todos" && s.time?._id !== filtroTime) return false; - - // Filtro de busca - if (filtroBusca && !s.funcionario?.nome.toLowerCase().includes(filtroBusca.toLowerCase())) { - return false; - } - return true; }) ); - // Estatísticas + // Estatísticas gerais const stats = $derived({ total: solicitacoes.length, aguardando: solicitacoes.filter((s: any) => s.status === "aguardando_aprovacao").length, aprovadas: solicitacoes.filter((s: any) => s.status === "aprovado" || s.status === "data_ajustada_aprovada").length, reprovadas: solicitacoes.filter((s: any) => s.status === "reprovado").length, - emFerias: funcionarios.filter((f: any) => f.statusFerias === "em_ferias").length, }); - // Times únicos para filtro - const timesDisponiveis = $derived( - Array.from(new Set(solicitacoes.map((s: any) => s.time).filter(Boolean))) - ); - function getStatusBadge(status: string) { const badges: Record = { aguardando_aprovacao: "badge-warning", @@ -104,7 +85,7 @@ -
+
@@ -148,38 +129,13 @@
{stats.reprovadas}
Indeferidas
- -
-
- - - -
-
Em Férias
-
{stats.emFerias}
-
Agora
-

Filtros

-
- -
- - -
- +
- - -
- - -
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte index 90dda4c..d7cde28 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte @@ -99,6 +99,20 @@ let mostrarFormularioCurso = $state(false); let cursoAtual = $state({ descricao: "", data: "", arquivo: null as File | null }); + // Dependentes + let dependentes = $state>([]); + let mostrarFormularioDependente = $state(false); + let dependenteAtual = $state<{ parentesco: string; nome: string; cpf: string; nascimento: string; arquivo: File | null; documentoId?: string; salarioFamilia?: boolean; impostoRenda?: boolean }>({ + parentesco: "", + nome: "", + cpf: "", + nascimento: "", + arquivo: null, + documentoId: undefined, + salarioFamilia: false, + impostoRenda: false, + }); + function adicionarCurso() { if (!cursoAtual.descricao.trim() || !cursoAtual.data.trim()) { alert("Preencha a descrição e a data do curso"); @@ -129,6 +143,51 @@ return result.storageId; } + async function uploadDocumentoDependente(file: File): Promise { + const uploadUrl = await client.mutation(api.documentos.generateUploadUrl, {}); + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }); + const result = await response.json(); + return result.storageId as string; + } + + function adicionarDependente() { + if (dependentes.length >= 10) { + alert("Limite de 10 dependentes atingido"); + return; + } + if (!dependenteAtual.parentesco || !dependenteAtual.nome.trim() || !dependenteAtual.cpf.trim() || !dependenteAtual.nascimento.trim()) { + alert("Preencha Parentesco, Nome, CPF e Data de Nascimento do dependente"); + return; + } + if (!validateCPF(dependenteAtual.cpf)) { + alert("CPF do dependente inválido"); + return; + } + if (!validateDate(dependenteAtual.nascimento)) { + alert("Data de nascimento do dependente inválida"); + return; + } + dependentes.push({ + id: crypto.randomUUID(), + parentesco: dependenteAtual.parentesco, + nome: dependenteAtual.nome.trim(), + cpf: onlyDigits(dependenteAtual.cpf), + nascimento: dependenteAtual.nascimento, + documentoId: dependenteAtual.documentoId, + salarioFamilia: !!dependenteAtual.salarioFamilia, + impostoRenda: !!dependenteAtual.impostoRenda, + }); + dependenteAtual = { parentesco: "", nome: "", cpf: "", nascimento: "", arquivo: null, documentoId: undefined, salarioFamilia: false, impostoRenda: false }; + } + + function removerDependente(id: string) { + dependentes = dependentes.filter((d) => d.id !== id); + } + async function loadSimbolos() { const list = await client.query(api.simbolos.getAll, {} as any); simbolos = list.map((s: any) => ({ @@ -267,6 +326,18 @@ ...Object.fromEntries( Object.entries(documentosStorage).map(([key, value]) => [key, value as any]) ), + // Dependentes (opcional) + dependentes: dependentes.length + ? dependentes.map((d) => ({ + parentesco: d.parentesco, + nome: d.nome, + cpf: d.cpf, + nascimento: d.nascimento, + documentoId: d.documentoId as any, + salarioFamilia: !!d.salarioFamilia, + impostoRenda: !!d.impostoRenda, + })) + : undefined, }; const novoFuncionarioId = await client.mutation(api.funcionarios.create, payload as any); @@ -358,7 +429,7 @@ {/if} -
{ e.preventDefault(); handleSubmit(); }}> + { e.preventDefault(); }}>
@@ -955,7 +1026,7 @@ Endereço e Contato -
+
-
+
@@ -1060,6 +1131,120 @@
+ +
+
+

+ + + + Dependentes +

+ + {#if dependentes.length > 0} +
+

Dependentes adicionados ({dependentes.length}/10)

+ {#each dependentes as dep} +
+
+

{dep.nome} — {dep.parentesco}

+

CPF: {dep.cpf} • Nasc.: {dep.nascimento}

+
+ + +
+
+ +
+ {/each} +
+ {/if} + + {#if dependentes.length < 10} +
+ +
Adicionar Dependente
+
+
+
+ + +
+ +
+ + +
+ +
+ + { const t = e.target as HTMLInputElement; dependenteAtual.cpf = maskCPF(t.value); t.value = dependenteAtual.cpf; }} /> +
+ +
+ + { const t = e.target as HTMLInputElement; dependenteAtual.nascimento = maskDate(t.value); t.value = dependenteAtual.nascimento; }} /> +
+ +
+ + { const file = e.currentTarget.files?.[0]; dependenteAtual.arquivo = file || null; if (file) { try { dependenteAtual.documentoId = await uploadDocumentoDependente(file); } catch { alert("Falha no upload do documento do dependente"); } } }} /> +
+ + +
+ +
+
+ +
+ +
+ +
+
+
+
+ {/if} +
+
+
@@ -1345,9 +1530,10 @@ Cancelar
+ + + + + diff --git a/apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte b/apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte index 4801dd4..3cdd9c3 100644 --- a/apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte +++ b/apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte @@ -72,17 +72,28 @@ } -
-
-

Solicitar Acesso ao SGSE

-

- Preencha o formulário abaixo para solicitar acesso ao Sistema de Gerenciamento da Secretaria de Esportes. - Sua solicitação será analisada pela equipe de Tecnologia da Informação. -

-
+
+ +
+
+
+
+ + Acesso ao Sistema + +

+ Solicitar Acesso ao SGSE +

+

+ Preencha o formulário abaixo para solicitar acesso ao Sistema de Gerenciamento da Secretaria de Esportes. + Sua solicitação será analisada pela equipe de Tecnologia da Informação. +

+
+
+ {#if notice} -
+
{/if} - {notice.message} + {notice.message}
{/if} -
-
+ +
+
+
{ e.preventDefault(); @@ -118,25 +131,28 @@ form.handleSubmit(); }} > -
+
{#snippet children(field)}
field.handleChange(e.currentTarget.value)} /> {#if field.state.meta.errors.length > 0} - {field.state.meta.errors[0]} + {/if}
{/snippet} @@ -147,19 +163,22 @@ {#snippet children(field)}
field.handleChange(e.currentTarget.value)} /> {#if field.state.meta.errors.length > 0} - {field.state.meta.errors[0]} + {/if}
{/snippet} @@ -170,19 +189,22 @@ {#snippet children(field)}
field.handleChange(e.currentTarget.value)} /> {#if field.state.meta.errors.length > 0} - {field.state.meta.errors[0]} + {/if}
{/snippet} @@ -193,13 +215,14 @@ {#snippet children(field)}
{ @@ -210,26 +233,36 @@ maxlength="15" /> {#if field.state.meta.errors.length > 0} - {field.state.meta.errors[0]} + {/if}
{/snippet}
-
- -
-
+
-
+ +
-

Informações Importantes

-
-
    -
  • Todos os campos marcados com * são obrigatórios
  • -
  • Sua solicitação será analisada pela equipe de TI em até 48 horas úteis
  • -
  • Você receberá um e-mail com o resultado da análise
  • -
  • Em caso de dúvidas, entre em contato com o suporte técnico
  • -
+

Informações Importantes

+
+
+ + Todos os campos marcados com * são obrigatórios +
+
+ + Sua solicitação será analisada pela equipe de TI em até 48 horas úteis +
+
+ + Você receberá um e-mail com o resultado da análise +
+
+ + Em caso de dúvidas, entre em contato com o suporte técnico +
+ diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index ade7b7f..b59a15e 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -1,337 +1,410 @@ -
-

Tecnologia da Informação

- -
- -
-
-
-
- - - -
-

Painel Administrativo

-
-

- Acesso restrito para gerenciamento de solicitações de acesso ao sistema e outras configurações administrativas. +

+
+
+
+
+
+ + Tecnologia da Informação + +

+ Sistemas de Informação +

+

+ Acesso restrito para gerenciamento de solicitações de acesso ao sistema, configuração de permissões e monitoramento técnico das operações do SGSE.

- +
+
+

Status

+

Operacional

+
+
+

Última atualização

+

Agora mesmo

+
+
+
+ Monitoramento em tempo real. + SGSE
+
- -
-
-
-
+
+ {#each featureCards as card (card.title)} +
+
+
+
+ {#each iconPaths[card.icon] as path (path.d)} + d={path.d} + stroke-linecap={path.strokeLinecap ?? "round"} + stroke-linejoin={path.strokeLinejoin ?? "round"} + stroke-width={path.strokeWidth ?? 2} + /> + {/each}
-

Suporte Técnico

-
-

- Central de atendimento para resolução de problemas técnicos e dúvidas sobre o sistema. -

-
-
+ + {#if card.highlightBadges} +
+ {#each card.highlightBadges as badge (badge.label)} + {#if badge.variant === "solid"} + {badge.label} + {:else} + + {badge.label} + + {/if} + {/each} +
+ {/if} + +
+ {#if card.href && !card.disabled} + + {card.ctaLabel} + + {:else} + + {/if}
-
-
+ + {/each} + - -
-
-
-
- - - -
-

Gerenciar Permissões

-
-

- Configure as permissões de acesso aos menus do sistema por função. Controle quem pode acessar, consultar e gravar dados. -

- -
-
- - -
-
-
-
- - - -
-

Configuração de Email

-
-

- Configure o servidor SMTP para envio automático de notificações e emails do sistema. -

- -
-
- - -
-
-
-
- - - -
-

Gerenciar Usuários

-
-

- Criar, editar, bloquear e gerenciar usuários do sistema. Controle total sobre contas de acesso. -

- -
-
- - -
-
-
-
- - - -
-

Gerenciar Perfis

-
-

- Crie e gerencie perfis de acesso personalizados com permissões específicas para grupos de usuários. -

- -
-
- - -
-
-
-
- - - -
-

Notificações e Mensagens

-
-

- Envie notificações para usuários do sistema via chat ou email. Configure templates de mensagens reutilizáveis. -

- -
-
- - -
-
-
-
- - - -
-

Monitorar SGSE

-
-

- Monitore em tempo real as métricas técnicas do sistema: CPU, memória, rede, usuários online e muito mais. Configure alertas personalizados. -

-
-
Tempo Real
-
Alertas
-
Relatórios
-
- -
-
- - -
-
-
-
- - - -
-

Documentação

-
-

- Manuais, guias e documentação técnica do sistema para usuários e administradores. -

-
- -
-
-
-
- -
- +
+
+
+
+ + /> +
-

Área Restrita

-
- Esta é uma área de acesso restrito. Apenas usuários autorizados pela equipe de TI podem acessar o Painel Administrativo. +

Área Restrita

+

+ Esta área é exclusiva da equipe de Tecnologia da Informação. Garanta que apenas usuários autorizados acessem o Painel Administrativo e mantenha suas credenciais em segurança. +

-
+
diff --git a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte index f39ac55..85ccaa2 100644 --- a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte @@ -15,7 +15,8 @@ month: '2-digit', year: 'numeric', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', + second: '2-digit' }); } @@ -30,55 +31,128 @@ }; return colors[acao] || "badge-neutral"; } + + function getAcaoLabel(acao: string) { + const labels: Record = { + criar: "Criar", + editar: "Editar", + excluir: "Excluir", + bloquear: "Bloquear", + desbloquear: "Desbloquear", + resetar_senha: "Resetar Senha" + }; + return labels[acao] || acao; + } + + // Estatísticas + const totalAtividades = $derived(atividades?.data?.length || 0); + const totalLogins = $derived(logins?.data?.length || 0); + const loginsSucesso = $derived(logins?.data?.filter(l => l.sucesso).length || 0); + const loginsFalha = $derived(logins?.data?.filter(l => !l.sucesso).length || 0); -
+
+ + + -
-
-
- +
+
+
+
-

Auditoria e Logs

-

Histórico completo de atividades e acessos

+

Auditoria e Logs

+

Monitoramento completo de atividades e acessos do sistema

+ +
+
+
+ + + +
+
Atividades
+
{totalAtividades}
+
Registros exibidos
+
+ +
+
+ + + +
+
Logins Totais
+
{totalLogins}
+
Tentativas de acesso
+
+ +
+
+ + + +
+
Logins Bem-sucedidos
+
{loginsSucesso}
+
{totalLogins > 0 ? Math.round((loginsSucesso / totalLogins) * 100) : 0}% de sucesso
+
+ +
+
+ + + +
+
Logins Falhados
+
{loginsFalha}
+
{totalLogins > 0 ? Math.round((loginsFalha / totalLogins) * 100) : 0}% de falhas
+
+
+ -
+
-
+
-
- + +
+
+ +
+{/if} diff --git a/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte b/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte index 10630be..0349c24 100644 --- a/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte @@ -4,18 +4,35 @@ import StatsCard from "$lib/components/ti/StatsCard.svelte"; const client = useConvexClient(); - const usuarios = useQuery(api.usuarios.listar, {}); + const usuariosQuery = useQuery(api.usuarios.listar, {}); + + // Verificar se está carregando + const carregando = $derived(usuariosQuery === undefined); + + // Extrair dados dos usuários + const usuarios = $derived(usuariosQuery?.data ?? []); // Estatísticas derivadas const stats = $derived.by(() => { - if (!usuarios?.data || !Array.isArray(usuarios.data)) return null; + // Se ainda está carregando, retorna null para mostrar loading + if (carregando) return null; - const ativos = usuarios.data.filter(u => u.ativo && !u.bloqueado).length; - const bloqueados = usuarios.data.filter(u => u.bloqueado).length; - const inativos = usuarios.data.filter(u => !u.ativo).length; + // Se não há usuários, retorna stats zeradas (mas não null para não mostrar loading) + if (!Array.isArray(usuarios) || usuarios.length === 0) { + return { + total: 0, + ativos: 0, + bloqueados: 0, + inativos: 0 + }; + } + + const ativos = usuarios.filter(u => u.ativo && !u.bloqueado).length; + const bloqueados = usuarios.filter(u => u.bloqueado === true).length; + const inativos = usuarios.filter(u => !u.ativo).length; return { - total: usuarios.data.length, + total: usuarios.length, ativos, bloqueados, inativos @@ -52,7 +69,7 @@ diff --git a/apps/web/src/routes/(dashboard)/ti/painel-permissoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/painel-permissoes/+page.svelte index a7cae71..2bbf51f 100644 --- a/apps/web/src/routes/(dashboard)/ti/painel-permissoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/painel-permissoes/+page.svelte @@ -4,6 +4,7 @@ import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import { goto } from "$app/navigation"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; + import { authStore } from "$lib/stores/auth.svelte"; type RoleRow = { _id: Id<"roles">; _creationTime: number; @@ -32,6 +33,14 @@ // Formato: { "roleId-recurso": true/false } let recursosExpandidos: Record = $state({}); + // Gerenciamento de Perfis + let modalGerenciarPerfisAberto = $state(false); + let perfilSendoEditado = $state(null); + let nomeNovoPerfil = $state(""); + let descricaoNovoPerfil = $state(""); + let nivelNovoPerfil = $state(3); + let processando = $state(false); + // Cache de permissões por role let permissoesPorRole: Record< string, @@ -66,13 +75,13 @@ const rolesFiltradas = $derived.by(() => { if (!rolesQuery.data) return []; - let rs: Array = rolesQuery.data as Array; + let rs = rolesQuery.data; // Removed explicit type annotation if (filtroRole) - rs = rs.filter((r: RoleRow) => r._id === (filtroRole as any)); + rs = rs.filter((r) => r._id === (filtroRole)); // Removed as any if (busca.trim()) { const b = busca.toLowerCase(); rs = rs.filter( - (r: RoleRow) => + (r) => r.descricao.toLowerCase().includes(b) || r.nome.toLowerCase().includes(b) ); @@ -120,8 +129,9 @@ ]; } mostrarMensagem("success", "Permissão atualizada com sucesso!"); - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao atualizar permissão"); + } catch (error: unknown) { // Changed to unknown + const message = error instanceof Error ? error.message : "Erro ao atualizar permissão"; + mostrarMensagem("error", message); } finally { salvando = false; } @@ -132,6 +142,90 @@ const entry = dados?.find((e) => e.recurso === recurso); return entry ? entry.acoes.includes(acao) : false; } + + function abrirModalCriarPerfil() { + nomeNovoPerfil = ""; + descricaoNovoPerfil = ""; + nivelNovoPerfil = 3; // Default to a common level + perfilSendoEditado = null; + modalGerenciarPerfisAberto = true; + } + + function prepararEdicaoPerfil(role: RoleRow) { + perfilSendoEditado = role; + nomeNovoPerfil = role.nome; + descricaoNovoPerfil = role.descricao; + nivelNovoPerfil = role.nivel; + modalGerenciarPerfisAberto = true; + } + + function fecharModalGerenciarPerfis() { + modalGerenciarPerfisAberto = false; + perfilSendoEditado = null; + } + + async function criarNovoPerfil() { + if (!nomeNovoPerfil.trim()) return; + + processando = true; + try { + const result = await client.mutation(api.roles.criar, { + nome: nomeNovoPerfil.trim(), + descricao: descricaoNovoPerfil.trim(), + nivel: nivelNovoPerfil, + customizado: true, + }); + + if (result.sucesso) { + mostrarMensagem("success", "Perfil criado com sucesso!"); + nomeNovoPerfil = ""; + descricaoNovoPerfil = ""; + nivelNovoPerfil = 3; + fecharModalGerenciarPerfis(); + if (rolesQuery.refetch) { // Verificação para garantir que refetch existe + rolesQuery.refetch(); // Atualiza a lista de perfis + } + } else { + mostrarMensagem("error", `Erro ao criar perfil: ${result.erro}`); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + mostrarMensagem("error", `Erro ao criar perfil: ${message}`); + } finally { + processando = false; + } + } + + async function editarPerfil() { + if (!perfilSendoEditado || !nomeNovoPerfil.trim()) return; + + processando = true; + try { + const result = await client.mutation(api.roles.atualizar, { + roleId: perfilSendoEditado._id, + nome: nomeNovoPerfil.trim(), + descricao: descricaoNovoPerfil.trim(), + nivel: nivelNovoPerfil, + setor: perfilSendoEditado.setor, // Manter setor existente + }); + + if (result.sucesso) { + mostrarMensagem("success", "Perfil atualizado com sucesso!"); + fecharModalGerenciarPerfis(); + if (rolesQuery.refetch) { // Verificação para garantir que refetch existe + rolesQuery.refetch(); // Atualiza a lista de perfis + } + } else { + mostrarMensagem("error", `Erro ao atualizar perfil: ${result.erro}`); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + mostrarMensagem("error", `Erro ao atualizar perfil: ${message}`); + } finally { + processando = false; + } + } + @@ -160,7 +254,7 @@
  • TI
  • -
  • Gerenciar Permissões
  • +
  • Gerenciar Perfis & Permissões
  • @@ -185,12 +279,32 @@

    - Gerenciar Permissões de Acesso + Gerenciar Perfis & Permissões de Acesso

    Configure as permissões de acesso aos menus do sistema por função

    +
    +
    + +
    {#if roleRow.nivel <= 1} @@ -574,4 +697,148 @@
    {/each} {/if} + + + {#if modalGerenciarPerfisAberto} + + + + + {/if} diff --git a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte index 505d108..e61b8e5 100644 --- a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte @@ -2,238 +2,153 @@ import { useQuery, useConvexClient } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; - import { authStore } from "$lib/stores/auth.svelte"; + import StatsCard from "$lib/components/ti/StatsCard.svelte"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; + import { format } from "date-fns"; + import { ptBR } from "date-fns/locale"; + + type Role = { + _id: Id<"roles">; + _creationTime: number; + nome: string; + descricao: string; + nivel: number; + setor?: string; + }; const client = useConvexClient(); - - // Queries - const perfisQuery = useQuery(api.perfisCustomizados.listarPerfisCustomizados, {}); const rolesQuery = useQuery(api.roles.listar, {}); + const roles = $derived(rolesQuery?.data ?? []); + const carregando = $derived(rolesQuery === undefined); - // Estados - let modo = $state<"listar" | "criar" | "editar" | "detalhes">("listar"); - let perfilSelecionado = $state(null); - let processando = $state(false); - let mensagem = $state<{ tipo: "success" | "error" | "warning"; texto: string } | null>(null); - let modalExcluir = $state(false); - let perfilParaExcluir = $state(null); + let busca = $state(""); + let filtroSetor = $state(""); + let filtroNivel = $state(""); + let roleSelecionada = $state(null); + let modalDetalhesAberto = $state(false); - // Formulário - let formNome = $state(""); - let formDescricao = $state(""); - let formNivel = $state(3); - let formClonarDeRoleId = $state(""); - - // Detalhes do perfil - let detalhesQuery = $state(null); + const setoresDisponiveis = $derived.by(() => { + const setores = new Set(); + roles.forEach((r) => { + if (r.setor) setores.add(r.setor); + }); + return Array.from(setores).sort(); + }); - function mostrarMensagem(tipo: "success" | "error" | "warning", texto: string) { - mensagem = { tipo, texto }; - setTimeout(() => { - mensagem = null; - }, 5000); - } - - function abrirCriar() { - modo = "criar"; - formNome = ""; - formDescricao = ""; - formNivel = 3; - formClonarDeRoleId = ""; - } - - function abrirEditar(perfil: any) { - modo = "editar"; - perfilSelecionado = perfil; - formNome = perfil.nome; - formDescricao = perfil.descricao; - formNivel = perfil.nivel; - } - - async function abrirDetalhes(perfil: any) { - modo = "detalhes"; - perfilSelecionado = perfil; + // Estatísticas + const stats = $derived.by(() => { + if (carregando) return null; - // Buscar detalhes completos - try { - const detalhes = await client.query(api.perfisCustomizados.obterPerfilComPermissoes, { - perfilId: perfil._id, - }); - detalhesQuery = detalhes; - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao carregar detalhes"); - } - } + const porNivel = { + 0: roles.filter(r => r.nivel === 0).length, + 1: roles.filter(r => r.nivel === 1).length, + 2: roles.filter(r => r.nivel === 2).length, + 3: roles.filter(r => r.nivel >= 3).length, + }; - function voltar() { - modo = "listar"; - perfilSelecionado = null; - detalhesQuery = null; - } + return { + total: roles.length, + nivelMaximo: porNivel[0], + nivelAlto: porNivel[1], + nivelMedio: porNivel[2], + nivelBaixo: porNivel[3], + comSetor: roles.filter(r => r.setor).length, + }; + }); - async function criarPerfil() { - if (!formNome.trim() || !formDescricao.trim()) { - mostrarMensagem("warning", "Preencha todos os campos obrigatórios"); - return; + const rolesFiltradas = $derived.by(() => { + let resultado = roles; + + // Filtro por busca (nome ou descrição) + if (busca.trim()) { + const buscaLower = busca.toLowerCase(); + resultado = resultado.filter( + (r) => + r.nome.toLowerCase().includes(buscaLower) || + r.descricao.toLowerCase().includes(buscaLower) + ); } - if (formNivel < 3) { - mostrarMensagem("warning", "O nível mínimo para perfis customizados é 3"); - return; + // Filtro por setor + if (filtroSetor) { + resultado = resultado.filter((r) => r.setor === filtroSetor); } - if (!authStore.usuario) { - mostrarMensagem("error", "Usuário não autenticado"); - return; - } - - try { - processando = true; - - const resultado = await client.mutation(api.perfisCustomizados.criarPerfilCustomizado, { - nome: formNome.trim(), - descricao: formDescricao.trim(), - nivel: formNivel, - clonarDeRoleId: formClonarDeRoleId ? (formClonarDeRoleId as Id<"roles">) : undefined, - criadoPorId: authStore.usuario._id as Id<"usuarios">, - }); - - if (resultado.sucesso) { - mostrarMensagem("success", "Perfil criado com sucesso!"); - voltar(); + // Filtro por nível + if (filtroNivel !== "") { + if (filtroNivel === 3) { + resultado = resultado.filter((r) => r.nivel >= 3); } else { - mostrarMensagem("error", resultado.erro); + resultado = resultado.filter((r) => r.nivel === filtroNivel); } - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao criar perfil"); - } finally { - processando = false; } + + return resultado.sort((a, b) => { + // Ordenar por nível primeiro (menor nível = maior privilégio) + if (a.nivel !== b.nivel) return a.nivel - b.nivel; + // Depois por nome + return a.nome.localeCompare(b.nome); + }); + }); + + function obterCorNivel(nivel: number): string { + if (nivel === 0) return "badge-error"; + if (nivel === 1) return "badge-warning"; + if (nivel === 2) return "badge-info"; + return "badge-ghost"; } - async function editarPerfil() { - if (!perfilSelecionado) return; - - if (!formNome.trim() || !formDescricao.trim()) { - mostrarMensagem("warning", "Preencha todos os campos obrigatórios"); - return; - } - - if (!authStore.usuario) { - mostrarMensagem("error", "Usuário não autenticado"); - return; - } - - try { - processando = true; - - const resultado = await client.mutation(api.perfisCustomizados.editarPerfilCustomizado, { - perfilId: perfilSelecionado._id, - nome: formNome.trim(), - descricao: formDescricao.trim(), - editadoPorId: authStore.usuario._id as Id<"usuarios">, - }); - - if (resultado.sucesso) { - mostrarMensagem("success", "Perfil atualizado com sucesso!"); - voltar(); - } else { - mostrarMensagem("error", resultado.erro); - } - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao editar perfil"); - } finally { - processando = false; - } + function obterTextoNivel(nivel: number): string { + if (nivel === 0) return "Máximo"; + if (nivel === 1) return "Alto"; + if (nivel === 2) return "Médio"; + if (nivel === 3) return "Baixo"; + return `Nível ${nivel}`; } - function abrirModalExcluir(perfil: any) { - perfilParaExcluir = perfil; - modalExcluir = true; + function obterCorCardNivel(nivel: number): string { + if (nivel === 0) return "border-l-4 border-error"; + if (nivel === 1) return "border-l-4 border-warning"; + if (nivel === 2) return "border-l-4 border-info"; + return "border-l-4 border-base-300"; } - function fecharModalExcluir() { - modalExcluir = false; - perfilParaExcluir = null; + function abrirDetalhes(role: Role) { + roleSelecionada = role; + modalDetalhesAberto = true; } - async function confirmarExclusao() { - if (!perfilParaExcluir || !authStore.usuario) { - mostrarMensagem("error", "Erro ao excluir perfil"); - return; - } - - try { - processando = true; - modalExcluir = false; - - const resultado = await client.mutation(api.perfisCustomizados.excluirPerfilCustomizado, { - perfilId: perfilParaExcluir._id, - excluidoPorId: authStore.usuario._id as Id<"usuarios">, - }); - - if (resultado.sucesso) { - mostrarMensagem("success", "Perfil excluído com sucesso!"); - } else { - mostrarMensagem("error", resultado.erro); - } - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao excluir perfil"); - } finally { - processando = false; - perfilParaExcluir = null; - } - } - - async function clonarPerfil(perfil: any) { - const novoNome = prompt(`Digite o nome para o novo perfil (clone de "${perfil.nome}"):`); - if (!novoNome?.trim()) return; - - const novaDescricao = prompt("Digite a descrição para o novo perfil:"); - if (!novaDescricao?.trim()) return; - - if (!authStore.usuario) { - mostrarMensagem("error", "Usuário não autenticado"); - return; - } - - try { - processando = true; - - const resultado = await client.mutation(api.perfisCustomizados.clonarPerfil, { - perfilOrigemId: perfil._id, - novoNome: novoNome.trim(), - novaDescricao: novaDescricao.trim(), - criadoPorId: authStore.usuario._id as Id<"usuarios">, - }); - - if (resultado.sucesso) { - mostrarMensagem("success", "Perfil clonado com sucesso!"); - } else { - mostrarMensagem("error", resultado.erro); - } - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao clonar perfil"); - } finally { - processando = false; - } + function fecharDetalhes() { + modalDetalhesAberto = false; + roleSelecionada = null; } function formatarData(timestamp: number): string { - return new Date(timestamp).toLocaleString("pt-BR"); + try { + return format(new Date(timestamp), "dd/MM/yyyy HH:mm", { locale: ptBR }); + } catch { + return "Data inválida"; + } } + + function limparFiltros() { + busca = ""; + filtroSetor = ""; + filtroNivel = ""; + } + + const temFiltrosAtivos = $derived(busca.trim() !== "" || filtroSetor !== "" || filtroNivel !== ""); - +
    -
    +
    -

    Gerenciar Perfis Customizados

    -

    - Crie e gerencie perfis de acesso personalizados para os usuários -

    +

    Gestão de Perfis

    +

    Visualize e gerencie os perfis de acesso do sistema

    - -
    - {#if modo !== "listar"} - - {/if} - {#if modo === "listar"} - - - - - Voltar para TI - - - {/if} -
    - - {#if mensagem} -
    - - {#if mensagem.tipo === "success"} - - {:else if mensagem.tipo === "error"} - - {:else} - - {/if} - - {mensagem.texto} + + {#if stats} +
    + + + + +
    {/if} - - {#if modo === "listar"} -
    + + {#if !carregando && roles.length > 0} +
    - {#if !perfisQuery} -
    - -
    - {:else if perfisQuery.data && perfisQuery.data.length === 0} -
    -
    📋
    -

    Nenhum perfil customizado

    -

    - Crie seu primeiro perfil personalizado clicando no botão acima -

    -
    - {:else if perfisQuery.data} -
    - - - - - - - - - - - - - - {#each perfisQuery.data as perfil} - - - - - - - - - - {/each} - -
    NomeDescriçãoNívelUsuáriosCriado PorCriado EmAções
    -
    {perfil.nome}
    -
    -
    - {perfil.descricao} -
    -
    -
    {perfil.nivel}
    -
    -
    - {perfil.numeroUsuarios} usuário{perfil.numeroUsuarios !== 1 ? "s" : ""} -
    -
    -
    {perfil.criadorNome}
    -
    -
    {formatarData(perfil.criadoEm)}
    -
    -
    - - - - -
    -
    -
    - {/if} -
    -
    - {/if} - - - {#if modo === "criar"} -
    -
    -

    Criar Novo Perfil Customizado

    - -
    { - e.preventDefault(); - criarPerfil(); - }} - > -
    - -
    - - -
    - - -
    - - -
    - Mínimo: 3 (perfis customizados) -
    -
    - - -
    - - -
    - - -
    - - -
    - Selecione um perfil existente para copiar suas permissões -
    -
    -
    - -
    - - -
    -
    -
    -
    - {/if} - - - {#if modo === "editar" && perfilSelecionado} -
    -
    -

    Editar Perfil: {perfilSelecionado.nome}

    - -
    { - e.preventDefault(); - editarPerfil(); - }} - > -
    - -
    - - -
    - - -
    - - -
    - - -
    - - - - O nível de acesso não pode ser alterado após a criação (Nível: {formNivel}) -
    -
    - -
    - - -
    -
    -
    -
    - {/if} - - - {#if modo === "detalhes" && perfilSelecionado} -
    - -
    -
    -

    {perfilSelecionado.nome}

    -
    -
    -

    Descrição

    -

    {perfilSelecionado.descricao}

    -
    -
    -

    Nível de Acesso

    -

    - {perfilSelecionado.nivel} -

    -
    -
    -

    Criado Por

    -

    {perfilSelecionado.criadorNome}

    -
    -
    -

    Criado Em

    -

    {formatarData(perfilSelecionado.criadoEm)}

    -
    -
    -

    Usuários com este Perfil

    -

    - {perfilSelecionado.numeroUsuarios} usuário{perfilSelecionado.numeroUsuarios !== - 1 - ? "s" - : ""} -

    -
    -
    -
    -
    - - - {#if !detalhesQuery} -
    -
    -
    - -
    -
    -
    - {:else} - - {#if detalhesQuery.menuPermissoes && detalhesQuery.menuPermissoes.length > 0} -
    -
    -

    Permissões de Menu

    -
    - - - - - - - - - - - {#each detalhesQuery.menuPermissoes as perm} - - - - - - - {/each} - -
    MenuAcessarConsultarGravar
    {perm.menuPath} - {#if perm.podeAcessar} - Sim - {:else} - Não - {/if} - - {#if perm.podeConsultar} - Sim - {:else} - Não - {/if} - - {#if perm.podeGravar} - Sim - {:else} - Não - {/if} -
    -
    - -
    -
    - {:else} -
    +
    +
    -
    -

    Sem permissões de menu configuradas

    -
    - Configure as permissões de menu no Painel de Permissões -
    -
    +

    Filtros de Busca

    - {/if} + {#if temFiltrosAtivos} + + {/if} +
    - - {#if detalhesQuery.usuarios && detalhesQuery.usuarios.length > 0} -
    -
    -

    Usuários com este Perfil

    -
    - - - - - - - - - - - {#each detalhesQuery.usuarios as usuario} - - - - - - - {/each} - -
    NomeMatrículaEmailStatus
    {usuario.nome}{usuario.matricula}{usuario.email} - {#if usuario.ativo && !usuario.bloqueado} - Ativo - {:else if usuario.bloqueado} - Bloqueado - {:else} - Inativo - {/if} -
    -
    +
    + +
    + +
    + + + +
    - {/if} - {/if} + + +
    + + +
    + + +
    + + +
    +
    + +
    +
    + {rolesFiltradas.length} de {roles.length} perfil(is) + {#if temFiltrosAtivos} + Filtrado + {/if} +
    +
    +
    +
    + {/if} + + + {#if carregando} +
    + +
    + {:else if roles.length === 0} +
    + + + +

    Nenhum perfil encontrado

    +

    Não há perfis cadastrados no sistema.

    +
    + {:else if rolesFiltradas.length === 0} +
    +
    +
    + + + +

    Nenhum perfil encontrado

    +

    Nenhum perfil corresponde aos filtros aplicados.

    + {#if temFiltrosAtivos} + + {/if} +
    +
    +
    + {:else} +
    + {#each rolesFiltradas as role} +
    abrirDetalhes(role)}> +
    +
    +
    +

    {role.descricao}

    +
    {obterTextoNivel(role.nivel)}
    +
    +
    + + + +
    +
    + +
    +
    + + + + Nome técnico: + {role.nome} +
    + + {#if role.setor} +
    + + + + Setor: + {role.setor} +
    + {/if} + +
    + + + + Nível: + {role.nivel} +
    +
    + +
    + +
    +
    +
    + {/each}
    {/if}
    - - {#if modalExcluir && perfilParaExcluir} - - + +
    {/if} diff --git a/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte b/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte new file mode 100644 index 0000000..1034330 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte @@ -0,0 +1,693 @@ + + + +
    + + {#if mensagem} +
    + {#if mensagem.tipo === "success"} + + + + {:else if mensagem.tipo === "error"} + + + + {/if} + {mensagem.texto} +
    + {/if} + + +
    +
    +
    + + + +
    +
    +

    Solicitações de Acesso

    +

    Gerencie e analise solicitações de acesso ao sistema

    +
    +
    +
    + + + {#if stats} +
    + + + + + + + +
    + {:else} +
    + +
    + {/if} + + +
    +
    + +
    + + + + +
    + + +
    + +
    + + + + +
    +
    +
    +
    + + + {#if carregando} +
    + +
    + {:else if solicitacoesFiltradas.length === 0} +
    +
    + + + +

    Nenhuma solicitação encontrada

    +

    + {#if busca.trim() || filtroStatus !== "todos"} + Tente ajustar os filtros ou a busca. + {:else} + Ainda não há solicitações de acesso cadastradas. + {/if} +

    +
    +
    + {:else} +
    + {#each solicitacoesFiltradas as solicitacao} +
    +
    +
    +
    +
    +

    {solicitacao.nome}

    + + {getStatusTexto(solicitacao.status)} + +
    + +
    +
    + + + + Matrícula: + {solicitacao.matricula} +
    + +
    + + + + E-mail: + {solicitacao.email} +
    + +
    + + + + Telefone: + {solicitacao.telefone} +
    +
    + +
    + Solicitado em: {formatarData(solicitacao.dataSolicitacao)} ({formatarDataRelativa(solicitacao.dataSolicitacao)}) + {#if solicitacao.dataResposta} + Processado em: {formatarData(solicitacao.dataResposta)} + {/if} +
    +
    + +
    + + + {#if solicitacao.status === "pendente"} + + + + {/if} +
    +
    +
    +
    + {/each} +
    + {/if} + + + {#if modalDetalhesAberto && solicitacaoSelecionada} + + + + + {/if} + + + {#if modalAprovarAberto && solicitacaoSelecionada} + + + + + {/if} + + + {#if modalRejeitarAberto && solicitacaoSelecionada} + + + + + {/if} +
    +
    diff --git a/apps/web/src/routes/(dashboard)/ti/times/+page.svelte b/apps/web/src/routes/(dashboard)/ti/times/+page.svelte index 6f2aa89..4b87a11 100644 --- a/apps/web/src/routes/(dashboard)/ti/times/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/times/+page.svelte @@ -2,7 +2,82 @@ import { useConvexClient, useQuery } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; import { authStore } from "$lib/stores/auth.svelte"; + import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import { goto } from "$app/navigation"; + import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel"; + + // Tipos baseados nos retornos das queries do backend + type Usuario = { + _id: Id<"usuarios">; + matricula: string; + nome: string; + email: string; + ativo: boolean; + bloqueado?: boolean; + motivoBloqueio?: string; + primeiroAcesso: boolean; + ultimoAcesso?: number; + criadoEm: number; + role: { + _id: Id<"roles">; + _creationTime?: number; + criadoPor?: Id<"usuarios">; + customizado?: boolean; + descricao: string; + editavel?: boolean; + nome: string; + nivel: number; + setor?: string; + erro?: boolean; + }; + funcionario?: { + _id: Id<"funcionarios">; + nome: string; + matricula?: string; + descricaoCargo?: string; + simboloTipo: "cargo_comissionado" | "funcao_gratificada"; + }; + avisos?: Array<{ + tipo: "erro" | "aviso" | "info"; + mensagem: string; + }>; + }; + + type Funcionario = { + _id: Id<"funcionarios">; + nome: string; + matricula?: string; + cpf?: string; + rg?: string; + nascimento?: string; + email?: string; + telefone?: string; + endereco?: string; + cep?: string; + cidade?: string; + uf?: string; + simboloId: Id<"simbolos">; + simboloTipo: "cargo_comissionado" | "funcao_gratificada"; + admissaoData?: string; + desligamentoData?: string; + descricaoCargo?: string; + }; + + type Gestor = Doc<"usuarios"> | null; + + type TimeComDetalhes = Doc<"times"> & { + gestor: Gestor; + totalMembros: number; + }; + + type MembroTime = Doc<"timesMembros"> & { + funcionario: Doc<"funcionarios"> | null; + }; + + type TimeComMembros = Doc<"times"> & { + gestor: Gestor; + membros: MembroTime[]; + }; const client = useConvexClient(); @@ -11,17 +86,23 @@ const usuariosQuery = useQuery(api.usuarios.listar, {}); const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); - const times = $derived(timesQuery?.data || []); - const usuarios = $derived(usuariosQuery?.data || []); - const funcionarios = $derived(funcionariosQuery?.data || []); + const times = $derived((timesQuery?.data || []) as TimeComDetalhes[]); + const usuarios = $derived((usuariosQuery?.data || []) as Usuario[]); + const funcionarios = $derived((funcionariosQuery?.data || []) as Funcionario[]); + + const carregando = $derived( + timesQuery === undefined || + usuariosQuery === undefined || + funcionariosQuery === undefined + ); // Estados let modoEdicao = $state(false); - let timeEmEdicao = $state(null); + let timeEmEdicao = $state(null); let mostrarModalMembros = $state(false); - let timeParaMembros = $state(null); + let timeParaMembros = $state(null); let mostrarConfirmacaoExclusao = $state(false); - let timeParaExcluir = $state(null); + let timeParaExcluir = $state(null); let processando = $state(false); // Form @@ -32,9 +113,9 @@ // Membros let membrosDisponiveis = $derived( - funcionarios.filter((f: any) => { + funcionarios.filter((f: Funcionario) => { // Verificar se o funcionário já está em algum time ativo - const jaNaEquipe = timeParaMembros?.membros?.some((m: any) => m.funcionario?._id === f._id); + const jaNaEquipe = timeParaMembros?.membros?.some((m: MembroTime) => m.funcionario?._id === f._id); return !jaNaEquipe; }) ); @@ -60,7 +141,7 @@ formCor = coresDisponiveis[Math.floor(Math.random() * coresDisponiveis.length)]; } - function editarTime(time: any) { + function editarTime(time: TimeComDetalhes) { modoEdicao = true; timeEmEdicao = time; formNome = time.nome; @@ -91,26 +172,27 @@ id: timeEmEdicao._id, nome: formNome, descricao: formDescricao || undefined, - gestorId: formGestorId as any, + gestorId: formGestorId as Id<"usuarios">, cor: formCor, }); } else { await client.mutation(api.times.criar, { nome: formNome, descricao: formDescricao || undefined, - gestorId: formGestorId as any, + gestorId: formGestorId as Id<"usuarios">, cor: formCor, }); } cancelarEdicao(); - } catch (e: any) { - alert("Erro ao salvar: " + (e.message || e)); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert("Erro ao salvar: " + errorMessage); } finally { processando = false; } } - function confirmarExclusao(time: any) { + function confirmarExclusao(time: TimeComDetalhes) { timeParaExcluir = time; mostrarConfirmacaoExclusao = true; } @@ -123,17 +205,20 @@ await client.mutation(api.times.desativar, { id: timeParaExcluir._id }); mostrarConfirmacaoExclusao = false; timeParaExcluir = null; - } catch (e: any) { - alert("Erro ao excluir: " + (e.message || e)); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert("Erro ao excluir: " + errorMessage); } finally { processando = false; } } - async function abrirGerenciarMembros(time: any) { + async function abrirGerenciarMembros(time: TimeComDetalhes) { const detalhes = await client.query(api.times.obterPorId, { id: time._id }); - timeParaMembros = detalhes; - mostrarModalMembros = true; + if (detalhes) { + timeParaMembros = detalhes as TimeComMembros; + mostrarModalMembros = true; + } } async function adicionarMembro(funcionarioId: string) { @@ -143,14 +228,17 @@ try { await client.mutation(api.times.adicionarMembro, { timeId: timeParaMembros._id, - funcionarioId: funcionarioId as any, + funcionarioId: funcionarioId as Id<"funcionarios">, }); // Recarregar detalhes do time const detalhes = await client.query(api.times.obterPorId, { id: timeParaMembros._id }); - timeParaMembros = detalhes; - } catch (e: any) { - alert("Erro: " + (e.message || e)); + if (detalhes) { + timeParaMembros = detalhes as TimeComMembros; + } + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert("Erro: " + errorMessage); } finally { processando = false; } @@ -161,13 +249,18 @@ processando = true; try { - await client.mutation(api.times.removerMembro, { membroId: membroId as any }); + await client.mutation(api.times.removerMembro, { membroId: membroId as Id<"timesMembros"> }); // Recarregar detalhes do time - const detalhes = await client.query(api.times.obterPorId, { id: timeParaMembros._id }); - timeParaMembros = detalhes; - } catch (e: any) { - alert("Erro: " + (e.message || e)); + if (timeParaMembros) { + const detalhes = await client.query(api.times.obterPorId, { id: timeParaMembros._id }); + if (detalhes) { + timeParaMembros = detalhes as TimeComMembros; + } + } + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert("Erro: " + errorMessage); } finally { processando = false; } @@ -179,7 +272,8 @@ } -
    + +
    +
    diff --git a/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte b/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte index cfde9d7..b5bd160 100644 --- a/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte @@ -1,536 +1,1222 @@ -
    - -
    -
    -

    Gestão de Usuários

    -

    Gerenciar usuários do sistema

    + +
    + +
    +
    +
    + + + +
    +
    +

    Gestão de Usuários

    +

    Administre os usuários do sistema

    +
    +
    +
    - - - - - Criar Usuário - + + + {#if !carregandoUsuarios && usuariosComProblemas.length > 0} +
    + + + +
    +

    Atenção: Usuários com Problemas Detectados

    +
    +

    + {usuariosComProblemas.length} usuário(s) possui(em) problemas que requerem atenção: +

    +
      + {#each usuariosComProblemas.slice(0, 3) as usuario} +
    • + {usuario.nome} ({usuario.matricula}) + {#if usuario.avisos && usuario.avisos.length > 0} + - {usuario.avisos[0].mensagem} + {/if} +
    • + {/each} + {#if usuariosComProblemas.length > 3} +
    • ... e mais {usuariosComProblemas.length - 3} usuário(s)
    • + {/if} +
    +

    + Por favor, corrija os perfis desses usuários para garantir acesso adequado ao sistema. +

    +
    +
    +
    + {/if} + + + {#if mensagem} +
    + {#if mensagem.tipo === "success"} + + + + {:else if mensagem.tipo === "error"} + + + + {:else} + + + + {/if} + {mensagem.texto} +
    + {/if} + + + {#if !carregandoUsuarios && usuarios.length > 0} +
    +
    +
    +

    Filtros de Busca

    + +
    + +
    + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    +
    + +
    + Mostrando {usuariosFiltrados.length} de {usuarios.length} usuário(s) +
    +
    +
    + {/if} + + + {#if carregandoUsuarios} +
    + +

    Carregando usuários...

    +
    + {:else if erroUsuarios} +
    + + + +
    +

    Erro ao carregar usuários

    +
    {erroUsuarios}
    +
    + Por favor, recarregue a página ou entre em contato com o suporte técnico se o problema persistir. +
    +
    +
    + {:else if usuarios.length === 0} +
    + + + +

    Nenhum usuário encontrado

    +

    + Cadastre um usuário para começar a gestão de acessos. +

    +
    + {:else} +
    +
    +

    Usuários ({usuarios.length})

    + +
    + + + + + + + + + + + + + + + + + + {#each usuariosFiltrados as usuario} + + + + + + + + + + + + + + {/each} + +
    MatrículaNomeEmailRole/PerfilSetorFuncionário VinculadoStatusPrimeiro AcessoÚltimo AcessoData de CriaçãoAções
    {usuario.matricula}{usuario.nome}{usuario.email} +
    + {#if usuario.role.erro} +
    + + + + {usuario.role.descricao} +
    + {#if usuario.avisos && usuario.avisos.length > 0} +
    + +
    + {/if} + {:else} +
    {usuario.role.nome}
    + {/if} +
    +
    {usuario.role.setor || "-"} + {#if usuario.funcionario} +
    +
    + + + + Associado +
    +
    {usuario.funcionario.nome}
    + {#if usuario.funcionario.matricula} +
    + Mat: {usuario.funcionario.matricula} +
    + {/if} +
    + {:else} +
    + + + + Não associado +
    + {/if} +
    + + + {#if usuario.primeiroAcesso} +
    Sim
    + {:else} +
    Não
    + {/if} +
    + {formatarData(usuario.ultimoAcesso)} + + {formatarData(usuario.criadoEm)} + + +
    +
    +
    +
    + {/if}
    - - {#if stats} -
    -
    -
    Total
    -
    {stats.total}
    + + {#if modalAssociarAberto && usuarioSelecionado} +
    -
    - +
    + Cancelar -
    - diff --git a/apps/web/src/routes/api/auth/[...all]/+server.ts b/apps/web/src/routes/api/auth/[...all]/+server.ts new file mode 100644 index 0000000..dd7705e --- /dev/null +++ b/apps/web/src/routes/api/auth/[...all]/+server.ts @@ -0,0 +1,3 @@ +import { createSvelteKitHandler } from "@mmailaender/convex-better-auth-svelte/sveltekit"; + +export const { GET, POST } = createSvelteKitHandler(); diff --git a/bun.lock b/bun.lock index 4317cd3..16a9a59 100644 --- a/bun.lock +++ b/bun.lock @@ -8,13 +8,11 @@ "chart.js": "^4.5.1", "lucide-svelte": "^0.548.0", "svelte-chartjs": "^3.1.5", + "svelte-sonner": "^1.0.5", }, "devDependencies": { "@biomejs/biome": "^2.3.2", - "turbo": "^2.5.4", - }, - "optionalDependencies": { - "@rollup/rollup-win32-x64-msvc": "^4.52.5", + "turbo": "^2.5.8", }, }, "apps/web": { @@ -32,7 +30,7 @@ "@sgse-app/backend": "*", "@tanstack/svelte-form": "^1.19.2", "@types/papaparse": "^5.3.14", - "convex": "^1.28.0", + "convex": "catalog:", "convex-svelte": "^0.0.11", "date-fns": "^4.1.0", "emoji-picker-element": "^1.27.0", @@ -54,7 +52,7 @@ "svelte": "^5.38.1", "svelte-check": "^4.3.1", "tailwindcss": "^4.1.12", - "typescript": "^5.9.2", + "typescript": "catalog:", "vite": "^7.1.2", }, }, @@ -63,7 +61,7 @@ "version": "1.0.0", "dependencies": { "@dicebear/avataaars": "^9.2.4", - "convex": "^1.28.0", + "convex": "^1.17.4", "nodemailer": "^7.0.10", }, "devDependencies": { @@ -75,10 +73,14 @@ "@types/pako": "^2.0.4", "@types/raf": "^3.4.3", "@types/trusted-types": "^2.0.7", - "typescript": "^5.9.2", + "typescript": "catalog:", }, }, }, + "catalog": { + "convex": "^1.28.0", + "typescript": "^5.9.2", + }, "packages": { "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], @@ -88,57 +90,57 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-sesv2": ["@aws-sdk/client-sesv2@3.920.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.920.0", "@aws-sdk/credential-provider-node": "3.920.0", "@aws-sdk/middleware-host-header": "3.920.0", "@aws-sdk/middleware-logger": "3.920.0", "@aws-sdk/middleware-recursion-detection": "3.920.0", "@aws-sdk/middleware-user-agent": "3.920.0", "@aws-sdk/region-config-resolver": "3.920.0", "@aws-sdk/signature-v4-multi-region": "3.920.0", "@aws-sdk/types": "3.920.0", "@aws-sdk/util-endpoints": "3.920.0", "@aws-sdk/util-user-agent-browser": "3.920.0", "@aws-sdk/util-user-agent-node": "3.920.0", "@smithy/config-resolver": "^4.4.0", "@smithy/core": "^3.17.1", "@smithy/fetch-http-handler": "^5.3.4", "@smithy/hash-node": "^4.2.3", "@smithy/invalid-dependency": "^4.2.3", "@smithy/middleware-content-length": "^4.2.3", "@smithy/middleware-endpoint": "^4.3.5", "@smithy/middleware-retry": "^4.4.5", "@smithy/middleware-serde": "^4.2.3", "@smithy/middleware-stack": "^4.2.3", "@smithy/node-config-provider": "^4.3.3", "@smithy/node-http-handler": "^4.4.3", "@smithy/protocol-http": "^5.3.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "@smithy/url-parser": "^4.2.3", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.4", "@smithy/util-defaults-mode-node": "^4.2.6", "@smithy/util-endpoints": "^3.2.3", "@smithy/util-middleware": "^4.2.3", "@smithy/util-retry": "^4.2.3", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-MDt2nQh14WFzlQcEcAxsw1QQzfaIDjwiqUA0AZAP5In/MZuDZnb54RIW/Af3R5IWywV9fGg9UFJpmbnmbm1dmA=="], + "@aws-sdk/client-sesv2": ["@aws-sdk/client-sesv2@3.921.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.921.0", "@aws-sdk/credential-provider-node": "3.921.0", "@aws-sdk/middleware-host-header": "3.921.0", "@aws-sdk/middleware-logger": "3.921.0", "@aws-sdk/middleware-recursion-detection": "3.921.0", "@aws-sdk/middleware-user-agent": "3.921.0", "@aws-sdk/region-config-resolver": "3.921.0", "@aws-sdk/signature-v4-multi-region": "3.921.0", "@aws-sdk/types": "3.921.0", "@aws-sdk/util-endpoints": "3.921.0", "@aws-sdk/util-user-agent-browser": "3.921.0", "@aws-sdk/util-user-agent-node": "3.921.0", "@smithy/config-resolver": "^4.4.1", "@smithy/core": "^3.17.2", "@smithy/fetch-http-handler": "^5.3.5", "@smithy/hash-node": "^4.2.4", "@smithy/invalid-dependency": "^4.2.4", "@smithy/middleware-content-length": "^4.2.4", "@smithy/middleware-endpoint": "^4.3.6", "@smithy/middleware-retry": "^4.4.6", "@smithy/middleware-serde": "^4.2.4", "@smithy/middleware-stack": "^4.2.4", "@smithy/node-config-provider": "^4.3.4", "@smithy/node-http-handler": "^4.4.4", "@smithy/protocol-http": "^5.3.4", "@smithy/smithy-client": "^4.9.2", "@smithy/types": "^4.8.1", "@smithy/url-parser": "^4.2.4", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.5", "@smithy/util-defaults-mode-node": "^4.2.7", "@smithy/util-endpoints": "^3.2.4", "@smithy/util-middleware": "^4.2.4", "@smithy/util-retry": "^4.2.4", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-SokAr3tgHo+M+U/nrqidZNiuhA8//l/L6A6R9e8lNEQZtI3tFB0SQcKFYQCy8H96/cNZhnYIqkxsUysEl0bNdw=="], - "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.920.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.920.0", "@aws-sdk/middleware-host-header": "3.920.0", "@aws-sdk/middleware-logger": "3.920.0", "@aws-sdk/middleware-recursion-detection": "3.920.0", "@aws-sdk/middleware-user-agent": "3.920.0", "@aws-sdk/region-config-resolver": "3.920.0", "@aws-sdk/types": "3.920.0", "@aws-sdk/util-endpoints": "3.920.0", "@aws-sdk/util-user-agent-browser": "3.920.0", "@aws-sdk/util-user-agent-node": "3.920.0", "@smithy/config-resolver": "^4.4.0", "@smithy/core": "^3.17.1", "@smithy/fetch-http-handler": "^5.3.4", "@smithy/hash-node": "^4.2.3", "@smithy/invalid-dependency": "^4.2.3", "@smithy/middleware-content-length": "^4.2.3", "@smithy/middleware-endpoint": "^4.3.5", "@smithy/middleware-retry": "^4.4.5", "@smithy/middleware-serde": "^4.2.3", "@smithy/middleware-stack": "^4.2.3", "@smithy/node-config-provider": "^4.3.3", "@smithy/node-http-handler": "^4.4.3", "@smithy/protocol-http": "^5.3.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "@smithy/url-parser": "^4.2.3", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.4", "@smithy/util-defaults-mode-node": "^4.2.6", "@smithy/util-endpoints": "^3.2.3", "@smithy/util-middleware": "^4.2.3", "@smithy/util-retry": "^4.2.3", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-m/Gb/ojGX4uqJAcvFWCbutVBnRXAKnlU+rrHUy3ugmg4lmMl1RjP4mwqlj+p+thCq2OmoEJtqZIuO2a/5N/NPA=="], + "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.921.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.921.0", "@aws-sdk/middleware-host-header": "3.921.0", "@aws-sdk/middleware-logger": "3.921.0", "@aws-sdk/middleware-recursion-detection": "3.921.0", "@aws-sdk/middleware-user-agent": "3.921.0", "@aws-sdk/region-config-resolver": "3.921.0", "@aws-sdk/types": "3.921.0", "@aws-sdk/util-endpoints": "3.921.0", "@aws-sdk/util-user-agent-browser": "3.921.0", "@aws-sdk/util-user-agent-node": "3.921.0", "@smithy/config-resolver": "^4.4.1", "@smithy/core": "^3.17.2", "@smithy/fetch-http-handler": "^5.3.5", "@smithy/hash-node": "^4.2.4", "@smithy/invalid-dependency": "^4.2.4", "@smithy/middleware-content-length": "^4.2.4", "@smithy/middleware-endpoint": "^4.3.6", "@smithy/middleware-retry": "^4.4.6", "@smithy/middleware-serde": "^4.2.4", "@smithy/middleware-stack": "^4.2.4", "@smithy/node-config-provider": "^4.3.4", "@smithy/node-http-handler": "^4.4.4", "@smithy/protocol-http": "^5.3.4", "@smithy/smithy-client": "^4.9.2", "@smithy/types": "^4.8.1", "@smithy/url-parser": "^4.2.4", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.5", "@smithy/util-defaults-mode-node": "^4.2.7", "@smithy/util-endpoints": "^3.2.4", "@smithy/util-middleware": "^4.2.4", "@smithy/util-retry": "^4.2.4", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qWyT7WikdkPRAMuWidZ2l8jcQAPwNjvLcFZ/8K+oCAaMLt0LKLd7qeTwZ5tZFNqRNPXKfE8MkvAjyqSpE3i2yg=="], - "@aws-sdk/core": ["@aws-sdk/core@3.920.0", "", { "dependencies": { "@aws-sdk/types": "3.920.0", "@aws-sdk/xml-builder": "3.914.0", "@smithy/core": "^3.17.1", "@smithy/node-config-provider": "^4.3.3", "@smithy/property-provider": "^4.2.3", "@smithy/protocol-http": "^5.3.3", "@smithy/signature-v4": "^5.3.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.3", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-vETnyaBJgIK6dh0hXzxw8e6v9SEFs/NgP6fJOn87QC+0M8U/omaB298kJ+i7P3KJafW6Pv/CWTsciMP/NNrg6A=="], + "@aws-sdk/core": ["@aws-sdk/core@3.921.0", "", { "dependencies": { "@aws-sdk/types": "3.921.0", "@aws-sdk/xml-builder": "3.921.0", "@smithy/core": "^3.17.2", "@smithy/node-config-provider": "^4.3.4", "@smithy/property-provider": "^4.2.4", "@smithy/protocol-http": "^5.3.4", "@smithy/signature-v4": "^5.3.4", "@smithy/smithy-client": "^4.9.2", "@smithy/types": "^4.8.1", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.4", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-1eiD9ZO9cvEHdQUn/pwJVGN9LXg6D0O7knGVA0TA/v7nFSYy0n8RYG8vdnlcoYYnV1BcHgaf4KmRVMOszafNZQ=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.920.0", "", { "dependencies": { "@aws-sdk/core": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/property-provider": "^4.2.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-f8AcW9swaoJnJIj43TNyUVCR7ToEbUftD9y5Ht6IwNhRq2iPwZ7uTvgrkjfdxOayj1uD7Gw5MkeC3Ki5lcsasA=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.921.0", "", { "dependencies": { "@aws-sdk/core": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/property-provider": "^4.2.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-RGG+zZdOYGJBQ8+L7BI6v41opoF8knErMtBZAUGcD3gvWEhjatc7lSbIpBeYWbTaWPPLHQU+ZVbmQ/jRLBgefw=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.920.0", "", { "dependencies": { "@aws-sdk/core": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/fetch-http-handler": "^5.3.4", "@smithy/node-http-handler": "^4.4.3", "@smithy/property-provider": "^4.2.3", "@smithy/protocol-http": "^5.3.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "@smithy/util-stream": "^4.5.4", "tslib": "^2.6.2" } }, "sha512-C75OGAnyHuILiIFfwbSUyV1YIJvcQt2U63IqlZ25eufV1NA+vP3Y60nvaxrzSxvditxXL95+YU3iLa4n2M0Omw=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.921.0", "", { "dependencies": { "@aws-sdk/core": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/fetch-http-handler": "^5.3.5", "@smithy/node-http-handler": "^4.4.4", "@smithy/property-provider": "^4.2.4", "@smithy/protocol-http": "^5.3.4", "@smithy/smithy-client": "^4.9.2", "@smithy/types": "^4.8.1", "@smithy/util-stream": "^4.5.5", "tslib": "^2.6.2" } }, "sha512-TAv08Ow0oF/olV4DTLoPDj46KMk35bL1IUCfToESDrWk1TOSur7d4sCL0p/7dUsAxS244cEgeyIIijKNtxj2AA=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.920.0", "", { "dependencies": { "@aws-sdk/core": "3.920.0", "@aws-sdk/credential-provider-env": "3.920.0", "@aws-sdk/credential-provider-http": "3.920.0", "@aws-sdk/credential-provider-process": "3.920.0", "@aws-sdk/credential-provider-sso": "3.920.0", "@aws-sdk/credential-provider-web-identity": "3.920.0", "@aws-sdk/nested-clients": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/credential-provider-imds": "^4.2.3", "@smithy/property-provider": "^4.2.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-rwTWfPhE2cs1kQ5dBpOEedhlzNcXf9LRzd9K4rn577pLJiWUc/n/Ibh4Hvw8Px1cp9krIk1q6wo+iK+kLQD8YA=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.921.0", "", { "dependencies": { "@aws-sdk/core": "3.921.0", "@aws-sdk/credential-provider-env": "3.921.0", "@aws-sdk/credential-provider-http": "3.921.0", "@aws-sdk/credential-provider-process": "3.921.0", "@aws-sdk/credential-provider-sso": "3.921.0", "@aws-sdk/credential-provider-web-identity": "3.921.0", "@aws-sdk/nested-clients": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/credential-provider-imds": "^4.2.4", "@smithy/property-provider": "^4.2.4", "@smithy/shared-ini-file-loader": "^4.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-MUSRYGiMRq5NRGPRgJ7Nuh7GqXzE9iteAwdbzMJ4pnImgr7CjeWDihCIGk+gKLSG+NoRVVJM0V9PA4rxFir0Pg=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.920.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.920.0", "@aws-sdk/credential-provider-http": "3.920.0", "@aws-sdk/credential-provider-ini": "3.920.0", "@aws-sdk/credential-provider-process": "3.920.0", "@aws-sdk/credential-provider-sso": "3.920.0", "@aws-sdk/credential-provider-web-identity": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/credential-provider-imds": "^4.2.3", "@smithy/property-provider": "^4.2.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-PGlmTe22KOLzk79urV7ILRF2ka3RXkiS6B5dgJC+OUjf209plcI+fs/p/sGdKCGCrPCYWgTHgqpyY2c8nO9B2A=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.921.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.921.0", "@aws-sdk/credential-provider-http": "3.921.0", "@aws-sdk/credential-provider-ini": "3.921.0", "@aws-sdk/credential-provider-process": "3.921.0", "@aws-sdk/credential-provider-sso": "3.921.0", "@aws-sdk/credential-provider-web-identity": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/credential-provider-imds": "^4.2.4", "@smithy/property-provider": "^4.2.4", "@smithy/shared-ini-file-loader": "^4.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-bxUAqRyo49WzKWn/XS0d8QXT9GydY/ew5m58PYfSMwYfmwBZXx1GLSWe3tZnefm6santFiqmIWfMmeRWdygKmQ=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.920.0", "", { "dependencies": { "@aws-sdk/core": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/property-provider": "^4.2.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-7dc0L0BCme4P17BgK/RtWLmwnM/R+si4Xd1cZe1oBLWRV+s++AXU/nDwfy1ErOLVpE9+lGG3Iw5zEPA/pJc7gQ=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.921.0", "", { "dependencies": { "@aws-sdk/core": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/property-provider": "^4.2.4", "@smithy/shared-ini-file-loader": "^4.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-DM62ooWI/aZ+ENBcLszuKmOkiICf6p4vYO2HgA3Cy2OEsTsjb67NEcntksxpZkD3mSIrCy/Qi4Z7tc77gle2Nw=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.920.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.920.0", "@aws-sdk/core": "3.920.0", "@aws-sdk/token-providers": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/property-provider": "^4.2.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-+g1ajAa7nZGyLjKvQTzbasFvBwVWqMYSJl/3GbM61rpgjyHjeTPDZy2WXpQcpVGeCp6fWJG3J36Qjj7f9pfNeQ=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.921.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.921.0", "@aws-sdk/core": "3.921.0", "@aws-sdk/token-providers": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/property-provider": "^4.2.4", "@smithy/shared-ini-file-loader": "^4.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-Nh5jPJ6Y6nu3cHzZnq394lGXE5YO8Szke5zlATbNI7Tl0QJR65GE0IZsBcjzRMGpYX6ENCqPDK8FmklkmCYyVQ=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.920.0", "", { "dependencies": { "@aws-sdk/core": "3.920.0", "@aws-sdk/nested-clients": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/property-provider": "^4.2.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-B/YX/5A9LcYBLMjb9Fjn88KEJXdl22dSGwLfW/iHr/ET7XrZgc2Vh+f0KtsH+0GOa/uq7m1G6rIuvQ6FojpJ1A=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.921.0", "", { "dependencies": { "@aws-sdk/core": "3.921.0", "@aws-sdk/nested-clients": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/property-provider": "^4.2.4", "@smithy/shared-ini-file-loader": "^4.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-VWcbgB2/shPPK674roHV4s8biCtvn0P/05EbTqy9WeyM5Oblx291gRGccyDhQbJbOL/6diRPBM08tlKPlBKNfw=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.920.0", "", { "dependencies": { "@aws-sdk/types": "3.920.0", "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-XQv9GRKGhXuWv797l/GnE9pt4UhlbzY39f2G3prcsLJCLyeIMeZ00QACIyshlArQ3ZhJp5FCRGGBcoSPQ2nk0Q=="], + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.921.0", "", { "dependencies": { "@aws-sdk/types": "3.921.0", "@smithy/protocol-http": "^5.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-eX1Ka29XzuEcXG4YABTwyLtPLchjmcjSjaq4irKJTFkxSYzX7gjoKt18rh/ZzOWOSqi23+cpjvBacL4VBKvE2Q=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.920.0", "", { "dependencies": { "@aws-sdk/types": "3.920.0", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-96v4hvJ9Cg/+XTYtM2aVTwZPzDOwyUiBh+FLioMng32mR64ofO1lvet4Zi1Uer9j7s086th3DJWkvqpi3K83dQ=="], + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.921.0", "", { "dependencies": { "@aws-sdk/types": "3.921.0", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-14Qqp8wisKGj/2Y22OfO5jTBG5Xez+p3Zr2piAtz7AcbY8vBEoZbd6f+9lwwVFC73Aobkau223wzKbGT8HYQMw=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.920.0", "", { "dependencies": { "@aws-sdk/types": "3.920.0", "@aws/lambda-invoke-store": "^0.1.1", "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-5OfZ4RDYAW08kxMaGxIebJoUhzH7/MpGOoPzVMfxxfGbf+e4p0DNHJ9EL6msUAsbGBhGccDl1b4aytnYW+IkgA=="], + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.921.0", "", { "dependencies": { "@aws-sdk/types": "3.921.0", "@aws/lambda-invoke-store": "^0.1.1", "@smithy/protocol-http": "^5.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-MYU5oI2b97M7u1dC1nt7SiGEvvLrQDlzV6hq9CB5TYX2glgbyvkaS//1Tjm87VF6qVSf5jYfwFDPeFGd8O1NrQ=="], - "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.920.0", "", { "dependencies": { "@aws-sdk/core": "3.920.0", "@aws-sdk/types": "3.920.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/core": "^3.17.1", "@smithy/node-config-provider": "^4.3.3", "@smithy/protocol-http": "^5.3.3", "@smithy/signature-v4": "^5.3.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.3", "@smithy/util-stream": "^4.5.4", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-VmqcDyuZweqplI9XtDSg5JJfNs6BMuf6x0W3MxFeiTQu89b6RP0ATNsHYGLIp8dx7xuNNnHcRKZW0xAXqj1yDQ=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.921.0", "", { "dependencies": { "@aws-sdk/core": "3.921.0", "@aws-sdk/types": "3.921.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/core": "^3.17.2", "@smithy/node-config-provider": "^4.3.4", "@smithy/protocol-http": "^5.3.4", "@smithy/signature-v4": "^5.3.4", "@smithy/smithy-client": "^4.9.2", "@smithy/types": "^4.8.1", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.4", "@smithy/util-stream": "^4.5.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-u4fkE6sn5KWojhPUeDIqRx0BJlQug60PzAnLPlxeIvy2+ZeTSY64WYwF6V7wIZCf1RIstiBA/hQUsX07LfbvNg=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.920.0", "", { "dependencies": { "@aws-sdk/core": "3.920.0", "@aws-sdk/types": "3.920.0", "@aws-sdk/util-endpoints": "3.920.0", "@smithy/core": "^3.17.1", "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-7kvJyz7a1v0C243DJUZTu4C++4U5gyFYKN35Ng7rBR03kQC8oE10qHfWNNc39Lj3urabjRvQ80e06pA/vCZ8xA=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.921.0", "", { "dependencies": { "@aws-sdk/core": "3.921.0", "@aws-sdk/types": "3.921.0", "@aws-sdk/util-endpoints": "3.921.0", "@smithy/core": "^3.17.2", "@smithy/protocol-http": "^5.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-gXgokMBTPZAbQMm1+JOxItqA81aSFK6n7V2mAwxdmHjzCUZacX5RzkVPNbSaPPgDkroYnIzK09EusIpM6dLaqw=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.920.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.920.0", "@aws-sdk/middleware-host-header": "3.920.0", "@aws-sdk/middleware-logger": "3.920.0", "@aws-sdk/middleware-recursion-detection": "3.920.0", "@aws-sdk/middleware-user-agent": "3.920.0", "@aws-sdk/region-config-resolver": "3.920.0", "@aws-sdk/types": "3.920.0", "@aws-sdk/util-endpoints": "3.920.0", "@aws-sdk/util-user-agent-browser": "3.920.0", "@aws-sdk/util-user-agent-node": "3.920.0", "@smithy/config-resolver": "^4.4.0", "@smithy/core": "^3.17.1", "@smithy/fetch-http-handler": "^5.3.4", "@smithy/hash-node": "^4.2.3", "@smithy/invalid-dependency": "^4.2.3", "@smithy/middleware-content-length": "^4.2.3", "@smithy/middleware-endpoint": "^4.3.5", "@smithy/middleware-retry": "^4.4.5", "@smithy/middleware-serde": "^4.2.3", "@smithy/middleware-stack": "^4.2.3", "@smithy/node-config-provider": "^4.3.3", "@smithy/node-http-handler": "^4.4.3", "@smithy/protocol-http": "^5.3.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "@smithy/url-parser": "^4.2.3", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.4", "@smithy/util-defaults-mode-node": "^4.2.6", "@smithy/util-endpoints": "^3.2.3", "@smithy/util-middleware": "^4.2.3", "@smithy/util-retry": "^4.2.3", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-1JlzZJ0qp68zr6wPoLFwZ0+EH6HQvKMJjF8e2y9yO82LC3CsetaMxLUC2em7uY+3Gp0TMSA/Yxy4rTShf0vmng=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.921.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.921.0", "@aws-sdk/middleware-host-header": "3.921.0", "@aws-sdk/middleware-logger": "3.921.0", "@aws-sdk/middleware-recursion-detection": "3.921.0", "@aws-sdk/middleware-user-agent": "3.921.0", "@aws-sdk/region-config-resolver": "3.921.0", "@aws-sdk/types": "3.921.0", "@aws-sdk/util-endpoints": "3.921.0", "@aws-sdk/util-user-agent-browser": "3.921.0", "@aws-sdk/util-user-agent-node": "3.921.0", "@smithy/config-resolver": "^4.4.1", "@smithy/core": "^3.17.2", "@smithy/fetch-http-handler": "^5.3.5", "@smithy/hash-node": "^4.2.4", "@smithy/invalid-dependency": "^4.2.4", "@smithy/middleware-content-length": "^4.2.4", "@smithy/middleware-endpoint": "^4.3.6", "@smithy/middleware-retry": "^4.4.6", "@smithy/middleware-serde": "^4.2.4", "@smithy/middleware-stack": "^4.2.4", "@smithy/node-config-provider": "^4.3.4", "@smithy/node-http-handler": "^4.4.4", "@smithy/protocol-http": "^5.3.4", "@smithy/smithy-client": "^4.9.2", "@smithy/types": "^4.8.1", "@smithy/url-parser": "^4.2.4", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.5", "@smithy/util-defaults-mode-node": "^4.2.7", "@smithy/util-endpoints": "^3.2.4", "@smithy/util-middleware": "^4.2.4", "@smithy/util-retry": "^4.2.4", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GV9aV8WqH/EWo4x3T5BrYb2ph1yfYuzUXZc0hhvxbFbDKD8m2fX9menao3Mgm7E5C68Su392u+MD9SGmGCmfKQ=="], - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.920.0", "", { "dependencies": { "@aws-sdk/types": "3.920.0", "@smithy/config-resolver": "^4.4.0", "@smithy/node-config-provider": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-4g88FyRN+O4iFe8azt/9IEGeyktQcJPgjwpCCFwGL9QmOIOJja+F+Og05ydjnMBcUxH4CrWXJm0a54MXS2C9Fg=="], + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.921.0", "", { "dependencies": { "@aws-sdk/types": "3.921.0", "@smithy/config-resolver": "^4.4.1", "@smithy/node-config-provider": "^4.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-cSycw4wXcvsrssUdcEaeYQhQcZYVsBwHtgATh9HcIm01PrMV0lV71vcoyZ+9vUhwHwchRT6dItAyTHSQxwjvjg=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.920.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/protocol-http": "^5.3.3", "@smithy/signature-v4": "^5.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-qrCYhUHtjsV6TpcuRiG0Wlu0jphAE752x18lKtkLZ0ZX33jPWbUkW7stmM/ahwDMrCK4eI7X2b7F3RrI/HnmMw=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.921.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/protocol-http": "^5.3.4", "@smithy/signature-v4": "^5.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-pFtJXtrf8cOsCgEb2OoPwQP4BKrnwIq69FuLowvWrXllFntAoAdEYaj9wNxPyl4pGqvo/9zO9CtkMb53PNxmWQ=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.920.0", "", { "dependencies": { "@aws-sdk/core": "3.920.0", "@aws-sdk/nested-clients": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/property-provider": "^4.2.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-ESDgN6oTq9ypqxK2qVAs5+LJMJCjky41B52k38LDfgyjgrZwqHcRgCQhH2L9/gC4MVOaE4fI24TgZsJlfyJ5dA=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.921.0", "", { "dependencies": { "@aws-sdk/core": "3.921.0", "@aws-sdk/nested-clients": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/property-provider": "^4.2.4", "@smithy/shared-ini-file-loader": "^4.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-d+w6X7ykqXirFBF+dYyK5Ntw0KmO2sgMj+JLR/vAe1vaR8/Fuqs3yOAFU7yNEzpcnbLJmMznxKpht03CSEMh4Q=="], - "@aws-sdk/types": ["@aws-sdk/types@3.920.0", "", { "dependencies": { "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-W8FI6HteaMwACb49IQzNABjbaGM/fP0t4lLBHeL6KXBmXung2S9FMIBHGxoZvBCRt5omFF31yDCbFaDN/1BPYQ=="], + "@aws-sdk/types": ["@aws-sdk/types@3.921.0", "", { "dependencies": { "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-mqEG8+vFh5w0ZZC+R8VCOdSk998Hy93pIDuwYpfMAWgYwVhFaIMOLn1fZw0w2DhTs5+ONHHwMJ6uVXtuuqOLQQ=="], "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA=="], - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.920.0", "", { "dependencies": { "@aws-sdk/types": "3.920.0", "@smithy/types": "^4.8.0", "@smithy/url-parser": "^4.2.3", "@smithy/util-endpoints": "^3.2.3", "tslib": "^2.6.2" } }, "sha512-PuoK3xl27LPLkm6VaeajBBTEtIF24aY+EfBWRKr/zqUJ6lTqicBLbxY0MqhsQ9KXALg/Ju0Aq7O4G0jpLu5S8w=="], + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.921.0", "", { "dependencies": { "@aws-sdk/types": "3.921.0", "@smithy/types": "^4.8.1", "@smithy/url-parser": "^4.2.4", "@smithy/util-endpoints": "^3.2.4", "tslib": "^2.6.2" } }, "sha512-kuJYRqug6V8gOg401BuK4w4IAVO3575VDR8iYiFw0gPwNIfOXvdlChfsJQoREqwJfif45J4eSmUsFtMfx87BQg=="], "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.920.0", "", { "dependencies": { "@aws-sdk/types": "3.920.0", "@smithy/types": "^4.8.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7nMoQjTa1SwULoUXBHm1hx24cb969e98AwPbrSmGwEZl2ZYXULOX3EZuDaX9QTzHutw8AMOgoI6JxCXhRQfmAg=="], + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.921.0", "", { "dependencies": { "@aws-sdk/types": "3.921.0", "@smithy/types": "^4.8.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-buhv/ICWr4Nt8bquHOejCiVikBsfEYw4/HSc9U050QebRXIakt50zKYaWDQw4iCMeeqCiwE9mElEaXJAysythg=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.920.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.920.0", "@aws-sdk/types": "3.920.0", "@smithy/node-config-provider": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-JJykxGXilkeUeU5x3g8bXvkyedtmZ/gXZVwCnWfe/DHxoUDHgYhF0VAz+QJoh2lSN/lRnUV08K0ILmEzGQzY4A=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.921.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.921.0", "@aws-sdk/types": "3.921.0", "@smithy/node-config-provider": "^4.3.4", "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Ilftai6AMAU1cEaUqIiTxkyj1NupLhP9Eq8HRfVuIH8489J2wLCcOyiLklAgSzBNmrxW+fagxkY+Dg0lFwmcVA=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.914.0", "", { "dependencies": { "@smithy/types": "^4.8.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-k75evsBD5TcIjedycYS7QXQ98AmOtbnxRJOPtCo0IwYRmy7UvqgS/gBL5SmrIqeV6FDSYRQMgdBxSMp6MLmdew=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.921.0", "", { "dependencies": { "@smithy/types": "^4.8.1", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-LVHg0jgjyicKKvpNIEMXIMr1EBViESxcPkqfOlT+X1FkmUMTNZEEVF18tOJg4m4hV5vxtkWcqtr4IEeWa1C41Q=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.1.1", "", {}, "sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts deleted file mode 100644 index 7d71a01..0000000 --- a/convex/_generated/api.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type { - ApiFromModules, - FilterApi, - FunctionReference, -} from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -declare const fullApi: ApiFromModules<{}>; -export declare const api: FilterApi< - typeof fullApi, - FunctionReference ->; -export declare const internal: FilterApi< - typeof fullApi, - FunctionReference ->; diff --git a/convex/_generated/api.js b/convex/_generated/api.js deleted file mode 100644 index 3f9c482..0000000 --- a/convex/_generated/api.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { anyApi } from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export const api = anyApi; -export const internal = anyApi; diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts deleted file mode 100644 index fb12533..0000000 --- a/convex/_generated/dataModel.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable */ -/** - * Generated data model types. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { AnyDataModel } from "convex/server"; -import type { GenericId } from "convex/values"; - -/** - * No `schema.ts` file found! - * - * This generated code has permissive types like `Doc = any` because - * Convex doesn't know your schema. If you'd like more type safety, see - * https://docs.convex.dev/using/schemas for instructions on how to add a - * schema file. - * - * After you change a schema, rerun codegen with `npx convex dev`. - */ - -/** - * The names of all of your Convex tables. - */ -export type TableNames = string; - -/** - * The type of a document stored in Convex. - */ -export type Doc = any; - -/** - * An identifier for a document in Convex. - * - * Convex documents are uniquely identified by their `Id`, which is accessible - * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). - * - * Documents can be loaded using `db.get(id)` in query and mutation functions. - * - * IDs are just strings at runtime, but this type can be used to distinguish them from other - * strings when type checking. - */ -export type Id = - GenericId; - -/** - * A type describing your Convex data model. - * - * This type includes information about what tables you have, the type of - * documents stored in those tables, and the indexes defined on them. - * - * This type is used to parameterize methods like `queryGeneric` and - * `mutationGeneric` to make them type-safe. - */ -export type DataModel = AnyDataModel; diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts deleted file mode 100644 index 7f337a4..0000000 --- a/convex/_generated/server.d.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - 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. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @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; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @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; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @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; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @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; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @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; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @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; - -/** - * Define an HTTP action. - * - * This function will be used to respond to HTTP requests received by a Convex - * deployment if the requests matches the path and method where this action - * is routed. Be sure to route your action in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export declare const httpAction: HttpActionBuilder; - -/** - * A set of services for use within Convex query functions. - * - * The query context is passed as the first argument to any Convex query - * function run on the server. - * - * This differs from the {@link MutationCtx} because all of the services are - * read-only. - */ -export type QueryCtx = GenericQueryCtx; - -/** - * A set of services for use within Convex mutation functions. - * - * The mutation context is passed as the first argument to any Convex mutation - * function run on the server. - */ -export type MutationCtx = GenericMutationCtx; - -/** - * A set of services for use within Convex action functions. - * - * The action context is passed as the first argument to any Convex action - * function run on the server. - */ -export type ActionCtx = GenericActionCtx; - -/** - * An interface to read from the database within Convex query functions. - * - * The two entry points are {@link DatabaseReader.get}, which fetches a single - * document by its {@link Id}, or {@link DatabaseReader.query}, which starts - * building a query. - */ -export type DatabaseReader = GenericDatabaseReader; - -/** - * An interface to read from and write to the database within Convex mutation - * functions. - * - * Convex guarantees that all writes within a single mutation are - * executed atomically, so you never have to worry about partial writes leaving - * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) - * for the guarantees Convex provides your functions. - */ -export type DatabaseWriter = GenericDatabaseWriter; diff --git a/convex/_generated/server.js b/convex/_generated/server.js deleted file mode 100644 index 566d485..0000000 --- a/convex/_generated/server.js +++ /dev/null @@ -1,89 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, -} from "convex/server"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @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 const query = queryGeneric; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @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 const internalQuery = internalQueryGeneric; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @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 const mutation = mutationGeneric; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @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 const internalMutation = internalMutationGeneric; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @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 const action = actionGeneric; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @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 const internalAction = internalActionGeneric; - -/** - * Define a Convex HTTP action. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object - * as its second. - * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. - */ -export const httpAction = httpActionGeneric; diff --git a/package.json b/package.json index ab4b2b9..f8b266d 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,16 @@ "name": "sgse-app", "private": true, "type": "module", - "workspaces": [ - "apps/*", - "packages/*" - ], + "workspaces": { + "packages": [ + "apps/*", + "packages/*" + ], + "catalog": { + "convex": "^1.28.0", + "typescript": "^5.9.2" + } + }, "scripts": { "check": "biome check --write .", "dev": "turbo dev", @@ -18,16 +24,14 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.2", - "turbo": "^2.5.4" + "turbo": "^2.5.8" }, "dependencies": { "@tanstack/svelte-form": "^1.23.8", "chart.js": "^4.5.1", "lucide-svelte": "^0.548.0", - "svelte-chartjs": "^3.1.5" - }, - "optionalDependencies": { - "@rollup/rollup-win32-x64-msvc": "^4.52.5" + "svelte-chartjs": "^3.1.5", + "svelte-sonner": "^1.0.5" }, "packageManager": "bun@1.3.0" } \ No newline at end of file diff --git a/packages/backend/CRIAR_ENV.bat b/packages/backend/CRIAR_ENV.bat deleted file mode 100644 index 969818b..0000000 --- a/packages/backend/CRIAR_ENV.bat +++ /dev/null @@ -1,121 +0,0 @@ -@echo off -chcp 65001 >nul -echo. -echo ═══════════════════════════════════════════════════════════ -echo 🔐 CRIAR ARQUIVO .env - SGSE (Convex Local) -echo ═══════════════════════════════════════════════════════════ -echo. - -echo [1/4] Verificando se .env já existe... - -if exist .env ( - echo. - echo ⚠️ ATENÇÃO: Arquivo .env já existe! - echo. - echo Deseja sobrescrever? (S/N^) - set /p resposta="> " - - if /i not "%resposta%"=="S" ( - echo. - echo ❌ Operação cancelada. Arquivo .env mantido. - pause - exit /b - ) -) - -echo. -echo [2/4] Criando arquivo .env... - -( -echo # ══════════════════════════════════════════════════════════ -echo # CONFIGURAÇÃO DE AMBIENTE - SGSE -echo # Gerado automaticamente em: %date% %time% -echo # ══════════════════════════════════════════════════════════ -echo. -echo # Segurança Better Auth -echo # Secret para criptografia de tokens de autenticação -echo BETTER_AUTH_SECRET=+Nfg4jTxPv1giF5MlmyYTxpU/VkS3QaDOvgSWd+QmbY= -echo. -echo # URL da aplicação -echo # Desenvolvimento: http://localhost:5173 -echo # Produção: https://sgse.pe.gov.br ^(alterar quando for para produção^) -echo SITE_URL=http://localhost:5173 -echo. -echo # ══════════════════════════════════════════════════════════ -echo # IMPORTANTE - SEGURANÇA -echo # ══════════════════════════════════════════════════════════ -echo # 1. Este arquivo NÃO deve ser commitado no Git -echo # 2. Antes de ir para produção, gere um NOVO secret -echo # 3. Em produção, altere SITE_URL para a URL real -echo # ══════════════════════════════════════════════════════════ -) > .env - -if not exist .env ( - echo. - echo ❌ ERRO: Falha ao criar arquivo .env - echo. - pause - exit /b 1 -) - -echo ✅ Arquivo .env criado com sucesso! - -echo. -echo [3/4] Verificando .gitignore... - -if not exist .gitignore ( - echo # Arquivos de ambiente > .gitignore - echo .env >> .gitignore - echo .env.local >> .gitignore - echo .env.*.local >> .gitignore - echo ✅ .gitignore criado -) else ( - findstr /C:".env" .gitignore >nul - if errorlevel 1 ( - echo .env >> .gitignore - echo .env.local >> .gitignore - echo .env.*.local >> .gitignore - echo ✅ .env adicionado ao .gitignore - ) else ( - echo ✅ .env já está no .gitignore - ) -) - -echo. -echo [4/4] Resumo da configuração: -echo. -echo ┌─────────────────────────────────────────────────────────┐ -echo │ ✅ Arquivo criado: packages/backend/.env │ -echo │ │ -echo │ Variáveis configuradas: │ -echo │ • BETTER_AUTH_SECRET: Configurado │ -echo │ • SITE_URL: http://localhost:5173 │ -echo └─────────────────────────────────────────────────────────┘ -echo. - -echo ═══════════════════════════════════════════════════════════ -echo 📋 PRÓXIMOS PASSOS -echo ═══════════════════════════════════════════════════════════ -echo. -echo 1. Reinicie o servidor Convex: -echo ^> cd packages\backend -echo ^> bunx convex dev -echo. -echo 2. Reinicie o servidor Web (em outro terminal^): -echo ^> cd apps\web -echo ^> bun run dev -echo. -echo 3. Verifique que as mensagens de erro pararam -echo. - -echo ═══════════════════════════════════════════════════════════ -echo ⚠️ LEMBRE-SE -echo ═══════════════════════════════════════════════════════════ -echo. -echo • NÃO commite o arquivo .env no Git -echo • Gere um NOVO secret antes de ir para produção -echo • Altere SITE_URL quando for para produção -echo. - -pause - diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index efdf934..a0b2e5f 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -8,14 +8,13 @@ * @module */ +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 betterAuth__generated_api from "../betterAuth/_generated/api.js"; -import type * as betterAuth__generated_server from "../betterAuth/_generated/server.js"; import type * as chat from "../chat.js"; import type * as configuracaoEmail from "../configuracaoEmail.js"; -import type * as criarFuncionarioTeste from "../criarFuncionarioTeste.js"; -import type * as criarUsuarioTeste from "../criarUsuarioTeste.js"; import type * as crons from "../crons.js"; import type * as cursos from "../cursos.js"; import type * as dashboard from "../dashboard.js"; @@ -29,11 +28,7 @@ import type * as limparPerfisAntigos from "../limparPerfisAntigos.js"; import type * as logsAcesso from "../logsAcesso.js"; import type * as logsAtividades from "../logsAtividades.js"; import type * as logsLogin from "../logsLogin.js"; -import type * as menuPermissoes from "../menuPermissoes.js"; -import type * as migrarParaTimes from "../migrarParaTimes.js"; -import type * as migrarUsuariosAdmin from "../migrarUsuariosAdmin.js"; import type * as monitoramento from "../monitoramento.js"; -import type * as perfisCustomizados from "../perfisCustomizados.js"; import type * as permissoesAcoes from "../permissoesAcoes.js"; import type * as roles from "../roles.js"; import type * as saldoFerias from "../saldoFerias.js"; @@ -44,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 { @@ -61,14 +57,13 @@ 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; - "betterAuth/_generated/api": typeof betterAuth__generated_api; - "betterAuth/_generated/server": typeof betterAuth__generated_server; chat: typeof chat; configuracaoEmail: typeof configuracaoEmail; - criarFuncionarioTeste: typeof criarFuncionarioTeste; - criarUsuarioTeste: typeof criarUsuarioTeste; crons: typeof crons; cursos: typeof cursos; dashboard: typeof dashboard; @@ -82,11 +77,7 @@ declare const fullApi: ApiFromModules<{ logsAcesso: typeof logsAcesso; logsAtividades: typeof logsAtividades; logsLogin: typeof logsLogin; - menuPermissoes: typeof menuPermissoes; - migrarParaTimes: typeof migrarParaTimes; - migrarUsuariosAdmin: typeof migrarUsuariosAdmin; monitoramento: typeof monitoramento; - perfisCustomizados: typeof perfisCustomizados; permissoesAcoes: typeof permissoesAcoes; roles: typeof roles; saldoFerias: typeof saldoFerias; @@ -97,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/actions/email.ts b/packages/backend/convex/actions/email.ts new file mode 100644 index 0000000..3ae0249 --- /dev/null +++ b/packages/backend/convex/actions/email.ts @@ -0,0 +1,118 @@ +"use node"; + +import { action } from "../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../_generated/api"; + +export const enviar = action({ + args: { + emailId: v.id("notificacoesEmail"), + }, + returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), + handler: async (ctx, args) => { + "use node"; + const nodemailer = await import("nodemailer"); + + try { + // Buscar email da fila + const email = await ctx.runQuery(internal.email.getEmailById, { + emailId: args.emailId, + }); + + if (!email) { + return { sucesso: false, erro: "Email não encontrado" }; + } + + // Buscar configuração SMTP ativa com senha descriptografada + const config = await ctx.runQuery(internal.email.getActiveEmailConfigWithPassword, {}); + + if (!config) { + return { + sucesso: false, + erro: "Configuração de email não encontrada ou inativa", + }; + } + + if (!config.testadoEm) { + return { + sucesso: false, + erro: "Configuração SMTP não foi testada. Teste a conexão primeiro!", + }; + } + + // Marcar como enviando + await ctx.runMutation(internal.email.markEmailEnviando, { + emailId: args.emailId, + }); + + // Criar transporter do nodemailer + const transporter = nodemailer.createTransport({ + host: config.servidor, + port: config.porta, + secure: config.usarSSL, + requireTLS: config.usarTLS, + auth: { + user: config.usuario, + pass: config.senha, // Senha já descriptografada + }, + tls: { + // Permitir certificados autoassinados apenas se necessário + rejectUnauthorized: false, + ciphers: "SSLv3", + }, + connectionTimeout: 10000, // 10 segundos + greetingTimeout: 10000, + socketTimeout: 10000, + }); + + // Validar email destinatário antes de enviar + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email.destinatario)) { + throw new Error(`Email destinatário inválido: ${email.destinatario}`); + } + + // Enviar email + const info = await transporter.sendMail({ + from: `"${config.nomeRemetente}" <${config.emailRemetente}>`, + to: email.destinatario, + subject: email.assunto, + html: email.corpo, + text: email.corpo.replace(/<[^>]*>/g, ""), // Versão texto para clientes que não suportam HTML + }); + + interface MessageInfo { + messageId?: string; + response?: string; + } + + const messageInfo = info as MessageInfo; + + console.log("✅ Email enviado com sucesso!", { + para: email.destinatario, + assunto: email.assunto, + messageId: messageInfo.messageId, + response: messageInfo.response, + }); + + // Marcar como enviado + await ctx.runMutation(internal.email.markEmailEnviado, { + emailId: args.emailId, + }); + + return { sucesso: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Erro ao enviar email:", errorMessage); + + // Marcar como falha + await ctx.runMutation(internal.email.markEmailFalha, { + emailId: args.emailId, + erro: errorMessage, + }); + + return { sucesso: false, erro: errorMessage }; + } + }, +}); + + diff --git a/packages/backend/convex/actions/smtp.ts b/packages/backend/convex/actions/smtp.ts new file mode 100644 index 0000000..c68285a --- /dev/null +++ b/packages/backend/convex/actions/smtp.ts @@ -0,0 +1,63 @@ +"use node"; + +import { action } from "../_generated/server"; +import { v } from "convex/values"; + +export const testarConexao = action({ + args: { + servidor: v.string(), + porta: v.number(), + usuario: v.string(), + senha: v.string(), + usarSSL: v.boolean(), + usarTLS: v.boolean(), + }, + returns: v.union( + v.object({ sucesso: v.literal(true) }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args) => { + "use node"; + const nodemailer = await import("nodemailer"); + + try { + // Validações básicas + if (!args.servidor || args.servidor.trim() === "") { + return { + sucesso: false as const, + erro: "Servidor SMTP não pode estar vazio", + }; + } + + if (args.porta < 1 || args.porta > 65535) { + return { sucesso: false as const, erro: "Porta inválida" }; + } + + const transporter = nodemailer.createTransport({ + host: args.servidor, + port: args.porta, + secure: args.usarSSL, + auth: { + user: args.usuario, + pass: args.senha, + }, + tls: { + rejectUnauthorized: false, + }, + connectionTimeout: 10000, // 10 segundos + greetingTimeout: 10000, + socketTimeout: 10000, + }); + + // Verificar conexão + await transporter.verify(); + + return { sucesso: true as const }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { sucesso: false as const, erro: errorMessage }; + } + }, +}); + + diff --git a/packages/backend/convex/atestadosLicencas.ts b/packages/backend/convex/atestadosLicencas.ts new file mode 100644 index 0000000..385aef7 --- /dev/null +++ b/packages/backend/convex/atestadosLicencas.ts @@ -0,0 +1,1064 @@ +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(); + }, +}); + +/** + * Obter URL de um documento armazenado + */ +export const obterUrlDocumento = query({ + args: { + storageId: v.id("_storage"), + }, + returns: v.union(v.string(), v.null()), + handler: async (ctx, args) => { + const usuario = await getUsuarioAutenticado(ctx); + if (!usuario) throw new Error("Não autenticado"); + + return await ctx.storage.getUrl(args.storageId); + }, +}); + +/** + * 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 9a8b2a8..44ae1ce 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, @@ -8,16 +8,17 @@ import { validarSenha, } from "./auth/utils"; import { registrarLogin } from "./logsLogin"; -import { Id } from "./_generated/dataModel"; +import { Id, Doc } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; /** * Helper para verificar se usuário está bloqueado */ -async function verificarBloqueioUsuario(ctx: any, usuarioId: Id<"usuarios">) { +async function verificarBloqueioUsuario(ctx: QueryCtx, usuarioId: Id<"usuarios">) { const bloqueio = await ctx.db .query("bloqueiosUsuarios") - .withIndex("by_usuario", (q: any) => q.eq("usuarioId", usuarioId)) - .filter((q: any) => q.eq(q.field("ativo"), true)) + .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioId)) + .filter((q) => q.eq(q.field("ativo"), true)) .first(); return bloqueio !== null; @@ -26,17 +27,17 @@ async function verificarBloqueioUsuario(ctx: any, usuarioId: Id<"usuarios">) { /** * Helper para verificar rate limiting por IP */ -async function verificarRateLimitIP(ctx: any, ipAddress: string) { +async function verificarRateLimitIP(ctx: QueryCtx, ipAddress: string) { // Últimas 15 minutos const dataLimite = Date.now() - 15 * 60 * 1000; const tentativas = await ctx.db .query("logsLogin") - .withIndex("by_ip", (q: any) => q.eq("ipAddress", ipAddress)) - .filter((q: any) => q.gte(q.field("timestamp"), dataLimite)) + .withIndex("by_ip", (q) => q.eq("ipAddress", ipAddress)) + .filter((q) => q.gte(q.field("timestamp"), dataLimite)) .collect(); - const falhas = tentativas.filter((t: any) => !t.sucesso).length; + const falhas = tentativas.filter((t) => !t.sucesso).length; // Bloquear se 5 ou mais tentativas falhas em 15 minutos return falhas >= 5; @@ -61,6 +62,7 @@ export const login = mutation({ 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(), @@ -99,19 +101,20 @@ export const login = mutation({ const isEmail = args.matriculaOuEmail.includes("@"); // Buscar usuário - let usuario; + 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 { - usuario = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => - q.eq("matricula", args.matriculaOuEmail) - ) - .first(); + 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) { @@ -243,7 +246,7 @@ export const login = mutation({ }); // Buscar role do usuário - const role = await ctx.db.get(usuario.roleId); + const role: Doc<"roles"> | null = await ctx.db.get(usuario.roleId); if (!role) { return { sucesso: false as const, @@ -291,14 +294,303 @@ export const login = mutation({ timestamp: agora, }); + // Obter matrícula do funcionário se houver + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; + } + return { sucesso: true as const, token, usuario: { _id: usuario._id, - matricula: usuario.matricula, + matricula: 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, + }, + }; + }, +}); + +/** + * 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, + }); + + // Obter matrícula do funcionário se houver + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; + } + + return { + sucesso: true as const, + token, + usuario: { + _id: usuario._id, + matricula: matricula || "", + nome: usuario.nome, + email: usuario.email, + funcionarioId: usuario.funcionarioId, role: { _id: role._id, nome: role.nome, @@ -359,6 +651,7 @@ export const verificarSessao = query({ 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(), @@ -375,7 +668,7 @@ export const verificarSessao = query({ ), handler: async (ctx, args) => { // Buscar sessão - const sessao = await ctx.db + const sessao: Doc<"sessoes"> | null = await ctx.db .query("sessoes") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); @@ -395,7 +688,7 @@ export const verificarSessao = query({ } // Buscar usuário - const usuario = await ctx.db.get(sessao.usuarioId); + const usuario: Doc<"usuarios"> | null = await ctx.db.get(sessao.usuarioId); if (!usuario || !usuario.ativo) { return { valido: false as const, @@ -404,18 +697,26 @@ export const verificarSessao = query({ } // Buscar role - const role = await ctx.db.get(usuario.roleId); + const role: Doc<"roles"> | null = await ctx.db.get(usuario.roleId); if (!role) { return { valido: false as const, motivo: "Role não encontrada" }; } + // Obter matrícula do funcionário se houver + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; + } + return { valido: true as const, usuario: { _id: usuario._id, - matricula: usuario.matricula, + matricula: matricula || "", nome: usuario.nome, email: usuario.email, + funcionarioId: usuario.funcionarioId, role: { _id: role._id, nome: role.nome, @@ -478,7 +779,7 @@ export const alterarSenha = mutation({ ), handler: async (ctx, args) => { // Verificar sessão - const sessao = await ctx.db + const sessao: Doc<"sessoes"> | null = await ctx.db .query("sessoes") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); @@ -487,7 +788,7 @@ export const alterarSenha = mutation({ return { sucesso: false as const, erro: "Sessão inválida" }; } - const usuario = await ctx.db.get(sessao.usuarioId); + const usuario: Doc<"usuarios"> | null = await ctx.db.get(sessao.usuarioId); if (!usuario) { return { sucesso: false as const, erro: "Usuário não encontrado" }; } diff --git a/packages/backend/convex/auth.config.ts b/packages/backend/convex/auth.config.ts deleted file mode 100644 index f4eb564..0000000 --- a/packages/backend/convex/auth.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default { - providers: [ - { - domain: process.env.CONVEX_SITE_URL, - applicationID: "convex", - }, - ], -}; diff --git a/packages/backend/convex/auth/utils.ts b/packages/backend/convex/auth/utils.ts index 708ea0a..6e5fe29 100644 --- a/packages/backend/convex/auth/utils.ts +++ b/packages/backend/convex/auth/utils.ts @@ -130,3 +130,106 @@ export function validarSenha(senha: string): boolean { return regex.test(senha); } +/** + * Criptografia reversível para senhas SMTP usando AES-GCM + * NOTA: Esta função é usada apenas para senhas SMTP que precisam ser descriptografadas. + * Para senhas de usuários, use hashPassword() que é unidirecional. + */ + +// Chave de criptografia derivada (em produção, deve vir de variável de ambiente) +// Para desenvolvimento, usando uma chave fixa. Em produção, deve ser configurada via env var. +const getEncryptionKey = async (): Promise => { + // Chave base - em produção, isso deve vir de process.env.ENCRYPTION_KEY + // Por enquanto, usando uma chave derivada de um valor fixo + const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024"); + + // Deriva uma chave de 256 bits usando PBKDF2 + const key = await crypto.subtle.importKey( + "raw", + keyMaterial, + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"] + ); + + return await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: new TextEncoder().encode("SGSE-SALT"), + iterations: 100000, + hash: "SHA-256", + }, + key, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +}; + +/** + * Criptografa uma senha SMTP usando AES-GCM + */ +export async function encryptSMTPPassword(password: string): Promise { + try { + const key = await getEncryptionKey(); + const encoder = new TextEncoder(); + const data = encoder.encode(password); + + // Gerar IV (Initialization Vector) aleatório + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Criptografar + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + data + ); + + // Combinar IV + dados criptografados e converter para base64 + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + + return btoa(String.fromCharCode(...combined)); + } catch (error) { + console.error("Erro ao criptografar senha SMTP:", error); + throw new Error("Falha ao criptografar senha SMTP"); + } +} + +/** + * Descriptografa uma senha SMTP usando AES-GCM + */ +export async function decryptSMTPPassword(encryptedPassword: string): Promise { + try { + const key = await getEncryptionKey(); + + // Decodificar base64 + const combined = Uint8Array.from(atob(encryptedPassword), (c) => c.charCodeAt(0)); + + // Extrair IV e dados criptografados + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + // Descriptografar + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + encrypted + ); + + // Converter para string + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (error) { + console.error("Erro ao descriptografar senha SMTP:", error); + throw new Error("Falha ao descriptografar senha SMTP"); + } +} + diff --git a/packages/backend/convex/betterAuth/_generated/api.d.ts b/packages/backend/convex/betterAuth/_generated/api.d.ts deleted file mode 100644 index 385ea79..0000000 --- a/packages/backend/convex/betterAuth/_generated/api.d.ts +++ /dev/null @@ -1,993 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type * as adapter from "../adapter.js"; -import type * as auth from "../auth.js"; - -import type { - ApiFromModules, - FilterApi, - FunctionReference, -} from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -declare const fullApi: ApiFromModules<{ - adapter: typeof adapter; - auth: typeof auth; -}>; -export type Mounts = { - adapter: { - create: FunctionReference< - "mutation", - "public", - { - input: - | { - data: { - createdAt: number; - email: string; - emailVerified: boolean; - image?: null | string; - name: string; - updatedAt: number; - userId?: null | string; - }; - model: "user"; - } - | { - data: { - createdAt: number; - expiresAt: number; - ipAddress?: null | string; - token: string; - updatedAt: number; - userAgent?: null | string; - userId: string; - }; - model: "session"; - } - | { - data: { - accessToken?: null | string; - accessTokenExpiresAt?: null | number; - accountId: string; - createdAt: number; - idToken?: null | string; - password?: null | string; - providerId: string; - refreshToken?: null | string; - refreshTokenExpiresAt?: null | number; - scope?: null | string; - updatedAt: number; - userId: string; - }; - model: "account"; - } - | { - data: { - createdAt: number; - expiresAt: number; - identifier: string; - updatedAt: number; - value: string; - }; - model: "verification"; - } - | { - data: { - createdAt: number; - privateKey: string; - publicKey: string; - }; - model: "jwks"; - }; - onCreateHandle?: string; - select?: Array; - }, - any - >; - deleteMany: FunctionReference< - "mutation", - "public", - { - input: - | { - model: "user"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "email" - | "emailVerified" - | "image" - | "createdAt" - | "updatedAt" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "session"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "expiresAt" - | "token" - | "createdAt" - | "updatedAt" - | "ipAddress" - | "userAgent" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "account"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "accountId" - | "providerId" - | "userId" - | "accessToken" - | "refreshToken" - | "idToken" - | "accessTokenExpiresAt" - | "refreshTokenExpiresAt" - | "scope" - | "password" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "verification"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "identifier" - | "value" - | "expiresAt" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "jwks"; - where?: Array<{ - connector?: "AND" | "OR"; - field: "publicKey" | "privateKey" | "createdAt" | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }; - onDeleteHandle?: string; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - any - >; - deleteOne: FunctionReference< - "mutation", - "public", - { - input: - | { - model: "user"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "email" - | "emailVerified" - | "image" - | "createdAt" - | "updatedAt" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "session"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "expiresAt" - | "token" - | "createdAt" - | "updatedAt" - | "ipAddress" - | "userAgent" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "account"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "accountId" - | "providerId" - | "userId" - | "accessToken" - | "refreshToken" - | "idToken" - | "accessTokenExpiresAt" - | "refreshTokenExpiresAt" - | "scope" - | "password" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "verification"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "identifier" - | "value" - | "expiresAt" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "jwks"; - where?: Array<{ - connector?: "AND" | "OR"; - field: "publicKey" | "privateKey" | "createdAt" | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }; - onDeleteHandle?: string; - }, - any - >; - findMany: FunctionReference< - "query", - "public", - { - limit?: number; - model: "user" | "session" | "account" | "verification" | "jwks"; - offset?: number; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - sortBy?: { direction: "asc" | "desc"; field: string }; - where?: Array<{ - connector?: "AND" | "OR"; - field: string; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }, - any - >; - findOne: FunctionReference< - "query", - "public", - { - model: "user" | "session" | "account" | "verification" | "jwks"; - select?: Array; - where?: Array<{ - connector?: "AND" | "OR"; - field: string; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }, - any - >; - updateMany: FunctionReference< - "mutation", - "public", - { - input: - | { - model: "user"; - update: { - createdAt?: number; - email?: string; - emailVerified?: boolean; - image?: null | string; - name?: string; - updatedAt?: number; - userId?: null | string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "email" - | "emailVerified" - | "image" - | "createdAt" - | "updatedAt" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "session"; - update: { - createdAt?: number; - expiresAt?: number; - ipAddress?: null | string; - token?: string; - updatedAt?: number; - userAgent?: null | string; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "expiresAt" - | "token" - | "createdAt" - | "updatedAt" - | "ipAddress" - | "userAgent" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "account"; - update: { - accessToken?: null | string; - accessTokenExpiresAt?: null | number; - accountId?: string; - createdAt?: number; - idToken?: null | string; - password?: null | string; - providerId?: string; - refreshToken?: null | string; - refreshTokenExpiresAt?: null | number; - scope?: null | string; - updatedAt?: number; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "accountId" - | "providerId" - | "userId" - | "accessToken" - | "refreshToken" - | "idToken" - | "accessTokenExpiresAt" - | "refreshTokenExpiresAt" - | "scope" - | "password" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "verification"; - update: { - createdAt?: number; - expiresAt?: number; - identifier?: string; - updatedAt?: number; - value?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "identifier" - | "value" - | "expiresAt" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "jwks"; - update: { - createdAt?: number; - privateKey?: string; - publicKey?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: "publicKey" | "privateKey" | "createdAt" | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }; - onUpdateHandle?: string; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - any - >; - updateOne: FunctionReference< - "mutation", - "public", - { - input: - | { - model: "user"; - update: { - createdAt?: number; - email?: string; - emailVerified?: boolean; - image?: null | string; - name?: string; - updatedAt?: number; - userId?: null | string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "email" - | "emailVerified" - | "image" - | "createdAt" - | "updatedAt" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "session"; - update: { - createdAt?: number; - expiresAt?: number; - ipAddress?: null | string; - token?: string; - updatedAt?: number; - userAgent?: null | string; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "expiresAt" - | "token" - | "createdAt" - | "updatedAt" - | "ipAddress" - | "userAgent" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "account"; - update: { - accessToken?: null | string; - accessTokenExpiresAt?: null | number; - accountId?: string; - createdAt?: number; - idToken?: null | string; - password?: null | string; - providerId?: string; - refreshToken?: null | string; - refreshTokenExpiresAt?: null | number; - scope?: null | string; - updatedAt?: number; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "accountId" - | "providerId" - | "userId" - | "accessToken" - | "refreshToken" - | "idToken" - | "accessTokenExpiresAt" - | "refreshTokenExpiresAt" - | "scope" - | "password" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "verification"; - update: { - createdAt?: number; - expiresAt?: number; - identifier?: string; - updatedAt?: number; - value?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "identifier" - | "value" - | "expiresAt" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "jwks"; - update: { - createdAt?: number; - privateKey?: string; - publicKey?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: "publicKey" | "privateKey" | "createdAt" | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }; - onUpdateHandle?: string; - }, - any - >; - }; -}; -// For now fullApiWithMounts is only fullApi which provides -// jump-to-definition in component client code. -// Use Mounts for the same type without the inference. -declare const fullApiWithMounts: typeof fullApi; - -export declare const api: FilterApi< - typeof fullApiWithMounts, - FunctionReference ->; -export declare const internal: FilterApi< - typeof fullApiWithMounts, - FunctionReference ->; - -export declare const components: {}; diff --git a/packages/backend/convex/betterAuth/_generated/api.js b/packages/backend/convex/betterAuth/_generated/api.js deleted file mode 100644 index 44bf985..0000000 --- a/packages/backend/convex/betterAuth/_generated/api.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { anyApi, componentsGeneric } from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export const api = anyApi; -export const internal = anyApi; -export const components = componentsGeneric(); diff --git a/packages/backend/convex/betterAuth/_generated/dataModel.d.ts b/packages/backend/convex/betterAuth/_generated/dataModel.d.ts deleted file mode 100644 index 8541f31..0000000 --- a/packages/backend/convex/betterAuth/_generated/dataModel.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable */ -/** - * Generated data model types. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type { - 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. - */ -export type TableNames = TableNamesInDataModel; - -/** - * The type of a document stored in Convex. - * - * @typeParam TableName - A string literal type of the table name (like "users"). - */ -export type Doc = DocumentByName< - DataModel, - TableName ->; - -/** - * An identifier for a document in Convex. - * - * Convex documents are uniquely identified by their `Id`, which is accessible - * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). - * - * Documents can be loaded using `db.get(id)` in query and mutation functions. - * - * IDs are just strings at runtime, but this type can be used to distinguish them from other - * strings when type checking. - * - * @typeParam TableName - A string literal type of the table name (like "users"). - */ -export type Id = - GenericId; - -/** - * A type describing your Convex data model. - * - * This type includes information about what tables you have, the type of - * documents stored in those tables, and the indexes defined on them. - * - * This type is used to parameterize methods like `queryGeneric` and - * `mutationGeneric` to make them type-safe. - */ -export type DataModel = DataModelFromSchemaDefinition; diff --git a/packages/backend/convex/betterAuth/_generated/server.d.ts b/packages/backend/convex/betterAuth/_generated/server.d.ts deleted file mode 100644 index b5c6828..0000000 --- a/packages/backend/convex/betterAuth/_generated/server.d.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - ActionBuilder, - AnyComponents, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter, - FunctionReference, -} from "convex/server"; -import type { DataModel } from "./dataModel.js"; - -type GenericCtx = - | GenericActionCtx - | GenericMutationCtx - | GenericQueryCtx; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @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; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @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; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @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; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @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; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @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; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @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; - -/** - * Define an HTTP action. - * - * This function will be used to respond to HTTP requests received by a Convex - * deployment if the requests matches the path and method where this action - * is routed. Be sure to route your action in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export declare const httpAction: HttpActionBuilder; - -/** - * A set of services for use within Convex query functions. - * - * The query context is passed as the first argument to any Convex query - * function run on the server. - * - * This differs from the {@link MutationCtx} because all of the services are - * read-only. - */ -export type QueryCtx = GenericQueryCtx; - -/** - * A set of services for use within Convex mutation functions. - * - * The mutation context is passed as the first argument to any Convex mutation - * function run on the server. - */ -export type MutationCtx = GenericMutationCtx; - -/** - * A set of services for use within Convex action functions. - * - * The action context is passed as the first argument to any Convex action - * function run on the server. - */ -export type ActionCtx = GenericActionCtx; - -/** - * An interface to read from the database within Convex query functions. - * - * The two entry points are {@link DatabaseReader.get}, which fetches a single - * document by its {@link Id}, or {@link DatabaseReader.query}, which starts - * building a query. - */ -export type DatabaseReader = GenericDatabaseReader; - -/** - * An interface to read from and write to the database within Convex mutation - * functions. - * - * Convex guarantees that all writes within a single mutation are - * executed atomically, so you never have to worry about partial writes leaving - * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) - * for the guarantees Convex provides your functions. - */ -export type DatabaseWriter = GenericDatabaseWriter; diff --git a/packages/backend/convex/betterAuth/_generated/server.js b/packages/backend/convex/betterAuth/_generated/server.js deleted file mode 100644 index 4a21df4..0000000 --- a/packages/backend/convex/betterAuth/_generated/server.js +++ /dev/null @@ -1,90 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, - componentsGeneric, -} from "convex/server"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @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 const query = queryGeneric; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @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 const internalQuery = internalQueryGeneric; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @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 const mutation = mutationGeneric; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @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 const internalMutation = internalMutationGeneric; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @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 const action = actionGeneric; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @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 const internalAction = internalActionGeneric; - -/** - * Define a Convex HTTP action. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object - * as its second. - * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. - */ -export const httpAction = httpActionGeneric; diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts index 48151c8..56bb63d 100644 --- a/packages/backend/convex/chat.ts +++ b/packages/backend/convex/chat.ts @@ -1,1077 +1,1179 @@ -import { v } from "convex/values"; -import { mutation, query, internalMutation } from "./_generated/server"; -import { Doc, Id } from "./_generated/dataModel"; -import type { QueryCtx, MutationCtx } from "./_generated/server"; - -// ========== HELPERS ========== - -/** - * Helper function para obter usuário autenticado (Better Auth ou Sessão) - */ -async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { - // Tentar autenticação via Better Auth primeiro - 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(); - } - - // Se não encontrou via Better Auth, tentar via sessão - if (!usuarioAtual) { - const sessaoAtiva = await ctx.db - .query("sessoes") - .filter((q) => q.eq(q.field("ativo"), true)) - .first(); - - if (sessaoAtiva) { - usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); - } - } - - return usuarioAtual; -} - -// ========== MUTATIONS ========== - -/** - * Cria uma nova conversa (individual ou grupo) - */ -export const criarConversa = mutation({ - args: { - tipo: v.union(v.literal("individual"), v.literal("grupo")), - participantes: v.array(v.id("usuarios")), - nome: v.optional(v.string()), - avatar: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Validar participantes - if (!args.participantes.includes(usuarioAtual._id)) { - args.participantes.push(usuarioAtual._id); - } - - // Se for conversa individual, verificar se já existe - if (args.tipo === "individual" && args.participantes.length === 2) { - const conversaExistente = await ctx.db - .query("conversas") - .filter((q) => q.eq(q.field("tipo"), "individual")) - .collect(); - - for (const conversa of conversaExistente) { - if ( - conversa.participantes.length === 2 && - conversa.participantes.every((p) => args.participantes.includes(p)) - ) { - return conversa._id; - } - } - } - - // Criar nova conversa - const conversaId = await ctx.db.insert("conversas", { - tipo: args.tipo, - nome: args.nome, - avatar: args.avatar, - participantes: args.participantes, - criadoPor: usuarioAtual._id, - criadoEm: Date.now(), - }); - - // Criar notificações para outros participantes - if (args.tipo === "grupo") { - for (const participanteId of args.participantes) { - if (participanteId !== usuarioAtual._id) { - await ctx.db.insert("notificacoes", { - usuarioId: participanteId, - tipo: "adicionado_grupo", - conversaId, - remetenteId: usuarioAtual._id, - titulo: "Adicionado a grupo", - descricao: `Você foi adicionado ao grupo "${args.nome || "Sem nome"}" por ${usuarioAtual.nome}`, - lida: false, - criadaEm: Date.now(), - }); - } - } - } - - return conversaId; - }, -}); - -/** - * Cria ou busca uma conversa individual com outro usuário - */ -export const criarOuBuscarConversaIndividual = mutation({ - args: { - outroUsuarioId: v.id("usuarios"), - }, - returns: v.id("conversas"), - handler: async (ctx, args) => { - // TENTAR BETTER AUTH PRIMEIRO - const identity = await ctx.auth.getUserIdentity(); - - let usuarioAtual = null; - - if (identity && identity.email) { - // Buscar por email (Better Auth) - usuarioAtual = await ctx.db - .query("usuarios") - .withIndex("by_email", (q) => q.eq("email", identity.email!)) - .first(); - } - - // SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado) - 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); - } - } - - if (!usuarioAtual) throw new Error("Usuário não autenticado"); - - // Buscar conversa individual existente entre os dois usuários - const conversasExistentes = await ctx.db - .query("conversas") - .filter((q) => q.eq(q.field("tipo"), "individual")) - .collect(); - - for (const conversa of conversasExistentes) { - if ( - conversa.participantes.length === 2 && - conversa.participantes.includes(usuarioAtual._id) && - conversa.participantes.includes(args.outroUsuarioId) - ) { - return conversa._id; - } - } - - // Se não existe, criar nova conversa individual - const conversaId = await ctx.db.insert("conversas", { - tipo: "individual", - participantes: [usuarioAtual._id, args.outroUsuarioId], - criadoPor: usuarioAtual._id, - criadoEm: Date.now(), - }); - - return conversaId; - }, -}); - -/** - * Envia uma mensagem em uma conversa - */ -export const enviarMensagem = mutation({ - args: { - conversaId: v.id("conversas"), - conteudo: v.string(), - tipo: v.union( - v.literal("texto"), - v.literal("arquivo"), - v.literal("imagem") - ), - arquivoId: v.optional(v.id("_storage")), - arquivoNome: v.optional(v.string()), - arquivoTamanho: v.optional(v.number()), - arquivoTipo: v.optional(v.string()), - mencoes: v.optional(v.array(v.id("usuarios"))), - permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo - }, - 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"); - } - - // Criar mensagem - const mensagemId = await ctx.db.insert("mensagens", { - conversaId: args.conversaId, - remetenteId: usuarioAtual._id, - tipo: args.tipo, - conteudo: args.conteudo, - arquivoId: args.arquivoId, - arquivoNome: args.arquivoNome, - arquivoTamanho: args.arquivoTamanho, - arquivoTipo: args.arquivoTipo, - mencoes: args.mencoes, - enviadaEm: Date.now(), - }); - - // Atualizar última mensagem da conversa - await ctx.db.patch(args.conversaId, { - ultimaMensagem: args.conteudo.substring(0, 100), - ultimaMensagemTimestamp: Date.now(), - }); - - // Criar notificações para participantes (com tratamento de erro) - try { - for (const participanteId of conversa.participantes) { - // ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa - const ehOMesmoUsuario = participanteId === usuarioAtual._id; - const deveCriarNotificacao = !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo; - - if (deveCriarNotificacao) { - const tipoNotificacao = args.mencoes?.includes(participanteId) - ? "mencao" - : "nova_mensagem"; - - await ctx.db.insert("notificacoes", { - usuarioId: participanteId, - tipo: tipoNotificacao, - conversaId: args.conversaId, - mensagemId, - remetenteId: usuarioAtual._id, - titulo: - tipoNotificacao === "mencao" - ? `${usuarioAtual.nome} mencionou você` - : `Nova mensagem de ${usuarioAtual.nome}`, - descricao: args.conteudo.substring(0, 100), - lida: false, - criadaEm: Date.now(), - }); - } - } - } catch (error) { - // Log do erro mas não falhar o envio da mensagem - console.error("Erro ao criar notificações:", error); - // A mensagem já foi criada, então retornamos o ID normalmente - } - - return mensagemId; - }, -}); - -/** - * Agenda uma mensagem para envio futuro - */ -export const agendarMensagem = mutation({ - args: { - conversaId: v.id("conversas"), - conteudo: v.string(), - agendadaPara: v.number(), // timestamp - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Validar data futura - if (args.agendadaPara <= Date.now()) { - throw new Error("Data de agendamento deve ser futura"); - } - - // 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"); - } - - // Criar mensagem agendada - const mensagemId = await ctx.db.insert("mensagens", { - conversaId: args.conversaId, - remetenteId: usuarioAtual._id, - tipo: "texto", - conteudo: args.conteudo, - agendadaPara: args.agendadaPara, - enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada - }); - - return mensagemId; - }, -}); - -/** - * Cancela uma mensagem agendada - */ -export const cancelarMensagemAgendada = mutation({ - args: { - mensagemId: v.id("mensagens"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - const mensagem = await ctx.db.get(args.mensagemId); - if (!mensagem) throw new Error("Mensagem não encontrada"); - if (mensagem.remetenteId !== usuarioAtual._id) { - throw new Error("Você só pode cancelar suas próprias mensagens"); - } - - await ctx.db.delete(args.mensagemId); - return true; - }, -}); - -/** - * Adiciona uma reação (emoji) a uma mensagem - */ -export const reagirMensagem = mutation({ - args: { - mensagemId: v.id("mensagens"), - emoji: v.string(), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - const mensagem = await ctx.db.get(args.mensagemId); - if (!mensagem) throw new Error("Mensagem não encontrada"); - - const reacoes = mensagem.reagiuPor || []; - const reacaoExistente = reacoes.find( - (r) => r.usuarioId === usuarioAtual._id && r.emoji === args.emoji - ); - - if (reacaoExistente) { - // Remover reação - await ctx.db.patch(args.mensagemId, { - reagiuPor: reacoes.filter( - (r) => !(r.usuarioId === usuarioAtual._id && r.emoji === args.emoji) - ), - }); - } else { - // Adicionar reação - await ctx.db.patch(args.mensagemId, { - reagiuPor: [ - ...reacoes, - { usuarioId: usuarioAtual._id, emoji: args.emoji }, - ], - }); - } - - return true; - }, -}); - -/** - * Marca mensagens de uma conversa como lidas - */ -export const marcarComoLida = mutation({ - args: { - conversaId: v.id("conversas"), - mensagemId: v.id("mensagens"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Buscar registro de leitura existente - const leituraExistente = await ctx.db - .query("leituras") - .withIndex("by_conversa_usuario", (q) => - q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id) - ) - .first(); - - if (leituraExistente) { - await ctx.db.patch(leituraExistente._id, { - ultimaMensagemLida: args.mensagemId, - lidaEm: Date.now(), - }); - } else { - await ctx.db.insert("leituras", { - conversaId: args.conversaId, - usuarioId: usuarioAtual._id, - ultimaMensagemLida: args.mensagemId, - lidaEm: Date.now(), - }); - } - - // Marcar notificações desta conversa como lidas - const notificacoes = await ctx.db - .query("notificacoes") - .withIndex("by_usuario_lida", (q) => - q.eq("usuarioId", usuarioAtual._id).eq("lida", false) - ) - .filter((q) => q.eq(q.field("conversaId"), args.conversaId)) - .collect(); - - for (const notificacao of notificacoes) { - await ctx.db.patch(notificacao._id, { lida: true }); - } - - return true; - }, -}); - -/** - * Atualiza o status de presença do usuário - */ -export const atualizarStatusPresenca = mutation({ - args: { - status: v.union( - v.literal("online"), - v.literal("offline"), - v.literal("ausente"), - v.literal("externo"), - v.literal("em_reuniao") - ), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - await ctx.db.patch(usuarioAtual._id, { - statusPresenca: args.status, - ultimaAtividade: Date.now(), - }); - - return true; - }, -}); - -/** - * Indica que o usuário está digitando em uma conversa - */ -export const indicarDigitacao = mutation({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Buscar indicador existente - const indicadorExistente = await ctx.db - .query("digitando") - .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)) - .filter((q) => q.eq(q.field("conversaId"), args.conversaId)) - .first(); - - if (indicadorExistente) { - await ctx.db.patch(indicadorExistente._id, { - iniciouEm: Date.now(), - }); - } else { - await ctx.db.insert("digitando", { - conversaId: args.conversaId, - usuarioId: usuarioAtual._id, - iniciouEm: Date.now(), - }); - } - - return true; - }, -}); - -/** - * Gera URL para upload de arquivo no chat - */ -export const uploadArquivoChat = mutation({ - args: { - conversaId: v.id("conversas"), - }, - 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"); - } - - return await ctx.storage.generateUploadUrl(); - }, -}); - -/** - * Marca uma notificação como lida - */ -export const marcarNotificacaoLida = mutation({ - args: { - notificacaoId: v.id("notificacoes"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - await ctx.db.patch(args.notificacaoId, { lida: true }); - return true; - }, -}); - -/** - * Marca todas as notificações como lidas - */ -export const marcarTodasNotificacoesLidas = mutation({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - const notificacoes = await ctx.db - .query("notificacoes") - .withIndex("by_usuario_lida", (q) => - q.eq("usuarioId", usuarioAtual._id).eq("lida", false) - ) - .collect(); - - for (const notificacao of notificacoes) { - await ctx.db.patch(notificacao._id, { lida: true }); - } - - return true; - }, -}); - -/** - * Deleta uma mensagem (soft delete) - */ -export const deletarMensagem = mutation({ - args: { - mensagemId: v.id("mensagens"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - const mensagem = await ctx.db.get(args.mensagemId); - if (!mensagem) throw new Error("Mensagem não encontrada"); - - if (mensagem.remetenteId !== usuarioAtual._id) { - throw new Error("Você só pode deletar suas próprias mensagens"); - } - - await ctx.db.patch(args.mensagemId, { - deletada: true, - conteudo: "Mensagem deletada", - }); - - return true; - }, -}); - -// ========== QUERIES ========== - -/** - * Lista todas as conversas do usuário logado - */ -export const listarConversas = query({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Buscar todas as conversas do usuário - const todasConversas = await ctx.db.query("conversas").collect(); - const conversasDoUsuario = todasConversas.filter((c) => - c.participantes.includes(usuarioAtual._id) - ); - - // Ordenar por última mensagem - conversasDoUsuario.sort((a, b) => { - const timestampA = a.ultimaMensagemTimestamp || a.criadoEm; - const timestampB = b.ultimaMensagemTimestamp || b.criadoEm; - return timestampB - timestampA; - }); - - // Enriquecer com informações dos participantes - const conversasEnriquecidas = await Promise.all( - conversasDoUsuario.map(async (conversa) => { - // Buscar participantes - const participantes = await Promise.all( - conversa.participantes.map((id) => ctx.db.get(id)) - ); - - // Para conversas individuais, pegar o outro usuário - let outroUsuario = null; - if (conversa.tipo === "individual") { - const outroUsuarioRaw = participantes.find((p) => p?._id !== usuarioAtual._id); - if (outroUsuarioRaw) { - // 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot) - const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id); - - if (usuarioAtualizado) { - // Adicionar URL da foto de perfil - let fotoPerfilUrl = null; - if (usuarioAtualizado.fotoPerfil) { - fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtualizado.fotoPerfil); - } - outroUsuario = { - ...usuarioAtualizado, - fotoPerfilUrl, - }; - } - } - } - - // Contar mensagens não lidas (apenas mensagens NÃO agendadas) - const leitura = await ctx.db - .query("leituras") - .withIndex("by_conversa_usuario", (q) => - q.eq("conversaId", conversa._id).eq("usuarioId", usuarioAtual._id) - ) - .first(); - - // CORRIGIDO: Buscar apenas mensagens NÃO agendadas (agendadaPara === undefined) - const todasMensagens = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) - .collect(); - - const mensagens = todasMensagens.filter((m) => !m.agendadaPara); - - let naoLidas = 0; - if (leitura) { - naoLidas = mensagens.filter( - (m) => - m.enviadaEm > (leitura.lidaEm || 0) && - m.remetenteId !== usuarioAtual._id - ).length; - } else { - naoLidas = mensagens.filter( - (m) => m.remetenteId !== usuarioAtual._id - ).length; - } - - return { - ...conversa, - outroUsuario, - participantesInfo: participantes.filter((p) => p !== null), - naoLidas, - }; - }) - ); - - return conversasEnriquecidas; - }, -}); - -/** - * Obtém as mensagens de uma conversa com paginação - */ -export const obterMensagens = query({ - args: { - conversaId: v.id("conversas"), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Verificar se usuário pertence à conversa - const conversa = await ctx.db.get(args.conversaId); - if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { - return []; - } - - // Buscar mensagens (excluir agendadas) - const mensagens = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) - .order("desc") - .take(args.limit || 50); - - // Filtrar mensagens agendadas - const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara); - - // Enriquecer com informações do remetente - const mensagensEnriquecidas = await Promise.all( - mensagensFiltradas.map(async (mensagem) => { - const remetente = await ctx.db.get(mensagem.remetenteId); - let arquivoUrl = null; - if (mensagem.arquivoId) { - arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId); - } - return { - ...mensagem, - remetente, - arquivoUrl, - }; - }) - ); - - return mensagensEnriquecidas.reverse(); - }, -}); - -/** - * Obtém mensagens agendadas de uma conversa - */ -export const obterMensagensAgendadas = query({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Buscar mensagens agendadas - const todasMensagens = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) - .collect(); - - // Filtrar apenas as agendadas do usuário atual - const minhasMensagensAgendadas = todasMensagens.filter( - (m) => - m.remetenteId === usuarioAtual._id && - m.agendadaPara && - m.agendadaPara > Date.now() - ); - - return minhasMensagensAgendadas.sort( - (a, b) => (a.agendadaPara || 0) - (b.agendadaPara || 0) - ); - }, -}); - -/** - * Obtém as notificações do usuário - */ -export const obterNotificacoes = query({ - args: { - apenasPendentes: v.optional(v.boolean()), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - let query = ctx.db - .query("notificacoes") - .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)); - - if (args.apenasPendentes) { - query = ctx.db - .query("notificacoes") - .withIndex("by_usuario_lida", (q) => - q.eq("usuarioId", usuarioAtual._id).eq("lida", false) - ); - } - - const notificacoes = await query.order("desc").take(50); - - // Enriquecer com informações do remetente - const notificacoesEnriquecidas = await Promise.all( - notificacoes.map(async (notificacao) => { - let remetente = null; - if (notificacao.remetenteId) { - remetente = await ctx.db.get(notificacao.remetenteId); - } - return { - ...notificacao, - remetente, - }; - }) - ); - - return notificacoesEnriquecidas; - }, -}); - -/** - * Conta o número de notificações não lidas - */ -export const contarNotificacoesNaoLidas = query({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return 0; - - const notificacoes = await ctx.db - .query("notificacoes") - .withIndex("by_usuario_lida", (q) => - q.eq("usuarioId", usuarioAtual._id).eq("lida", false) - ) - .collect(); - - return notificacoes.length; - }, -}); - -/** - * Obtém usuários online - */ -export const obterUsuariosOnline = query({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_status_presenca", (q) => q.eq("statusPresenca", "online")) - .collect(); - - return usuarios.map((u) => ({ - _id: u._id, - nome: u.nome, - email: u.email, - avatar: u.avatar, - fotoPerfil: u.fotoPerfil, - statusPresenca: u.statusPresenca, - statusMensagem: u.statusMensagem, - setor: u.setor, - })); - }, -}); - -/** - * Lista todos os usuários (para criar nova conversa) - */ -export const listarTodosUsuarios = query({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_ativo", (q) => q.eq("ativo", true)) - .collect(); - - // Excluir o usuário atual - return usuarios - .filter((u) => u._id !== usuarioAtual._id) - .map((u) => ({ - _id: u._id, - nome: u.nome, - email: u.email, - matricula: u.matricula, - avatar: u.avatar, - fotoPerfil: u.fotoPerfil, - statusPresenca: u.statusPresenca, - statusMensagem: u.statusMensagem, - setor: u.setor, - })); - }, -}); - -/** - * Busca mensagens em conversas - */ -export const buscarMensagens = query({ - args: { - query: v.string(), - conversaId: v.optional(v.id("conversas")), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Buscar em todas as conversas do usuário - const todasConversas = await ctx.db.query("conversas").collect(); - const conversasDoUsuario = todasConversas.filter((c) => - c.participantes.includes(usuarioAtual._id) - ); - - let mensagens: any[] = []; - - if (args.conversaId !== undefined) { - // Buscar em conversa específica - const mensagensConversa = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!)) - .collect(); - mensagens = mensagensConversa; - } else { - // Buscar em todas as conversas - for (const conversa of conversasDoUsuario) { - const mensagensConversa = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) - .collect(); - mensagens.push(...mensagensConversa); - } - } - - // Filtrar por query - const queryLower = args.query.toLowerCase(); - const mensagensFiltradas = mensagens.filter( - (m) => - !m.deletada && - !m.agendadaPara && - m.conteudo.toLowerCase().includes(queryLower) - ); - - // Enriquecer com informações - const mensagensEnriquecidas = await Promise.all( - mensagensFiltradas.map(async (mensagem) => { - const remetente = await ctx.db.get(mensagem.remetenteId); - const conversa = await ctx.db.get(mensagem.conversaId); - return { - ...mensagem, - remetente, - conversa, - }; - }) - ); - - return mensagensEnriquecidas - .sort((a, b) => b.enviadaEm - a.enviadaEm) - .slice(0, 50); - }, -}); - -/** - * Obtém quem está digitando em uma conversa - */ -export const obterDigitando = query({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Buscar indicadores de digitação (últimos 10 segundos) - const dezSegundosAtras = Date.now() - 10000; - const digitando = await ctx.db - .query("digitando") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) - .filter((q) => q.gte(q.field("iniciouEm"), dezSegundosAtras)) - .collect(); - - // Filtrar usuário atual e buscar informações - const digitandoFiltrado = digitando.filter( - (d) => d.usuarioId !== usuarioAtual._id - ); - - const usuarios = await Promise.all( - digitandoFiltrado.map(async (d) => { - const usuario = await ctx.db.get(d.usuarioId); - return usuario; - }) - ); - - return usuarios.filter((u) => u !== null); - }, -}); - -/** - * Conta mensagens não lidas de uma conversa - */ -export const contarNaoLidas = query({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return 0; - - const leitura = await ctx.db - .query("leituras") - .withIndex("by_conversa_usuario", (q) => - q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id) - ) - .first(); - - const mensagens = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) - .filter((q) => q.eq(q.field("agendadaPara"), undefined)) - .collect(); - - if (leitura) { - return mensagens.filter( - (m) => - m.enviadaEm > (leitura.lidaEm || 0) && - m.remetenteId !== usuarioAtual._id - ).length; - } - - return mensagens.filter((m) => m.remetenteId !== usuarioAtual._id).length; - }, -}); - -// ========== INTERNAL MUTATIONS (para crons) ========== - -/** - * Envia mensagens agendadas (chamado pelo cron) - */ -export const enviarMensagensAgendadas = internalMutation({ - args: {}, - handler: async (ctx) => { - const agora = Date.now(); - - // Buscar mensagens que deveriam ser enviadas - const mensagensAgendadas = await ctx.db - .query("mensagens") - .withIndex("by_agendamento") - .filter((q) => - q.and( - q.neq(q.field("agendadaPara"), undefined), - q.lte(q.field("agendadaPara"), agora) - ) - ) - .collect(); - - for (const mensagem of mensagensAgendadas) { - // Atualizar mensagem para "enviada" - await ctx.db.patch(mensagem._id, { - agendadaPara: undefined, - enviadaEm: agora, - }); - - // Atualizar última mensagem da conversa - const conversa = await ctx.db.get(mensagem.conversaId); - if (conversa) { - await ctx.db.patch(mensagem.conversaId, { - ultimaMensagem: mensagem.conteudo.substring(0, 100), - ultimaMensagemTimestamp: agora, - }); - - // Criar notificações para outros participantes - const remetente = await ctx.db.get(mensagem.remetenteId); - for (const participanteId of conversa.participantes) { - if (participanteId !== mensagem.remetenteId) { - await ctx.db.insert("notificacoes", { - usuarioId: participanteId, - tipo: "nova_mensagem", - conversaId: mensagem.conversaId, - mensagemId: mensagem._id, - remetenteId: mensagem.remetenteId, - titulo: `Nova mensagem de ${remetente?.nome || "Usuário"}`, - descricao: mensagem.conteudo.substring(0, 100), - lida: false, - criadaEm: agora, - }); - } - } - } - } - - return mensagensAgendadas.length; - }, -}); - -/** - * Limpa indicadores de digitação antigos (chamado pelo cron) - */ -export const limparIndicadoresDigitacao = internalMutation({ - args: {}, - handler: async (ctx) => { - const dezSegundosAtras = Date.now() - 10000; - - const indicadoresAntigos = await ctx.db - .query("digitando") - .filter((q) => q.lt(q.field("iniciouEm"), dezSegundosAtras)) - .collect(); - - for (const indicador of indicadoresAntigos) { - await ctx.db.delete(indicador._id); - } - - return indicadoresAntigos.length; - }, -}); +import { v } from "convex/values"; +import { mutation, query, internalMutation } from "./_generated/server"; +import { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; + +// ========== HELPERS ========== + +/** + * Helper function para obter usuário autenticado (Better Auth ou Sessão) + */ +async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { + // Tentar autenticação via Better Auth primeiro + 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(); + } + + // Se não encontrou via Better Auth, tentar via sessão mais recente + 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; +} + +// ========== MUTATIONS ========== + +/** + * Cria uma nova conversa (individual ou grupo) + */ +export const criarConversa = mutation({ + args: { + tipo: v.union(v.literal("individual"), v.literal("grupo")), + participantes: v.array(v.id("usuarios")), + nome: v.optional(v.string()), + avatar: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Validar participantes + if (!args.participantes.includes(usuarioAtual._id)) { + args.participantes.push(usuarioAtual._id); + } + + // Se for conversa individual, verificar se já existe + if (args.tipo === "individual" && args.participantes.length === 2) { + const conversaExistente = await ctx.db + .query("conversas") + .filter((q) => q.eq(q.field("tipo"), "individual")) + .collect(); + + for (const conversa of conversaExistente) { + if ( + conversa.participantes.length === 2 && + conversa.participantes.every((p) => args.participantes.includes(p)) + ) { + return conversa._id; + } + } + } + + // Criar nova conversa + const conversaId = await ctx.db.insert("conversas", { + tipo: args.tipo, + nome: args.nome, + avatar: args.avatar, + participantes: args.participantes, + criadoPor: usuarioAtual._id, + criadoEm: Date.now(), + }); + + // Criar notificações para outros participantes + if (args.tipo === "grupo") { + for (const participanteId of args.participantes) { + if (participanteId !== usuarioAtual._id) { + await ctx.db.insert("notificacoes", { + usuarioId: participanteId, + tipo: "adicionado_grupo", + conversaId, + remetenteId: usuarioAtual._id, + titulo: "Adicionado a grupo", + descricao: `Você foi adicionado ao grupo "${ + args.nome || "Sem nome" + }" por ${usuarioAtual.nome}`, + lida: false, + criadaEm: Date.now(), + }); + } + } + } + + return conversaId; + }, +}); + +/** + * Cria ou busca uma conversa individual com outro usuário + */ +export const criarOuBuscarConversaIndividual = mutation({ + args: { + outroUsuarioId: v.id("usuarios"), + }, + returns: v.id("conversas"), + handler: async (ctx, args) => { + // TENTAR BETTER AUTH PRIMEIRO + const identity = await ctx.auth.getUserIdentity(); + + let usuarioAtual = null; + + if (identity && identity.email) { + // Buscar por email (Better Auth) + usuarioAtual = await ctx.db + .query("usuarios") + .withIndex("by_email", (q) => q.eq("email", identity.email!)) + .first(); + } + + // SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado) + 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); + } + } + + if (!usuarioAtual) throw new Error("Usuário não autenticado"); + + // Buscar conversa individual existente entre os dois usuários + const conversasExistentes = await ctx.db + .query("conversas") + .filter((q) => q.eq(q.field("tipo"), "individual")) + .collect(); + + for (const conversa of conversasExistentes) { + if ( + conversa.participantes.length === 2 && + conversa.participantes.includes(usuarioAtual._id) && + conversa.participantes.includes(args.outroUsuarioId) + ) { + return conversa._id; + } + } + + // Se não existe, criar nova conversa individual + const conversaId = await ctx.db.insert("conversas", { + tipo: "individual", + participantes: [usuarioAtual._id, args.outroUsuarioId], + criadoPor: usuarioAtual._id, + criadoEm: Date.now(), + }); + + return conversaId; + }, +}); + +/** + * Envia uma mensagem em uma conversa + */ +export const enviarMensagem = mutation({ + args: { + conversaId: v.id("conversas"), + conteudo: v.string(), + tipo: v.union( + v.literal("texto"), + v.literal("arquivo"), + v.literal("imagem") + ), + arquivoId: v.optional(v.id("_storage")), + arquivoNome: v.optional(v.string()), + arquivoTamanho: v.optional(v.number()), + arquivoTipo: v.optional(v.string()), + mencoes: v.optional(v.array(v.id("usuarios"))), + permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo + }, + 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"); + } + + // Criar mensagem + const mensagemId = await ctx.db.insert("mensagens", { + conversaId: args.conversaId, + remetenteId: usuarioAtual._id, + tipo: args.tipo, + conteudo: args.conteudo, + arquivoId: args.arquivoId, + arquivoNome: args.arquivoNome, + arquivoTamanho: args.arquivoTamanho, + arquivoTipo: args.arquivoTipo, + mencoes: args.mencoes, + enviadaEm: Date.now(), + }); + + // Atualizar última mensagem da conversa + await ctx.db.patch(args.conversaId, { + ultimaMensagem: args.conteudo.substring(0, 100), + ultimaMensagemTimestamp: Date.now(), + }); + + // Criar notificações para participantes (com tratamento de erro) + try { + for (const participanteId of conversa.participantes) { + // ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa + const ehOMesmoUsuario = participanteId === usuarioAtual._id; + const deveCriarNotificacao = + !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo; + + if (deveCriarNotificacao) { + const tipoNotificacao = args.mencoes?.includes(participanteId) + ? "mencao" + : "nova_mensagem"; + + await ctx.db.insert("notificacoes", { + usuarioId: participanteId, + tipo: tipoNotificacao, + conversaId: args.conversaId, + mensagemId, + remetenteId: usuarioAtual._id, + titulo: + tipoNotificacao === "mencao" + ? `${usuarioAtual.nome} mencionou você` + : `Nova mensagem de ${usuarioAtual.nome}`, + descricao: args.conteudo.substring(0, 100), + lida: false, + criadaEm: Date.now(), + }); + } + } + } catch (error) { + // Log do erro mas não falhar o envio da mensagem + console.error("Erro ao criar notificações:", error); + // A mensagem já foi criada, então retornamos o ID normalmente + } + + return mensagemId; + }, +}); + +/** + * Agenda uma mensagem para envio futuro + */ +export const agendarMensagem = mutation({ + args: { + conversaId: v.id("conversas"), + conteudo: v.string(), + agendadaPara: v.number(), // timestamp + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Validar data futura + if (args.agendadaPara <= Date.now()) { + throw new Error("Data de agendamento deve ser futura"); + } + + // 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"); + } + + // Criar mensagem agendada + const mensagemId = await ctx.db.insert("mensagens", { + conversaId: args.conversaId, + remetenteId: usuarioAtual._id, + tipo: "texto", + conteudo: args.conteudo, + agendadaPara: args.agendadaPara, + enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada + }); + + return mensagemId; + }, +}); + +/** + * Cancela uma mensagem agendada + */ +export const cancelarMensagemAgendada = mutation({ + args: { + mensagemId: v.id("mensagens"), + }, + returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), + handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return { sucesso: false, erro: "Usuário não autenticado" }; + } + + const mensagem = await ctx.db.get(args.mensagemId); + if (!mensagem) { + return { sucesso: false, erro: "Mensagem não encontrada" }; + } + + if (mensagem.remetenteId !== usuarioAtual._id) { + return { + sucesso: false, + erro: "Você só pode cancelar suas próprias mensagens", + }; + } + + if (!mensagem.agendadaPara) { + return { sucesso: false, erro: "Esta mensagem não está agendada" }; + } + + if (mensagem.agendadaPara <= Date.now()) { + return { sucesso: false, erro: "A data de agendamento já passou" }; + } + + await ctx.db.delete(args.mensagemId); + return { sucesso: true }; + }, +}); + +/** + * Adiciona uma reação (emoji) a uma mensagem + */ +export const reagirMensagem = mutation({ + args: { + mensagemId: v.id("mensagens"), + emoji: v.string(), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + const mensagem = await ctx.db.get(args.mensagemId); + if (!mensagem) throw new Error("Mensagem não encontrada"); + + const reacoes = mensagem.reagiuPor || []; + const reacaoExistente = reacoes.find( + (r) => r.usuarioId === usuarioAtual._id && r.emoji === args.emoji + ); + + if (reacaoExistente) { + // Remover reação + await ctx.db.patch(args.mensagemId, { + reagiuPor: reacoes.filter( + (r) => !(r.usuarioId === usuarioAtual._id && r.emoji === args.emoji) + ), + }); + } else { + // Adicionar reação + await ctx.db.patch(args.mensagemId, { + reagiuPor: [ + ...reacoes, + { usuarioId: usuarioAtual._id, emoji: args.emoji }, + ], + }); + } + + return true; + }, +}); + +/** + * Marca mensagens de uma conversa como lidas + */ +export const marcarComoLida = mutation({ + args: { + conversaId: v.id("conversas"), + mensagemId: v.id("mensagens"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Buscar registro de leitura existente + const leituraExistente = await ctx.db + .query("leituras") + .withIndex("by_conversa_usuario", (q) => + q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id) + ) + .first(); + + if (leituraExistente) { + await ctx.db.patch(leituraExistente._id, { + ultimaMensagemLida: args.mensagemId, + lidaEm: Date.now(), + }); + } else { + await ctx.db.insert("leituras", { + conversaId: args.conversaId, + usuarioId: usuarioAtual._id, + ultimaMensagemLida: args.mensagemId, + lidaEm: Date.now(), + }); + } + + // Marcar notificações desta conversa como lidas + const notificacoes = await ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ) + .filter((q) => q.eq(q.field("conversaId"), args.conversaId)) + .collect(); + + for (const notificacao of notificacoes) { + await ctx.db.patch(notificacao._id, { lida: true }); + } + + return true; + }, +}); + +/** + * Atualiza o status de presença do usuário + */ +export const atualizarStatusPresenca = mutation({ + args: { + status: v.union( + v.literal("online"), + v.literal("offline"), + v.literal("ausente"), + v.literal("externo"), + v.literal("em_reuniao") + ), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + await ctx.db.patch(usuarioAtual._id, { + statusPresenca: args.status, + ultimaAtividade: Date.now(), + }); + + return true; + }, +}); + +/** + * Indica que o usuário está digitando em uma conversa + */ +export const indicarDigitacao = mutation({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Buscar indicador existente + const indicadorExistente = await ctx.db + .query("digitando") + .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)) + .filter((q) => q.eq(q.field("conversaId"), args.conversaId)) + .first(); + + if (indicadorExistente) { + await ctx.db.patch(indicadorExistente._id, { + iniciouEm: Date.now(), + }); + } else { + await ctx.db.insert("digitando", { + conversaId: args.conversaId, + usuarioId: usuarioAtual._id, + iniciouEm: Date.now(), + }); + } + + return true; + }, +}); + +/** + * Gera URL para upload de arquivo no chat + */ +export const uploadArquivoChat = mutation({ + args: { + conversaId: v.id("conversas"), + }, + 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"); + } + + return await ctx.storage.generateUploadUrl(); + }, +}); + +/** + * Marca uma notificação como lida + */ +export const marcarNotificacaoLida = mutation({ + args: { + notificacaoId: v.id("notificacoes"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + await ctx.db.patch(args.notificacaoId, { lida: true }); + return true; + }, +}); + +/** + * Marca todas as notificações como lidas + */ +export const marcarTodasNotificacoesLidas = mutation({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + const notificacoes = await ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ) + .collect(); + + for (const notificacao of notificacoes) { + await ctx.db.patch(notificacao._id, { lida: true }); + } + + return true; + }, +}); + +/** + * Deleta uma mensagem (soft delete) + */ +export const deletarMensagem = mutation({ + args: { + mensagemId: v.id("mensagens"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + const mensagem = await ctx.db.get(args.mensagemId); + if (!mensagem) throw new Error("Mensagem não encontrada"); + + if (mensagem.remetenteId !== usuarioAtual._id) { + throw new Error("Você só pode deletar suas próprias mensagens"); + } + + await ctx.db.patch(args.mensagemId, { + deletada: true, + conteudo: "Mensagem deletada", + }); + + return true; + }, +}); + +// ========== QUERIES ========== + +/** + * Lista todas as conversas do usuário logado + */ +export const listarConversas = query({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Buscar todas as conversas do usuário + const todasConversas = await ctx.db.query("conversas").collect(); + const conversasDoUsuario = todasConversas.filter((c) => + c.participantes.includes(usuarioAtual._id) + ); + + // Ordenar por última mensagem + conversasDoUsuario.sort((a, b) => { + const timestampA = a.ultimaMensagemTimestamp || a.criadoEm; + const timestampB = b.ultimaMensagemTimestamp || b.criadoEm; + return timestampB - timestampA; + }); + + // Enriquecer com informações dos participantes + const conversasEnriquecidas = await Promise.all( + conversasDoUsuario.map(async (conversa) => { + // Buscar participantes + const participantes = await Promise.all( + conversa.participantes.map((id) => ctx.db.get(id)) + ); + + // Para conversas individuais, pegar o outro usuário + let outroUsuario = null; + if (conversa.tipo === "individual") { + const outroUsuarioRaw = participantes.find( + (p) => p?._id !== usuarioAtual._id + ); + if (outroUsuarioRaw) { + // 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot) + const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id); + + if (usuarioAtualizado) { + // Adicionar URL da foto de perfil + let fotoPerfilUrl = null; + if (usuarioAtualizado.fotoPerfil) { + fotoPerfilUrl = await ctx.storage.getUrl( + usuarioAtualizado.fotoPerfil + ); + } + outroUsuario = { + ...usuarioAtualizado, + fotoPerfilUrl, + }; + } + } + } + + // Contar mensagens não lidas (apenas mensagens NÃO agendadas) + const leitura = await ctx.db + .query("leituras") + .withIndex("by_conversa_usuario", (q) => + q.eq("conversaId", conversa._id).eq("usuarioId", usuarioAtual._id) + ) + .first(); + + // CORRIGIDO: Buscar apenas mensagens NÃO agendadas (agendadaPara === undefined) + const todasMensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) + .collect(); + + const mensagens = todasMensagens.filter((m) => !m.agendadaPara); + + let naoLidas = 0; + if (leitura) { + naoLidas = mensagens.filter( + (m) => + m.enviadaEm > (leitura.lidaEm || 0) && + m.remetenteId !== usuarioAtual._id + ).length; + } else { + naoLidas = mensagens.filter( + (m) => m.remetenteId !== usuarioAtual._id + ).length; + } + + return { + ...conversa, + outroUsuario, + participantesInfo: participantes.filter((p) => p !== null), + naoLidas, + }; + }) + ); + + return conversasEnriquecidas; + }, +}); + +/** + * Obtém as mensagens de uma conversa com paginação + */ +export const obterMensagens = query({ + args: { + conversaId: v.id("conversas"), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Verificar se usuário pertence à conversa + const conversa = await ctx.db.get(args.conversaId); + if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { + return []; + } + + // Buscar mensagens (excluir agendadas) + const mensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .order("desc") + .take(args.limit || 50); + + // Filtrar mensagens agendadas + const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara); + + // Enriquecer com informações do remetente + const mensagensEnriquecidas = await Promise.all( + mensagensFiltradas.map(async (mensagem) => { + const remetente = await ctx.db.get(mensagem.remetenteId); + let arquivoUrl = null; + if (mensagem.arquivoId) { + arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId); + } + return { + ...mensagem, + remetente, + arquivoUrl, + }; + }) + ); + + return mensagensEnriquecidas.reverse(); + }, +}); + +/** + * Obtém mensagens agendadas de uma conversa + */ +export const obterMensagensAgendadas = query({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args): Promise[]> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Buscar mensagens agendadas + const todasMensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .collect(); + + // Filtrar apenas as agendadas do usuário atual + const minhasMensagensAgendadas = todasMensagens.filter( + (m) => + m.remetenteId === usuarioAtual._id && + m.agendadaPara !== undefined && + m.agendadaPara > Date.now() + ); + + return minhasMensagensAgendadas.sort( + (a, b) => (a.agendadaPara ?? 0) - (b.agendadaPara ?? 0) + ); + }, +}); + +/** + * Listar todas as mensagens agendadas do usuário atual (para página de notificações) + */ +export const listarAgendamentosChat = query({ + args: {}, + handler: async ( + ctx + ): Promise< + Array< + Doc<"mensagens"> & { + conversaInfo: Doc<"conversas"> | null; + destinatarioInfo: Doc<"usuarios"> | null; + } + > + > => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return []; + } + + // Buscar todas as mensagens agendadas do usuário + const todasMensagens = await ctx.db + .query("mensagens") + .withIndex("by_remetente", (q) => q.eq("remetenteId", usuarioAtual._id)) + .collect(); + + // Filtrar apenas as que têm agendamento (passadas ou futuras) + const mensagensAgendadas = todasMensagens.filter( + (m) => m.agendadaPara !== undefined + ); + + // Enriquecer com informações da conversa e destinatário + const mensagensEnriquecidas = await Promise.all( + mensagensAgendadas.map(async (mensagem) => { + let conversaInfo: Doc<"conversas"> | null = null; + let destinatarioInfo: Doc<"usuarios"> | null = null; + + conversaInfo = await ctx.db.get(mensagem.conversaId); + + // Se for conversa individual, encontrar o outro participante + if (conversaInfo && conversaInfo.tipo === "individual") { + const outroParticipanteId = conversaInfo.participantes.find( + (p) => p !== usuarioAtual._id + ); + if (outroParticipanteId) { + destinatarioInfo = await ctx.db.get(outroParticipanteId); + } + } + + return { + ...mensagem, + conversaInfo, + destinatarioInfo, + }; + }) + ); + + // Ordenar por data de agendamento (mais próximos primeiro) + return mensagensEnriquecidas.sort((a, b) => { + const dataA = a.agendadaPara ?? 0; + const dataB = b.agendadaPara ?? 0; + return dataA - dataB; + }); + }, +}); + +/** + * Obtém as notificações do usuário + */ +export const obterNotificacoes = query({ + args: { + apenasPendentes: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + let query = ctx.db + .query("notificacoes") + .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)); + + if (args.apenasPendentes) { + query = ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ); + } + + const notificacoes = await query.order("desc").take(50); + + // Enriquecer com informações do remetente + const notificacoesEnriquecidas = await Promise.all( + notificacoes.map(async (notificacao) => { + let remetente = null; + if (notificacao.remetenteId) { + remetente = await ctx.db.get(notificacao.remetenteId); + } + return { + ...notificacao, + remetente, + }; + }) + ); + + return notificacoesEnriquecidas; + }, +}); + +/** + * Conta o número de notificações não lidas + */ +export const contarNotificacoesNaoLidas = query({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return 0; + + const notificacoes = await ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ) + .collect(); + + return notificacoes.length; + }, +}); + +/** + * Obtém usuários online + */ +export const obterUsuariosOnline = query({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + const usuarios = await ctx.db + .query("usuarios") + .withIndex("by_status_presenca", (q) => q.eq("statusPresenca", "online")) + .collect(); + + return usuarios.map((u) => ({ + _id: u._id, + nome: u.nome, + email: u.email, + avatar: u.avatar, + fotoPerfil: u.fotoPerfil, + statusPresenca: u.statusPresenca, + statusMensagem: u.statusMensagem, + setor: u.setor, + })); + }, +}); + +/** + * Lista todos os usuários (para criar nova conversa) + */ +export const listarTodosUsuarios = query({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + const usuarios = await ctx.db + .query("usuarios") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .collect(); + + // Excluir o usuário atual e buscar matrículas + const usuariosComMatricula = await Promise.all( + usuarios + .filter((u) => u._id !== usuarioAtual._id) + .map(async (u) => { + let matricula: string | undefined = undefined; + if (u.funcionarioId) { + const funcionario = await ctx.db.get(u.funcionarioId); + matricula = funcionario?.matricula; + } + return { + _id: u._id, + nome: u.nome, + email: u.email, + matricula, + avatar: u.avatar, + fotoPerfil: u.fotoPerfil, + statusPresenca: u.statusPresenca, + statusMensagem: u.statusMensagem, + setor: u.setor, + }; + }) + ); + + return usuariosComMatricula; + }, +}); + +/** + * Busca mensagens em conversas + */ +export const buscarMensagens = query({ + args: { + query: v.string(), + conversaId: v.optional(v.id("conversas")), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Buscar em todas as conversas do usuário + const todasConversas = await ctx.db.query("conversas").collect(); + const conversasDoUsuario = todasConversas.filter((c) => + c.participantes.includes(usuarioAtual._id) + ); + + let mensagens: Doc<"mensagens">[] = []; + + if (args.conversaId !== undefined) { + // Buscar em conversa específica + const mensagensConversa = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!)) + .collect(); + mensagens = mensagensConversa; + } else { + // Buscar em todas as conversas + for (const conversa of conversasDoUsuario) { + const mensagensConversa = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) + .collect(); + mensagens.push(...mensagensConversa); + } + } + + // Filtrar por query + const queryLower = args.query.toLowerCase(); + const mensagensFiltradas = mensagens.filter( + (m) => + !m.deletada && + !m.agendadaPara && + m.conteudo.toLowerCase().includes(queryLower) + ); + + // Enriquecer com informações + const mensagensEnriquecidas = await Promise.all( + mensagensFiltradas.map(async (mensagem) => { + const remetente = await ctx.db.get(mensagem.remetenteId); + const conversa = await ctx.db.get(mensagem.conversaId); + return { + ...mensagem, + remetente, + conversa, + }; + }) + ); + + return mensagensEnriquecidas + .sort((a, b) => b.enviadaEm - a.enviadaEm) + .slice(0, 50); + }, +}); + +/** + * Obtém quem está digitando em uma conversa + */ +export const obterDigitando = query({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Buscar indicadores de digitação (últimos 10 segundos) + const dezSegundosAtras = Date.now() - 10000; + const digitando = await ctx.db + .query("digitando") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .filter((q) => q.gte(q.field("iniciouEm"), dezSegundosAtras)) + .collect(); + + // Filtrar usuário atual e buscar informações + const digitandoFiltrado = digitando.filter( + (d) => d.usuarioId !== usuarioAtual._id + ); + + const usuarios = await Promise.all( + digitandoFiltrado.map(async (d) => { + const usuario = await ctx.db.get(d.usuarioId); + return usuario; + }) + ); + + return usuarios.filter((u) => u !== null); + }, +}); + +/** + * Conta mensagens não lidas de uma conversa + */ +export const contarNaoLidas = query({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return 0; + + const leitura = await ctx.db + .query("leituras") + .withIndex("by_conversa_usuario", (q) => + q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id) + ) + .first(); + + const mensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .filter((q) => q.eq(q.field("agendadaPara"), undefined)) + .collect(); + + if (leitura) { + return mensagens.filter( + (m) => + m.enviadaEm > (leitura.lidaEm || 0) && + m.remetenteId !== usuarioAtual._id + ).length; + } + + return mensagens.filter((m) => m.remetenteId !== usuarioAtual._id).length; + }, +}); + +// ========== INTERNAL MUTATIONS (para crons) ========== + +/** + * Envia mensagens agendadas (chamado pelo cron) + */ +export const enviarMensagensAgendadas = internalMutation({ + args: {}, + handler: async (ctx) => { + const agora = Date.now(); + + // Buscar mensagens que deveriam ser enviadas + const mensagensAgendadas = await ctx.db + .query("mensagens") + .withIndex("by_agendamento") + .filter((q) => + q.and( + q.neq(q.field("agendadaPara"), undefined), + q.lte(q.field("agendadaPara"), agora) + ) + ) + .collect(); + + for (const mensagem of mensagensAgendadas) { + // Atualizar mensagem para "enviada" + await ctx.db.patch(mensagem._id, { + agendadaPara: undefined, + enviadaEm: agora, + }); + + // Atualizar última mensagem da conversa + const conversa = await ctx.db.get(mensagem.conversaId); + if (conversa) { + await ctx.db.patch(mensagem.conversaId, { + ultimaMensagem: mensagem.conteudo.substring(0, 100), + ultimaMensagemTimestamp: agora, + }); + + // Criar notificações para outros participantes + const remetente = await ctx.db.get(mensagem.remetenteId); + for (const participanteId of conversa.participantes) { + if (participanteId !== mensagem.remetenteId) { + await ctx.db.insert("notificacoes", { + usuarioId: participanteId, + tipo: "nova_mensagem", + conversaId: mensagem.conversaId, + mensagemId: mensagem._id, + remetenteId: mensagem.remetenteId, + titulo: `Nova mensagem de ${remetente?.nome || "Usuário"}`, + descricao: mensagem.conteudo.substring(0, 100), + lida: false, + criadaEm: agora, + }); + } + } + } + } + + return mensagensAgendadas.length; + }, +}); + +/** + * Limpa indicadores de digitação antigos (chamado pelo cron) + */ +export const limparIndicadoresDigitacao = internalMutation({ + args: {}, + handler: async (ctx) => { + const dezSegundosAtras = Date.now() - 10000; + + const indicadoresAntigos = await ctx.db + .query("digitando") + .filter((q) => q.lt(q.field("iniciouEm"), dezSegundosAtras)) + .collect(); + + for (const indicador of indicadoresAntigos) { + await ctx.db.delete(indicador._id); + } + + return indicadoresAntigos.length; + }, +}); diff --git a/packages/backend/convex/configuracaoEmail.ts b/packages/backend/convex/configuracaoEmail.ts index 76acde6..8453f87 100644 --- a/packages/backend/convex/configuracaoEmail.ts +++ b/packages/backend/convex/configuracaoEmail.ts @@ -1,7 +1,8 @@ import { v } from "convex/values"; -import { mutation, query, action } from "./_generated/server"; -import { hashPassword } from "./auth/utils"; +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) @@ -62,8 +63,29 @@ export const salvarConfigEmail = mutation({ return { sucesso: false as const, erro: "Email remetente inválido" }; } - // Criptografar senha - const senhaHash = await hashPassword(args.senha); + // 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" }; + } + + // Buscar config ativa anterior para manter senha se não fornecida + const configAtiva = await ctx.db + .query("configuracaoEmail") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + // Determinar senhaHash: usar nova senha se fornecida, senão manter a atual + let senhaHash: string; + if (args.senha && args.senha.trim().length > 0) { + // Nova senha fornecida, criptografar usando criptografia reversível (AES) + senhaHash = await encryptSMTPPassword(args.senha); + } else if (configAtiva) { + // Senha não fornecida, manter a atual (já criptografada) + senhaHash = configAtiva.senhaHash; + } else { + // Sem senha e sem config existente - erro + return { sucesso: false as const, erro: "Senha é obrigatória para nova configuração" }; + } // Desativar config anterior const configsAntigas = await ctx.db @@ -105,10 +127,23 @@ export const salvarConfigEmail = mutation({ }); /** - * Testar conexão SMTP (action - precisa de Node.js) - * - * NOTA: Esta action será implementada quando instalarmos nodemailer. - * Por enquanto, retorna sucesso simulado para não bloquear o desenvolvimento. + * Mutation interna para atualizar testadoEm + */ +export const atualizarTestadoEm = internalMutation({ + args: { + configId: v.id("configuracaoEmail"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.configId, { + testadoEm: Date.now(), + }); + return null; + }, +}); + +/** + * Testar conexão SMTP (action que chama action real) */ export const testarConexaoSMTP = action({ args: { @@ -123,28 +158,58 @@ export const testarConexaoSMTP = action({ v.object({ sucesso: v.literal(true) }), v.object({ sucesso: v.literal(false), erro: v.string() }) ), - handler: async (ctx, args) => { - // TODO: Implementar teste real com nodemailer - // Por enquanto, simula sucesso - + 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" }; + } + + if (!args.porta || args.porta < 1 || args.porta > 65535) { + return { sucesso: false as const, erro: "Porta inválida. Deve ser entre 1 e 65535" }; + } + + if (!args.usuario || args.usuario.trim().length === 0) { + return { sucesso: false as const, erro: "Usuário não pode estar vazio" }; + } + + if (!args.senha || args.senha.trim().length === 0) { + return { sucesso: false as const, erro: "Senha não pode estar vazia" }; + } + + // Validação de SSL/TLS mutuamente exclusivos + if (args.usarSSL && args.usarTLS) { + return { sucesso: false as const, erro: "SSL e TLS não podem estar habilitados simultaneamente" }; + } + + // Chamar action de teste real (que usa nodemailer) try { - // Validações básicas - if (!args.servidor || args.servidor.trim() === "") { - return { sucesso: false as const, erro: "Servidor SMTP não pode estar vazio" }; + const resultado: { sucesso: true } | { sucesso: false; erro: string } = await ctx.runAction(api.actions.smtp.testarConexao, { + servidor: args.servidor, + porta: args.porta, + usuario: args.usuario, + senha: args.senha, + usarSSL: args.usarSSL, + usarTLS: args.usarTLS, + }); + + // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm + if (resultado.sucesso) { + const configAtiva = await ctx.runQuery(api.configuracaoEmail.obterConfigEmail, {}); + + if (configAtiva) { + await ctx.runMutation(internal.configuracaoEmail.atualizarTestadoEm, { + configId: configAtiva._id, + }); + } } - if (args.porta < 1 || args.porta > 65535) { - return { sucesso: false as const, erro: "Porta inválida" }; - } - - // Simular delay de teste - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Retornar sucesso simulado - console.log("⚠️ AVISO: Teste de conexão SMTP simulado (nodemailer não instalado ainda)"); - return { sucesso: true as const }; - } catch (error: any) { - return { sucesso: false as const, erro: error.message || "Erro ao testar conexão" }; + return resultado; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + sucesso: false as const, + erro: errorMessage || "Erro ao conectar com o servidor SMTP" + }; } }, }); @@ -162,5 +227,3 @@ export const marcarConfigTestada = mutation({ }); }, }); - - diff --git a/packages/backend/convex/criarFuncionarioTeste.ts b/packages/backend/convex/criarFuncionarioTeste.ts deleted file mode 100644 index d70afc4..0000000 --- a/packages/backend/convex/criarFuncionarioTeste.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { v } from "convex/values"; -import { mutation } from "./_generated/server"; - -/** - * Mutation de teste para criar um funcionário e associar ao usuário TI Master - * Isso permite testar o sistema de férias completo - */ -export const criarFuncionarioParaTIMaster = mutation({ - args: { - usuarioEmail: v.string(), // Email do usuário TI Master - }, - returns: v.union( - v.object({ sucesso: v.literal(true), funcionarioId: v.id("funcionarios") }), - v.object({ sucesso: v.literal(false), erro: v.string() }) - ), - handler: async (ctx, args) => { - // Buscar usuário - const usuario = await ctx.db - .query("usuarios") - .withIndex("by_email", (q) => q.eq("email", args.usuarioEmail)) - .first(); - - if (!usuario) { - return { sucesso: false as const, erro: "Usuário não encontrado" }; - } - - // Verificar se já tem funcionário associado - if (usuario.funcionarioId) { - return { sucesso: false as const, erro: "Usuário já tem funcionário associado" }; - } - - // Buscar um símbolo qualquer (pegamos o primeiro) - const simbolo = await ctx.db.query("simbolos").first(); - - if (!simbolo) { - return { sucesso: false as const, erro: "Nenhum símbolo encontrado no sistema" }; - } - - // Criar funcionário de teste - const funcionarioId = await ctx.db.insert("funcionarios", { - nome: usuario.nome, - cpf: "000.000.000-00", // CPF de teste - rg: "0000000", - endereco: "Endereço de Teste", - bairro: "Centro", - cidade: "Recife", - uf: "PE", - telefone: "(81) 99999-9999", - email: usuario.email, - matricula: usuario.matricula, - admissaoData: "2023-01-01", // Data de admissão: 1 ano atrás - simboloId: simbolo._id, - simboloTipo: simbolo.tipo, - statusFerias: "ativo", - - // IMPORTANTE: Definir regime de trabalho - // Altere aqui para testar diferentes regimes: - // - "clt" = CLT (máx 3 períodos, mín 5 dias) - // - "estatutario_pe" = Servidor Público PE (máx 2 períodos, mín 10 dias) - regimeTrabalho: "clt", - - // Dados opcionais - descricaoCargo: "Gestor de TI - Cargo de Teste", - nomePai: "Pai de Teste", - nomeMae: "Mãe de Teste", - naturalidade: "Recife", - naturalidadeUF: "PE", - sexo: "masculino", - estadoCivil: "solteiro", - nacionalidade: "Brasileira", - grauInstrucao: "superior_completo", - tipoSanguineo: "O+", - }); - - // Associar funcionário ao usuário - await ctx.db.patch(usuario._id, { - funcionarioId, - }); - - return { sucesso: true as const, funcionarioId }; - }, -}); - -/** - * Mutation para alterar o regime de trabalho de um funcionário - * Útil para testar diferentes regras (CLT vs Servidor PE) - */ -export const alterarRegimeTrabalho = mutation({ - args: { - funcionarioId: v.id("funcionarios"), - novoRegime: v.union( - v.literal("clt"), - v.literal("estatutario_pe"), - v.literal("estatutario_federal"), - v.literal("estatutario_municipal") - ), - }, - returns: v.object({ sucesso: v.boolean() }), - handler: async (ctx, args) => { - await ctx.db.patch(args.funcionarioId, { - regimeTrabalho: args.novoRegime, - }); - - return { sucesso: true }; - }, -}); - -/** - * Mutation para alterar data de admissão - * Útil para testar diferentes períodos aquisitivos - */ -export const alterarDataAdmissao = mutation({ - args: { - funcionarioId: v.id("funcionarios"), - novaData: v.string(), // Formato: "YYYY-MM-DD" - }, - returns: v.object({ sucesso: v.boolean() }), - handler: async (ctx, args) => { - await ctx.db.patch(args.funcionarioId, { - admissaoData: args.novaData, - }); - - return { sucesso: true }; - }, -}); - - diff --git a/packages/backend/convex/criarUsuarioTeste.ts b/packages/backend/convex/criarUsuarioTeste.ts deleted file mode 100644 index 81a3de4..0000000 --- a/packages/backend/convex/criarUsuarioTeste.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { v } from "convex/values"; -import { mutation } from "./_generated/server"; -import { hashPassword } from "./auth/utils"; - -/** - * Cria um usuário de teste com funcionário associado - * para testar o sistema de férias - */ -export const criarUsuarioParaTesteFerias = mutation({ - args: {}, - returns: v.object({ - sucesso: v.boolean(), - login: v.string(), - senha: v.string(), - mensagem: v.string(), - }), - handler: async (ctx, args) => { - const loginTeste = "teste.ferias"; - const senhaTeste = "Teste@2025"; - const emailTeste = "teste.ferias@sgse.pe.gov.br"; - const nomeTeste = "João Silva (Teste)"; - - // Verificar se já existe - const usuarioExistente = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", loginTeste)) - .first(); - - if (usuarioExistente) { - return { - sucesso: true, - login: loginTeste, - senha: senhaTeste, - mensagem: "Usuário de teste já existe! Use as credenciais abaixo.", - }; - } - - // Buscar role padrão (usuário comum) - const roleUsuario = await ctx.db - .query("roles") - .filter((q) => q.eq(q.field("nome"), "usuario")) - .first(); - - if (!roleUsuario) { - return { - sucesso: false, - login: "", - senha: "", - mensagem: "Erro: Role 'usuario' não encontrada", - }; - } - - // Buscar um símbolo qualquer - const simbolo = await ctx.db.query("simbolos").first(); - - if (!simbolo) { - return { - sucesso: false, - login: "", - senha: "", - mensagem: "Erro: Nenhum símbolo encontrado. Crie um símbolo primeiro.", - }; - } - - // Criar funcionário - const funcionarioId = await ctx.db.insert("funcionarios", { - nome: nomeTeste, - cpf: "111.222.333-44", - rg: "1234567", - nascimento: "1990-05-15", - endereco: "Rua de Teste, 123", - bairro: "Centro", - cidade: "Recife", - uf: "PE", - cep: "50000-000", - telefone: "(81) 98765-4321", - email: emailTeste, - matricula: loginTeste, - admissaoData: "2023-01-15", // Admitido em jan/2023 (quase 2 anos) - simboloId: simbolo._id, - simboloTipo: simbolo.tipo, - statusFerias: "ativo", - regimeTrabalho: "clt", // CLT para testar - descricaoCargo: "Analista Administrativo", - nomePai: "José Silva", - nomeMae: "Maria Silva", - naturalidade: "Recife", - naturalidadeUF: "PE", - sexo: "masculino", - estadoCivil: "solteiro", - nacionalidade: "Brasileira", - grauInstrucao: "superior", - }); - - // Criar usuário - const senhaHash = await hashPassword(senhaTeste); - const usuarioId = await ctx.db.insert("usuarios", { - matricula: loginTeste, - senhaHash, - nome: nomeTeste, - email: emailTeste, - funcionarioId, - roleId: roleUsuario._id, - ativo: true, - primeiroAcesso: false, // Já consideramos que fez primeiro acesso - criadoEm: Date.now(), - atualizadoEm: Date.now(), - }); - - return { - sucesso: true, - login: loginTeste, - senha: senhaTeste, - mensagem: "Usuário de teste criado com sucesso!", - }; - }, -}); - diff --git a/packages/backend/convex/documentos.ts b/packages/backend/convex/documentos.ts index 4115e51..3efa9b2 100644 --- a/packages/backend/convex/documentos.ts +++ b/packages/backend/convex/documentos.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; 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({ @@ -27,7 +28,7 @@ export const updateDocumento = mutation({ // Atualizar o campo específico do documento await ctx.db.patch(args.funcionarioId, { [args.campo]: args.storageId, - } as any); + }); return null; }, @@ -72,9 +73,70 @@ export const getDocumentosUrls = query({ throw new Error("Funcionário não encontrado"); } - // Gerar URLs para todos os documentos - const urls: Record = {}; - const campos = [ + // Tipo exato do retorno para alinhar com o validator + type DocumentUrls = { + certidaoAntecedentesPF: string | null; + certidaoAntecedentesJFPE: string | null; + certidaoAntecedentesSDS: string | null; + certidaoAntecedentesTJPE: string | null; + certidaoImprobidade: string | null; + rgFrente: string | null; + rgVerso: string | null; + cpfFrente: string | null; + cpfVerso: string | null; + situacaoCadastralCPF: string | null; + tituloEleitorFrente: string | null; + tituloEleitorVerso: string | null; + comprovanteVotacao: string | null; + carteiraProfissionalFrente: string | null; + carteiraProfissionalVerso: string | null; + comprovantePIS: string | null; + certidaoRegistroCivil: string | null; + certidaoNascimentoDependentes: string | null; + cpfDependentes: string | null; + reservistaDoc: string | null; + comprovanteEscolaridade: string | null; + comprovanteResidencia: string | null; + comprovanteContaBradesco: string | null; + declaracaoAcumulacaoCargo: string | null; + declaracaoDependentesIR: string | null; + declaracaoIdoneidade: string | null; + termoNepotismo: string | null; + termoOpcaoRemuneracao: string | null; + }; + + const urls: DocumentUrls = { + certidaoAntecedentesPF: null, + certidaoAntecedentesJFPE: null, + certidaoAntecedentesSDS: null, + certidaoAntecedentesTJPE: null, + certidaoImprobidade: null, + rgFrente: null, + rgVerso: null, + cpfFrente: null, + cpfVerso: null, + situacaoCadastralCPF: null, + tituloEleitorFrente: null, + tituloEleitorVerso: null, + comprovanteVotacao: null, + carteiraProfissionalFrente: null, + carteiraProfissionalVerso: null, + comprovantePIS: null, + certidaoRegistroCivil: null, + certidaoNascimentoDependentes: null, + cpfDependentes: null, + reservistaDoc: null, + comprovanteEscolaridade: null, + comprovanteResidencia: null, + comprovanteContaBradesco: null, + declaracaoAcumulacaoCargo: null, + declaracaoDependentesIR: null, + declaracaoIdoneidade: null, + termoNepotismo: null, + termoOpcaoRemuneracao: null, + }; + + const campos: Array = [ "certidaoAntecedentesPF", "certidaoAntecedentesJFPE", "certidaoAntecedentesSDS", @@ -106,7 +168,9 @@ export const getDocumentosUrls = query({ ]; for (const campo of campos) { - const storageId = (funcionario as any)[campo]; + const storageId = (funcionario as Record)[campo as string] as + | Id<"_storage"> + | undefined; if (storageId) { urls[campo] = await ctx.storage.getUrl(storageId); } else { @@ -114,7 +178,7 @@ export const getDocumentosUrls = query({ } } - return urls as any; + return urls; }, }); diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index fa58c37..af8208f 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -6,9 +6,43 @@ import { internalMutation, internalQuery, } from "./_generated/server"; -import { Id } from "./_generated/dataModel"; +import { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; import { renderizarTemplate } from "./templatesMensagens"; -import { internal } from "./_generated/api"; +import { internal, api } from "./_generated/api"; + +// ========== HELPERS ========== + +/** + * Helper function para obter usuário autenticado (Better Auth ou Sessão) + */ +async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise | null> { + // Tentar autenticação via Better Auth primeiro + const identity = await ctx.auth.getUserIdentity(); + let usuarioAtual: Doc<"usuarios"> | null = null; + + if (identity && identity.email) { + usuarioAtual = await ctx.db + .query("usuarios") + .withIndex("by_email", (q) => q.eq("email", identity.email!)) + .first(); + } + + // Se não encontrou via Better Auth, tentar via sessão mais recente + 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; +} /** * Enfileirar email para envio @@ -21,6 +55,7 @@ export const enfileirarEmail = mutation({ corpo: v.string(), templateId: v.optional(v.id("templatesMensagens")), enviadoPorId: v.id("usuarios"), + agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento }, returns: v.object({ sucesso: v.boolean(), @@ -33,6 +68,13 @@ export const enfileirarEmail = mutation({ return { sucesso: false }; } + // Validar agendamento se fornecido + if (args.agendadaPara !== undefined) { + if (args.agendadaPara <= Date.now()) { + return { sucesso: false }; + } + } + // Adicionar à fila const emailId = await ctx.db.insert("notificacoesEmail", { destinatario: args.destinatario, @@ -44,8 +86,23 @@ export const enfileirarEmail = mutation({ tentativas: 0, enviadoPor: args.enviadoPorId, criadoEm: Date.now(), + agendadaPara: args.agendadaPara, }); + // Agendar envio + if (args.agendadaPara !== undefined) { + // Agendar para o momento especificado + const delayMs = args.agendadaPara - Date.now(); + await ctx.scheduler.runAfter(delayMs, api.actions.email.enviar, { + emailId, + }); + } else { + // Envio imediato + await ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId, + }); + } + return { sucesso: true, emailId }; }, }); @@ -58,8 +115,9 @@ export const enviarEmailComTemplate = mutation({ destinatario: v.string(), destinatarioId: v.optional(v.id("usuarios")), templateCodigo: v.string(), - variaveis: v.any(), // Record + variaveis: v.record(v.string(), v.string()), enviadoPorId: v.id("usuarios"), + agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento }, returns: v.object({ sucesso: v.boolean(), @@ -77,6 +135,13 @@ export const enviarEmailComTemplate = mutation({ return { sucesso: false }; } + // Validar agendamento se fornecido + if (args.agendadaPara !== undefined) { + if (args.agendadaPara <= Date.now()) { + return { sucesso: false }; + } + } + // Renderizar template const assunto = renderizarTemplate(template.titulo, args.variaveis); const corpo = renderizarTemplate(template.corpo, args.variaveis); @@ -92,8 +157,23 @@ export const enviarEmailComTemplate = mutation({ tentativas: 0, enviadoPor: args.enviadoPorId, criadoEm: Date.now(), + agendadaPara: args.agendadaPara, }); + // Agendar envio + if (args.agendadaPara !== undefined) { + // Agendar para o momento especificado + const delayMs = args.agendadaPara - Date.now(); + await ctx.scheduler.runAfter(delayMs, api.actions.email.enviar, { + emailId, + }); + } else { + // Envio imediato + await ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId, + }); + } + return { sucesso: true, emailId }; }, }); @@ -113,7 +193,7 @@ export const listarFilaEmails = query({ ), limite: v.optional(v.number()), }, - returns: v.array(v.any()), + // Tipo inferido automaticamente pelo Convex handler: async (ctx, args) => { if (args.status) { const emails = await ctx.db @@ -140,11 +220,21 @@ export const reenviarEmail = mutation({ args: { emailId: v.id("notificacoesEmail"), }, - returns: v.object({ sucesso: v.boolean() }), - handler: async (ctx, args) => { + returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), + handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => { const email = await ctx.db.get(args.emailId); if (!email) { - return { sucesso: false }; + return { sucesso: false, erro: "Email não encontrado" }; + } + + // Verificar se o email não foi enviado com sucesso ainda + if (email.status === "enviado") { + return { sucesso: false, erro: "Este email já foi enviado com sucesso" }; + } + + // Verificar se ainda não excedeu o limite de tentativas (max 3) + if ((email.tentativas || 0) >= 3 && email.status !== "falha") { + return { sucesso: false, erro: "Número máximo de tentativas excedido. Crie um novo email." }; } // Resetar status para pendente @@ -155,6 +245,57 @@ export const reenviarEmail = mutation({ erroDetalhes: undefined, }); + // Agendar envio imediato + await ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId: args.emailId, + }); + + return { sucesso: true }; + }, +}); + +/** + * Cancelar agendamento de email + */ +export const cancelarAgendamentoEmail = mutation({ + args: { + emailId: v.id("notificacoesEmail"), + }, + returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), + handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return { sucesso: false, erro: "Usuário não autenticado" }; + } + + const email = await ctx.db.get(args.emailId); + if (!email) { + return { sucesso: false, erro: "Email não encontrado" }; + } + + // Verificar se o email pertence ao usuário atual + if (email.enviadoPor !== usuarioAtual._id) { + return { sucesso: false, erro: "Você não tem permissão para cancelar este agendamento" }; + } + + // Verificar se o email está agendado + if (!email.agendadaPara) { + return { sucesso: false, erro: "Este email não está agendado" }; + } + + // Verificar se ainda não foi enviado + if (email.status === "enviado") { + return { sucesso: false, erro: "Este email já foi enviado" }; + } + + // Verificar se já passou a data de agendamento + if (email.agendadaPara <= Date.now()) { + return { sucesso: false, erro: "A data de agendamento já passou" }; + } + + // Deletar o email agendado + await ctx.db.delete(args.emailId); + return { sucesso: true }; }, }); @@ -166,15 +307,137 @@ export const reenviarEmail = mutation({ */ export const getEmailById = internalQuery({ args: { emailId: v.id("notificacoesEmail") }, - returns: v.union(v.any(), v.null()), + // Tipo inferido automaticamente pelo Convex handler: async (ctx, args) => { return await ctx.db.get(args.emailId); }, }); +/** + * Buscar emails por IDs (query pública) + */ +export const buscarEmailsPorIds = query({ + args: { + emailIds: v.array(v.id("notificacoesEmail")), + }, + handler: async (ctx, args): Promise[]> => { + const emails: Doc<"notificacoesEmail">[] = []; + for (const emailId of args.emailIds) { + const email = await ctx.db.get(emailId); + if (email) { + emails.push(email); + } + } + return emails; + }, +}); + +/** + * Obter estatísticas da fila de emails + */ +export const obterEstatisticasFilaEmails = query({ + args: {}, + returns: v.object({ + total: v.number(), + pendentes: v.number(), + enviando: v.number(), + enviados: v.number(), + falhas: v.number(), + comErro: v.number(), + ultimaExecucaoCron: v.optional(v.number()), + }), + handler: async (ctx) => { + const todosEmails = await ctx.db + .query("notificacoesEmail") + .collect(); + + const estatisticas = { + total: todosEmails.length, + pendentes: 0, + enviando: 0, + enviados: 0, + falhas: 0, + comErro: 0, + }; + + for (const email of todosEmails) { + switch (email.status) { + case "pendente": + estatisticas.pendentes++; + break; + case "enviando": + estatisticas.enviando++; + break; + case "enviado": + estatisticas.enviados++; + break; + case "falha": + estatisticas.falhas++; + if (email.erroDetalhes) { + estatisticas.comErro++; + } + break; + } + } + + return estatisticas; + }, +}); + +/** + * Listar agendamentos de email do usuário atual + */ +export const listarAgendamentosEmail = query({ + args: {}, + handler: async (ctx): Promise & { destinatarioInfo: Doc<"usuarios"> | null; templateInfo: Doc<"templatesMensagens"> | null }>> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return []; + } + + // Buscar todos os emails do usuário + const todosEmails = await ctx.db + .query("notificacoesEmail") + .withIndex("by_enviado_por", (q) => q.eq("enviadoPor", usuarioAtual._id)) + .collect(); + + // Filtrar apenas os que têm agendamento (passados ou futuros) + const emailsAgendados = todosEmails.filter((email) => email.agendadaPara !== undefined); + + // Enriquecer com informações do destinatário e template + const emailsEnriquecidos = await Promise.all( + emailsAgendados.map(async (email) => { + let destinatarioInfo: Doc<"usuarios"> | null = null; + let templateInfo: Doc<"templatesMensagens"> | null = null; + + if (email.destinatarioId) { + destinatarioInfo = await ctx.db.get(email.destinatarioId); + } + + if (email.templateId) { + templateInfo = await ctx.db.get(email.templateId); + } + + return { + ...email, + destinatarioInfo, + templateInfo, + }; + }) + ); + + // Ordenar por data de agendamento (mais próximos primeiro) + return emailsEnriquecidos.sort((a, b) => { + const dataA = a.agendadaPara ?? 0; + const dataB = b.agendadaPara ?? 0; + return dataA - dataB; + }); + }, +}); + export const getActiveEmailConfig = internalQuery({ args: {}, - returns: v.union(v.any(), v.null()), + // Tipo inferido automaticamente pelo Convex handler: async (ctx) => { return await ctx.db .query("configuracaoEmail") @@ -183,14 +446,39 @@ export const getActiveEmailConfig = internalQuery({ }, }); +// Query interna para obter configuração com senha descriptografada +export const getActiveEmailConfigWithPassword = internalQuery({ + args: {}, + handler: async (ctx) => { + const { decryptSMTPPassword } = await import("./auth/utils"); + const config = await ctx.db + .query("configuracaoEmail") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + if (!config) { + return null; + } + + // Descriptografar senha + const senhaDescriptografada = await decryptSMTPPassword(config.senhaHash); + + return { + ...config, + senha: senhaDescriptografada, + }; + }, +}); + export const markEmailEnviando = internalMutation({ args: { emailId: v.id("notificacoesEmail") }, returns: v.null(), handler: async (ctx, args) => { const email = await ctx.db.get(args.emailId); + if (!email) return null; await ctx.db.patch(args.emailId, { status: "enviando", - tentativas: ((email as any)?.tentativas || 0) + 1, + tentativas: (email.tentativas || 0) + 1, ultimaTentativa: Date.now(), }); return null; @@ -214,121 +502,40 @@ export const markEmailFalha = internalMutation({ returns: v.null(), handler: async (ctx, args) => { const email = await ctx.db.get(args.emailId); + if (!email) return null; await ctx.db.patch(args.emailId, { status: "falha", erroDetalhes: args.erro, - tentativas: ((email as any)?.tentativas || 0) + 1, + tentativas: (email.tentativas || 0) + 1, }); return null; }, }); -export const enviarEmailAction = action({ - args: { - emailId: v.id("notificacoesEmail"), - }, - returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), - handler: async (ctx, args) => { - "use node"; - // eslint-disable-next-line @typescript-eslint/no-var-requires - const nodemailer = require("nodemailer"); - - try { - // Buscar email da fila - const email = await ctx.runQuery(internal.email.getEmailById, { - emailId: args.emailId, - }); - - if (!email) { - return { sucesso: false, erro: "Email não encontrado" }; - } - - // Buscar configuração SMTP - const config = await ctx.runQuery( - internal.email.getActiveEmailConfig, - {} - ); - - if (!config) { - return { - sucesso: false, - erro: "Configuração de email não encontrada ou inativa", - }; - } - - if (!config.testadoEm) { - return { - sucesso: false, - erro: "Configuração SMTP não foi testada. Teste a conexão primeiro!", - }; - } - - // Marcar como enviando - await ctx.runMutation(internal.email.markEmailEnviando, { - emailId: args.emailId, - }); - - // Criar transporter do nodemailer - const transporter = nodemailer.createTransport({ - host: (config as any).smtpHost, - port: (config as any).smtpPort, - secure: (config as any).smtpSecure, - auth: { - user: (config as any).smtpUser, - pass: (config as any).smtpPassword, - }, - tls: { - rejectUnauthorized: false, - }, - }); - - // Enviar email REAL - const info = await transporter.sendMail({ - from: `"${(config as any).remetenteNome}" <${(config as any).remetenteEmail}>`, - to: (email as any).destinatario, - subject: (email as any).assunto, - html: (email as any).corpo, - }); - - console.log("✅ Email enviado com sucesso!"); - console.log(" Para:", (email as any).destinatario); - console.log(" Assunto:", (email as any).assunto); - console.log(" Message ID:", info.messageId); - - // Marcar como enviado - await ctx.runMutation(internal.email.markEmailEnviado, { - emailId: args.emailId, - }); - - return { sucesso: true }; - } catch (error: any) { - console.error("❌ Erro ao enviar email:", error.message); - - // Marcar como falha - await ctx.runMutation(internal.email.markEmailFalha, { - emailId: args.emailId, - erro: error.message || "Erro ao enviar email", - }); - - return { sucesso: false, erro: error.message || "Erro ao enviar email" }; - } - }, -}); +// Action de envio foi movida para `actions/email.ts` /** * Processar fila de emails (cron job - processa emails pendentes) */ export const processarFilaEmails = internalMutation({ args: {}, - returns: v.object({ processados: v.number() }), + returns: v.object({ processados: v.number(), falhas: v.number() }), handler: async (ctx) => { - // Buscar emails pendentes (max 10 por execução) + // Buscar emails pendentes que não estão agendados para o futuro (max 10 por execução) + const agora = Date.now(); const emailsPendentes = await ctx.db .query("notificacoesEmail") .withIndex("by_status", (q) => q.eq("status", "pendente")) + .filter((q) => + q.or( + q.eq(q.field("agendadaPara"), undefined), + q.lte(q.field("agendadaPara"), agora) + ) + ) .take(10); let processados = 0; + let falhas = 0; for (const email of emailsPendentes) { // Verificar se não excedeu tentativas (max 3) @@ -337,23 +544,115 @@ export const processarFilaEmails = internalMutation({ status: "falha", erroDetalhes: "Número máximo de tentativas excedido", }); + falhas++; continue; } // Agendar envio via action - // IMPORTANTE: Não podemos chamar action diretamente de mutation - // Por isso, usaremos o scheduler - await ctx.scheduler.runAfter(0, "email:enviarEmailAction" as any, { - emailId: email._id, - }); - - processados++; + try { + await ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId: email._id, + }); + processados++; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Erro ao agendar email ${email._id}:`, errorMessage); + await ctx.db.patch(email._id, { + status: "falha", + erroDetalhes: `Erro ao agendar envio: ${errorMessage}`, + tentativas: (email.tentativas || 0) + 1, + }); + falhas++; + } } - console.log( - `📧 Fila de emails processada: ${processados} emails agendados para envio` - ); + if (processados > 0 || falhas > 0) { + console.log( + `📧 Fila de emails processada: ${processados} emails agendados, ${falhas} falhas` + ); + } - return { processados }; + return { processados, falhas }; + }, +}); + +/** + * Processar fila de emails manualmente (para testes e envio imediato) + */ +export const processarFilaEmailsManual = mutation({ + args: { + limite: v.optional(v.number()), + }, + returns: v.object({ + sucesso: v.boolean(), + processados: v.number(), + falhas: v.number(), + erro: v.optional(v.string()) + }), + handler: async (ctx, args): Promise<{ + sucesso: boolean; + processados: number; + falhas: number; + erro?: string + }> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return { sucesso: false, processados: 0, falhas: 0, erro: "Usuário não autenticado" }; + } + + // Verificar se usuário tem permissão (TI_MASTER ou admin) + const role = await ctx.db.get(usuarioAtual.roleId); + if (!role || (role.nivel !== 0 && role.nivel !== 1)) { + return { sucesso: false, processados: 0, falhas: 0, erro: "Permissão negada" }; + } + + const limite = args.limite || 10; + const agora = Date.now(); + + // Buscar emails pendentes que não estão agendados para o futuro + const emailsPendentes = await ctx.db + .query("notificacoesEmail") + .withIndex("by_status", (q) => q.eq("status", "pendente")) + .filter((q) => + q.or( + q.eq(q.field("agendadaPara"), undefined), + q.lte(q.field("agendadaPara"), agora) + ) + ) + .take(limite); + + let processados = 0; + let falhas = 0; + + for (const email of emailsPendentes) { + // Verificar se não excedeu tentativas (max 3) + if ((email.tentativas || 0) >= 3) { + await ctx.db.patch(email._id, { + status: "falha", + erroDetalhes: "Número máximo de tentativas excedido", + }); + falhas++; + continue; + } + + // Agendar envio via action + try { + await ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId: email._id, + }); + processados++; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Erro ao agendar email ${email._id}:`, errorMessage); + await ctx.db.patch(email._id, { + status: "falha", + erroDetalhes: `Erro ao agendar envio: ${errorMessage}`, + tentativas: (email.tentativas || 0) + 1, + }); + falhas++; + } + } + + return { sucesso: true, processados, falhas }; }, }); diff --git a/packages/backend/convex/ferias.ts b/packages/backend/convex/ferias.ts index 5c0437d..d08633f 100644 --- a/packages/backend/convex/ferias.ts +++ b/packages/backend/convex/ferias.ts @@ -1,7 +1,7 @@ import { v } from "convex/values"; import { mutation, query, internalMutation } from "./_generated/server"; import { internal } from "./_generated/api"; -import { Id } from "./_generated/dataModel"; +import { Id, Doc } from "./_generated/dataModel"; // Validador para períodos const periodoValidator = v.object({ @@ -11,28 +11,30 @@ const periodoValidator = v.object({ }); // Query: Listar TODAS as solicitações (para RH) +// Retorna tipo inferido automaticamente pelo Convex export const listarTodas = query({ args: {}, - returns: v.array(v.any()), handler: async (ctx) => { const solicitacoes = await ctx.db.query("solicitacoesFerias").collect(); - + const solicitacoesComDetalhes = await Promise.all( solicitacoes.map(async (s) => { const funcionario = await ctx.db.get(s.funcionarioId); - + // Buscar time do funcionário const membroTime = await ctx.db .query("timesMembros") - .withIndex("by_funcionario", (q) => q.eq("funcionarioId", s.funcionarioId)) + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", s.funcionarioId) + ) .filter((q) => q.eq(q.field("ativo"), true)) .first(); - + let time = null; if (membroTime) { time = await ctx.db.get(membroTime.timeId); } - + return { ...s, funcionario, @@ -40,28 +42,61 @@ export const listarTodas = query({ }; }) ); - - return solicitacoesComDetalhes.sort((a, b) => b._creationTime - a._creationTime); + + return solicitacoesComDetalhes.sort( + (a, b) => b._creationTime - a._creationTime + ); }, }); // Query: Listar solicitações do funcionário export const listarMinhasSolicitacoes = query({ args: { funcionarioId: v.id("funcionarios") }, - returns: v.array(v.any()), + // returns não especificado - TypeScript inferirá automaticamente o tipo correto handler: async (ctx, args) => { - return await ctx.db + const solicitacoes = await ctx.db .query("solicitacoesFerias") - .withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId)) + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", args.funcionarioId) + ) .order("desc") .collect(); + + // Enriquecer com dados do funcionário e time + const solicitacoesComDetalhes = await Promise.all( + solicitacoes.map(async (s) => { + const funcionario = await ctx.db.get(s.funcionarioId); + + // Buscar time do funcionário + const membroTime = await ctx.db + .query("timesMembros") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", s.funcionarioId) + ) + .filter((q) => q.eq(q.field("ativo"), true)) + .first(); + + let time = null; + if (membroTime) { + time = await ctx.db.get(membroTime.timeId); + } + + return { + ...s, + funcionario, + time, + }; + }) + ); + + return solicitacoesComDetalhes; }, }); // Query: Listar solicitações dos subordinados (para gestores) +// Retorna tipo inferido automaticamente pelo Convex export const listarSolicitacoesSubordinados = query({ args: { gestorId: v.id("usuarios") }, - returns: v.array(v.any()), handler: async (ctx, args) => { // Buscar times onde o usuário é gestor const timesGestor = await ctx.db @@ -69,23 +104,30 @@ export const listarSolicitacoesSubordinados = query({ .withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId)) .filter((q) => q.eq(q.field("ativo"), true)) .collect(); - - const solicitacoes: Array = []; - + + const solicitacoes: Array & { + funcionario: Doc<"funcionarios"> | null; + time: Doc<"times"> | null; + }> = []; + for (const time of timesGestor) { // Buscar membros do time const membros = await ctx.db .query("timesMembros") - .withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true)) + .withIndex("by_time_and_ativo", (q) => + q.eq("timeId", time._id).eq("ativo", true) + ) .collect(); - + // Buscar solicitações de cada membro for (const membro of membros) { const solic = await ctx.db .query("solicitacoesFerias") - .withIndex("by_funcionario", (q) => q.eq("funcionarioId", membro.funcionarioId)) + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", membro.funcionarioId) + ) .collect(); - + // Adicionar info do funcionário for (const s of solic) { const funcionario = await ctx.db.get(s.funcionarioId); @@ -97,25 +139,25 @@ export const listarSolicitacoesSubordinados = query({ } } } - + return solicitacoes.sort((a, b) => b._creationTime - a._creationTime); }, }); // Query: Obter detalhes completos de uma solicitação +// Retorna tipo inferido automaticamente pelo Convex export const obterDetalhes = query({ args: { solicitacaoId: v.id("solicitacoesFerias") }, - returns: v.union(v.any(), v.null()), handler: async (ctx, args) => { const solicitacao = await ctx.db.get(args.solicitacaoId); if (!solicitacao) return null; - + const funcionario = await ctx.db.get(solicitacao.funcionarioId); let gestor = null; if (solicitacao.gestorId) { gestor = await ctx.db.get(solicitacao.gestorId); } - + return { ...solicitacao, funcionario, @@ -137,42 +179,46 @@ export const criarSolicitacao = mutation({ if (args.periodos.length === 0) { throw new Error("É necessário adicionar pelo menos 1 período"); } - + const funcionario = await ctx.db.get(args.funcionarioId); if (!funcionario) throw new Error("Funcionário não encontrado"); - + // Calcular total de dias let totalDias = 0; for (const p of args.periodos) { totalDias += p.diasCorridos; } - + // Reservar dias no saldo (impede uso duplo) await ctx.runMutation(internal.saldoFerias.reservarDias, { funcionarioId: args.funcionarioId, anoReferencia: args.anoReferencia, totalDias, }); - + // Buscar usuário que está criando (pode não ser o próprio funcionário) const usuario = await ctx.db .query("usuarios") - .withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", args.funcionarioId)) + .withIndex("by_funcionarioId", (q) => + q.eq("funcionarioId", args.funcionarioId) + ) .first(); - + const solicitacaoId = await ctx.db.insert("solicitacoesFerias", { funcionarioId: args.funcionarioId, anoReferencia: args.anoReferencia, status: "aguardando_aprovacao", periodos: args.periodos, observacao: args.observacao, - historicoAlteracoes: [{ - data: Date.now(), - usuarioId: usuario?._id || funcionario.gestorId!, - acao: "Solicitação criada", - }], + historicoAlteracoes: [ + { + data: Date.now(), + usuarioId: usuario?._id || funcionario.gestorId!, + acao: "Solicitação criada", + }, + ], }); - + // Notificar gestor if (funcionario.gestorId) { await ctx.db.insert("notificacoesFerias", { @@ -183,7 +229,7 @@ export const criarSolicitacao = mutation({ mensagem: `${funcionario.nome} solicitou férias`, }); } - + return solicitacaoId; }, }); @@ -198,13 +244,13 @@ export const aprovar = mutation({ handler: async (ctx, args) => { const solicitacao = await ctx.db.get(args.solicitacaoId); if (!solicitacao) throw new Error("Solicitação não encontrada"); - + if (solicitacao.status !== "aguardando_aprovacao") { throw new Error("Esta solicitação já foi processada"); } - + const funcionario = await ctx.db.get(solicitacao.funcionarioId); - + await ctx.db.patch(args.solicitacaoId, { status: "aprovado", gestorId: args.gestorId, @@ -218,19 +264,21 @@ export const aprovar = mutation({ }, ], }); - + // Atualizar saldo (de pendente para usado) await ctx.runMutation(internal.saldoFerias.atualizarSaldoAposAprovacao, { solicitacaoId: args.solicitacaoId, }); - + // Notificar funcionário if (funcionario) { const usuario = await ctx.db .query("usuarios") - .withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id)) + .withIndex("by_funcionarioId", (q) => + q.eq("funcionarioId", funcionario._id) + ) .first(); - + if (usuario) { await ctx.db.insert("notificacoesFerias", { destinatarioId: usuario._id, @@ -241,7 +289,7 @@ export const aprovar = mutation({ }); } } - + return null; }, }); @@ -257,13 +305,13 @@ export const reprovar = mutation({ handler: async (ctx, args) => { const solicitacao = await ctx.db.get(args.solicitacaoId); if (!solicitacao) throw new Error("Solicitação não encontrada"); - + if (solicitacao.status !== "aguardando_aprovacao") { throw new Error("Esta solicitação já foi processada"); } - + const funcionario = await ctx.db.get(solicitacao.funcionarioId); - + await ctx.db.patch(args.solicitacaoId, { status: "reprovado", gestorId: args.gestorId, @@ -278,19 +326,21 @@ export const reprovar = mutation({ }, ], }); - + // Liberar dias reservados de volta ao saldo await ctx.runMutation(internal.saldoFerias.liberarDias, { solicitacaoId: args.solicitacaoId, }); - + // Notificar funcionário if (funcionario) { const usuario = await ctx.db .query("usuarios") - .withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id)) + .withIndex("by_funcionarioId", (q) => + q.eq("funcionarioId", funcionario._id) + ) .first(); - + if (usuario) { await ctx.db.insert("notificacoesFerias", { destinatarioId: usuario._id, @@ -301,7 +351,7 @@ export const reprovar = mutation({ }); } } - + return null; }, }); @@ -317,34 +367,34 @@ export const ajustarEAprovar = mutation({ handler: async (ctx, args) => { const solicitacao = await ctx.db.get(args.solicitacaoId); if (!solicitacao) throw new Error("Solicitação não encontrada"); - + if (solicitacao.status !== "aguardando_aprovacao") { throw new Error("Esta solicitação já foi processada"); } - + if (args.novosPeriodos.length === 0) { throw new Error("É necessário adicionar pelo menos 1 período"); } - + const funcionario = await ctx.db.get(solicitacao.funcionarioId); - + // Liberar dias antigos await ctx.runMutation(internal.saldoFerias.liberarDias, { solicitacaoId: args.solicitacaoId, }); - + // Calcular novos dias e reservar let totalNovosDias = 0; for (const p of args.novosPeriodos) { totalNovosDias += p.diasCorridos; } - + await ctx.runMutation(internal.saldoFerias.reservarDias, { funcionarioId: solicitacao.funcionarioId, anoReferencia: solicitacao.anoReferencia, totalDias: totalNovosDias, }); - + await ctx.db.patch(args.solicitacaoId, { status: "data_ajustada_aprovada", periodos: args.novosPeriodos, @@ -360,19 +410,21 @@ export const ajustarEAprovar = mutation({ }, ], }); - + // Atualizar saldo (marcar como usado) await ctx.runMutation(internal.saldoFerias.atualizarSaldoAposAprovacao, { solicitacaoId: args.solicitacaoId, }); - + // Notificar funcionário if (funcionario) { const usuario = await ctx.db .query("usuarios") - .withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id)) + .withIndex("by_funcionarioId", (q) => + q.eq("funcionarioId", funcionario._id) + ) .first(); - + if (usuario) { await ctx.db.insert("notificacoesFerias", { destinatarioId: usuario._id, @@ -383,7 +435,7 @@ export const ajustarEAprovar = mutation({ }); } } - + return null; }, }); @@ -395,38 +447,41 @@ export const verificarStatusFerias = query({ handler: async (ctx, args) => { const hoje = new Date(); hoje.setHours(0, 0, 0, 0); - + const solicitacoesAprovadas = await ctx.db .query("solicitacoesFerias") .withIndex("by_funcionario_and_status", (q) => - q.eq("funcionarioId", args.funcionarioId) - .eq("status", "aprovado") + q.eq("funcionarioId", args.funcionarioId).eq("status", "aprovado") ) .collect(); - + const solicitacoesAjustadas = await ctx.db .query("solicitacoesFerias") .withIndex("by_funcionario_and_status", (q) => - q.eq("funcionarioId", args.funcionarioId) + q + .eq("funcionarioId", args.funcionarioId) .eq("status", "data_ajustada_aprovada") ) .collect(); - - const todasSolicitacoes = [...solicitacoesAprovadas, ...solicitacoesAjustadas]; - + + const todasSolicitacoes = [ + ...solicitacoesAprovadas, + ...solicitacoesAjustadas, + ]; + for (const solicitacao of todasSolicitacoes) { for (const periodo of solicitacao.periodos) { const inicio = new Date(periodo.dataInicio); const fim = new Date(periodo.dataFim); inicio.setHours(0, 0, 0, 0); fim.setHours(23, 59, 59, 999); - + if (hoje >= inicio && hoje <= fim) { return "em_ferias"; } } } - + return "ativo"; }, }); @@ -434,7 +489,6 @@ export const verificarStatusFerias = query({ // Query: Obter notificações não lidas export const obterNotificacoesNaoLidas = query({ args: { usuarioId: v.id("usuarios") }, - returns: v.array(v.any()), handler: async (ctx, args) => { return await ctx.db .query("notificacoesFerias") @@ -461,29 +515,30 @@ export const atualizarStatusTodosFuncionarios = internalMutation({ returns: v.null(), handler: async (ctx) => { const funcionarios = await ctx.db.query("funcionarios").collect(); - + for (const func of funcionarios) { const hoje = new Date(); hoje.setHours(0, 0, 0, 0); - + const solicitacoesAprovadas = await ctx.db .query("solicitacoesFerias") .withIndex("by_funcionario_and_status", (q) => - q.eq("funcionarioId", func._id) - .eq("status", "aprovado") + q.eq("funcionarioId", func._id).eq("status", "aprovado") ) .collect(); - + const solicitacoesAjustadas = await ctx.db .query("solicitacoesFerias") .withIndex("by_funcionario_and_status", (q) => - q.eq("funcionarioId", func._id) - .eq("status", "data_ajustada_aprovada") + q.eq("funcionarioId", func._id).eq("status", "data_ajustada_aprovada") ) .collect(); - - const todasSolicitacoes = [...solicitacoesAprovadas, ...solicitacoesAjustadas]; - + + const todasSolicitacoes = [ + ...solicitacoesAprovadas, + ...solicitacoesAjustadas, + ]; + let emFerias = false; for (const solicitacao of todasSolicitacoes) { for (const periodo of solicitacao.periodos) { @@ -491,7 +546,7 @@ export const atualizarStatusTodosFuncionarios = internalMutation({ const fim = new Date(periodo.dataFim); inicio.setHours(0, 0, 0, 0); fim.setHours(23, 59, 59, 999); - + if (hoje >= inicio && hoje <= fim) { emFerias = true; break; @@ -499,15 +554,14 @@ export const atualizarStatusTodosFuncionarios = internalMutation({ } if (emFerias) break; } - + const novoStatus = emFerias ? "em_ferias" : "ativo"; - + if (func.statusFerias !== novoStatus) { await ctx.db.patch(func._id, { statusFerias: novoStatus }); } } - + return null; }, }); - diff --git a/packages/backend/convex/funcionarios.ts b/packages/backend/convex/funcionarios.ts index 6d6a7ad..47761f6 100644 --- a/packages/backend/convex/funcionarios.ts +++ b/packages/backend/convex/funcionarios.ts @@ -38,7 +38,6 @@ const aposentadoValidator = v.optional( export const getAll = query({ args: {}, - returns: v.array(v.any()), handler: async (ctx) => { // Autorização: listar funcionários await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { @@ -47,9 +46,8 @@ export const getAll = query({ }); const funcionarios = await ctx.db.query("funcionarios").collect(); // Retornar apenas os campos necessários para listagem - return funcionarios.map((f: any) => ({ + return funcionarios.map((f) => ({ _id: f._id, - _creationTime: f._creationTime, nome: f.nome, matricula: f.matricula, cpf: f.cpf, @@ -65,13 +63,14 @@ export const getAll = query({ simboloTipo: f.simboloTipo, admissaoData: f.admissaoData, desligamentoData: f.desligamentoData, + descricaoCargo: f.descricaoCargo, })); }, }); export const getById = query({ args: { id: v.id("funcionarios") }, - returns: v.union(v.any(), v.null()), + // Tipo inferido automaticamente pelo Convex handler: async (ctx, args) => { // Autorização: ver funcionário await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { @@ -175,6 +174,25 @@ export const create = mutation({ declaracaoIdoneidade: v.optional(v.id("_storage")), termoNepotismo: v.optional(v.id("_storage")), termoOpcaoRemuneracao: v.optional(v.id("_storage")), + // Dependentes (opcional) + dependentes: v.optional( + v.array( + v.object({ + parentesco: v.union( + v.literal("filho"), + v.literal("filha"), + v.literal("conjuge"), + v.literal("outro") + ), + nome: v.string(), + cpf: v.string(), + nascimento: v.string(), + documentoId: v.optional(v.id("_storage")), + salarioFamilia: v.optional(v.boolean()), + impostoRenda: v.optional(v.boolean()), + }) + ) + ), }, returns: v.id("funcionarios"), handler: async (ctx, args) => { @@ -199,11 +217,13 @@ export const create = mutation({ .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) .unique(); if (matriculaExists) { - throw new Error("Já existe um funcionário com esta matrícula. Por favor, use outra ou deixe em branco."); + throw new Error( + "Já existe um funcionário com esta matrícula. Por favor, use outra ou deixe em branco." + ); } } - const novoFuncionarioId = await ctx.db.insert("funcionarios", args as any); + const novoFuncionarioId = await ctx.db.insert("funcionarios", args); return novoFuncionarioId; }, }); @@ -302,6 +322,25 @@ export const update = mutation({ declaracaoIdoneidade: v.optional(v.id("_storage")), termoNepotismo: v.optional(v.id("_storage")), termoOpcaoRemuneracao: v.optional(v.id("_storage")), + // Dependentes (opcional) + dependentes: v.optional( + v.array( + v.object({ + parentesco: v.union( + v.literal("filho"), + v.literal("filha"), + v.literal("conjuge"), + v.literal("outro") + ), + nome: v.string(), + cpf: v.string(), + nascimento: v.string(), + documentoId: v.optional(v.id("_storage")), + salarioFamilia: v.optional(v.boolean()), + impostoRenda: v.optional(v.boolean()), + }) + ) + ), }, returns: v.null(), handler: async (ctx, args) => { @@ -326,12 +365,14 @@ export const update = mutation({ .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) .unique(); if (matriculaExists && matriculaExists._id !== args.id) { - throw new Error("Já existe um funcionário com esta matrícula. Por favor, use outra ou deixe em branco."); + throw new Error( + "Já existe um funcionário com esta matrícula. Por favor, use outra ou deixe em branco." + ); } } const { id, ...updateData } = args; - await ctx.db.patch(id, updateData as any); + await ctx.db.patch(id, updateData); return null; }, }); @@ -354,7 +395,7 @@ export const remove = mutation({ // Query para obter ficha completa para impressão export const getFichaCompleta = query({ args: { id: v.id("funcionarios") }, - returns: v.union(v.any(), v.null()), + // Tipo inferido automaticamente pelo Convex handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: "funcionarios", @@ -394,11 +435,10 @@ export const getFichaCompleta = query({ ? { nome: simbolo.nome, descricao: simbolo.descricao, - // campos adicionais, se existirem no símbolo - tipo: (simbolo as any).tipo, - vencValor: (simbolo as any).vencValor, - repValor: (simbolo as any).repValor, - valor: (simbolo as any).valor, + tipo: simbolo.tipo, + vencValor: simbolo.vencValor, + repValor: simbolo.repValor, + valor: simbolo.valor, } : null, cursos: cursosComUrls, 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/limparPerfisAntigos.ts b/packages/backend/convex/limparPerfisAntigos.ts index 9ed3797..9e7d53d 100644 --- a/packages/backend/convex/limparPerfisAntigos.ts +++ b/packages/backend/convex/limparPerfisAntigos.ts @@ -190,15 +190,6 @@ export const limparPerfisAntigos = internalMutation({ await ctx.db.delete(perm._id); } - // Remover menu permissões associadas - const menuPerms = await ctx.db - .query("menuPermissoes") - .withIndex("by_role", (q) => q.eq("roleId", role._id)) - .collect(); - for (const menuPerm of menuPerms) { - await ctx.db.delete(menuPerm._id); - } - // Remover o role await ctx.db.delete(role._id); diff --git a/packages/backend/convex/logsAcesso.ts b/packages/backend/convex/logsAcesso.ts index 70e6421..bfa1d76 100644 --- a/packages/backend/convex/logsAcesso.ts +++ b/packages/backend/convex/logsAcesso.ts @@ -88,9 +88,14 @@ export const listar = query({ if (log.usuarioId) { const user = await ctx.db.get(log.usuarioId); if (user) { + let matricula: string | undefined = undefined; + if (user.funcionarioId) { + const funcionario = await ctx.db.get(user.funcionarioId); + matricula = funcionario?.matricula; + } usuario = { _id: user._id, - matricula: user.matricula, + matricula: matricula || "", nome: user.nome, }; } diff --git a/packages/backend/convex/logsAtividades.ts b/packages/backend/convex/logsAtividades.ts index 2a87b16..090957a 100644 --- a/packages/backend/convex/logsAtividades.ts +++ b/packages/backend/convex/logsAtividades.ts @@ -78,10 +78,15 @@ export const listarAtividades = query({ const atividadesComUsuarios = await Promise.all( atividades.map(async (atividade) => { const usuario = await ctx.db.get(atividade.usuarioId); + let matricula = "N/A"; + if (usuario?.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula || "N/A"; + } return { ...atividade, usuarioNome: usuario?.nome || "Usuário Desconhecido", - usuarioMatricula: usuario?.matricula || "N/A", + usuarioMatricula: matricula, }; }) ); @@ -157,10 +162,15 @@ export const obterHistoricoRecurso = query({ const atividadesComUsuarios = await Promise.all( atividades.map(async (atividade) => { const usuario = await ctx.db.get(atividade.usuarioId); + let matricula = "N/A"; + if (usuario?.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula || "N/A"; + } return { ...atividade, usuarioNome: usuario?.nome || "Usuário Desconhecido", - usuarioMatricula: usuario?.matricula || "N/A", + usuarioMatricula: matricula, }; }) ); diff --git a/packages/backend/convex/logsLogin.ts b/packages/backend/convex/logsLogin.ts index acb377d..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, @@ -37,26 +69,182 @@ export async function registrarLogin( // Helpers para extrair informações do userAgent function extrairDevice(userAgent: string): string { - if (/mobile/i.test(userAgent)) return "Mobile"; - if (/tablet/i.test(userAgent)) return "Tablet"; + const ua = userAgent.toLowerCase(); + + // Detectar dispositivos móveis primeiro + if (/mobile|android|iphone|ipod|blackberry|opera mini|iemobile|wpdesktop/i.test(ua)) { + // Verificar se é tablet + if (/ipad|tablet|playbook|silk|(android(?!.*mobile))/i.test(ua)) { + return "Tablet"; + } + return "Mobile"; + } + + // Detectar outros dispositivos + if (/smart-tv|smarttv|googletv|appletv|roku|chromecast/i.test(ua)) { + return "Smart TV"; + } + + if (/watch|wear/i.test(ua)) { + return "Smart Watch"; + } + + // Padrão: Desktop return "Desktop"; } function extrairBrowser(userAgent: string): string { - if (/edg/i.test(userAgent)) return "Edge"; - if (/chrome/i.test(userAgent)) return "Chrome"; - if (/firefox/i.test(userAgent)) return "Firefox"; - if (/safari/i.test(userAgent)) return "Safari"; - if (/opera/i.test(userAgent)) return "Opera"; + const ua = userAgent.toLowerCase(); + + // Ordem de detecção é importante (Edge deve vir antes de Chrome) + if (/edgios/i.test(ua)) { + return "Edge iOS"; + } + + if (/edg/i.test(ua)) { + // Extrair versão do Edge + const match = ua.match(/edg[e\/]([\d.]+)/i); + return match ? `Edge ${match[1]}` : "Edge"; + } + + if (/opr|opera/i.test(ua)) { + const match = ua.match(/(?:opr|opera)[\/\s]([\d.]+)/i); + return match ? `Opera ${match[1]}` : "Opera"; + } + + if (/chrome|crios/i.test(ua) && !/edg|opr|opera/i.test(ua)) { + const match = ua.match(/chrome[/\s]([\d.]+)/i); + return match ? `Chrome ${match[1]}` : "Chrome"; + } + + if (/firefox|fxios/i.test(ua)) { + const match = ua.match(/firefox[/\s]([\d.]+)/i); + return match ? `Firefox ${match[1]}` : "Firefox"; + } + + if (/safari/i.test(ua) && !/chrome|crios|android/i.test(ua)) { + const match = ua.match(/version[/\s]([\d.]+)/i); + return match ? `Safari ${match[1]}` : "Safari"; + } + + if (/msie|trident/i.test(ua)) { + const match = ua.match(/(?:msie |rv:)([\d.]+)/i); + return match ? `Internet Explorer ${match[1]}` : "Internet Explorer"; + } + + if (/samsungbrowser/i.test(ua)) { + return "Samsung Internet"; + } + + if (/ucbrowser/i.test(ua)) { + return "UC Browser"; + } + + if (/micromessenger/i.test(ua)) { + return "WeChat"; + } + + if (/baiduboxapp/i.test(ua)) { + return "Baidu Browser"; + } + return "Desconhecido"; } function extrairSistema(userAgent: string): string { - if (/windows/i.test(userAgent)) return "Windows"; - if (/mac/i.test(userAgent)) return "MacOS"; - if (/linux/i.test(userAgent)) return "Linux"; - if (/android/i.test(userAgent)) return "Android"; - if (/ios/i.test(userAgent)) return "iOS"; + const ua = userAgent.toLowerCase(); + + // Windows + if (/windows nt 10.0/i.test(ua)) { + return "Windows 10/11"; + } + if (/windows nt 6.3/i.test(ua)) { + return "Windows 8.1"; + } + if (/windows nt 6.2/i.test(ua)) { + return "Windows 8"; + } + if (/windows nt 6.1/i.test(ua)) { + return "Windows 7"; + } + if (/windows nt 6.0/i.test(ua)) { + return "Windows Vista"; + } + if (/windows nt 5.1/i.test(ua)) { + return "Windows XP"; + } + if (/windows/i.test(ua)) { + return "Windows"; + } + + // macOS + if (/macintosh|mac os x/i.test(ua)) { + const match = ua.match(/mac os x ([\d_]+)/i); + if (match) { + const version = match[1].replace(/_/g, '.'); + return `macOS ${version}`; + } + return "macOS"; + } + + // iOS + if (/iphone|ipad|ipod/i.test(ua)) { + const match = ua.match(/os ([\d_]+)/i); + if (match) { + const version = match[1].replace(/_/g, '.'); + return `iOS ${version}`; + } + return "iOS"; + } + + // Android + if (/android/i.test(ua)) { + const match = ua.match(/android ([\d.]+)/i); + if (match) { + return `Android ${match[1]}`; + } + return "Android"; + } + + // Linux + if (/linux/i.test(ua)) { + // Tentar identificar distribuição + if (/ubuntu/i.test(ua)) { + return "Ubuntu"; + } + if (/debian/i.test(ua)) { + return "Debian"; + } + if (/fedora/i.test(ua)) { + return "Fedora"; + } + if (/centos/i.test(ua)) { + return "CentOS"; + } + if (/redhat/i.test(ua)) { + return "Red Hat"; + } + if (/suse/i.test(ua)) { + return "SUSE"; + } + return "Linux"; + } + + // Chrome OS + if (/cros/i.test(ua)) { + return "Chrome OS"; + } + + // BlackBerry + if (/blackberry/i.test(ua)) { + return "BlackBerry OS"; + } + + // Windows Phone + if (/windows phone/i.test(ua)) { + return "Windows Phone"; + } + return "Desconhecido"; } diff --git a/packages/backend/convex/migrarParaTimes.ts b/packages/backend/convex/migrarParaTimes.ts deleted file mode 100644 index df26043..0000000 --- a/packages/backend/convex/migrarParaTimes.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { internalMutation } from "./_generated/server"; -import { v } from "convex/values"; - -/** - * Migração: Converte estrutura antiga de gestores individuais para times - * - * Esta função cria automaticamente times baseados nos gestores existentes - * e adiciona os funcionários subordinados aos respectivos times. - * - * Execute uma vez via dashboard do Convex: - * Settings > Functions > Internal > migrarParaTimes > executar - */ -export const executar = internalMutation({ - args: {}, - returns: v.object({ - timesCreated: v.number(), - funcionariosAtribuidos: v.number(), - erros: v.array(v.string()), - }), - handler: async (ctx) => { - const erros: string[] = []; - let timesCreated = 0; - let funcionariosAtribuidos = 0; - - try { - // 1. Buscar todos os funcionários que têm gestor definido - const funcionariosComGestor = await ctx.db - .query("funcionarios") - .filter((q) => q.neq(q.field("gestorId"), undefined)) - .collect(); - - if (funcionariosComGestor.length === 0) { - return { - timesCreated: 0, - funcionariosAtribuidos: 0, - erros: ["Nenhum funcionário com gestor configurado encontrado"], - }; - } - - // 2. Agrupar funcionários por gestor - const gestoresMap = new Map(); - - for (const funcionario of funcionariosComGestor) { - if (!funcionario.gestorId) continue; - - const gestorId = funcionario.gestorId; - if (!gestoresMap.has(gestorId)) { - gestoresMap.set(gestorId, []); - } - gestoresMap.get(gestorId)!.push(funcionario); - } - - // 3. Para cada gestor, criar um time - for (const [gestorId, subordinados] of gestoresMap.entries()) { - try { - const gestor = await ctx.db.get(gestorId as any); - - if (!gestor) { - erros.push(`Gestor ${gestorId} não encontrado`); - continue; - } - - // Verificar se já existe time para este gestor - const timeExistente = await ctx.db - .query("times") - .withIndex("by_gestor", (q) => q.eq("gestorId", gestorId as any)) - .filter((q) => q.eq(q.field("ativo"), true)) - .first(); - - let timeId; - - if (timeExistente) { - timeId = timeExistente._id; - } else { - // Criar novo time - timeId = await ctx.db.insert("times", { - nome: `Equipe ${gestor.nome}`, - descricao: `Time gerenciado por ${gestor.nome} (migração automática)`, - gestorId: gestorId as any, - ativo: true, - cor: "#3B82F6", - }); - timesCreated++; - } - - // Adicionar membros ao time - for (const funcionario of subordinados) { - try { - // Verificar se já está em algum time - const membroExistente = await ctx.db - .query("timesMembros") - .withIndex("by_funcionario", (q) => q.eq("funcionarioId", funcionario._id)) - .filter((q) => q.eq(q.field("ativo"), true)) - .first(); - - if (!membroExistente) { - await ctx.db.insert("timesMembros", { - timeId: timeId, - funcionarioId: funcionario._id, - dataEntrada: Date.now(), - ativo: true, - }); - funcionariosAtribuidos++; - } - } catch (e: any) { - erros.push(`Erro ao adicionar ${funcionario.nome} ao time: ${e.message}`); - } - } - } catch (e: any) { - erros.push(`Erro ao processar gestor ${gestorId}: ${e.message}`); - } - } - - return { - timesCreated, - funcionariosAtribuidos, - erros, - }; - } catch (e: any) { - erros.push(`Erro geral na migração: ${e.message}`); - return { - timesCreated, - funcionariosAtribuidos, - erros, - }; - } - }, -}); - -/** - * Função auxiliar para limpar times inativos antigos - */ -export const limparTimesInativos = internalMutation({ - args: { - diasInativos: v.optional(v.number()), - }, - returns: v.number(), - handler: async (ctx, args) => { - const diasLimite = args.diasInativos || 30; - const dataLimite = Date.now() - (diasLimite * 24 * 60 * 60 * 1000); - - const timesInativos = await ctx.db - .query("times") - .filter((q) => q.eq(q.field("ativo"), false)) - .collect(); - - let removidos = 0; - - for (const time of timesInativos) { - if (time._creationTime < dataLimite) { - // Remover membros inativos do time - const membrosInativos = await ctx.db - .query("timesMembros") - .withIndex("by_time", (q) => q.eq("timeId", time._id)) - .filter((q) => q.eq(q.field("ativo"), false)) - .collect(); - - for (const membro of membrosInativos) { - await ctx.db.delete(membro._id); - } - - // Remover o time - await ctx.db.delete(time._id); - removidos++; - } - } - - return removidos; - }, -}); - diff --git a/packages/backend/convex/migrarUsuariosAdmin.ts b/packages/backend/convex/migrarUsuariosAdmin.ts deleted file mode 100644 index df9e9b2..0000000 --- a/packages/backend/convex/migrarUsuariosAdmin.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { internalMutation, query } from "./_generated/server"; -import { v } from "convex/values"; - -/** - * Listar usuários usando o perfil "admin" antigo (nível 0) - */ -export const listarUsuariosAdminAntigo = query({ - args: {}, - returns: v.array( - v.object({ - _id: v.id("usuarios"), - matricula: v.string(), - nome: v.string(), - email: v.string(), - roleId: v.id("roles"), - roleNome: v.string(), - roleNivel: v.number(), - }) - ), - handler: async (ctx) => { - // Buscar todos os perfis "admin" - const allAdmins = await ctx.db - .query("roles") - .filter((q) => q.eq(q.field("nome"), "admin")) - .collect(); - - console.log("Perfis 'admin' encontrados:", allAdmins.length); - - // Identificar o admin antigo (nível 0) - const adminAntigo = allAdmins.find((r) => r.nivel === 0); - - if (!adminAntigo) { - console.log("Nenhum admin antigo (nível 0) encontrado"); - return []; - } - - console.log("Admin antigo encontrado:", adminAntigo); - - // Buscar usuários usando este perfil - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id)) - .collect(); - - console.log("Usuários usando admin antigo:", usuarios.length); - - return usuarios.map((u) => ({ - _id: u._id, - matricula: u.matricula, - nome: u.nome, - email: u.email || "", - roleId: u.roleId, - roleNome: adminAntigo.nome, - roleNivel: adminAntigo.nivel, - })); - }, -}); - -/** - * Migrar usuários do perfil "admin" antigo (nível 0) para o novo (nível 2) - */ -export const migrarUsuariosParaAdminNovo = internalMutation({ - args: {}, - returns: v.object({ - migrados: v.number(), - usuariosMigrados: v.array( - v.object({ - matricula: v.string(), - nome: v.string(), - roleAntigo: v.string(), - roleNovo: v.string(), - }) - ), - }), - handler: async (ctx) => { - // Buscar todos os perfis "admin" - const allAdmins = await ctx.db - .query("roles") - .filter((q) => q.eq(q.field("nome"), "admin")) - .collect(); - - // Identificar admin antigo (nível 0) e admin novo (nível 2) - const adminAntigo = allAdmins.find((r) => r.nivel === 0); - const adminNovo = allAdmins.find((r) => r.nivel === 2); - - if (!adminAntigo) { - console.log("❌ Admin antigo (nível 0) não encontrado"); - return { migrados: 0, usuariosMigrados: [] }; - } - - if (!adminNovo) { - console.log("❌ Admin novo (nível 2) não encontrado"); - return { migrados: 0, usuariosMigrados: [] }; - } - - console.log("✅ Admin antigo ID:", adminAntigo._id, "- Nível:", adminAntigo.nivel); - console.log("✅ Admin novo ID:", adminNovo._id, "- Nível:", adminNovo.nivel); - - // Buscar usuários usando o admin antigo - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id)) - .collect(); - - console.log(`📊 Encontrados ${usuarios.length} usuário(s) para migrar`); - - const usuariosMigrados: Array<{ - matricula: string; - nome: string; - roleAntigo: string; - roleNovo: string; - }> = []; - - // Migrar cada usuário - for (const usuario of usuarios) { - await ctx.db.patch(usuario._id, { - roleId: adminNovo._id, - }); - - usuariosMigrados.push({ - matricula: usuario.matricula, - nome: usuario.nome, - roleAntigo: `admin (nível 0) - ${adminAntigo._id}`, - roleNovo: `admin (nível 2) - ${adminNovo._id}`, - }); - - console.log( - `✅ MIGRADO: ${usuario.nome} (${usuario.matricula}) → admin nível 2` - ); - } - - return { - migrados: usuarios.length, - usuariosMigrados, - }; - }, -}); - -/** - * Remover perfil "admin" antigo (nível 0) após migração - */ -export const removerAdminAntigo = internalMutation({ - args: {}, - returns: v.object({ - sucesso: v.boolean(), - mensagem: v.string(), - }), - handler: async (ctx) => { - // Buscar todos os perfis "admin" - const allAdmins = await ctx.db - .query("roles") - .filter((q) => q.eq(q.field("nome"), "admin")) - .collect(); - - // Identificar admin antigo (nível 0) - const adminAntigo = allAdmins.find((r) => r.nivel === 0); - - if (!adminAntigo) { - return { - sucesso: false, - mensagem: "Admin antigo (nível 0) não encontrado", - }; - } - - // Verificar se ainda há usuários usando - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id)) - .collect(); - - if (usuarios.length > 0) { - return { - sucesso: false, - mensagem: `Ainda há ${usuarios.length} usuário(s) usando este perfil. Execute migrarUsuariosParaAdminNovo primeiro.`, - }; - } - - // Remover permissões associadas - const permissoes = await ctx.db - .query("rolePermissoes") - .withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id)) - .collect(); - for (const perm of permissoes) { - await ctx.db.delete(perm._id); - } - - // Remover menu permissões associadas - const menuPerms = await ctx.db - .query("menuPermissoes") - .withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id)) - .collect(); - for (const menuPerm of menuPerms) { - await ctx.db.delete(menuPerm._id); - } - - // Remover o perfil - await ctx.db.delete(adminAntigo._id); - - console.log( - `🗑️ REMOVIDO: Admin antigo (nível 0) - ${adminAntigo._id}` - ); - - return { - sucesso: true, - mensagem: "Admin antigo removido com sucesso", - }; - }, -}); - - - diff --git a/packages/backend/convex/monitoramento.ts b/packages/backend/convex/monitoramento.ts index c1c15a4..f318b7f 100644 --- a/packages/backend/convex/monitoramento.ts +++ b/packages/backend/convex/monitoramento.ts @@ -1,15 +1,16 @@ import { v } from "convex/values"; import { mutation, query, internalMutation } from "./_generated/server"; import { internal } from "./_generated/api"; -import { Id } from "./_generated/dataModel"; +import { Id, Doc } from "./_generated/dataModel"; +import type { QueryCtx } from "./_generated/server"; /** * Helper para obter usuário autenticado */ -async function getUsuarioAutenticado(ctx: any) { +async function getUsuarioAutenticado(ctx: QueryCtx) { const usuariosOnline = await ctx.db.query("usuarios").collect(); const usuarioOnline = usuariosOnline.find( - (u: any) => u.statusPresenca === "online" + (u) => u.statusPresenca === "online" ); return usuarioOnline || null; } @@ -190,23 +191,36 @@ export const obterMetricas = query({ }) ), handler: async (ctx, args) => { - let query = ctx.db.query("systemMetrics"); - - // Filtrar por data se fornecido - if (args.dataInicio !== undefined || args.dataFim !== undefined) { - query = query.withIndex("by_timestamp", (q) => { - if (args.dataInicio !== undefined && args.dataFim !== undefined) { - return q.gte("timestamp", args.dataInicio).lte("timestamp", args.dataFim); - } else if (args.dataInicio !== undefined) { - return q.gte("timestamp", args.dataInicio); - } else { - return q.lte("timestamp", args.dataFim!); - } - }); + // Construir consulta respeitando tipos sem reatribuições + let metricas; + if (args.dataInicio !== undefined && args.dataFim !== undefined) { + const inicio: number = args.dataInicio as number; + const fim: number = args.dataFim as number; + metricas = await ctx.db + .query("systemMetrics") + .withIndex("by_timestamp", (q) => + q.gte("timestamp", inicio).lte("timestamp", fim) + ) + .order("desc") + .collect(); + } else if (args.dataInicio !== undefined) { + const inicio: number = args.dataInicio as number; + metricas = await ctx.db + .query("systemMetrics") + .withIndex("by_timestamp", (q) => q.gte("timestamp", inicio)) + .order("desc") + .collect(); + } else if (args.dataFim !== undefined) { + const fim: number = args.dataFim as number; + metricas = await ctx.db + .query("systemMetrics") + .withIndex("by_timestamp", (q) => q.lte("timestamp", fim)) + .order("desc") + .collect(); + } else { + metricas = await ctx.db.query("systemMetrics").order("desc").collect(); } - let metricas = await query.order("desc").collect(); - // Limitar resultados if (args.limit !== undefined && args.limit > 0) { metricas = metricas.slice(0, args.limit); @@ -297,10 +311,10 @@ export const verificarAlertasInternal = internalMutation({ .collect(); for (const alerta of alertasAtivos) { - // Obter valor da métrica correspondente - const metricValue = (metrica as any)[alerta.metricName]; - - if (metricValue === undefined) continue; + // Obter valor da métrica correspondente, validando tipo número + const rawValue = (metrica as Record)[alerta.metricName]; + if (typeof rawValue !== "number") continue; + const metricValue = rawValue; // Verificar se o alerta deve ser disparado let shouldTrigger = false; @@ -352,11 +366,16 @@ export const verificarAlertasInternal = internalMutation({ // Criar notificação no chat se configurado if (alerta.notifyByChat) { - // Buscar usuários TI para notificar + // Buscar roles administrativas (nível <= 1) e filtrar usuários por roleId + const rolesAdminOuTi = await ctx.db + .query("roles") + .filter((q) => q.lte(q.field("nivel"), 1)) + .collect(); + + const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id)); + const usuarios = await ctx.db.query("usuarios").collect(); - const usuariosTI = usuarios.filter( - (u: any) => u.role?.nome === "ti" || u.role?.nivel === 0 - ); + const usuariosTI = usuarios.filter((u) => rolesPermitidas.has(u.roleId)); for (const usuario of usuariosTI) { await ctx.db.insert("notificacoes", { diff --git a/packages/backend/convex/perfisCustomizados.ts b/packages/backend/convex/perfisCustomizados.ts index 34529bc..8b13789 100644 --- a/packages/backend/convex/perfisCustomizados.ts +++ b/packages/backend/convex/perfisCustomizados.ts @@ -1,436 +1 @@ -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; -import { registrarAtividade } from "./logsAtividades"; -import { api } from "./_generated/api"; -import { Id } from "./_generated/dataModel"; -/** - * Listar todos os perfis customizados - */ -export const listarPerfisCustomizados = query({ - args: {}, - returns: v.array(v.any()), - handler: async (ctx) => { - const perfis = await ctx.db.query("perfisCustomizados").collect(); - - // Buscar role correspondente para cada perfil - const perfisComDetalhes = await Promise.all( - perfis.map(async (perfil) => { - const role = await ctx.db.get(perfil.roleId); - const criador = await ctx.db.get(perfil.criadoPor); - - // Contar usuários usando este perfil - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_role", (q) => q.eq("roleId", perfil.roleId)) - .collect(); - - return { - ...perfil, - roleNome: role?.nome || "Desconhecido", - criadorNome: criador?.nome || "Desconhecido", - numeroUsuarios: usuarios.length, - }; - }) - ); - - return perfisComDetalhes; - }, -}); - -/** - * Obter perfil com permissões detalhadas - */ -export const obterPerfilComPermissoes = query({ - args: { - perfilId: v.id("perfisCustomizados"), - }, - returns: v.union( - v.object({ - perfil: v.any(), - role: v.any(), - permissoes: v.array(v.any()), - menuPermissoes: v.array(v.any()), - usuarios: v.array(v.any()), - }), - v.null() - ), - handler: async (ctx, args) => { - const perfil = await ctx.db.get(args.perfilId); - if (!perfil) { - return null; - } - - const role = await ctx.db.get(perfil.roleId); - if (!role) { - return null; - } - - // Buscar permissões do role - const rolePermissoes = await ctx.db - .query("rolePermissoes") - .withIndex("by_role", (q) => q.eq("roleId", perfil.roleId)) - .collect(); - - const permissoes = await Promise.all( - rolePermissoes.map(async (rp) => { - return await ctx.db.get(rp.permissaoId); - }) - ); - - // Buscar permissões de menu - const menuPermissoes = await ctx.db - .query("menuPermissoes") - .withIndex("by_role", (q) => q.eq("roleId", perfil.roleId)) - .collect(); - - // Buscar usuários usando este perfil - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_role", (q) => q.eq("roleId", perfil.roleId)) - .collect(); - - return { - perfil, - role, - permissoes: permissoes.filter((p) => p !== null), - menuPermissoes, - usuarios, - }; - }, -}); - -/** - * Criar perfil customizado (apenas TI_MASTER) - */ -export const criarPerfilCustomizado = mutation({ - args: { - nome: v.string(), - descricao: v.string(), - nivel: v.number(), // >= 3 - clonarDeRoleId: v.optional(v.id("roles")), // role para copiar permissões - criadoPorId: v.id("usuarios"), - }, - returns: v.union( - v.object({ - sucesso: v.literal(true), - perfilId: v.id("perfisCustomizados"), - }), - v.object({ sucesso: v.literal(false), erro: v.string() }) - ), - handler: async (ctx, args) => { - // Validar nível (deve ser >= 3) - if (args.nivel < 3) { - return { - sucesso: false as const, - erro: "Perfis customizados devem ter nível >= 3", - }; - } - - // Verificar se nome já existe - const roles = await ctx.db.query("roles").collect(); - const nomeExiste = roles.some( - (r) => r.nome.toLowerCase() === args.nome.toLowerCase() - ); - if (nomeExiste) { - return { - sucesso: false as const, - erro: "Já existe um perfil com este nome", - }; - } - - // Criar role correspondente - const roleId = await ctx.db.insert("roles", { - nome: args.nome.toLowerCase().replace(/\s+/g, "_"), - descricao: args.descricao, - nivel: args.nivel, - customizado: true, - criadoPor: args.criadoPorId, - editavel: true, - }); - - // Copiar permissões se especificado - if (args.clonarDeRoleId) { - // Copiar permissões gerais - const permissoesClonar = await ctx.db - .query("rolePermissoes") - .withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId!)) - .collect(); - - for (const perm of permissoesClonar) { - await ctx.db.insert("rolePermissoes", { - roleId, - permissaoId: perm.permissaoId, - }); - } - - // Copiar permissões de menu - const menuPermsClonar = await ctx.db - .query("menuPermissoes") - .withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId!)) - .collect(); - - for (const menuPerm of menuPermsClonar) { - await ctx.db.insert("menuPermissoes", { - roleId, - menuPath: menuPerm.menuPath, - podeAcessar: menuPerm.podeAcessar, - podeConsultar: menuPerm.podeConsultar, - podeGravar: menuPerm.podeGravar, - }); - } - } - - // Criar perfil customizado - const perfilId = await ctx.db.insert("perfisCustomizados", { - nome: args.nome, - descricao: args.descricao, - nivel: args.nivel, - roleId, - criadoPor: args.criadoPorId, - criadoEm: Date.now(), - atualizadoEm: Date.now(), - }); - - // Log de atividade - await registrarAtividade( - ctx, - args.criadoPorId, - "criar", - "perfis", - JSON.stringify({ perfilId, nome: args.nome, nivel: args.nivel }), - perfilId - ); - - return { sucesso: true as const, perfilId }; - }, -}); - -/** - * Editar perfil customizado (apenas TI_MASTER) - */ -export const editarPerfilCustomizado = mutation({ - args: { - perfilId: v.id("perfisCustomizados"), - nome: v.optional(v.string()), - descricao: v.optional(v.string()), - editadoPorId: v.id("usuarios"), - }, - returns: v.union( - v.object({ sucesso: v.literal(true) }), - v.object({ sucesso: v.literal(false), erro: v.string() }) - ), - handler: async (ctx, args) => { - const perfil = await ctx.db.get(args.perfilId); - if (!perfil) { - return { sucesso: false as const, erro: "Perfil não encontrado" }; - } - - // Atualizar perfil - const updates: any = { - atualizadoEm: Date.now(), - }; - - if (args.nome !== undefined) updates.nome = args.nome; - if (args.descricao !== undefined) updates.descricao = args.descricao; - - await ctx.db.patch(args.perfilId, updates); - - // Atualizar role correspondente se nome mudou - if (args.nome !== undefined) { - await ctx.db.patch(perfil.roleId, { - nome: args.nome.toLowerCase().replace(/\s+/g, "_"), - }); - } - - if (args.descricao !== undefined) { - await ctx.db.patch(perfil.roleId, { - descricao: args.descricao, - }); - } - - // Log de atividade - await registrarAtividade( - ctx, - args.editadoPorId, - "editar", - "perfis", - JSON.stringify(updates), - args.perfilId - ); - - return { sucesso: true as const }; - }, -}); - -/** - * Excluir perfil customizado (apenas TI_MASTER) - */ -export const excluirPerfilCustomizado = mutation({ - args: { - perfilId: v.id("perfisCustomizados"), - excluidoPorId: v.id("usuarios"), - }, - returns: v.union( - v.object({ sucesso: v.literal(true) }), - v.object({ sucesso: v.literal(false), erro: v.string() }) - ), - handler: async (ctx, args) => { - const perfil = await ctx.db.get(args.perfilId); - if (!perfil) { - return { sucesso: false as const, erro: "Perfil não encontrado" }; - } - - // Verificar se existem usuários usando este perfil - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_role", (q) => q.eq("roleId", perfil.roleId)) - .collect(); - - if (usuarios.length > 0) { - return { - sucesso: false as const, - erro: `Não é possível excluir. ${usuarios.length} usuário(s) ainda usa(m) este perfil.`, - }; - } - - // Remover permissões associadas ao role - const rolePermissoes = await ctx.db - .query("rolePermissoes") - .withIndex("by_role", (q) => q.eq("roleId", perfil.roleId)) - .collect(); - - for (const rp of rolePermissoes) { - await ctx.db.delete(rp._id); - } - - // Remover permissões de menu - const menuPermissoes = await ctx.db - .query("menuPermissoes") - .withIndex("by_role", (q) => q.eq("roleId", perfil.roleId)) - .collect(); - - for (const mp of menuPermissoes) { - await ctx.db.delete(mp._id); - } - - // Excluir role - await ctx.db.delete(perfil.roleId); - - // Excluir perfil - await ctx.db.delete(args.perfilId); - - // Log de atividade - await registrarAtividade( - ctx, - args.excluidoPorId, - "excluir", - "perfis", - JSON.stringify({ perfilId: args.perfilId, nome: perfil.nome }), - args.perfilId - ); - - return { sucesso: true as const }; - }, -}); - -/** - * Clonar perfil existente - */ -export const clonarPerfil = mutation({ - args: { - perfilOrigemId: v.id("perfisCustomizados"), - novoNome: v.string(), - novaDescricao: v.string(), - criadoPorId: v.id("usuarios"), - }, - returns: v.union( - v.object({ - sucesso: v.literal(true), - perfilId: v.id("perfisCustomizados"), - }), - v.object({ sucesso: v.literal(false), erro: v.string() }) - ), - handler: async (ctx, args) => { - const perfilOrigem = await ctx.db.get(args.perfilOrigemId); - if (!perfilOrigem) { - return { sucesso: false as const, erro: "Perfil origem não encontrado" }; - } - - // Verificar se nome já existe - const roles = await ctx.db.query("roles").collect(); - const nomeExiste = roles.some( - (r) => r.nome.toLowerCase() === args.novoNome.toLowerCase() - ); - if (nomeExiste) { - return { - sucesso: false as const, - erro: "Já existe um perfil com este nome", - }; - } - - // Criar role correspondente - const roleId = await ctx.db.insert("roles", { - nome: args.novoNome.toLowerCase().replace(/\s+/g, "_"), - descricao: args.novaDescricao, - nivel: perfilOrigem.nivel, - customizado: true, - criadoPor: args.criadoPorId, - editavel: true, - }); - - // Copiar permissões gerais do perfil de origem - const permissoesClonar = await ctx.db - .query("rolePermissoes") - .withIndex("by_role", (q) => q.eq("roleId", perfilOrigem.roleId)) - .collect(); - for (const perm of permissoesClonar) { - await ctx.db.insert("rolePermissoes", { - roleId, - permissaoId: perm.permissaoId, - }); - } - - // Copiar permissões de menu - const menuPermsClonar = await ctx.db - .query("menuPermissoes") - .withIndex("by_role", (q) => q.eq("roleId", perfilOrigem.roleId)) - .collect(); - for (const menuPerm of menuPermsClonar) { - await ctx.db.insert("menuPermissoes", { - roleId, - menuPath: menuPerm.menuPath, - podeAcessar: menuPerm.podeAcessar, - podeConsultar: menuPerm.podeConsultar, - podeGravar: menuPerm.podeGravar, - }); - } - - // Criar perfil customizado - const perfilId = await ctx.db.insert("perfisCustomizados", { - nome: args.novoNome, - descricao: args.novaDescricao, - nivel: perfilOrigem.nivel, - roleId, - criadoPor: args.criadoPorId, - criadoEm: Date.now(), - atualizadoEm: Date.now(), - }); - - // Log de atividade - await registrarAtividade( - ctx as any, - args.criadoPorId, - "criar", - "perfis", - JSON.stringify({ - perfilId, - nome: args.novoNome, - nivel: perfilOrigem.nivel, - }), - perfilId - ); - - return { sucesso: true as const, perfilId }; - }, -}); diff --git a/packages/backend/convex/permissoesAcoes.ts b/packages/backend/convex/permissoesAcoes.ts index 25820e4..e17a9f4 100644 --- a/packages/backend/convex/permissoesAcoes.ts +++ b/packages/backend/convex/permissoesAcoes.ts @@ -1,5 +1,6 @@ import { query, mutation, internalQuery } from "./_generated/server"; import { v } from "convex/values"; +import type { Doc } from "./_generated/dataModel"; // Catálogo base de recursos e ações // Ajuste/expanda conforme os módulos disponíveis no sistema @@ -165,7 +166,7 @@ export const assertPermissaoAcaoAtual = internalQuery({ returns: v.null(), handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); - let usuarioAtual: any = null; + let usuarioAtual: Doc<"usuarios"> | null = null; if (identity && identity.email) { usuarioAtual = await ctx.db @@ -187,9 +188,9 @@ export const assertPermissaoAcaoAtual = internalQuery({ if (!usuarioAtual) throw new Error("acesso_negado"); - const role: any = await ctx.db.get(usuarioAtual.roleId as any); + const role = await ctx.db.get(usuarioAtual.roleId); if (!role) throw new Error("acesso_negado"); - if ((role as any).nivel <= 1) return null; + if (role.nivel <= 1) return null; const permissao = await ctx.db .query("permissoes") @@ -201,7 +202,7 @@ export const assertPermissaoAcaoAtual = internalQuery({ const links = await ctx.db .query("rolePermissoes") - .withIndex("by_role", (q) => q.eq("roleId", (role as any)._id as any)) + .withIndex("by_role", (q) => q.eq("roleId", role._id)) .collect(); const ok = links.some((rp) => rp.permissaoId === permissao!._id); if (!ok) throw new Error("acesso_negado"); diff --git a/packages/backend/convex/roles.ts b/packages/backend/convex/roles.ts index d4f2d48..6f9d25f 100644 --- a/packages/backend/convex/roles.ts +++ b/packages/backend/convex/roles.ts @@ -6,19 +6,6 @@ import { query } from "./_generated/server"; */ export const listar = query({ args: {}, - returns: v.array( - v.object({ - _id: v.id("roles"), - _creationTime: v.number(), - nome: v.string(), - descricao: v.string(), - nivel: v.number(), - setor: v.optional(v.string()), - customizado: v.optional(v.boolean()), - editavel: v.optional(v.boolean()), - criadoPor: v.optional(v.id("usuarios")), - }) - ), handler: async (ctx) => { return await ctx.db.query("roles").collect(); }, @@ -45,3 +32,4 @@ export const buscarPorId = query({ return await ctx.db.get(args.roleId); }, }); + diff --git a/packages/backend/convex/saldoFerias.ts b/packages/backend/convex/saldoFerias.ts index 589e1ea..9a7fc56 100644 --- a/packages/backend/convex/saldoFerias.ts +++ b/packages/backend/convex/saldoFerias.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; import { query, mutation, internalMutation } from "./_generated/server"; import { internal } from "./_generated/api"; import { Id } from "./_generated/dataModel"; +import type { QueryCtx } from "./_generated/server"; /** * SISTEMA DE CÁLCULO DE SALDO DE FÉRIAS @@ -87,7 +88,7 @@ function calcularDataFimPeriodo(dataAdmissao: string, anosPassados: number): str } // Helper: Obter regime de trabalho do funcionário -async function obterRegimeTrabalho(ctx: any, funcionarioId: Id<"funcionarios">): Promise { +async function obterRegimeTrabalho(ctx: QueryCtx, funcionarioId: Id<"funcionarios">): Promise { const funcionario = await ctx.db.get(funcionarioId); return funcionario?.regimeTrabalho || "clt"; // Default CLT } @@ -126,7 +127,7 @@ export const obterSaldo = query({ .first(); if (!periodo) { - // Se não existe, criar automaticamente + // Se não existe, calcular e retornar dados previstos sem mutar o banco const funcionario = await ctx.db.get(args.funcionarioId); if (!funcionario || !funcionario.admissaoData) return null; @@ -139,23 +140,14 @@ export const obterSaldo = query({ if (anosDesdeAdmissao < 1) return null; // Ainda não tem direito - const dataInicio = calcularDataFimPeriodo(funcionario.admissaoData, anosDesdeAdmissao - 1); - const dataFim = calcularDataFimPeriodo(funcionario.admissaoData, anosDesdeAdmissao); - - // Criar período aquisitivo - await ctx.db.insert("periodosAquisitivos", { - funcionarioId: args.funcionarioId, - anoReferencia: args.anoReferencia, - dataInicio, - dataFim, - diasDireito: 30, - diasUsados: 0, - diasPendentes: 0, - diasDisponiveis: 30, - abonoPermitido: config.abonoPermitido, - diasAbono: 0, - status: "ativo", - }); + const dataInicio = calcularDataFimPeriodo( + funcionario.admissaoData, + anosDesdeAdmissao - 1 + ); + const dataFim = calcularDataFimPeriodo( + funcionario.admissaoData, + anosDesdeAdmissao + ); return { anoReferencia: args.anoReferencia, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 744de8e..d561bbd 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -30,18 +30,19 @@ export default defineSchema({ 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")) + ), + // Regime de trabalho (para cálculo correto de férias) - regimeTrabalho: v.optional(v.union( - v.literal("clt"), // CLT - Consolidação das Leis do Trabalho - v.literal("estatutario_pe"), // Servidor Público Estadual de Pernambuco - v.literal("estatutario_federal"), // Servidor Público Federal - v.literal("estatutario_municipal") // Servidor Público Municipal - )), + regimeTrabalho: v.optional( + v.union( + v.literal("clt"), // CLT - Consolidação das Leis do Trabalho + v.literal("estatutario_pe"), // Servidor Público Estadual de Pernambuco + v.literal("estatutario_federal"), // Servidor Público Federal + v.literal("estatutario_municipal") // Servidor Público Municipal + ) + ), // Dados Pessoais Adicionais (opcionais) nomePai: v.optional(v.string()), @@ -134,6 +135,27 @@ export default defineSchema({ comprovanteResidencia: v.optional(v.id("_storage")), comprovanteContaBradesco: v.optional(v.id("_storage")), + // Dependentes do funcionário (uploads opcionais) + dependentes: v.optional( + v.array( + v.object({ + parentesco: v.union( + v.literal("filho"), + v.literal("filha"), + v.literal("conjuge"), + v.literal("outro") + ), + nome: v.string(), + cpf: v.string(), + nascimento: v.string(), + documentoId: v.optional(v.id("_storage")), + // Benefícios/declarações por dependente + salarioFamilia: v.optional(v.boolean()), + impostoRenda: v.optional(v.boolean()), + }) + ) + ), + // Declarações (Storage IDs) declaracaoAcumulacaoCargo: v.optional(v.id("_storage")), declaracaoDependentesIR: v.optional(v.id("_storage")), @@ -151,11 +173,40 @@ 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"), @@ -184,11 +235,15 @@ export default defineSchema({ data: v.number(), usuarioId: v.id("usuarios"), acao: v.string(), - periodosAnteriores: v.optional(v.array(v.object({ - dataInicio: v.string(), - dataFim: v.string(), - diasCorridos: v.number(), - }))), + periodosAnteriores: v.optional( + v.array( + v.object({ + dataInicio: v.string(), + dataFim: v.string(), + diasCorridos: v.number(), + }) + ) + ), }) ) ), @@ -290,7 +345,6 @@ export default defineSchema({ // Sistema de Autenticação e Controle de Acesso usuarios: defineTable({ - matricula: v.string(), senhaHash: v.string(), // Senha criptografada com bcrypt nome: v.string(), email: v.string(), @@ -327,7 +381,6 @@ export default defineSchema({ notificacoesAtivadas: v.optional(v.boolean()), somNotificacao: v.optional(v.boolean()), }) - .index("by_matricula", ["matricula"]) .index("by_email", ["email"]) .index("by_role", ["roleId"]) .index("by_ativo", ["ativo"]) @@ -366,32 +419,6 @@ export default defineSchema({ .index("by_role", ["roleId"]) .index("by_permissao", ["permissaoId"]), - // Permissões de Menu (granulares por role) - menuPermissoes: defineTable({ - roleId: v.id("roles"), - menuPath: v.string(), // "/recursos-humanos", "/financeiro", etc. - podeAcessar: v.boolean(), - podeConsultar: v.boolean(), // Pode apenas visualizar - podeGravar: v.boolean(), // Pode criar/editar/excluir - }) - .index("by_role", ["roleId"]) - .index("by_menu", ["menuPath"]) - .index("by_role_and_menu", ["roleId", "menuPath"]), - - // Permissões de Menu Personalizadas (por matrícula) - menuPermissoesPersonalizadas: defineTable({ - usuarioId: v.id("usuarios"), - matricula: v.string(), // Para facilitar busca - menuPath: v.string(), - podeAcessar: v.boolean(), - podeConsultar: v.boolean(), - podeGravar: v.boolean(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_matricula", ["matricula"]) - .index("by_usuario_and_menu", ["usuarioId", "menuPath"]) - .index("by_matricula_and_menu", ["matricula", "menuPath"]), - sessoes: defineTable({ usuarioId: v.id("usuarios"), token: v.string(), @@ -473,19 +500,6 @@ export default defineSchema({ .index("by_data_inicio", ["dataInicio"]), // Perfis Customizados - perfisCustomizados: defineTable({ - nome: v.string(), - descricao: v.string(), - nivel: v.number(), // >= 3 - roleId: v.id("roles"), // role correspondente criada - criadoPor: v.id("usuarios"), // TI_MASTER que criou - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_nome", ["nome"]) - .index("by_nivel", ["nivel"]) - .index("by_criado_por", ["criadoPor"]) - .index("by_role", ["roleId"]), // Templates de Mensagens templatesMensagens: defineTable({ @@ -510,7 +524,7 @@ export default defineSchema({ servidor: v.string(), // smtp.gmail.com porta: v.number(), // 587, 465, etc. usuario: v.string(), - senhaHash: v.string(), // senha criptografada + senhaHash: v.string(), // senha criptografada reversível (AES-GCM) - necessário para descriptografar e usar no SMTP emailRemetente: v.string(), nomeRemetente: v.string(), usarSSL: v.boolean(), @@ -540,11 +554,13 @@ export default defineSchema({ enviadoPor: v.id("usuarios"), criadoEm: v.number(), enviadoEm: v.optional(v.number()), + agendadaPara: v.optional(v.number()), // timestamp para agendamento }) .index("by_status", ["status"]) .index("by_destinatario", ["destinatarioId"]) .index("by_enviado_por", ["enviadoPor"]) - .index("by_criado_em", ["criadoEm"]), + .index("by_criado_em", ["criadoEm"]) + .index("by_agendamento", ["agendadaPara"]), configuracaoAcesso: defineTable({ chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc. @@ -647,8 +663,7 @@ export default defineSchema({ mensagensPorMinuto: v.optional(v.number()), tempoRespostaMedio: v.optional(v.number()), errosCount: v.optional(v.number()), - }) - .index("by_timestamp", ["timestamp"]), + }).index("by_timestamp", ["timestamp"]), alertConfigurations: defineTable({ metricName: v.string(), @@ -665,8 +680,7 @@ export default defineSchema({ notifyByChat: v.boolean(), createdBy: v.id("usuarios"), lastModified: v.number(), - }) - .index("by_enabled", ["enabled"]), + }).index("by_enabled", ["enabled"]), alertHistory: defineTable({ configId: v.id("alertConfigurations"), diff --git a/packages/backend/convex/seed.ts b/packages/backend/convex/seed.ts index 29ec545..d24f307 100644 --- a/packages/backend/convex/seed.ts +++ b/packages/backend/convex/seed.ts @@ -1,6 +1,8 @@ -import { internalMutation } from "./_generated/server"; +import { internalMutation, mutation, query } from "./_generated/server"; +import { internal } from "./_generated/api"; import { v } from "convex/values"; import { hashPassword } from "./auth/utils"; +import { Id } from "./_generated/dataModel"; // Dados exportados do Convex Cloud const simbolosData = [ @@ -246,203 +248,41 @@ export const seedDatabase = internalMutation({ }); console.log(" ✅ Role criada: financeiro"); - const roleControladoria = await ctx.db.insert("roles", { - nome: "controladoria", - descricao: "Controladoria", - nivel: 2, - setor: "controladoria", - customizado: false, - editavel: false, - }); - console.log(" ✅ Role criada: controladoria"); - - const roleLicitacoes = await ctx.db.insert("roles", { - nome: "licitacoes", - descricao: "Licitações", - nivel: 2, - setor: "licitacoes", - customizado: false, - editavel: false, - }); - console.log(" ✅ Role criada: licitacoes"); - - const roleCompras = await ctx.db.insert("roles", { - nome: "compras", - descricao: "Compras", - nivel: 2, - setor: "compras", - customizado: false, - editavel: false, - }); - console.log(" ✅ Role criada: compras"); - - const roleJuridico = await ctx.db.insert("roles", { - nome: "juridico", - descricao: "Jurídico", - nivel: 2, - setor: "juridico", - customizado: false, - editavel: false, - }); - console.log(" ✅ Role criada: juridico"); - - const roleComunicacao = await ctx.db.insert("roles", { - nome: "comunicacao", - descricao: "Comunicação", - nivel: 2, - setor: "comunicacao", - customizado: false, - editavel: false, - }); - console.log(" ✅ Role criada: comunicacao"); - - const roleProgramasEsportivos = await ctx.db.insert("roles", { - nome: "programas_esportivos", - descricao: "Programas Esportivos", - nivel: 2, - setor: "programas_esportivos", - customizado: false, - editavel: false, - }); - console.log(" ✅ Role criada: programas_esportivos"); - - const roleSecretariaExecutiva = await ctx.db.insert("roles", { - nome: "secretaria_executiva", - descricao: "Secretaria Executiva", - nivel: 2, - setor: "secretaria_executiva", - customizado: false, - editavel: false, - }); - console.log(" ✅ Role criada: secretaria_executiva"); - - const roleGestaoPessoas = await ctx.db.insert("roles", { - nome: "gestao_pessoas", - descricao: "Gestão de Pessoas", - nivel: 2, - setor: "gestao_pessoas", - customizado: false, - editavel: false, - }); - console.log(" ✅ Role criada: gestao_pessoas"); - const roleUsuario = await ctx.db.insert("roles", { nome: "usuario", - descricao: "Usuário Comum", - nivel: 10, + descricao: "Usuário Padrão", + nivel: 3, + setor: undefined, customizado: false, editavel: false, }); - console.log(" ✅ Role criada: usuario"); + console.log(" ✅ Role criada: usuario (Nível 3 - Padrão)"); - // 2. Criar usuários iniciais - console.log("👤 Criando usuários iniciais..."); - - // TI Master - const senhaTIMaster = await hashPassword("TI@123"); - await ctx.db.insert("usuarios", { - matricula: "1000", - senhaHash: senhaTIMaster, - nome: "Gestor TI Master", - email: "ti.master@sgse.pe.gov.br", - setor: "ti", - roleId: roleTIMaster as any, - ativo: true, - primeiroAcesso: false, - criadoEm: Date.now(), - atualizadoEm: Date.now(), - }); - console.log(" ✅ TI Master criado (matrícula: 1000, senha: TI@123)"); - - // Admin (permissões configuráveis) - const senhaAdmin = await hashPassword("Admin@123"); - const adminId = await ctx.db.insert("usuarios", { - matricula: "2000", - senhaHash: senhaAdmin, - nome: "Administrador Geral", - email: "admin@sgse.pe.gov.br", - setor: "administrativo", - roleId: roleAdmin as any, - ativo: true, - primeiroAcesso: false, - criadoEm: Date.now(), - atualizadoEm: Date.now(), - }); - console.log(" ✅ Admin criado (matrícula: 2000, senha: Admin@123)"); - - // 2.1 Criar catálogo de permissões por ação e conceder a Admin/TI - console.log("🔐 Criando permissões por ação..."); - const CATALOGO_RECURSOS = [ - { recurso: "dashboard", acoes: ["ver"] }, - { - recurso: "funcionarios", - acoes: ["ver", "listar", "criar", "editar", "excluir"], - }, - { - recurso: "simbolos", - acoes: ["ver", "listar", "criar", "editar", "excluir"], - }, - { - recurso: "usuarios", - acoes: ["ver", "listar", "criar", "editar", "excluir"], - }, - { - recurso: "perfis", - acoes: ["ver", "listar", "criar", "editar", "excluir"], - }, - ] as const; - - const permissaoKeyToId = new Map(); - for (const item of CATALOGO_RECURSOS) { - for (const acao of item.acoes) { - const nome = `${item.recurso}.${acao}`; - const id = await ctx.db.insert("permissoes", { - nome, - descricao: `Permite ${acao} em ${item.recurso}`, - recurso: item.recurso, - acao, - }); - permissaoKeyToId.set(nome, id); - } - } - console.log(` ✅ ${permissaoKeyToId.size} permissões criadas`); - - // Conceder todas permissões a Admin e TI - const rolesParaConceder = [roleAdmin, roleTIUsuario, roleTIMaster]; - for (const roleId of rolesParaConceder) { - for (const [, permId] of permissaoKeyToId) { - await ctx.db.insert("rolePermissoes", { - roleId: roleId as any, - permissaoId: permId as any, - }); - } - } - console.log(" ✅ Todas as permissões concedidas a Admin e TI"); - - // 3. Inserir símbolos - console.log("📝 Inserindo símbolos..."); - const simbolosMap = new Map(); + // 2. Criar Símbolos (Cargos) + console.log("💰 Criando símbolos..."); + const simbolosMap = new Map>(); for (const simbolo of simbolosData) { - const id = await ctx.db.insert("simbolos", { - descricao: simbolo.descricao, + const simboloId = await ctx.db.insert("simbolos", { nome: simbolo.nome, - repValor: simbolo.repValor, + descricao: simbolo.descricao, tipo: simbolo.tipo, valor: simbolo.valor, - vencValor: simbolo.vencValor, + repValor: simbolo.repValor || "", + vencValor: simbolo.vencValor || "", }); - simbolosMap.set(simbolo.nome, id); + simbolosMap.set(simbolo.nome, simboloId); console.log(` ✅ Símbolo criado: ${simbolo.nome}`); } - // 4. Inserir funcionários - console.log("👥 Inserindo funcionários..."); - const funcionariosMap = new Map(); + // 3. Criar Funcionários + console.log("👥 Criando funcionários..."); + const funcionariosMap = new Map>(); + for (const funcionario of funcionariosData) { const simboloId = simbolosMap.get(funcionario.simboloNome); if (!simboloId) { - console.error( + console.log( ` ❌ Símbolo não encontrado: ${funcionario.simboloNome}` ); continue; @@ -459,7 +299,7 @@ export const seedDatabase = internalMutation({ nascimento: funcionario.nascimento, nome: funcionario.nome, rg: funcionario.rg, - simboloId: simboloId as any, + simboloId: simboloId as Id<"simbolos">, simboloTipo: funcionario.simboloTipo, telefone: funcionario.telefone, uf: funcionario.uf, @@ -476,12 +316,11 @@ export const seedDatabase = internalMutation({ const senhaInicial = await hashPassword("Mudar@123"); await ctx.db.insert("usuarios", { - matricula: funcionario.matricula, senhaHash: senhaInicial, nome: funcionario.nome, email: funcionario.email, - funcionarioId: funcId as any, - roleId: roleUsuario as any, + funcionarioId: funcId as Id<"funcionarios">, + roleId: roleUsuario, ativo: true, primeiroAcesso: true, criadoEm: Date.now(), @@ -495,90 +334,55 @@ export const seedDatabase = internalMutation({ // 6. Inserir solicitações de acesso console.log("📋 Inserindo solicitações de acesso..."); for (const solicitacao of solicitacoesAcessoData) { - await ctx.db.insert("solicitacoesAcesso", { - dataResposta: solicitacao.dataResposta, - dataSolicitacao: solicitacao.dataSolicitacao, - email: solicitacao.email, - matricula: solicitacao.matricula, + const dadosSolicitacao: { + nome: string; + matricula: string; + email: string; + telefone: string; + status: "pendente" | "aprovado" | "rejeitado"; + dataSolicitacao: number; + dataResposta?: number; + observacoes?: string; + } = { nome: solicitacao.nome, - observacoes: solicitacao.observacoes, - status: solicitacao.status, + matricula: solicitacao.matricula, + email: solicitacao.email, telefone: solicitacao.telefone, - }); - console.log(` ✅ Solicitação criada: ${solicitacao.nome}`); + status: solicitacao.status, + dataSolicitacao: solicitacao.dataSolicitacao, + }; + + if (solicitacao.dataResposta) { + dadosSolicitacao.dataResposta = solicitacao.dataResposta; + } + + if (solicitacao.observacoes) { + dadosSolicitacao.observacoes = solicitacao.observacoes; + } + + await ctx.db.insert("solicitacoesAcesso", dadosSolicitacao); + console.log( + ` ✅ Solicitação criada: ${solicitacao.nome} (${solicitacao.status})` + ); } - // 7. Criar templates de mensagens padrão - console.log("📧 Criando templates de mensagens padrão..."); - const templatesPadrao = [ - { - codigo: "USUARIO_BLOQUEADO", - nome: "Usuário Bloqueado", - titulo: "Sua conta foi bloqueada", - corpo: - "Sua conta no SGSE foi bloqueada.\\n\\nMotivo: {{motivo}}\\n\\nPara mais informações, entre em contato com a TI.", - variaveis: ["motivo"], - }, - { - codigo: "USUARIO_DESBLOQUEADO", - nome: "Usuário Desbloqueado", - titulo: "Sua conta foi desbloqueada", - corpo: - "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.", - variaveis: [], - }, - { - codigo: "SENHA_RESETADA", - nome: "Senha Resetada", - titulo: "Sua senha foi resetada", - corpo: - "Sua senha foi resetada pela equipe de TI.\\n\\nNova senha temporária: {{senha}}\\n\\nPor favor, altere sua senha no próximo login.", - variaveis: ["senha"], - }, - { - codigo: "PERMISSAO_ALTERADA", - nome: "Permissão Alterada", - titulo: "Suas permissões foram atualizadas", - corpo: - "Suas permissões de acesso ao sistema foram atualizadas.\\n\\nPara verificar suas novas permissões, acesse o menu de perfil.", - variaveis: [], - }, - { - codigo: "AVISO_GERAL", - nome: "Aviso Geral", - titulo: "{{titulo}}", - corpo: "{{mensagem}}", - variaveis: ["titulo", "mensagem"], - }, - { - codigo: "BEM_VINDO", - nome: "Boas-vindas", - titulo: "Bem-vindo ao SGSE", - corpo: - "Olá {{nome}},\\n\\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\\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"], - }, - ]; + console.log("✨ Seed do banco de dados concluído com sucesso!"); + return null; + } +}); - for (const template of templatesPadrao) { - await ctx.db.insert("templatesMensagens", { - codigo: template.codigo, - nome: template.nome, - tipo: "sistema" as const, - titulo: template.titulo, - corpo: template.corpo, - variaveis: template.variaveis, - criadoEm: Date.now(), - }); - console.log(` ✅ Template criado: ${template.nome}`); - } - - console.log("✨ Seed concluído com sucesso!"); - console.log(""); - console.log("🔑 CREDENCIAIS DE ACESSO:"); - console.log(" Admin: matrícula 0000, senha Admin@123"); - console.log(" TI: matrícula 1000, senha TI@123"); - console.log(" Funcionários: usar matrícula, senha Mudar@123"); +/** + * Mutation pública para popular o banco de dados com os dados de seed + * Permite executar via CLI: `npx convex run seed:popularBanco` + */ +export const popularBanco = mutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + console.log("🌱 Executando popularBanco (wrapper público para seedDatabase)..."); + // Chama a internalMutation para reaproveitar a lógica de seed + await ctx.runMutation(internal.seed.seedDatabase, {}); + console.log("✅ Seed concluído pelo wrapper público"); return null; }, }); @@ -592,88 +396,559 @@ export const clearDatabase = internalMutation({ handler: async (ctx) => { console.log("🗑️ Limpando banco de dados..."); - // Limpar logs de acesso - const logs = await ctx.db.query("logsAcesso").collect(); - for (const log of logs) { + // Limpar em ordem (respeitando dependências) + + // 1. Tabelas de logs e auditoria + const logsAcesso = await ctx.db.query("logsAcesso").collect(); + for (const log of logsAcesso) { await ctx.db.delete(log._id); } - console.log(` ✅ ${logs.length} logs de acesso removidos`); + console.log(` ✅ ${logsAcesso.length} logs de acesso removidos`); - // Limpar sessões + const logsLogin = await ctx.db.query("logsLogin").collect(); + for (const log of logsLogin) { + await ctx.db.delete(log._id); + } + console.log(` ✅ ${logsLogin.length} logs de login removidos`); + + const logsAtividades = await ctx.db.query("logsAtividades").collect(); + for (const log of logsAtividades) { + await ctx.db.delete(log._id); + } + console.log(` ✅ ${logsAtividades.length} logs de atividades removidos`); + + // 2. Sistema de chat + const leituras = await ctx.db.query("leituras").collect(); + for (const leitura of leituras) { + await ctx.db.delete(leitura._id); + } + console.log(` ✅ ${leituras.length} leituras removidas`); + + const mensagens = await ctx.db.query("mensagens").collect(); + for (const mensagem of mensagens) { + await ctx.db.delete(mensagem._id); + } + console.log(` ✅ ${mensagens.length} mensagens removidas`); + + const digitando = await ctx.db.query("digitando").collect(); + for (const d of digitando) { + await ctx.db.delete(d._id); + } + console.log(` ✅ ${digitando.length} registros de digitando removidos`); + + const conversas = await ctx.db.query("conversas").collect(); + for (const conversa of conversas) { + await ctx.db.delete(conversa._id); + } + console.log(` ✅ ${conversas.length} conversas removidas`); + + // 3. Notificações + const notificacoes = await ctx.db.query("notificacoes").collect(); + for (const notificacao of notificacoes) { + await ctx.db.delete(notificacao._id); + } + console.log(` ✅ ${notificacoes.length} notificações removidas`); + + const notificacoesEmail = await ctx.db.query("notificacoesEmail").collect(); + for (const email of notificacoesEmail) { + await ctx.db.delete(email._id); + } + console.log( + ` ✅ ${notificacoesEmail.length} notificações de email removidas` + ); + + const notificacoesFerias = await ctx.db + .query("notificacoesFerias") + .collect(); + for (const notif of notificacoesFerias) { + await ctx.db.delete(notif._id); + } + console.log( + ` ✅ ${notificacoesFerias.length} notificações de férias removidas` + ); + + // 4. Férias e períodos aquisitivos + const solicitacoesFerias = await ctx.db + .query("solicitacoesFerias") + .collect(); + for (const solicitacao of solicitacoesFerias) { + await ctx.db.delete(solicitacao._id); + } + console.log( + ` ✅ ${solicitacoesFerias.length} solicitações de férias removidas` + ); + + const periodosAquisitivos = await ctx.db + .query("periodosAquisitivos") + .collect(); + for (const periodo of periodosAquisitivos) { + await ctx.db.delete(periodo._id); + } + console.log( + ` ✅ ${periodosAquisitivos.length} períodos aquisitivos removidos` + ); + + // 5. Atestados + const atestados = await ctx.db.query("atestados").collect(); + for (const atestado of atestados) { + await ctx.db.delete(atestado._id); + } + console.log(` ✅ ${atestados.length} atestados removidos`); + + // 6. Times e membros + const timesMembros = await ctx.db.query("timesMembros").collect(); + for (const membro of timesMembros) { + await ctx.db.delete(membro._id); + } + console.log(` ✅ ${timesMembros.length} membros de times removidos`); + + const times = await ctx.db.query("times").collect(); + for (const time of times) { + await ctx.db.delete(time._id); + } + console.log(` ✅ ${times.length} times removidos`); + + // 7. Cursos + const cursos = await ctx.db.query("cursos").collect(); + for (const curso of cursos) { + await ctx.db.delete(curso._id); + } + console.log(` ✅ ${cursos.length} cursos removidos`); + + // 8. Bloqueios de usuários + const bloqueiosUsuarios = await ctx.db.query("bloqueiosUsuarios").collect(); + for (const bloqueio of bloqueiosUsuarios) { + await ctx.db.delete(bloqueio._id); + } + console.log( + ` ✅ ${bloqueiosUsuarios.length} bloqueios de usuários removidos` + ); + + // 9. Perfis customizados + + // 10. Templates de mensagens + const templatesMensagens = await ctx.db + .query("templatesMensagens") + .collect(); + for (const template of templatesMensagens) { + await ctx.db.delete(template._id); + } + console.log( + ` ✅ ${templatesMensagens.length} templates de mensagens removidos` + ); + + // 11. Configurações + const configuracaoEmail = await ctx.db.query("configuracaoEmail").collect(); + for (const config of configuracaoEmail) { + await ctx.db.delete(config._id); + } + console.log( + ` ✅ ${configuracaoEmail.length} configurações de email removidas` + ); + + const configuracaoAcesso = await ctx.db + .query("configuracaoAcesso") + .collect(); + for (const config of configuracaoAcesso) { + await ctx.db.delete(config._id); + } + console.log( + ` ✅ ${configuracaoAcesso.length} configurações de acesso removidas` + ); + + // 12. Monitoramento + const alertHistory = await ctx.db.query("alertHistory").collect(); + for (const alert of alertHistory) { + await ctx.db.delete(alert._id); + } + console.log(` ✅ ${alertHistory.length} histórico de alertas removido`); + + const alertConfigurations = await ctx.db + .query("alertConfigurations") + .collect(); + for (const alert of alertConfigurations) { + await ctx.db.delete(alert._id); + } + console.log( + ` ✅ ${alertConfigurations.length} configurações de alertas removidas` + ); + + const systemMetrics = await ctx.db.query("systemMetrics").collect(); + for (const metric of systemMetrics) { + await ctx.db.delete(metric._id); + } + console.log(` ✅ ${systemMetrics.length} métricas do sistema removidas`); + + // 13. Sessões const sessoes = await ctx.db.query("sessoes").collect(); for (const sessao of sessoes) { await ctx.db.delete(sessao._id); } console.log(` ✅ ${sessoes.length} sessões removidas`); - // Limpar usuários - const usuarios = await ctx.db.query("usuarios").collect(); - for (const usuario of usuarios) { - await ctx.db.delete(usuario._id); - } - console.log(` ✅ ${usuarios.length} usuários removidos`); - - // Limpar funcionários - const funcionarios = await ctx.db.query("funcionarios").collect(); - for (const funcionario of funcionarios) { - await ctx.db.delete(funcionario._id); - } - console.log(` ✅ ${funcionarios.length} funcionários removidos`); - - // Limpar símbolos - const simbolos = await ctx.db.query("simbolos").collect(); - for (const simbolo of simbolos) { - await ctx.db.delete(simbolo._id); - } - console.log(` ✅ ${simbolos.length} símbolos removidos`); - - // Limpar solicitações de acesso - const solicitacoes = await ctx.db.query("solicitacoesAcesso").collect(); - for (const solicitacao of solicitacoes) { - await ctx.db.delete(solicitacao._id); - } - console.log(` ✅ ${solicitacoes.length} solicitações removidas`); - - // Limpar menu-permissões - const menuPermissoes = await ctx.db.query("menuPermissoes").collect(); - for (const mp of menuPermissoes) { - await ctx.db.delete(mp._id); - } - console.log(` ✅ ${menuPermissoes.length} menu-permissões removidas`); - - // Limpar menu-permissões personalizadas - const menuPermissoesPersonalizadas = await ctx.db - .query("menuPermissoesPersonalizadas") - .collect(); - for (const mpp of menuPermissoesPersonalizadas) { - await ctx.db.delete(mpp._id); - } - console.log( - ` ✅ ${menuPermissoesPersonalizadas.length} menu-permissões personalizadas removidas` - ); - - // Limpar role-permissões + // 14. Menu-permissões personalizadas + + // 15. Menu-permissões + + // 16. Role-permissões const rolePermissoes = await ctx.db.query("rolePermissoes").collect(); for (const rp of rolePermissoes) { await ctx.db.delete(rp._id); } console.log(` ✅ ${rolePermissoes.length} role-permissões removidas`); - // Limpar permissões + // 17. Permissões const permissoes = await ctx.db.query("permissoes").collect(); for (const permissao of permissoes) { await ctx.db.delete(permissao._id); } console.log(` ✅ ${permissoes.length} permissões removidas`); - // Limpar roles + // 18. Usuários (deve vir antes de roles se houver referência) + const usuarios = await ctx.db.query("usuarios").collect(); + for (const usuario of usuarios) { + await ctx.db.delete(usuario._id); + } + console.log(` ✅ ${usuarios.length} usuários removidos`); + + // 19. Funcionários + const funcionarios = await ctx.db.query("funcionarios").collect(); + for (const funcionario of funcionarios) { + await ctx.db.delete(funcionario._id); + } + console.log(` ✅ ${funcionarios.length} funcionários removidos`); + + // 20. Solicitações de acesso + const solicitacoesAcesso = await ctx.db + .query("solicitacoesAcesso") + .collect(); + for (const solicitacao of solicitacoesAcesso) { + await ctx.db.delete(solicitacao._id); + } + console.log( + ` ✅ ${solicitacoesAcesso.length} solicitações de acesso removidas` + ); + + // 21. Símbolos + const simbolos = await ctx.db.query("simbolos").collect(); + for (const simbolo of simbolos) { + await ctx.db.delete(simbolo._id); + } + console.log(` ✅ ${simbolos.length} símbolos removidos`); + + // 22. Roles (deve vir por último se outras tabelas referenciam) const roles = await ctx.db.query("roles").collect(); for (const role of roles) { await ctx.db.delete(role._id); } console.log(` ✅ ${roles.length} roles removidas`); - console.log("✨ Banco de dados limpo!"); + // 23. Todos (tabela de exemplo) + const todos = await ctx.db.query("todos").collect(); + for (const todo of todos) { + await ctx.db.delete(todo._id); + } + console.log(` ✅ ${todos.length} todos removidos`); + + console.log("✨ Banco de dados completamente limpo!"); return null; }, }); + +/** + * Mutation pública para limpar o banco de dados (para uso via CLI) + * ATENÇÃO: Esta função deleta TODOS os dados do banco! + */ +export const limparBanco = mutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + // Executa diretamente a limpeza (mesmo código da internalMutation) + console.log("🗑️ Limpando banco de dados..."); + + // Limpar em ordem (respeitando dependências) + + // 1. Tabelas de logs e auditoria + const logsAcesso = await ctx.db.query("logsAcesso").collect(); + for (const log of logsAcesso) { + await ctx.db.delete(log._id); + } + console.log(` ✅ ${logsAcesso.length} logs de acesso removidos`); + + const logsLogin = await ctx.db.query("logsLogin").collect(); + for (const log of logsLogin) { + await ctx.db.delete(log._id); + } + console.log(` ✅ ${logsLogin.length} logs de login removidos`); + + const logsAtividades = await ctx.db.query("logsAtividades").collect(); + for (const log of logsAtividades) { + await ctx.db.delete(log._id); + } + console.log(` ✅ ${logsAtividades.length} logs de atividades removidos`); + + // 2. Sistema de chat + const leituras = await ctx.db.query("leituras").collect(); + for (const leitura of leituras) { + await ctx.db.delete(leitura._id); + } + console.log(` ✅ ${leituras.length} leituras removidas`); + + const mensagens = await ctx.db.query("mensagens").collect(); + for (const mensagem of mensagens) { + await ctx.db.delete(mensagem._id); + } + console.log(` ✅ ${mensagens.length} mensagens removidas`); + + const digitando = await ctx.db.query("digitando").collect(); + for (const d of digitando) { + await ctx.db.delete(d._id); + } + console.log(` ✅ ${digitando.length} registros de digitando removidos`); + + const conversas = await ctx.db.query("conversas").collect(); + for (const conversa of conversas) { + await ctx.db.delete(conversa._id); + } + console.log(` ✅ ${conversas.length} conversas removidas`); + + // 3. Notificações + const notificacoes = await ctx.db.query("notificacoes").collect(); + for (const notificacao of notificacoes) { + await ctx.db.delete(notificacao._id); + } + console.log(` ✅ ${notificacoes.length} notificações removidas`); + + const notificacoesEmail = await ctx.db.query("notificacoesEmail").collect(); + for (const email of notificacoesEmail) { + await ctx.db.delete(email._id); + } + console.log( + ` ✅ ${notificacoesEmail.length} notificações de email removidas` + ); + + const notificacoesFerias = await ctx.db + .query("notificacoesFerias") + .collect(); + for (const notif of notificacoesFerias) { + await ctx.db.delete(notif._id); + } + console.log( + ` ✅ ${notificacoesFerias.length} notificações de férias removidas` + ); + + // 4. Férias e períodos aquisitivos + const solicitacoesFerias = await ctx.db + .query("solicitacoesFerias") + .collect(); + for (const solicitacao of solicitacoesFerias) { + await ctx.db.delete(solicitacao._id); + } + console.log( + ` ✅ ${solicitacoesFerias.length} solicitações de férias removidas` + ); + + const periodosAquisitivos = await ctx.db + .query("periodosAquisitivos") + .collect(); + for (const periodo of periodosAquisitivos) { + await ctx.db.delete(periodo._id); + } + console.log( + ` ✅ ${periodosAquisitivos.length} períodos aquisitivos removidos` + ); + + // 5. Atestados + const atestados = await ctx.db.query("atestados").collect(); + for (const atestado of atestados) { + await ctx.db.delete(atestado._id); + } + console.log(` ✅ ${atestados.length} atestados removidos`); + + // 6. Times e membros + const timesMembros = await ctx.db.query("timesMembros").collect(); + for (const membro of timesMembros) { + await ctx.db.delete(membro._id); + } + console.log(` ✅ ${timesMembros.length} membros de times removidos`); + + const times = await ctx.db.query("times").collect(); + for (const time of times) { + await ctx.db.delete(time._id); + } + console.log(` ✅ ${times.length} times removidos`); + + // 7. Cursos + const cursos = await ctx.db.query("cursos").collect(); + for (const curso of cursos) { + await ctx.db.delete(curso._id); + } + console.log(` ✅ ${cursos.length} cursos removidos`); + + // 8. Bloqueios de usuários + const bloqueiosUsuarios = await ctx.db.query("bloqueiosUsuarios").collect(); + for (const bloqueio of bloqueiosUsuarios) { + await ctx.db.delete(bloqueio._id); + } + console.log( + ` ✅ ${bloqueiosUsuarios.length} bloqueios de usuários removidos` + ); + + // 9. Perfis customizados (já está no código da internalMutation mas vazio) + + // 10. Templates de mensagens + const templatesMensagens = await ctx.db + .query("templatesMensagens") + .collect(); + for (const template of templatesMensagens) { + await ctx.db.delete(template._id); + } + console.log( + ` ✅ ${templatesMensagens.length} templates de mensagens removidos` + ); + + // 11. Configurações + const configuracaoEmail = await ctx.db.query("configuracaoEmail").collect(); + for (const config of configuracaoEmail) { + await ctx.db.delete(config._id); + } + console.log( + ` ✅ ${configuracaoEmail.length} configurações de email removidas` + ); + + const configuracaoAcesso = await ctx.db + .query("configuracaoAcesso") + .collect(); + for (const config of configuracaoAcesso) { + await ctx.db.delete(config._id); + } + console.log( + ` ✅ ${configuracaoAcesso.length} configurações de acesso removidas` + ); + + // 12. Monitoramento + const alertHistory = await ctx.db.query("alertHistory").collect(); + for (const alert of alertHistory) { + await ctx.db.delete(alert._id); + } + console.log(` ✅ ${alertHistory.length} histórico de alertas removido`); + + const alertConfigurations = await ctx.db + .query("alertConfigurations") + .collect(); + for (const alert of alertConfigurations) { + await ctx.db.delete(alert._id); + } + console.log( + ` ✅ ${alertConfigurations.length} configurações de alertas removidas` + ); + + const systemMetrics = await ctx.db.query("systemMetrics").collect(); + for (const metric of systemMetrics) { + await ctx.db.delete(metric._id); + } + console.log(` ✅ ${systemMetrics.length} métricas do sistema removidas`); + + // 13. Sessões + const sessoes = await ctx.db.query("sessoes").collect(); + for (const sessao of sessoes) { + await ctx.db.delete(sessao._id); + } + console.log(` ✅ ${sessoes.length} sessões removidas`); + + // 14. Menu-permissões personalizadas (já está no código da internalMutation mas vazio) + + // 15. Menu-permissões (já está no código da internalMutation mas vazio) + + // 16. Role-permissões + const rolePermissoes = await ctx.db.query("rolePermissoes").collect(); + for (const rp of rolePermissoes) { + await ctx.db.delete(rp._id); + } + console.log(` ✅ ${rolePermissoes.length} role-permissões removidas`); + + // 17. Permissões + const permissoes = await ctx.db.query("permissoes").collect(); + for (const permissao of permissoes) { + await ctx.db.delete(permissao._id); + } + console.log(` ✅ ${permissoes.length} permissões removidas`); + + // 18. Usuários (deve vir antes de roles se houver referência) + const usuarios = await ctx.db.query("usuarios").collect(); + for (const usuario of usuarios) { + await ctx.db.delete(usuario._id); + } + console.log(` ✅ ${usuarios.length} usuários removidos`); + + // 19. Funcionários + const funcionarios = await ctx.db.query("funcionarios").collect(); + for (const funcionario of funcionarios) { + await ctx.db.delete(funcionario._id); + } + console.log(` ✅ ${funcionarios.length} funcionários removidos`); + + // 20. Solicitações de acesso + const solicitacoesAcesso = await ctx.db + .query("solicitacoesAcesso") + .collect(); + for (const solicitacao of solicitacoesAcesso) { + await ctx.db.delete(solicitacao._id); + } + console.log( + ` ✅ ${solicitacoesAcesso.length} solicitações de acesso removidas` + ); + + // 21. Símbolos + const simbolos = await ctx.db.query("simbolos").collect(); + for (const simbolo of simbolos) { + await ctx.db.delete(simbolo._id); + } + console.log(` ✅ ${simbolos.length} símbolos removidos`); + + // 22. Roles (deve vir por último se outras tabelas referenciam) + const roles = await ctx.db.query("roles").collect(); + for (const role of roles) { + await ctx.db.delete(role._id); + } + console.log(` ✅ ${roles.length} roles removidas`); + + // 23. Todos (tabela de exemplo) + const todos = await ctx.db.query("todos").collect(); + for (const todo of todos) { + await ctx.db.delete(todo._id); + } + console.log(` ✅ ${todos.length} todos removidos`); + + console.log("✨ Banco de dados completamente limpo!"); + return null; + }, +}); + +/** + * Query para verificar quantos registros existem no banco + */ +export const verificarBanco = query({ + args: {}, + returns: v.object({ + usuarios: v.number(), + funcionarios: v.number(), + roles: v.number(), + simbolos: v.number(), + total: v.number(), + }), + handler: async (ctx) => { + const usuarios = await ctx.db.query("usuarios").collect(); + const funcionarios = await ctx.db.query("funcionarios").collect(); + const roles = await ctx.db.query("roles").collect(); + const simbolos = await ctx.db.query("simbolos").collect(); + + return { + usuarios: usuarios.length, + funcionarios: funcionarios.length, + roles: roles.length, + simbolos: simbolos.length, + total: usuarios.length + funcionarios.length + roles.length + simbolos.length, + }; + }, +}); diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index 115e28d..c1e5959 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { registrarAtividade } from "./logsAtividades"; +import { Doc } from "./_generated/dataModel"; /** * Listar todos os templates @@ -111,7 +112,7 @@ export const editarTemplate = mutation({ } // Atualizar template - const updates: any = {}; + const updates: Partial> = {}; if (args.nome !== undefined) updates.nome = args.nome; if (args.titulo !== undefined) updates.titulo = args.titulo; if (args.corpo !== undefined) updates.corpo = args.corpo; diff --git a/packages/backend/convex/times.ts b/packages/backend/convex/times.ts index 5c97724..dbde1ab 100644 --- a/packages/backend/convex/times.ts +++ b/packages/backend/convex/times.ts @@ -1,11 +1,11 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; -import { Id } from "./_generated/dataModel"; +import { Id, Doc } from "./_generated/dataModel"; // Query: Listar todos os times +// Tipo inferido automaticamente pelo Convex export const listar = query({ args: {}, - returns: v.array(v.any()), handler: async (ctx) => { const times = await ctx.db.query("times").collect(); @@ -31,9 +31,9 @@ export const listar = query({ }); // Query: Obter time por ID com membros +// Tipo inferido automaticamente pelo Convex export const obterPorId = query({ args: { id: v.id("times") }, - returns: v.union(v.any(), v.null()), handler: async (ctx, args) => { const time = await ctx.db.get(args.id); if (!time) return null; @@ -64,9 +64,9 @@ export const obterPorId = query({ }); // Query: Obter time do funcionário +// Tipo inferido automaticamente pelo Convex export const obterTimeFuncionario = query({ args: { funcionarioId: v.id("funcionarios") }, - returns: v.union(v.any(), v.null()), handler: async (ctx, args) => { const relacao = await ctx.db .query("timesMembros") @@ -89,9 +89,9 @@ export const obterTimeFuncionario = query({ }); // Query: Obter times do gestor +// Tipo inferido automaticamente pelo Convex export const listarPorGestor = query({ args: { gestorId: v.id("usuarios") }, - returns: v.array(v.any()), handler: async (ctx, args) => { const times = await ctx.db .query("times") diff --git a/packages/backend/convex/tsconfig.json b/packages/backend/convex/tsconfig.json index a761a93..f1a9352 100644 --- a/packages/backend/convex/tsconfig.json +++ b/packages/backend/convex/tsconfig.json @@ -11,15 +11,22 @@ "jsx": "react-jsx", "skipLibCheck": true, "allowSyntheticDefaultImports": true, - + "types": [], /* These compiler options are required by Convex */ "target": "ESNext", - "lib": ["ES2021", "dom"], + "lib": [ + "ES2021", + "dom" + ], "forceConsistentCasingInFileNames": true, "module": "ESNext", "isolatedModules": true, "noEmit": true }, - "include": ["./**/*"], - "exclude": ["./_generated"] -} + "include": [ + "./**/*" + ], + "exclude": [ + "./_generated" + ] +} \ No newline at end of file diff --git a/packages/backend/convex/usuarios.ts b/packages/backend/convex/usuarios.ts index 1cf0aa3..95946d9 100644 --- a/packages/backend/convex/usuarios.ts +++ b/packages/backend/convex/usuarios.ts @@ -2,8 +2,23 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { hashPassword, generateToken } from "./auth/utils"; import { registrarAtividade } from "./logsAtividades"; -import { Id } from "./_generated/dataModel"; +import { Id, Doc } from "./_generated/dataModel"; import { api } from "./_generated/api"; +import type { QueryCtx } from "./_generated/server"; + +/** + * Helper para obter a matrícula do usuário (do funcionário se houver) + */ +async function obterMatriculaUsuario( + ctx: QueryCtx, + usuario: Doc<"usuarios"> +): Promise { + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + return funcionario?.matricula; + } + return undefined; +} /** * Associar funcionário a um usuário @@ -30,8 +45,11 @@ export const associarFuncionario = mutation({ .first(); if (usuarioExistente && usuarioExistente._id !== args.usuarioId) { + const matricula = await obterMatriculaUsuario(ctx, usuarioExistente); throw new Error( - `Este funcionário já está associado ao usuário: ${usuarioExistente.nome} (${usuarioExistente.matricula})` + `Este funcionário já está associado ao usuário: ${ + usuarioExistente.nome + }${matricula ? ` (${matricula})` : ""}` ); } @@ -66,7 +84,6 @@ export const desassociarFuncionario = mutation({ */ export const criar = mutation({ args: { - matricula: v.string(), nome: v.string(), email: v.string(), roleId: v.id("roles"), @@ -78,16 +95,6 @@ export const criar = mutation({ v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async (ctx, args) => { - // Verificar se matrícula já existe - const existente = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) - .first(); - - if (existente) { - return { sucesso: false as const, erro: "Matrícula já cadastrada" }; - } - // Verificar se email já existe const emailExistente = await ctx.db .query("usuarios") @@ -103,7 +110,6 @@ export const criar = mutation({ // Criar usuário const usuarioId = await ctx.db.insert("usuarios", { - matricula: args.matricula, senhaHash, nome: args.nome, email: args.email, @@ -128,42 +134,83 @@ export const listar = query({ matricula: v.optional(v.string()), ativo: v.optional(v.boolean()), }, - returns: v.array( - v.object({ - _id: v.id("usuarios"), - matricula: v.string(), - nome: v.string(), - email: v.string(), - ativo: v.boolean(), - bloqueado: v.optional(v.boolean()), - motivoBloqueio: v.optional(v.string()), - primeiroAcesso: v.boolean(), - ultimoAcesso: v.optional(v.number()), - criadoEm: v.number(), - role: v.object({ - _id: v.id("roles"), - nome: v.string(), - nivel: v.number(), - setor: v.optional(v.string()), - }), - funcionario: v.optional( - v.object({ - _id: v.id("funcionarios"), - nome: v.string(), - simboloTipo: v.union( - v.literal("cargo_comissionado"), - v.literal("funcao_gratificada") - ), - }) - ), - }) - ), + // returns: v.array( + // v.object({ + // _id: v.id("usuarios"), + // matricula: v.string(), + // nome: v.string(), + // email: v.string(), + // ativo: v.boolean(), + // bloqueado: v.optional(v.boolean()), + // motivoBloqueio: v.optional(v.string()), + // primeiroAcesso: v.boolean(), + // ultimoAcesso: v.optional(v.number()), + // criadoEm: v.number(), + // role: v.union( + // v.object({ + // _id: v.id("roles"), + // _creationTime: v.optional(v.number()), + // criadoPor: v.optional(v.id("usuarios")), + // customizado: v.optional(v.boolean()), + // descricao: v.string(), + // editavel: v.optional(v.boolean()), + // nome: v.string(), + // nivel: v.number(), + // setor: v.optional(v.string()), + // }), + // v.object({ + // _id: v.id("roles"), + // _creationTime: v.optional(v.number()), + // criadoPor: v.optional(v.id("usuarios")), + // customizado: v.optional(v.boolean()), + // descricao: v.literal("Perfil não encontrado"), + // editavel: v.optional(v.boolean()), + // nome: v.literal("erro_role_ausente"), + // nivel: v.literal(999), + // setor: v.optional(v.string()), + // erro: v.literal(true), + // }) + // ), + // funcionario: v.optional( + // v.object({ + // _id: v.id("funcionarios"), + // nome: v.string(), + // matricula: v.optional(v.string()), + // descricaoCargo: v.optional(v.string()), + // simboloTipo: v.union( + // v.literal("cargo_comissionado"), + // v.literal("funcao_gratificada") + // ), + // }) + // ), + // avisos: v.optional( + // v.array( + // v.object({ + // tipo: v.union( + // v.literal("erro"), + // v.literal("aviso"), + // v.literal("info") + // ), + // mensagem: v.string(), + // }) + // ) + // ), + // }) + // ), handler: async (ctx, args) => { let usuarios = await ctx.db.query("usuarios").collect(); - // Filtrar por matrícula + // Filtrar por matrícula (buscar no funcionário) if (args.matricula) { - usuarios = usuarios.filter((u) => u.matricula.includes(args.matricula!)); + const usuariosComMatricula = await Promise.all( + usuarios.map(async (u) => { + const matricula = await obterMatriculaUsuario(ctx, u); + return { usuario: u, matricula }; + }) + ); + usuarios = usuariosComMatricula + .filter(({ matricula }) => matricula?.includes(args.matricula!)) + .map(({ usuario }) => usuario); } // Filtrar por ativo @@ -173,46 +220,158 @@ export const listar = query({ // Buscar roles e funcionários const resultado = []; + const usuariosSemRole: Array<{ + nome: string; + matricula: string; + roleId: Id<"roles">; + }> = []; + for (const usuario of usuarios) { - const role = await ctx.db.get(usuario.roleId); - if (!role) continue; + try { + const role = await ctx.db.get(usuario.roleId); - // Filtrar por setor - if (args.setor && role.setor !== args.setor) { - continue; - } + // Se a role não existe, criar uma role de erro mas ainda incluir o usuário + if (!role) { + const matricula = await obterMatriculaUsuario(ctx, usuario); + usuariosSemRole.push({ + nome: usuario.nome, + matricula: matricula || "N/A", + roleId: usuario.roleId, + }); - let funcionario = undefined; - if (usuario.funcionarioId) { - const func = await ctx.db.get(usuario.funcionarioId); - if (func) { - funcionario = { - _id: func._id, - nome: func.nome, - simboloTipo: func.simboloTipo, - }; + // Filtrar por setor - se filtro está ativo e role não existe, pular + if (args.setor) { + continue; + } + + // Incluir usuário com role de erro + let funcionario = undefined; + if (usuario.funcionarioId) { + try { + const func = await ctx.db.get(usuario.funcionarioId); + if (func) { + funcionario = { + _id: func._id, + nome: func.nome, + matricula: func.matricula, + descricaoCargo: func.descricaoCargo, + simboloTipo: func.simboloTipo, + }; + } + } catch (error) { + console.error( + `Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`, + error + ); + } + } + + const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario); + + // Criar role de erro (sem _creationTime pois a role não existe) + resultado.push({ + _id: usuario._id, + matricula: matriculaUsuario, + nome: usuario.nome, + email: usuario.email, + ativo: usuario.ativo, + bloqueado: usuario.bloqueado, + motivoBloqueio: usuario.motivoBloqueio, + primeiroAcesso: usuario.primeiroAcesso, + ultimoAcesso: usuario.ultimoAcesso, + criadoEm: usuario.criadoEm, + role: { + _id: usuario.roleId, + descricao: "Perfil não encontrado" as const, + nome: "erro_role_ausente" as const, + nivel: 999 as const, + erro: true as const, + }, + funcionario, + avisos: [ + { + tipo: "erro" as const, + mensagem: `Perfil de acesso (ID: ${usuario.roleId}) não encontrado. Este usuário precisa ter seu perfil reatribuído.`, + }, + ], + }); + continue; } - } - resultado.push({ - _id: usuario._id, - matricula: usuario.matricula, - nome: usuario.nome, - email: usuario.email, - ativo: usuario.ativo, - bloqueado: usuario.bloqueado, - motivoBloqueio: usuario.motivoBloqueio, - primeiroAcesso: usuario.primeiroAcesso, - ultimoAcesso: usuario.ultimoAcesso, - criadoEm: usuario.criadoEm, - role: { + // Filtrar por setor + if (args.setor && role.setor !== args.setor) { + continue; + } + + // Buscar funcionário associado + let funcionario = undefined; + if (usuario.funcionarioId) { + try { + const func = await ctx.db.get(usuario.funcionarioId); + if (func) { + funcionario = { + _id: func._id, + nome: func.nome, + matricula: func.matricula, + descricaoCargo: func.descricaoCargo, + simboloTipo: func.simboloTipo, + }; + } + } catch (error) { + console.error( + `Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`, + error + ); + } + } + + // Construir objeto role - incluir _creationTime se existir (campo automático do Convex) + const roleObj = { _id: role._id, + descricao: role.descricao, nome: role.nome, nivel: role.nivel, - setor: role.setor, - }, - funcionario, - }); + ...(role.criadoPor !== undefined && { criadoPor: role.criadoPor }), + ...(role.customizado !== undefined && { + customizado: role.customizado, + }), + ...(role.editavel !== undefined && { editavel: role.editavel }), + ...(role.setor !== undefined && { setor: role.setor }), + }; + + const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario); + + resultado.push({ + _id: usuario._id, + matricula: matriculaUsuario, + nome: usuario.nome, + email: usuario.email, + ativo: usuario.ativo, + bloqueado: usuario.bloqueado, + motivoBloqueio: usuario.motivoBloqueio, + primeiroAcesso: usuario.primeiroAcesso, + ultimoAcesso: usuario.ultimoAcesso, + criadoEm: usuario.criadoEm, + role: roleObj, + funcionario, + }); + } catch (error) { + console.error(`Erro ao processar usuário ${usuario._id}:`, error); + // Continua processando outros usuários mesmo se houver erro em um + } + } + + // Log de usuários sem role para depuração + if (usuariosSemRole.length > 0) { + console.warn( + `⚠️ Encontrados ${usuariosSemRole.length} usuário(s) com perfil ausente:`, + usuariosSemRole.map( + (u) => + `${u.nome}${ + u.matricula !== "N/A" ? ` (${u.matricula})` : "" + } - RoleID: ${u.roleId}` + ) + ); } return resultado; @@ -436,7 +595,9 @@ export const atualizarPerfil = mutation({ } // Atualizar apenas os campos fornecidos - const updates: any = { atualizadoEm: Date.now() }; + const updates: Partial> & { atualizadoEm: number } = { + atualizadoEm: Date.now(), + }; if (args.avatar !== undefined) updates.avatar = args.avatar; if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil; @@ -468,7 +629,7 @@ export const obterPerfil = query({ _id: v.id("usuarios"), nome: v.string(), email: v.string(), - matricula: v.string(), + matricula: v.optional(v.string()), funcionarioId: v.optional(v.id("funcionarios")), avatar: v.optional(v.string()), fotoPerfil: v.optional(v.id("_storage")), @@ -552,11 +713,13 @@ export const obterPerfil = query({ fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtual.fotoPerfil); } + const matricula = await obterMatriculaUsuario(ctx, usuarioAtual); + return { _id: usuarioAtual._id, nome: usuarioAtual.nome, email: usuarioAtual.email, - matricula: usuarioAtual.matricula, + matricula: matricula || undefined, funcionarioId: usuarioAtual.funcionarioId, avatar: usuarioAtual.avatar, fotoPerfil: usuarioAtual.fotoPerfil, @@ -612,11 +775,13 @@ export const listarParaChat = query({ fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil); } + const matricula = await obterMatriculaUsuario(ctx, usuario); + return { _id: usuario._id, nome: usuario.nome, email: usuario.email, - matricula: usuario.matricula || undefined, + matricula: matricula || undefined, avatar: usuario.avatar, fotoPerfil: usuario.fotoPerfil, fotoPerfilUrl, @@ -882,7 +1047,7 @@ export const editarUsuario = mutation({ } // Atualizar campos fornecidos - const updates: any = { + const updates: Partial> & { atualizadoEm: number } = { atualizadoEm: Date.now(), }; @@ -912,7 +1077,6 @@ export const editarUsuario = mutation({ */ export const criarAdminMaster = mutation({ args: { - matricula: v.string(), nome: v.string(), email: v.string(), senha: v.optional(v.string()), @@ -951,32 +1115,9 @@ export const criarAdminMaster = mutation({ }; } - // Se já existir usuário por matrícula, promove/atualiza - const existentePorMatricula = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) - .first(); - const senhaTemporaria = args.senha || gerarSenhaTemporaria(); const senhaHash = await hashPassword(senhaTemporaria); - if (existentePorMatricula) { - await ctx.db.patch(existentePorMatricula._id, { - nome: args.nome, - email: args.email, - senhaHash, - roleId: roleTIMaster._id, - ativo: true, - primeiroAcesso: true, - atualizadoEm: Date.now(), - }); - return { - sucesso: true as const, - usuarioId: existentePorMatricula._id, - senhaTemporaria, - }; - } - // Verificar se email já existe const existentePorEmail = await ctx.db .query("usuarios") @@ -985,7 +1126,6 @@ export const criarAdminMaster = mutation({ if (existentePorEmail) { // Promove usuário existente por email await ctx.db.patch(existentePorEmail._id, { - matricula: args.matricula, nome: args.nome, senhaHash, roleId: roleTIMaster._id, @@ -1002,7 +1142,6 @@ export const criarAdminMaster = mutation({ // Criar novo usuário TI Master const usuarioId = await ctx.db.insert("usuarios", { - matricula: args.matricula, senhaHash, nome: args.nome, email: args.email, @@ -1071,7 +1210,6 @@ export const excluirUsuarioLogico = mutation({ */ export const criarUsuarioCompleto = mutation({ args: { - matricula: v.string(), nome: v.string(), email: v.string(), roleId: v.id("roles"), @@ -1089,16 +1227,6 @@ export const criarUsuarioCompleto = mutation({ v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async (ctx, args) => { - // Verificar se matrícula já existe - const existente = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) - .first(); - - if (existente) { - return { sucesso: false as const, erro: "Matrícula já cadastrada" }; - } - // Verificar se email já existe const emailExistente = await ctx.db .query("usuarios") @@ -1115,7 +1243,6 @@ export const criarUsuarioCompleto = mutation({ // Criar usuário const usuarioId = await ctx.db.insert("usuarios", { - matricula: args.matricula, senhaHash, nome: args.nome, email: args.email, @@ -1133,7 +1260,7 @@ export const criarUsuarioCompleto = mutation({ args.criadoPorId, "criar", "usuarios", - JSON.stringify({ usuarioId, matricula: args.matricula, nome: args.nome }), + JSON.stringify({ usuarioId, nome: args.nome }), usuarioId ); @@ -1149,7 +1276,6 @@ export const criarUsuarioCompleto = mutation({ */ export const criarAdminPadrao = mutation({ args: { - matricula: v.optional(v.string()), nome: v.optional(v.string()), email: v.optional(v.string()), senha: v.optional(v.string()), @@ -1159,7 +1285,6 @@ export const criarAdminPadrao = mutation({ usuarioId: v.optional(v.id("usuarios")), }), handler: async (ctx, args) => { - const matricula = args.matricula ?? "0000"; const nome = args.nome ?? "Administrador Geral"; const email = args.email ?? "admin@sgse.pe.gov.br"; const senha = args.senha ?? "Admin@123"; @@ -1183,12 +1308,7 @@ export const criarAdminPadrao = mutation({ if (!roleAdmin) return { sucesso: false }; - // Verificar se já existe por matrícula ou email - const existentePorMatricula = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", matricula)) - .first(); - + // Verificar se já existe por email const existentePorEmail = await ctx.db .query("usuarios") .withIndex("by_email", (q) => q.eq("email", email)) @@ -1196,10 +1316,8 @@ export const criarAdminPadrao = mutation({ const senhaHash = await hashPassword(senha); - if (existentePorMatricula || existentePorEmail) { - const alvo = existentePorMatricula ?? existentePorEmail!; - await ctx.db.patch(alvo._id, { - matricula, + if (existentePorEmail) { + await ctx.db.patch(existentePorEmail._id, { nome, email, senhaHash, @@ -1208,11 +1326,10 @@ export const criarAdminPadrao = mutation({ primeiroAcesso: false, atualizadoEm: Date.now(), }); - return { sucesso: true, usuarioId: alvo._id }; + return { sucesso: true, usuarioId: existentePorEmail._id }; } const usuarioId = await ctx.db.insert("usuarios", { - matricula, senhaHash, nome, email, 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; +} + diff --git a/packages/backend/convex/verificarMatriculas.ts b/packages/backend/convex/verificarMatriculas.ts index 4d9f9d5..0b05979 100644 --- a/packages/backend/convex/verificarMatriculas.ts +++ b/packages/backend/convex/verificarMatriculas.ts @@ -1,8 +1,9 @@ import { internalMutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { Id, Doc } from "./_generated/dataModel"; /** - * Verificar duplicatas de matrícula + * Verificar duplicatas de matrícula (agora busca do funcionário associado) */ export const verificarDuplicatas = query({ args: {}, @@ -22,18 +23,27 @@ export const verificarDuplicatas = query({ handler: async (ctx) => { const usuarios = await ctx.db.query("usuarios").collect(); - // Agrupar por matrícula - const gruposPorMatricula = usuarios.reduce((acc, usuario) => { - if (!acc[usuario.matricula]) { - acc[usuario.matricula] = []; + // Agrupar por matrícula do funcionário associado + const gruposPorMatricula: Record; nome: string; email: string }>> = {}; + + for (const usuario of usuarios) { + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; } - acc[usuario.matricula].push({ - _id: usuario._id, - nome: usuario.nome, - email: usuario.email || "", - }); - return acc; - }, {} as Record); + + if (matricula) { + if (!gruposPorMatricula[matricula]) { + gruposPorMatricula[matricula] = []; + } + gruposPorMatricula[matricula].push({ + _id: usuario._id, + nome: usuario.nome, + email: usuario.email || "", + }); + } + } // Filtrar apenas duplicatas const duplicatas = Object.entries(gruposPorMatricula) @@ -49,7 +59,7 @@ export const verificarDuplicatas = query({ }); /** - * Remover duplicatas mantendo apenas o mais recente + * Remover duplicatas mantendo apenas o mais recente (agora busca do funcionário associado) */ export const removerDuplicatas = internalMutation({ args: {}, @@ -60,14 +70,23 @@ export const removerDuplicatas = internalMutation({ handler: async (ctx) => { const usuarios = await ctx.db.query("usuarios").collect(); - // Agrupar por matrícula - const gruposPorMatricula = usuarios.reduce((acc, usuario) => { - if (!acc[usuario.matricula]) { - acc[usuario.matricula] = []; + // Agrupar por matrícula do funcionário associado + const gruposPorMatricula: Record[]> = {}; + + for (const usuario of usuarios) { + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; } - acc[usuario.matricula].push(usuario); - return acc; - }, {} as Record); + + if (matricula) { + if (!gruposPorMatricula[matricula]) { + gruposPorMatricula[matricula] = []; + } + gruposPorMatricula[matricula].push(usuario); + } + } let removidos = 0; const matriculasDuplicadas: string[] = []; diff --git a/packages/backend/package.json b/packages/backend/package.json index dc60e84..e9aa35b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -11,17 +11,17 @@ "devDependencies": { "@types/cookie": "^1.0.0", "@types/estree": "^1.0.8", + "@types/nodemailer": "^7.0.3", "@types/json-schema": "^7.0.15", "@types/node": "^24.3.0", - "@types/nodemailer": "^7.0.3", "@types/pako": "^2.0.4", "@types/raf": "^3.4.3", "@types/trusted-types": "^2.0.7", - "typescript": "^5.9.2" + "typescript": "catalog:" }, "dependencies": { "@dicebear/avataaars": "^9.2.4", - "convex": "^1.28.0", + "convex": "catalog:", "nodemailer": "^7.0.10" } } \ No newline at end of file