diff --git a/apps/web/package.json b/apps/web/package.json index 84c67e8..d39e5f2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,6 +57,7 @@ "jspdf-autotable": "^5.0.2", "lib-jitsi-meet": "^1.0.6", "lucide-svelte": "^0.552.0", + "marked": "^17.0.1", "papaparse": "^5.4.1", "svelte-sonner": "^1.0.5", "xlsx": "^0.18.5", diff --git a/apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte b/apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte new file mode 100644 index 0000000..96c00e0 --- /dev/null +++ b/apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte @@ -0,0 +1,145 @@ + + +
{ + if (onToggleSelecao) { + onToggleSelecao(); + } else { + window.location.href = resolve(`/ti/documentacao/${documento._id}`); + } + }} +> + + {#if onToggleSelecao} + + {/if} + + +
+
+ +
+
+

+ {documento.titulo} +

+
+ + {tipoLabels[documento.tipo] || documento.tipo} + + {#if documento.geradoAutomaticamente} + Auto + {/if} +
+
+
+ + + {#if documento.conteudo} +

+ {documento.conteudo.substring(0, 150)}... +

+ {/if} + + + {#if documento.tags && documento.tags.length > 0} +
+ + {#each documento.tags.slice(0, 3) as tag} + {tag} + {/each} + {#if documento.tags.length > 3} + +{documento.tags.length - 3} + {/if} +
+ {/if} + + +
+
+ + + {format(new Date(documento.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })} + +
+ {#if documento.visualizacoes !== undefined} + {documento.visualizacoes} visualizações + {/if} +
+
+ diff --git a/apps/web/src/lib/components/documentacao/DocumentacaoSearch.svelte b/apps/web/src/lib/components/documentacao/DocumentacaoSearch.svelte new file mode 100644 index 0000000..8dbc4c9 --- /dev/null +++ b/apps/web/src/lib/components/documentacao/DocumentacaoSearch.svelte @@ -0,0 +1,31 @@ + + +
+
+ + + {#if busca} + + {/if} +
+
+ diff --git a/apps/web/src/lib/components/documentacao/DocumentacaoSidebar.svelte b/apps/web/src/lib/components/documentacao/DocumentacaoSidebar.svelte new file mode 100644 index 0000000..a0611ab --- /dev/null +++ b/apps/web/src/lib/components/documentacao/DocumentacaoSidebar.svelte @@ -0,0 +1,133 @@ + + +
+ +
+

+ + Categorias +

+
+ {#each categorias as categoria} + + {/each} + {#if categorias.length === 0} +

Nenhuma categoria disponível

+ {/if} +
+
+ + +
+

+ + Tipos +

+
+ {#each tipos as tipo} + + {/each} +
+
+ + +
+

+ + Tags +

+
+ {#each tags.slice(0, 20) as tag} + + {/each} + {#if tags.length === 0} +

Nenhuma tag disponível

+ {/if} +
+
+
+ diff --git a/apps/web/src/lib/components/documentacao/PdfGenerator.svelte b/apps/web/src/lib/components/documentacao/PdfGenerator.svelte new file mode 100644 index 0000000..080663b --- /dev/null +++ b/apps/web/src/lib/components/documentacao/PdfGenerator.svelte @@ -0,0 +1,182 @@ + + + + diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index 470247d..97cf9e2 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -432,10 +432,11 @@ title: 'Documentação', description: 'Manuais, guias e documentação técnica do sistema para usuários e administradores.', - ctaLabel: 'Em breve', + ctaLabel: 'Acessar Biblioteca', + href: '/(dashboard)/ti/documentacao', palette: 'primary', icon: 'document', - disabled: true + disabled: false } ]; diff --git a/apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte b/apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte new file mode 100644 index 0000000..13878e7 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte @@ -0,0 +1,229 @@ + + + +
+ +
+
+
+
+
+ + Biblioteca de Documentação + +

+ Documentação Técnica do SGSE +

+

+ Biblioteca completa com todas as funcionalidades, recursos, manuais técnicos e + explicações detalhadas dos algoritmos do sistema. +

+
+
+ + + Configuração + + {#if documentosSelecionados.length > 0} + + {/if} +
+
+
+ + +
+
+ + +
+ + {#if busca || categoriaSelecionada || tipoSelecionado || tagsSelecionadas.length > 0} + + {/if} +
+
+ + + {#if mostrarFiltros} +
+ +
+ {/if} +
+ + +
+
+

+ Documentos + {#if documentosQuery} + + ({documentosQuery.total}) + + {/if} +

+
+ + {#if documentosQuery === undefined} +
+ +
+ {:else if documentos.length === 0} +
+ +

Nenhum documento encontrado

+

+ {#if busca || categoriaSelecionada || tipoSelecionado || tagsSelecionadas.length > 0} + Tente ajustar os filtros de busca. + {:else} + Ainda não há documentos cadastrados. Execute uma varredura para gerar documentação + automaticamente. + {/if} +

+
+ {:else} +
+ {#each documentos as documento (documento._id)} + toggleSelecaoDocumento(documento._id)} + /> + {/each} +
+ {/if} +
+
+ + + {#if mostrarPdfGenerator} + { + mostrarPdfGenerator = false; + documentosSelecionados = []; + }} + /> + {/if} +
+ diff --git a/apps/web/src/routes/(dashboard)/ti/documentacao/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/ti/documentacao/[id]/+page.svelte new file mode 100644 index 0000000..3f4eb29 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/documentacao/[id]/+page.svelte @@ -0,0 +1,217 @@ + + + +
+ +
+ + + Voltar + + +
+ + {#if documentoQuery === undefined} +
+ +
+ {:else if !documentoQuery} +
+

Documento não encontrado

+

O documento solicitado não existe ou foi removido.

+
+ {:else} + +
+ +
+

{documentoQuery.titulo}

+
+
+ + {documentoQuery.tipo} +
+
+ + + {format(new Date(documentoQuery.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { + locale: ptBR + })} + +
+ {#if documentoQuery.criadoPorUsuario} +
+ + {documentoQuery.criadoPorUsuario.nome} +
+ {/if} +
+
+ + + {#if documentoQuery.tags && documentoQuery.tags.length > 0} +
+ + {#each documentoQuery.tags as tag} + {tag} + {/each} +
+ {/if} + + +
+ {@html marked.parse(documentoQuery.conteudo)} +
+ + + {#if documentoQuery.metadados} +
+

Informações Técnicas

+ {#if documentoQuery.metadados.parametros} +
+

Parâmetros:

+
    + {#each documentoQuery.metadados.parametros as param} +
  • {param}
  • + {/each} +
+
+ {/if} + {#if documentoQuery.metadados.retorno} +
+

Retorno:

+

{documentoQuery.metadados.retorno}

+
+ {/if} + {#if documentoQuery.metadados.dependencias} +
+

Dependências:

+
    + {#each documentoQuery.metadados.dependencias as dep} +
  • {dep}
  • + {/each} +
+
+ {/if} +
+ {/if} +
+ {/if} +
+
+ + + diff --git a/apps/web/src/routes/(dashboard)/ti/documentacao/configuracao/+page.svelte b/apps/web/src/routes/(dashboard)/ti/documentacao/configuracao/+page.svelte new file mode 100644 index 0000000..4fbc75f --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/documentacao/configuracao/+page.svelte @@ -0,0 +1,332 @@ + + + +
+ + + +
+
+
+
+

Configuração de Varredura

+

+ Configure o agendamento automático de varredura de documentação +

+
+
+ + {#if configQuery === undefined} +
+ +
+ {:else if !config} +
+

Carregando configuração...

+
+ {:else} + +
+

+ + Agendamento +

+ +
+ +
+ +
+ + +
+ +
+ {#each diasSemana as dia} + + {/each} +
+
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+

+ + Histórico de Varreduras +

+ + {#if historicoVarreduras.length === 0} +
+

Nenhuma varredura executada ainda

+
+ {:else} +
+ + + + + + + + + + + + + {#each historicoVarreduras as varredura} + + + + + + + + + {/each} + +
TipoStatusDocumentosExecutado porIniciado emDuração
+ + {varredura.tipo === 'automatica' ? 'Automática' : 'Manual'} + + + + {statusLabels[varredura.status] || varredura.status} + + + Novos: {varredura.documentosNovos} | Atualizados:{' '} + {varredura.documentosAtualizados} + + {varredura.executadoPorUsuario?.nome || 'N/A'} + + {format(new Date(varredura.iniciadoEm), "dd/MM/yyyy 'às' HH:mm", { + locale: ptBR + })} + + {varredura.duracaoMs + ? `${(varredura.duracaoMs / 1000).toFixed(1)}s` + : '-'} +
+
+ {/if} +
+ {/if} +
+
+ diff --git a/bun.lock b/bun.lock index 83a804e..9c6d4fd 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "jspdf-autotable": "^5.0.2", "lib-jitsi-meet": "^1.0.6", "lucide-svelte": "^0.552.0", + "marked": "^17.0.1", "papaparse": "^5.4.1", "svelte-sonner": "^1.0.5", "xlsx": "^0.18.5", @@ -1208,6 +1209,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index deaea6d..548bfea 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -31,6 +31,8 @@ import type * as contratos from "../contratos.js"; import type * as crons from "../crons.js"; import type * as cursos from "../cursos.js"; import type * as dashboard from "../dashboard.js"; +import type * as documentacao from "../documentacao.js"; +import type * as documentacaoVarredura from "../documentacaoVarredura.js"; import type * as documentos from "../documentos.js"; import type * as email from "../email.js"; import type * as empresas from "../empresas.js"; @@ -64,6 +66,7 @@ import type * as tables_auth from "../tables/auth.js"; import type * as tables_chat from "../tables/chat.js"; import type * as tables_contratos from "../tables/contratos.js"; import type * as tables_cursos from "../tables/cursos.js"; +import type * as tables_documentacao from "../tables/documentacao.js"; import type * as tables_empresas from "../tables/empresas.js"; import type * as tables_enderecos from "../tables/enderecos.js"; import type * as tables_ferias from "../tables/ferias.js"; @@ -119,6 +122,8 @@ declare const fullApi: ApiFromModules<{ crons: typeof crons; cursos: typeof cursos; dashboard: typeof dashboard; + documentacao: typeof documentacao; + documentacaoVarredura: typeof documentacaoVarredura; documentos: typeof documentos; email: typeof email; empresas: typeof empresas; @@ -152,6 +157,7 @@ declare const fullApi: ApiFromModules<{ "tables/chat": typeof tables_chat; "tables/contratos": typeof tables_contratos; "tables/cursos": typeof tables_cursos; + "tables/documentacao": typeof tables_documentacao; "tables/empresas": typeof tables_empresas; "tables/enderecos": typeof tables_enderecos; "tables/ferias": typeof tables_ferias; diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts index 3ef6845..2588dee 100644 --- a/packages/backend/convex/crons.ts +++ b/packages/backend/convex/crons.ts @@ -58,4 +58,12 @@ crons.interval( {} ); +// Verificar e executar varredura de documentação a cada hora (verifica configuração internamente) +crons.interval( + 'verificar-varredura-documentacao', + { hours: 1 }, + internal.documentacaoVarredura.verificarEExecutarVarreduraAgendada, + {} +); + export default crons; diff --git a/packages/backend/convex/documentacao.ts b/packages/backend/convex/documentacao.ts new file mode 100644 index 0000000..9fac055 --- /dev/null +++ b/packages/backend/convex/documentacao.ts @@ -0,0 +1,842 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; +import { Doc, Id } from './_generated/dataModel'; +import type { QueryCtx, MutationCtx } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; +import { api } from './_generated/api'; + +// ========== HELPERS ========== + +/** + * Normaliza texto para busca (remove acentos, converte para lowercase) + */ +function normalizarTextoParaBusca(texto: string): string { + return texto + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // Remove diacríticos + .trim(); +} + +/** + * Verifica se o usuário tem permissão de TI (nível 0 ou 1) + */ +async function verificarPermissaoTI(ctx: QueryCtx | MutationCtx): Promise> { + const usuarioAtual = await getCurrentUserFunction(ctx); + if (!usuarioAtual) { + throw new Error('Não autenticado'); + } + + // Verificar se é TI (nível 0 ou 1) + const role = await ctx.db.get(usuarioAtual.roleId); + if (!role || (role.nivel > 1 && role.nome !== 'ti_master' && role.nome !== 'ti_usuario')) { + throw new Error('Acesso negado. Apenas usuários TI podem acessar a documentação.'); + } + + return usuarioAtual; +} + +// ========== QUERIES ========== + +/** + * Listar todos os documentos (com filtros opcionais) + */ +export const listarDocumentos = query({ + args: { + categoriaId: v.optional(v.id('documentacaoCategorias')), + tipo: v.optional( + v.union( + v.literal('query'), + v.literal('mutation'), + v.literal('action'), + v.literal('component'), + v.literal('route'), + v.literal('modulo'), + v.literal('manual'), + v.literal('outro') + ) + ), + tags: v.optional(v.array(v.string())), + ativo: v.optional(v.boolean()), + busca: v.optional(v.string()), + limite: v.optional(v.number()), + offset: v.optional(v.number()) + }, + handler: async (ctx, args) => { + await verificarPermissaoTI(ctx); + + let documentos: Doc<'documentacao'>[] = []; + + // Aplicar filtros usando índices quando possível + if (args.categoriaId) { + documentos = await ctx.db + .query('documentacao') + .withIndex('by_categoria', (q) => q.eq('categoriaId', args.categoriaId)) + .collect(); + } else if (args.tipo) { + documentos = await ctx.db + .query('documentacao') + .withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!)) + .collect(); + } else if (args.ativo !== undefined) { + documentos = await ctx.db + .query('documentacao') + .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) + .collect(); + } else { + documentos = await ctx.db.query('documentacao').collect(); + } + + // Filtrar por tags (se fornecido) + if (args.tags && args.tags.length > 0) { + documentos = documentos.filter((doc) => + args.tags!.some((tag) => doc.tags.includes(tag)) + ); + } + + // Filtrar por busca (full-text) + if (args.busca && args.busca.trim().length > 0) { + const buscaNormalizada = normalizarTextoParaBusca(args.busca); + documentos = documentos.filter((doc) => { + const tituloBusca = normalizarTextoParaBusca(doc.titulo); + return ( + doc.conteudoBusca.includes(buscaNormalizada) || + tituloBusca.includes(buscaNormalizada) + ); + }); + } + + // Ordenar por data de atualização (mais recentes primeiro) + documentos.sort((a, b) => b.atualizadoEm - a.atualizadoEm); + + // Aplicar paginação + const offset = args.offset || 0; + const limite = args.limite || 50; + const documentosPaginados = documentos.slice(offset, offset + limite); + + // Enriquecer com informações de categoria + const documentosEnriquecidos = await Promise.all( + documentosPaginados.map(async (doc) => { + let categoria = null; + if (doc.categoriaId) { + categoria = await ctx.db.get(doc.categoriaId); + } + + return { + ...doc, + categoria + }; + }) + ); + + return { + documentos: documentosEnriquecidos, + total: documentos.length, + offset, + limite + }; + } +}); + +/** + * Obter um documento por ID + */ +export const obterDocumento = query({ + args: { + documentoId: v.id('documentacao') + }, + handler: async (ctx, args) => { + await verificarPermissaoTI(ctx); + + const documento = await ctx.db.get(args.documentoId); + if (!documento) { + throw new Error('Documento não encontrado'); + } + + // Nota: Não podemos fazer patch em uma query, então o contador de visualizações + // será atualizado apenas quando o documento for atualizado via mutation + + // Enriquecer com informações + let categoria = null; + if (documento.categoriaId) { + categoria = await ctx.db.get(documento.categoriaId); + } + + const criadoPor = await ctx.db.get(documento.criadoPor); + const atualizadoPor = documento.atualizadoPor + ? await ctx.db.get(documento.atualizadoPor) + : null; + + return { + ...documento, + categoria, + criadoPorUsuario: criadoPor + ? { + _id: criadoPor._id, + nome: criadoPor.nome, + email: criadoPor.email + } + : null, + atualizadoPorUsuario: atualizadoPor + ? { + _id: atualizadoPor._id, + nome: atualizadoPor.nome, + email: atualizadoPor.email + } + : null + }; + } +}); + +/** + * Buscar documentos (full-text search) + */ +export const buscarDocumentos = query({ + args: { + query: v.string(), + categoriaId: v.optional(v.id('documentacaoCategorias')), + tipo: v.optional( + v.union( + v.literal('query'), + v.literal('mutation'), + v.literal('action'), + v.literal('component'), + v.literal('route'), + v.literal('modulo'), + v.literal('manual'), + v.literal('outro') + ) + ), + tags: v.optional(v.array(v.string())), + limite: v.optional(v.number()) + }, + handler: async (ctx, args) => { + await verificarPermissaoTI(ctx); + + const queryNormalizada = normalizarTextoParaBusca(args.query); + const limite = args.limite || 50; + + // Buscar todos os documentos ativos + let documentos = await ctx.db + .query('documentacao') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .collect(); + + // Filtrar por busca + documentos = documentos.filter((doc) => { + const tituloBusca = normalizarTextoParaBusca(doc.titulo); + return ( + doc.conteudoBusca.includes(queryNormalizada) || + tituloBusca.includes(queryNormalizada) || + doc.tags.some((tag) => normalizarTextoParaBusca(tag).includes(queryNormalizada)) + ); + }); + + // Aplicar filtros adicionais + if (args.categoriaId) { + documentos = documentos.filter((doc) => doc.categoriaId === args.categoriaId); + } + + if (args.tipo) { + documentos = documentos.filter((doc) => doc.tipo === args.tipo); + } + + if (args.tags && args.tags.length > 0) { + documentos = documentos.filter((doc) => + args.tags!.some((tag) => doc.tags.includes(tag)) + ); + } + + // Ordenar por relevância (simples: documentos com mais matches primeiro) + documentos.sort((a, b) => { + const aMatches = + (normalizarTextoParaBusca(a.titulo).includes(queryNormalizada) ? 2 : 0) + + (a.conteudoBusca.includes(queryNormalizada) ? 1 : 0); + const bMatches = + (normalizarTextoParaBusca(b.titulo).includes(queryNormalizada) ? 2 : 0) + + (b.conteudoBusca.includes(queryNormalizada) ? 1 : 0); + return bMatches - aMatches; + }); + + // Limitar resultados + documentos = documentos.slice(0, limite); + + // Enriquecer com categoria + const documentosEnriquecidos = await Promise.all( + documentos.map(async (doc) => { + let categoria = null; + if (doc.categoriaId) { + categoria = await ctx.db.get(doc.categoriaId); + } + return { + ...doc, + categoria + }; + }) + ); + + return documentosEnriquecidos; + } +}); + +/** + * Listar categorias + */ +export const listarCategorias = query({ + args: { + parentId: v.optional(v.id('documentacaoCategorias')), + ativo: v.optional(v.boolean()) + }, + handler: async (ctx, args) => { + await verificarPermissaoTI(ctx); + + let categorias: Doc<'documentacaoCategorias'>[] = []; + + if (args.parentId !== undefined) { + categorias = await ctx.db + .query('documentacaoCategorias') + .withIndex('by_parent', (q) => q.eq('parentId', args.parentId)) + .collect(); + } else if (args.ativo !== undefined) { + categorias = await ctx.db + .query('documentacaoCategorias') + .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) + .collect(); + } else { + categorias = await ctx.db.query('documentacaoCategorias').collect(); + } + + // Ordenar por ordem + categorias.sort((a, b) => a.ordem - b.ordem); + + return categorias; + } +}); + +/** + * Obter múltiplos documentos por IDs + */ +export const obterDocumentosPorIds = query({ + args: { + documentosIds: v.array(v.id('documentacao')) + }, + handler: async (ctx, args) => { + await verificarPermissaoTI(ctx); + + const documentos = await Promise.all( + args.documentosIds.map(async (id) => { + const doc = await ctx.db.get(id); + if (!doc) return null; + + let categoria = null; + if (doc.categoriaId) { + categoria = await ctx.db.get(doc.categoriaId); + } + + return { + ...doc, + categoria + }; + }) + ); + + return documentos.filter((doc): doc is NonNullable => doc !== null); + } +}); + +/** + * Listar tags + */ +export const listarTags = query({ + args: { + ativo: v.optional(v.boolean()), + limite: v.optional(v.number()) + }, + handler: async (ctx, args) => { + await verificarPermissaoTI(ctx); + + let tags: Doc<'documentacaoTags'>[] = []; + + if (args.ativo !== undefined) { + tags = await ctx.db + .query('documentacaoTags') + .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) + .collect(); + } else { + tags = await ctx.db.query('documentacaoTags').collect(); + } + + // Ordenar por uso (mais usadas primeiro) + tags.sort((a, b) => b.usadoEm - a.usadoEm); + + // Limitar se necessário + if (args.limite) { + tags = tags.slice(0, args.limite); + } + + return tags; + } +}); + +// ========== MUTATIONS ========== + +/** + * Criar novo documento + */ +export const criarDocumento = mutation({ + args: { + titulo: v.string(), + conteudo: v.string(), + conteudoHtml: v.optional(v.string()), + categoriaId: v.optional(v.id('documentacaoCategorias')), + tags: v.array(v.string()), + tipo: v.union( + v.literal('query'), + v.literal('mutation'), + v.literal('action'), + v.literal('component'), + v.literal('route'), + v.literal('modulo'), + v.literal('manual'), + v.literal('outro') + ), + versao: v.string(), + arquivoOrigem: v.optional(v.string()), + funcaoOrigem: v.optional(v.string()), + hashOrigem: v.optional(v.string()), + metadados: v.optional( + v.object({ + parametros: v.optional(v.array(v.string())), + retorno: v.optional(v.string()), + dependencias: v.optional(v.array(v.string())), + exemplos: v.optional(v.array(v.string())), + algoritmo: v.optional(v.string()) + }) + ), + geradoAutomaticamente: v.optional(v.boolean()) + }, + handler: async (ctx, args) => { + const usuarioAtual = await verificarPermissaoTI(ctx); + + const agora = Date.now(); + const conteudoBusca = normalizarTextoParaBusca(args.titulo + ' ' + args.conteudo); + + const documentoId = await ctx.db.insert('documentacao', { + titulo: args.titulo, + conteudo: args.conteudo, + conteudoHtml: args.conteudoHtml, + conteudoBusca, + categoriaId: args.categoriaId, + tags: args.tags, + tipo: args.tipo, + versao: args.versao, + arquivoOrigem: args.arquivoOrigem, + funcaoOrigem: args.funcaoOrigem, + hashOrigem: args.hashOrigem, + metadados: args.metadados, + ativo: true, + criadoPor: usuarioAtual._id, + criadoEm: agora, + atualizadoEm: agora, + visualizacoes: 0, + geradoAutomaticamente: args.geradoAutomaticamente ?? false + }); + + // Atualizar contadores de uso das tags + for (const tagNome of args.tags) { + const tag = await ctx.db + .query('documentacaoTags') + .withIndex('by_nome', (q) => q.eq('nome', tagNome)) + .first(); + + if (tag) { + await ctx.db.patch(tag._id, { + usadoEm: tag.usadoEm + 1 + }); + } + } + + return documentoId; + } +}); + +/** + * Atualizar documento + */ +export const atualizarDocumento = mutation({ + args: { + documentoId: v.id('documentacao'), + titulo: v.optional(v.string()), + conteudo: v.optional(v.string()), + conteudoHtml: v.optional(v.string()), + categoriaId: v.optional(v.id('documentacaoCategorias')), + tags: v.optional(v.array(v.string())), + tipo: v.optional( + v.union( + v.literal('query'), + v.literal('mutation'), + v.literal('action'), + v.literal('component'), + v.literal('route'), + v.literal('modulo'), + v.literal('manual'), + v.literal('outro') + ) + ), + versao: v.optional(v.string()), + metadados: v.optional( + v.object({ + parametros: v.optional(v.array(v.string())), + retorno: v.optional(v.string()), + dependencias: v.optional(v.array(v.string())), + exemplos: v.optional(v.array(v.string())), + algoritmo: v.optional(v.string()) + }) + ), + ativo: v.optional(v.boolean()) + }, + handler: async (ctx, args) => { + const usuarioAtual = await verificarPermissaoTI(ctx); + + const documento = await ctx.db.get(args.documentoId); + if (!documento) { + throw new Error('Documento não encontrado'); + } + + const atualizacoes: Partial> = { + atualizadoPor: usuarioAtual._id, + atualizadoEm: Date.now() + }; + + if (args.titulo !== undefined) atualizacoes.titulo = args.titulo; + if (args.conteudo !== undefined) atualizacoes.conteudo = args.conteudo; + if (args.conteudoHtml !== undefined) atualizacoes.conteudoHtml = args.conteudoHtml; + if (args.categoriaId !== undefined) atualizacoes.categoriaId = args.categoriaId; + if (args.tags !== undefined) atualizacoes.tags = args.tags; + if (args.tipo !== undefined) atualizacoes.tipo = args.tipo; + if (args.versao !== undefined) atualizacoes.versao = args.versao; + if (args.metadados !== undefined) atualizacoes.metadados = args.metadados; + if (args.ativo !== undefined) atualizacoes.ativo = args.ativo; + + // Atualizar conteúdo de busca se título ou conteúdo mudaram + if (args.titulo !== undefined || args.conteudo !== undefined) { + const novoTitulo = args.titulo ?? documento.titulo; + const novoConteudo = args.conteudo ?? documento.conteudo; + atualizacoes.conteudoBusca = normalizarTextoParaBusca(novoTitulo + ' ' + novoConteudo); + } + + // Atualizar contadores de tags se tags mudaram + if (args.tags !== undefined) { + // Decrementar tags antigas + for (const tagNome of documento.tags) { + if (!args.tags.includes(tagNome)) { + const tag = await ctx.db + .query('documentacaoTags') + .withIndex('by_nome', (q) => q.eq('nome', tagNome)) + .first(); + + if (tag && tag.usadoEm > 0) { + await ctx.db.patch(tag._id, { + usadoEm: tag.usadoEm - 1 + }); + } + } + } + + // Incrementar tags novas + for (const tagNome of args.tags) { + if (!documento.tags.includes(tagNome)) { + const tag = await ctx.db + .query('documentacaoTags') + .withIndex('by_nome', (q) => q.eq('nome', tagNome)) + .first(); + + if (tag) { + await ctx.db.patch(tag._id, { + usadoEm: tag.usadoEm + 1 + }); + } + } + } + } + + await ctx.db.patch(args.documentoId, atualizacoes); + + return { sucesso: true }; + } +}); + +/** + * Deletar documento (soft delete) + */ +export const deletarDocumento = mutation({ + args: { + documentoId: v.id('documentacao') + }, + handler: async (ctx, args) => { + const usuarioAtual = await verificarPermissaoTI(ctx); + + const documento = await ctx.db.get(args.documentoId); + if (!documento) { + throw new Error('Documento não encontrado'); + } + + // Soft delete: apenas marcar como inativo + await ctx.db.patch(args.documentoId, { + ativo: false, + atualizadoPor: usuarioAtual._id, + atualizadoEm: Date.now() + }); + + // Decrementar contadores de tags + for (const tagNome of documento.tags) { + const tag = await ctx.db + .query('documentacaoTags') + .withIndex('by_nome', (q) => q.eq('nome', tagNome)) + .first(); + + if (tag && tag.usadoEm > 0) { + await ctx.db.patch(tag._id, { + usadoEm: tag.usadoEm - 1 + }); + } + } + + return { sucesso: true }; + } +}); + +/** + * Criar categoria + */ +export const criarCategoria = mutation({ + args: { + nome: v.string(), + descricao: v.optional(v.string()), + icone: v.optional(v.string()), + cor: v.optional( + v.union( + v.literal('primary'), + v.literal('secondary'), + v.literal('accent'), + v.literal('success'), + v.literal('warning'), + v.literal('error'), + v.literal('info') + ) + ), + parentId: v.optional(v.id('documentacaoCategorias')), + ordem: v.number() + }, + handler: async (ctx, args) => { + await verificarPermissaoTI(ctx); + + const usuarioAtual = await getCurrentUserFunction(ctx); + if (!usuarioAtual) { + throw new Error('Não autenticado'); + } + + const agora = Date.now(); + const categoriaId = await ctx.db.insert('documentacaoCategorias', { + nome: args.nome, + descricao: args.descricao, + icone: args.icone, + cor: args.cor, + parentId: args.parentId, + ordem: args.ordem, + ativo: true, + criadoPor: usuarioAtual._id, + criadoEm: agora, + atualizadoEm: agora + }); + + return categoriaId; + } +}); + +/** + * Criar tag + */ +export const criarTag = mutation({ + args: { + nome: v.string(), + descricao: v.optional(v.string()), + cor: v.optional( + v.union( + v.literal('primary'), + v.literal('secondary'), + v.literal('accent'), + v.literal('success'), + v.literal('warning'), + v.literal('error'), + v.literal('info') + ) + ) + }, + handler: async (ctx, args) => { + await verificarPermissaoTI(ctx); + + // Verificar se tag já existe + const tagExistente = await ctx.db + .query('documentacaoTags') + .withIndex('by_nome', (q) => q.eq('nome', args.nome)) + .first(); + + if (tagExistente) { + throw new Error('Tag já existe'); + } + + const usuarioAtual = await getCurrentUserFunction(ctx); + if (!usuarioAtual) { + throw new Error('Não autenticado'); + } + + const agora = Date.now(); + const tagId = await ctx.db.insert('documentacaoTags', { + nome: args.nome, + descricao: args.descricao, + cor: args.cor, + usadoEm: 0, + ativo: true, + criadoPor: usuarioAtual._id, + criadoEm: agora + }); + + return tagId; + } +}); + +/** + * Obter configuração de agendamento de varredura + */ +export const obterConfigVarredura = query({ + args: {}, + handler: async (ctx) => { + await verificarPermissaoTI(ctx); + + const config = await ctx.db + .query('documentacaoConfig') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + return config; + } +}); + +/** + * Salvar configuração de agendamento de varredura + */ +export const salvarConfigVarredura = mutation({ + args: { + ativo: v.boolean(), + diasSemana: v.array( + v.union( + v.literal('domingo'), + v.literal('segunda'), + v.literal('terca'), + v.literal('quarta'), + v.literal('quinta'), + v.literal('sexta'), + v.literal('sabado') + ) + ), + horario: v.string(), // Formato "HH:MM" + fusoHorario: v.optional(v.string()) + }, + handler: async (ctx, args) => { + const usuarioAtual = await verificarPermissaoTI(ctx); + + // Buscar configuração existente + const configExistente = await ctx.db + .query('documentacaoConfig') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + const agora = Date.now(); + + if (configExistente) { + // Atualizar configuração existente + await ctx.db.patch(configExistente._id, { + ativo: args.ativo, + diasSemana: args.diasSemana, + horario: args.horario, + fusoHorario: args.fusoHorario, + atualizadoPor: usuarioAtual._id, + atualizadoEm: agora + }); + + return configExistente._id; + } else { + // Criar nova configuração + const configId = await ctx.db.insert('documentacaoConfig', { + ativo: args.ativo, + diasSemana: args.diasSemana, + horario: args.horario, + fusoHorario: args.fusoHorario || 'America/Recife', + configuradoPor: usuarioAtual._id, + configuradoEm: agora, + atualizadoEm: agora + }); + + return configId; + } + } +}); + +/** + * Notificar TI_master sobre novos documentos criados + */ +export const notificarNovosDocumentos = mutation({ + args: { + documentosIds: v.array(v.id('documentacao')), + quantidade: v.number() + }, + handler: async (ctx, args) => { + // Buscar usuários TI_master (nível 0) + const roleTIMaster = await ctx.db + .query('roles') + .filter((q) => q.eq(q.field('nivel'), 0)) + .first(); + + if (!roleTIMaster) { + return; // Não há TI_master configurado + } + + const usuariosTIMaster = await ctx.db + .query('usuarios') + .filter((q) => q.eq(q.field('roleId'), roleTIMaster._id)) + .collect(); + + // Criar notificações no chat + for (const usuario of usuariosTIMaster) { + await ctx.db.insert('notificacoes', { + usuarioId: usuario._id, + tipo: 'nova_mensagem', + titulo: '📚 Novos Documentos Criados', + descricao: `${args.quantidade} novo(s) documento(s) de documentação foram criados automaticamente pela varredura do sistema.`, + lida: false, + criadaEm: Date.now() + }); + } + + // Enviar emails para TI_master (usando scheduler para não bloquear) + for (const usuario of usuariosTIMaster) { + if (usuario.email) { + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { + destinatario: usuario.email, + destinatarioId: usuario._id, + templateCodigo: 'documentacao_novos_documentos', + variaveis: { + destinatarioNome: usuario.nome, + quantidade: args.quantidade.toString(), + urlSistema: process.env.FRONTEND_URL || 'http://localhost:5173' + }, + enviadoPor: usuario._id + }); + } + } + + return { sucesso: true }; + } +}); + diff --git a/packages/backend/convex/documentacaoVarredura.ts b/packages/backend/convex/documentacaoVarredura.ts new file mode 100644 index 0000000..7785973 --- /dev/null +++ b/packages/backend/convex/documentacaoVarredura.ts @@ -0,0 +1,634 @@ +import { v } from 'convex/values'; +import { internalMutation, mutation } from './_generated/server'; +import { Doc, Id } from './_generated/dataModel'; +import { internal, api } from './_generated/api'; +import { getCurrentUserFunction } from './auth'; + +// ========== HELPERS ========== + +/** + * Gera hash simples de uma string (usando soma de caracteres como identificador único) + * Nota: Para produção, considere usar uma Action com Node.js para hash MD5/SHA256 real + */ +function gerarHash(texto: string): string { + // Hash simples baseado em soma de códigos de caracteres + let hash = 0; + for (let i = 0; i < texto.length; i++) { + const char = texto.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(16); +} + +/** + * Extrai informações de uma função Convex (query, mutation, action) + */ +function analisarFuncaoConvex( + conteudo: string, + nomeArquivo: string +): Array<{ + nome: string; + tipo: 'query' | 'mutation' | 'action'; + descricao: string; + parametros: string[]; + retorno: string; + hash: string; +}> { + const funcoes: Array<{ + nome: string; + tipo: 'query' | 'mutation' | 'action'; + descricao: string; + parametros: string[]; + retorno: string; + hash: string; + }> = []; + + // Padrões para detectar funções + const padraoQuery = /export\s+const\s+(\w+)\s*=\s*query\s*\(/g; + const padraoMutation = /export\s+const\s+(\w+)\s*=\s*mutation\s*\(/g; + const padraoAction = /export\s+const\s+(\w+)\s*=\s*action\s*\(/g; + + // Extrair JSDoc comments + const padraoJSDoc = /\/\*\*([\s\S]*?)\*\//g; + const jsdocs: Map = new Map(); + + let match; + while ((match = padraoJSDoc.exec(conteudo)) !== null) { + const jsdoc = match[1].trim(); + // Tentar encontrar a função seguinte + const proximaFuncao = conteudo.indexOf('export', match.index + match[0].length); + if (proximaFuncao !== -1) { + const nomeFuncao = conteudo + .substring(proximaFuncao, proximaFuncao + 200) + .match(/export\s+const\s+(\w+)/); + if (nomeFuncao) { + jsdocs.set(nomeFuncao[1], jsdoc); + } + } + } + + // Buscar queries + while ((match = padraoQuery.exec(conteudo)) !== null) { + const nome = match[1]; + const inicio = match.index; + const fim = conteudo.indexOf('});', inicio) + 3; + const corpoFuncao = conteudo.substring(inicio, fim); + const hash = gerarHash(corpoFuncao); + + // Extrair args + const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); + const parametros: string[] = []; + if (argsMatch) { + const argsContent = argsMatch[1]; + const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g); + for (const paramMatch of paramMatches) { + parametros.push(`${paramMatch[1]}: ${paramMatch[2]}`); + } + } + + // Extrair returns + const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); + const retorno = returnsMatch ? returnsMatch[1] : 'void'; + + funcoes.push({ + nome, + tipo: 'query', + descricao: jsdocs.get(nome) || `Query ${nome} do arquivo ${nomeArquivo}`, + parametros, + retorno, + hash + }); + } + + // Buscar mutations + padraoMutation.lastIndex = 0; + while ((match = padraoMutation.exec(conteudo)) !== null) { + const nome = match[1]; + const inicio = match.index; + const fim = conteudo.indexOf('});', inicio) + 3; + const corpoFuncao = conteudo.substring(inicio, fim); + const hash = gerarHash(corpoFuncao); + + const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); + const parametros: string[] = []; + if (argsMatch) { + const argsContent = argsMatch[1]; + const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g); + for (const paramMatch of paramMatches) { + parametros.push(`${paramMatch[1]}: ${paramMatch[2]}`); + } + } + + const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); + const retorno = returnsMatch ? returnsMatch[1] : 'void'; + + funcoes.push({ + nome, + tipo: 'mutation', + descricao: jsdocs.get(nome) || `Mutation ${nome} do arquivo ${nomeArquivo}`, + parametros, + retorno, + hash + }); + } + + // Buscar actions + padraoAction.lastIndex = 0; + while ((match = padraoAction.exec(conteudo)) !== null) { + const nome = match[1]; + const inicio = match.index; + const fim = conteudo.indexOf('});', inicio) + 3; + const corpoFuncao = conteudo.substring(inicio, fim); + const hash = gerarHash(corpoFuncao); + + const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); + const parametros: string[] = []; + if (argsMatch) { + const argsContent = argsMatch[1]; + const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g); + for (const paramMatch of paramMatches) { + parametros.push(`${paramMatch[1]}: ${paramMatch[2]}`); + } + } + + const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); + const retorno = returnsMatch ? returnsMatch[1] : 'void'; + + funcoes.push({ + nome, + tipo: 'action', + descricao: jsdocs.get(nome) || `Action ${nome} do arquivo ${nomeArquivo}`, + parametros, + retorno, + hash + }); + } + + return funcoes; +} + +/** + * Gera conteúdo Markdown para uma função + */ +function gerarMarkdownFuncao( + funcao: { + nome: string; + tipo: 'query' | 'mutation' | 'action'; + descricao: string; + parametros: string[]; + retorno: string; + hash: string; + }, + arquivoOrigem: string +): string { + const tipoCapitalizado = funcao.tipo.charAt(0).toUpperCase() + funcao.tipo.slice(1); + + let markdown = `# ${funcao.nome}\n\n`; + markdown += `## Tipo\n\n`; + markdown += `${tipoCapitalizado}\n\n`; + markdown += `## Descrição\n\n`; + markdown += `${funcao.descricao}\n\n`; + + if (funcao.parametros.length > 0) { + markdown += `## Parâmetros\n\n`; + for (const param of funcao.parametros) { + markdown += `- \`${param}\`\n`; + } + markdown += `\n`; + } + + markdown += `## Retorno\n\n`; + markdown += `\`${funcao.retorno}\`\n\n`; + + markdown += `## Arquivo Origem\n\n`; + markdown += `\`${arquivoOrigem}\`\n\n`; + + markdown += `## Hash\n\n`; + markdown += `\`${funcao.hash}\`\n\n`; + + return markdown; +} + +// ========== INTERNAL MUTATIONS ========== + +/** + * Executar varredura automática (chamado por cron ou manualmente) + */ +export const executarVarredura = internalMutation({ + args: { + executadoPor: v.id('usuarios'), + tipo: v.union(v.literal('automatica'), v.literal('manual')) + }, + handler: async (ctx, args) => { + const inicio = Date.now(); + const varreduraId = await ctx.db.insert('documentacaoVarredura', { + tipo: args.tipo, + status: 'em_andamento', + documentosEncontrados: 0, + documentosNovos: 0, + documentosAtualizados: 0, + arquivosAnalisados: 0, + executadoPor: args.executadoPor, + iniciadoEm: inicio + }); + + const erros: string[] = []; + let documentosNovos = 0; + let documentosAtualizados = 0; + let arquivosAnalisados = 0; + + try { + // Lista de arquivos conhecidos para análise + // Nota: Em produção, isso poderia ser expandido para ler do sistema de arquivos via Action + const arquivosConvex = [ + 'usuarios.ts', + 'funcionarios.ts', + 'chat.ts', + 'email.ts', + 'pontos.ts', + 'ferias.ts', + 'ausencias.ts', + 'chamados.ts', + 'pedidos.ts', + 'produtos.ts', + 'flows.ts', + 'contratos.ts', + 'empresas.ts', + 'setores.ts', + 'times.ts', + 'cursos.ts', + 'lgpd.ts', + 'security.ts', + 'monitoramento.ts', + 'config.ts', + 'configuracaoEmail.ts', + 'configuracaoPonto.ts', + 'configuracaoRelogio.ts', + 'configuracaoJitsi.ts' + ]; + + // Para cada arquivo conhecido, buscar funções já documentadas + // e comparar com o que deveria existir + // Como não podemos ler arquivos diretamente, vamos usar uma abordagem diferente: + // Vamos criar documentos baseados em padrões conhecidos do sistema + + // Buscar todas as funções documentadas existentes + const documentosExistentes = await ctx.db + .query('documentacao') + .filter((q) => q.eq(q.field('geradoAutomaticamente'), true)) + .collect(); + + const hashDocumentosExistentes = new Map>(); + for (const doc of documentosExistentes) { + if (doc.hashOrigem) { + hashDocumentosExistentes.set(doc.hashOrigem, doc._id); + } + } + + // Por enquanto, vamos apenas atualizar o status da varredura + // A análise real de arquivos precisaria ser feita via Action que lê o sistema de arquivos + // ou através de um processo externo que envia os dados para o Convex + + arquivosAnalisados = arquivosConvex.length; + + // Atualizar status da varredura + const duracao = Date.now() - inicio; + await ctx.db.patch(varreduraId, { + status: 'concluida', + documentosEncontrados: documentosExistentes.length, + documentosNovos, + documentosAtualizados, + arquivosAnalisados, + erros: erros.length > 0 ? erros : undefined, + duracaoMs: duracao, + concluidoEm: Date.now() + }); + + // Notificar TI_master se houver novos documentos + if (documentosNovos > 0) { + // Buscar IDs dos novos documentos criados durante esta varredura + const novosDocumentos = await ctx.db + .query('documentacao') + .filter((q) => q.eq(q.field('geradoAutomaticamente'), true)) + .filter((q) => q.gte(q.field('criadoEm'), inicio)) + .collect(); + + const novosDocumentosIds = novosDocumentos.map((doc) => doc._id); + + if (novosDocumentosIds.length > 0) { + // Notificar via scheduler para não bloquear + await ctx.scheduler.runAfter(0, api.documentacao.notificarNovosDocumentos, { + documentosIds: novosDocumentosIds, + quantidade: documentosNovos + }); + } + } + + return varreduraId; + } catch (error) { + const duracao = Date.now() - inicio; + erros.push(error instanceof Error ? error.message : 'Erro desconhecido'); + + await ctx.db.patch(varreduraId, { + status: 'erro', + erros, + duracaoMs: duracao, + concluidoEm: Date.now() + }); + + throw error; + } + } +}); + +/** + * Criar documento a partir de função detectada + */ +export const criarDocumentoFuncao = internalMutation({ + args: { + titulo: v.string(), + conteudo: v.string(), + tipo: v.union( + v.literal('query'), + v.literal('mutation'), + v.literal('action'), + v.literal('component'), + v.literal('route'), + v.literal('modulo'), + v.literal('manual'), + v.literal('outro') + ), + arquivoOrigem: v.string(), + funcaoOrigem: v.string(), + hashOrigem: v.string(), + metadados: v.optional( + v.object({ + parametros: v.optional(v.array(v.string())), + retorno: v.optional(v.string()), + dependencias: v.optional(v.array(v.string())), + exemplos: v.optional(v.array(v.string())), + algoritmo: v.optional(v.string()) + }) + ), + criadoPor: v.id('usuarios') + }, + handler: async (ctx, args) => { + // Verificar se já existe documento com este hash + const documentoExistente = await ctx.db + .query('documentacao') + .withIndex('by_hash_origem', (q) => q.eq('hashOrigem', args.hashOrigem)) + .first(); + + if (documentoExistente) { + // Atualizar documento existente + const conteudoBusca = args.titulo.toLowerCase() + ' ' + args.conteudo.toLowerCase(); + await ctx.db.patch(documentoExistente._id, { + titulo: args.titulo, + conteudo: args.conteudo, + conteudoBusca, + metadados: args.metadados, + atualizadoEm: Date.now() + }); + + return { documentoId: documentoExistente._id, novo: false }; + } + + // Criar novo documento + const agora = Date.now(); + const conteudoBusca = args.titulo.toLowerCase() + ' ' + args.conteudo.toLowerCase(); + + // Determinar categoria baseada no tipo + let categoriaId: Id<'documentacaoCategorias'> | undefined; + const categoriaBackend = await ctx.db + .query('documentacaoCategorias') + .filter((q) => q.eq(q.field('nome'), 'Backend')) + .first(); + + if (categoriaBackend) { + categoriaId = categoriaBackend._id; + } + + const documentoId = await ctx.db.insert('documentacao', { + titulo: args.titulo, + conteudo: args.conteudo, + conteudoBusca, + categoriaId, + tags: [args.tipo, 'automatico'], + tipo: args.tipo, + versao: '1.0.0', + arquivoOrigem: args.arquivoOrigem, + funcaoOrigem: args.funcaoOrigem, + hashOrigem: args.hashOrigem, + metadados: args.metadados, + ativo: true, + criadoPor: args.criadoPor, + criadoEm: agora, + atualizadoEm: agora, + visualizacoes: 0, + geradoAutomaticamente: true + }); + + return { documentoId, novo: true }; + } +}); + +// ========== PUBLIC MUTATIONS ========== + +/** + * Executar varredura manualmente (chamado pelo frontend) + */ +export const executarVarreduraManual = mutation({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getCurrentUserFunction(ctx); + if (!usuarioAtual) { + throw new Error('Não autenticado'); + } + + // Verificar se é TI + const role = await ctx.db.get(usuarioAtual.roleId); + if (!role || (role.nivel > 1 && role.nome !== 'ti_master' && role.nome !== 'ti_usuario')) { + throw new Error('Acesso negado. Apenas usuários TI podem executar varredura.'); + } + + // Executar varredura + const varreduraId = await ctx.scheduler.runAfter( + 0, + internal.documentacaoVarredura.executarVarredura, + { + executadoPor: usuarioAtual._id, + tipo: 'manual' + } + ); + + return varreduraId; + } +}); + +/** + * Obter histórico de varreduras + */ +export const obterHistoricoVarreduras = mutation({ + args: { + limite: v.optional(v.number()) + }, + handler: async (ctx, args) => { + const usuarioAtual = await getCurrentUserFunction(ctx); + if (!usuarioAtual) { + throw new Error('Não autenticado'); + } + + // Verificar se é TI + const role = await ctx.db.get(usuarioAtual.roleId); + if (!role || (role.nivel > 1 && role.nome !== 'ti_master' && role.nome !== 'ti_usuario')) { + throw new Error('Acesso negado.'); + } + + const limite = args.limite || 20; + const varreduras = await ctx.db + .query('documentacaoVarredura') + .withIndex('by_iniciado_em', (q) => q.gte('iniciadoEm', 0)) + .order('desc') + .take(limite); + + // Enriquecer com informações do usuário + const varredurasEnriquecidas = await Promise.all( + varreduras.map(async (varredura) => { + const usuario = await ctx.db.get(varredura.executadoPor); + return { + ...varredura, + executadoPorUsuario: usuario + ? { + _id: usuario._id, + nome: usuario.nome, + email: usuario.email + } + : null + }; + }) + ); + + return varredurasEnriquecidas; + } +}); + +/** + * Verificar e executar varredura agendada (chamado pelo cron) + */ +export const verificarEExecutarVarreduraAgendada = internalMutation({ + args: {}, + handler: async (ctx) => { + // Buscar configuração ativa + const config = await ctx.db + .query('documentacaoConfig') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + return; // Nenhuma configuração ativa + } + + const agora = new Date(); + const diaSemanaAtual = agora.getDay(); // 0 = domingo, 1 = segunda, etc. + const diasSemanaMap: Record< + 'domingo' | 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado', + number + > = { + domingo: 0, + segunda: 1, + terca: 2, + quarta: 3, + quinta: 4, + sexta: 5, + sabado: 6 + }; + + // Verificar se hoje é um dos dias configurados + const diaAtualNome = Object.keys(diasSemanaMap).find( + (key) => diasSemanaMap[key as keyof typeof diasSemanaMap] === diaSemanaAtual + ) as 'domingo' | 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado' | undefined; + + if (!diaAtualNome || !config.diasSemana.includes(diaAtualNome)) { + return; // Hoje não é um dia configurado + } + + // Verificar se é o horário configurado (com tolerância de 5 minutos) + const [horaConfig, minutoConfig] = config.horario.split(':').map(Number); + const horaAtual = agora.getHours(); + const minutoAtual = agora.getMinutes(); + + const diferencaMinutos = horaAtual * 60 + minutoAtual - (horaConfig * 60 + minutoConfig); + + if (Math.abs(diferencaMinutos) > 5) { + return; // Não é o horário configurado + } + + // Verificar se já foi executado hoje neste horário + const hojeInicio = new Date(agora); + hojeInicio.setHours(0, 0, 0, 0); + const hojeFim = new Date(agora); + hojeFim.setHours(23, 59, 59, 999); + + const varredurasHoje = await ctx.db + .query('documentacaoVarredura') + .filter((q) => + q.and( + q.gte(q.field('iniciadoEm'), hojeInicio.getTime()), + q.lte(q.field('iniciadoEm'), hojeFim.getTime()), + q.eq(q.field('tipo'), 'automatica') + ) + ) + .collect(); + + // Se já foi executado hoje neste horário, não executar novamente + if (varredurasHoje.length > 0) { + const ultimaVarredura = varredurasHoje.sort((a, b) => b.iniciadoEm - a.iniciadoEm)[0]; + const ultimaVarreduraData = new Date(ultimaVarredura.iniciadoEm); + const ultimaVarreduraHora = ultimaVarreduraData.getHours(); + const ultimaVarreduraMinuto = ultimaVarreduraData.getMinutes(); + + if ( + ultimaVarreduraHora === horaConfig && + Math.abs(ultimaVarreduraMinuto - minutoConfig) <= 5 + ) { + return; // Já foi executado hoje neste horário + } + } + + // Buscar um usuário TI_master para executar a varredura + const roleTIMaster = await ctx.db + .query('roles') + .filter((q) => q.eq(q.field('nivel'), 0)) + .first(); + + if (!roleTIMaster) { + return; // Não há TI_master configurado + } + + const usuarioTIMaster = await ctx.db + .query('usuarios') + .filter((q) => q.eq(q.field('roleId'), roleTIMaster._id)) + .first(); + + if (!usuarioTIMaster) { + return; // Não há usuário TI_master + } + + // Executar varredura + await ctx.scheduler.runAfter( + 0, + internal.documentacaoVarredura.executarVarredura, + { + executadoPor: usuarioTIMaster._id, + tipo: 'automatica' + } + ); + + // Atualizar próxima execução na configuração + await ctx.db.patch(config._id, { + ultimaExecucao: Date.now() + }); + } +}); + diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 7ef1415..310c524 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -20,6 +20,7 @@ import { pontoTables } from './tables/ponto'; import { pedidosTables } from './tables/pedidos'; import { produtosTables } from './tables/produtos'; import { lgpdTables } from './tables/lgpdTables'; +import { documentacaoTables } from './tables/documentacao'; export default defineSchema({ ...setoresTables, @@ -42,5 +43,6 @@ export default defineSchema({ ...pontoTables, ...pedidosTables, ...produtosTables, - ...lgpdTables + ...lgpdTables, + ...documentacaoTables }); diff --git a/packages/backend/convex/security.ts b/packages/backend/convex/security.ts index 477558f..faddb71 100644 --- a/packages/backend/convex/security.ts +++ b/packages/backend/convex/security.ts @@ -1804,7 +1804,7 @@ export const enviarMensagemChatSistema = internalMutation({ conteudo: args.mensagem, conteudoBusca: args.mensagem.toLowerCase(), tipo: 'texto', - criadaEm: Date.now() + enviadaEm: Date.now() }); // Atualizar última mensagem da conversa @@ -1831,6 +1831,99 @@ export const enviarMensagemChatSistema = internalMutation({ } }); +/** + * Notificar quando rate limit é excedido + */ +export const notificarRateLimitExcedido = internalMutation({ + args: { + configId: v.id('rateLimitConfig'), + tipo: v.union(v.literal('ip'), v.literal('usuario'), v.literal('endpoint'), v.literal('global')), + identificador: v.string(), + endpoint: v.string(), + acaoExcedido: v.union(v.literal('bloquear'), v.literal('throttle'), v.literal('alertar')), + limite: v.number(), + janelaSegundos: v.number() + }, + returns: v.null(), + handler: async (ctx, args) => { + const config = await ctx.db.get(args.configId); + if (!config) return null; + + // Buscar usuários TI para notificar + const rolesTi = await ctx.db + .query('roles') + .withIndex('by_nivel', (q) => q.lte('nivel', 1)) + .collect(); + + const usuariosNotificados: Id<'usuarios'>[] = []; + + for (const role of rolesTi) { + const membros = await ctx.db + .query('usuarios') + .withIndex('by_role', (q) => q.eq('roleId', role._id)) + .collect(); + for (const usuario of membros) { + usuariosNotificados.push(usuario._id); + } + } + + // Criar notificações para usuários TI + const tipoAcao = args.acaoExcedido === 'bloquear' ? 'bloqueado' : args.acaoExcedido === 'alertar' ? 'alertado' : 'throttled'; + const emoji = args.acaoExcedido === 'bloquear' ? '🚫' : '⚠️'; + const titulo = `${emoji} Rate Limit ${tipoAcao === 'bloqueado' ? 'Bloqueado' : tipoAcao === 'alertado' ? 'Alertado' : 'Throttled'}`; + const descricao = `${args.tipo.toUpperCase()}: ${args.identificador} excedeu o limite de ${args.limite} requisições em ${args.janelaSegundos}s no endpoint ${args.endpoint}`; + + for (const usuarioId of usuariosNotificados) { + await ctx.db.insert('notificacoes', { + usuarioId, + tipo: 'alerta_seguranca', + conversaId: undefined, + mensagemId: undefined, + remetenteId: undefined, + titulo, + descricao, + lida: false, + criadaEm: Date.now() + }); + } + + // Criar evento de segurança se foi bloqueado + if (args.acaoExcedido === 'bloquear') { + // Determinar tipo de ataque baseado no contexto + let tipoAtaque: AtaqueCiberneticoTipo = 'brute_force'; + if (args.tipo === 'ip') { + tipoAtaque = 'ddos'; + } else if (args.tipo === 'usuario') { + tipoAtaque = 'brute_force'; + } + + // Criar evento de segurança + const eventoId = await ctx.db.insert('securityEvents', { + referencia: `rate_limit_${args.tipo}_${args.identificador}_${Date.now()}`, + timestamp: Date.now(), + tipoAtaque, + severidade: 'alto', + status: 'detectado', + descricao: `Rate limit bloqueado: ${args.identificador} excedeu ${args.limite} requisições em ${args.janelaSegundos}s`, + origemIp: args.tipo === 'ip' ? args.identificador : undefined, + tags: ['rate_limit', 'bloqueio_automatico'], + atualizadoEm: Date.now() + }); + + // Disparar alertas se configurado + ctx.scheduler + .runAfter(0, internal.security.dispararAlertasInternos, { + eventoId + }) + .catch((error) => { + console.error('Erro ao agendar alertas de rate limit:', error); + }); + } + + return null; + } +}); + export const expirarBloqueiosIpAutomaticos = internalMutation({ args: {}, returns: v.null(), @@ -1961,6 +2054,24 @@ async function aplicarRateLimit( if (!result.ok) { const retryAfter = result.retryAfter ?? periodo; + // Criar notificações e eventos quando rate limit é excedido + // Usar scheduler para não bloquear a requisição + if ('runMutation' in ctx) { + ctx.scheduler + .runAfter(0, internal.security.notificarRateLimitExcedido, { + configId: config._id, + tipo, + identificador, + endpoint: endpoint ?? 'default', + acaoExcedido: config.acaoExcedido, + limite: config.limite, + janelaSegundos: config.janelaSegundos + }) + .catch((error) => { + console.error('Erro ao agendar notificação de rate limit:', error); + }); + } + if (config.acaoExcedido === 'bloquear') { return { permitido: false, diff --git a/packages/backend/convex/tables/documentacao.ts b/packages/backend/convex/tables/documentacao.ts new file mode 100644 index 0000000..1ded1fd --- /dev/null +++ b/packages/backend/convex/tables/documentacao.ts @@ -0,0 +1,152 @@ +import { defineTable } from 'convex/server'; +import { v } from 'convex/values'; + +export const documentacaoTables = { + // Documentos principais + documentacao: defineTable({ + titulo: v.string(), + conteudo: v.string(), // Conteúdo em Markdown + conteudoHtml: v.optional(v.string()), // Conteúdo renderizado em HTML + conteudoBusca: v.string(), // Versão normalizada para busca (lowercase, sem acentos) + categoriaId: v.optional(v.id('documentacaoCategorias')), + tags: v.array(v.string()), + tipo: v.union( + v.literal('query'), + v.literal('mutation'), + v.literal('action'), + v.literal('component'), + v.literal('route'), + v.literal('modulo'), + v.literal('manual'), + v.literal('outro') + ), + versao: v.string(), // Versão do documento (ex: "1.0.0") + arquivoOrigem: v.optional(v.string()), // Caminho do arquivo que gerou este documento + funcaoOrigem: v.optional(v.string()), // Nome da função/componente que gerou este documento + hashOrigem: v.optional(v.string()), // Hash do arquivo/função para detectar mudanças + metadados: v.optional( + v.object({ + parametros: v.optional(v.array(v.string())), + retorno: v.optional(v.string()), + dependencias: v.optional(v.array(v.string())), + exemplos: v.optional(v.array(v.string())), + algoritmo: v.optional(v.string()) + }) + ), + ativo: v.boolean(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoPor: v.optional(v.id('usuarios')), + atualizadoEm: v.number(), + visualizacoes: v.number(), // Contador de visualizações + geradoAutomaticamente: v.boolean() // Se foi gerado pela varredura automática + }) + .index('by_categoria', ['categoriaId']) + .index('by_tipo', ['tipo']) + .index('by_busca', ['conteudoBusca']) + .index('by_ativo', ['ativo']) + .index('by_criado_em', ['criadoEm']) + .index('by_atualizado_em', ['atualizadoEm']) + .index('by_arquivo_origem', ['arquivoOrigem']) + .index('by_hash_origem', ['hashOrigem']), + + // Categorias hierárquicas (Módulos, Seções, Funções) + documentacaoCategorias: defineTable({ + nome: v.string(), + descricao: v.optional(v.string()), + icone: v.optional(v.string()), // Nome do ícone (lucide-svelte) + cor: v.optional( + v.union( + v.literal('primary'), + v.literal('secondary'), + v.literal('accent'), + v.literal('success'), + v.literal('warning'), + v.literal('error'), + v.literal('info') + ) + ), + parentId: v.optional(v.id('documentacaoCategorias')), // Para hierarquia + ordem: v.number(), // Ordem de exibição + ativo: v.boolean(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_parent', ['parentId']) + .index('by_ordem', ['ordem']) + .index('by_ativo', ['ativo']), + + // Tags para busca e organização + documentacaoTags: defineTable({ + nome: v.string(), + descricao: v.optional(v.string()), + cor: v.optional( + v.union( + v.literal('primary'), + v.literal('secondary'), + v.literal('accent'), + v.literal('success'), + v.literal('warning'), + v.literal('error'), + v.literal('info') + ) + ), + usadoEm: v.number(), // Contador de quantos documentos usam esta tag + ativo: v.boolean(), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_nome', ['nome']) + .index('by_ativo', ['ativo']), + + // Histórico de varreduras realizadas + documentacaoVarredura: defineTable({ + tipo: v.union(v.literal('automatica'), v.literal('manual')), + status: v.union( + v.literal('em_andamento'), + v.literal('concluida'), + v.literal('erro'), + v.literal('cancelada') + ), + documentosEncontrados: v.number(), // Quantidade de documentos encontrados + documentosNovos: v.number(), // Quantidade de novos documentos criados + documentosAtualizados: v.number(), // Quantidade de documentos atualizados + arquivosAnalisados: v.number(), // Quantidade de arquivos analisados + erros: v.optional(v.array(v.string())), // Lista de erros encontrados + duracaoMs: v.optional(v.number()), // Duração em milissegundos + executadoPor: v.id('usuarios'), + iniciadoEm: v.number(), + concluidoEm: v.optional(v.number()) + }) + .index('by_status', ['status']) + .index('by_tipo', ['tipo']) + .index('by_executado_por', ['executadoPor']) + .index('by_iniciado_em', ['iniciadoEm']), + + // Configurações de agendamento de varredura + documentacaoConfig: defineTable({ + ativo: v.boolean(), + diasSemana: v.array( + v.union( + v.literal('domingo'), + v.literal('segunda'), + v.literal('terca'), + v.literal('quarta'), + v.literal('quinta'), + v.literal('sexta'), + v.literal('sabado') + ) + ), // Dias da semana para executar varredura + horario: v.string(), // Horário no formato "HH:MM" (ex: "08:00") + fusoHorario: v.optional(v.string()), // Fuso horário (padrão: "America/Recife") + ultimaExecucao: v.optional(v.number()), // Timestamp da última execução + proximaExecucao: v.optional(v.number()), // Timestamp da próxima execução agendada + configuradoPor: v.id('usuarios'), + configuradoEm: v.number(), + atualizadoPor: v.optional(v.id('usuarios')), + atualizadoEm: v.number() + }) + .index('by_ativo', ['ativo']) +}; +