From 10a729baedfeffa5246acf9e00a109a5dacd41a9 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 7 Dec 2025 11:49:20 -0300 Subject: [PATCH] feat: enhance DocumentacaoCard with visualizer button and improve PDF generation with structured content and metadata handling --- .../documentacao/DocumentacaoCard.svelte | 44 +- .../documentacao/DocumentacaoModal.svelte | 322 +++++++++ .../documentacao/PdfGenerator.svelte | 230 ++++-- .../(dashboard)/ti/documentacao/+page.svelte | 85 +-- .../ti/documentacao/[id]/+page.svelte | 514 +++++++++++--- .../ti/documentacao/configuracao/+page.svelte | 351 +++++++--- packages/backend/convex/_generated/api.d.ts | 2 + .../convex/actions/documentacaoVarredura.ts | 137 ++++ .../backend/convex/documentacaoVarredura.ts | 658 ++++++++++++++++-- 9 files changed, 1987 insertions(+), 356 deletions(-) create mode 100644 apps/web/src/lib/components/documentacao/DocumentacaoModal.svelte create mode 100644 packages/backend/convex/actions/documentacaoVarredura.ts diff --git a/apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte b/apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte index 0a0e95b..e6bb380 100644 --- a/apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte +++ b/apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte @@ -2,7 +2,7 @@ import { resolve } from '$app/paths'; import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; - import { FileText, Calendar, Tag, CheckCircle2, Circle } from 'lucide-svelte'; + import { FileText, Calendar, Tag, CheckCircle2, Circle, Eye } from 'lucide-svelte'; import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; type TipoDocumento = @@ -35,9 +35,10 @@ documento: Documento; selecionado?: boolean; onToggleSelecao?: () => void; + onVisualizar?: () => void; } - let { documento, selecionado = false, onToggleSelecao }: Props = $props(); + let { documento, selecionado = false, onToggleSelecao, onVisualizar }: Props = $props(); const tipoLabels: Record = { query: 'Query', @@ -63,23 +64,25 @@
{ - if (onToggleSelecao) { - onToggleSelecao(); - } else { + // Se não houver seleção habilitada, redireciona para a página do documento + if (!onToggleSelecao) { window.location.href = resolve(`/ti/documentacao/${documento._id}`); } + // Se houver seleção, não faz nada no clique do card (apenas o checkbox seleciona) }} > {#if onToggleSelecao} + {/if} +
diff --git a/apps/web/src/lib/components/documentacao/DocumentacaoModal.svelte b/apps/web/src/lib/components/documentacao/DocumentacaoModal.svelte new file mode 100644 index 0000000..10713fa --- /dev/null +++ b/apps/web/src/lib/components/documentacao/DocumentacaoModal.svelte @@ -0,0 +1,322 @@ + + +{#if documentoId} + {#if documentoQuery === undefined} + + {:else if documentoQuery} + {@const doc = documentoQuery} + {#if doc} + + {/if} + {:else} + + {/if} +{/if} + + + diff --git a/apps/web/src/lib/components/documentacao/PdfGenerator.svelte b/apps/web/src/lib/components/documentacao/PdfGenerator.svelte index 080663b..847e14f 100644 --- a/apps/web/src/lib/components/documentacao/PdfGenerator.svelte +++ b/apps/web/src/lib/components/documentacao/PdfGenerator.svelte @@ -3,6 +3,7 @@ import { api } from '@sgse-app/backend/convex/_generated/api'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import jsPDF from 'jspdf'; + import autoTable from 'jspdf-autotable'; import { marked } from 'marked'; import { X, Download, Loader2 } from 'lucide-svelte'; @@ -31,101 +32,224 @@ } const doc = new jsPDF(); - let yPos = 20; const pageHeight = doc.internal.pageSize.getHeight(); + const pageWidth = doc.internal.pageSize.getWidth(); const margin = 20; + const primaryColor = [102, 126, 234]; + const secondaryColor = [128, 128, 128]; - // Título - doc.setFontSize(20); - doc.setTextColor(102, 126, 234); - doc.text('Biblioteca de Documentação SGSE', margin, yPos); - yPos += 15; + // Cabeçalho + doc.setFillColor(...primaryColor); + doc.rect(0, 0, pageWidth, 40, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFontSize(22); + doc.setFont('helvetica', 'bold'); + doc.text('Biblioteca de Documentação SGSE', pageWidth / 2, 25, { align: 'center' }); - doc.setFontSize(12); + let yPos = 50; + + // Informações de geração doc.setTextColor(0, 0, 0); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, margin, yPos); - yPos += 10; + yPos += 6; doc.text(`Total de documentos: ${documentos.length}`, margin, yPos); yPos += 15; - // Índice + // Índice com tabela doc.setFontSize(16); - doc.setTextColor(102, 126, 234); + doc.setTextColor(...primaryColor); + doc.setFont('helvetica', 'bold'); doc.text('Índice', margin, yPos); yPos += 10; - doc.setFontSize(10); - doc.setTextColor(0, 0, 0); - documentos.forEach((documento, index) => { - if (yPos > pageHeight - 30) { - doc.addPage(); - yPos = margin; + const indiceData = documentos.map((doc, index) => [ + (index + 1).toString(), + doc.titulo, + doc.tipo, + doc.versao + ]); + + autoTable(doc, { + startY: yPos, + head: [['#', 'Título', 'Tipo', 'Versão']], + body: indiceData, + theme: 'striped', + headStyles: { + fillColor: primaryColor, + textColor: [255, 255, 255], + fontStyle: 'bold' + }, + styles: { + fontSize: 9, + cellPadding: 3 + }, + columnStyles: { + 0: { cellWidth: 15 }, + 1: { cellWidth: 'auto' }, + 2: { cellWidth: 40 }, + 3: { cellWidth: 30 } } - doc.text(`${index + 1}. ${documento.titulo}`, margin + 5, yPos); - yPos += 7; }); - yPos += 10; + yPos = (doc as any).lastAutoTable.finalY + 15; // Documentos for (let i = 0; i < documentos.length; i++) { const documento = documentos[i]; - // Nova página para cada documento - if (i > 0 || yPos > pageHeight - 50) { + // Nova página para cada documento (exceto o primeiro) + if (i > 0) { + doc.addPage(); + yPos = margin; + } else if (yPos > pageHeight - 50) { doc.addPage(); yPos = margin; } - // Título do documento - doc.setFontSize(18); - doc.setTextColor(102, 126, 234); - doc.text(documento.titulo, margin, yPos, { maxWidth: 170 }); - yPos += 10; + // Cabeçalho do documento + doc.setFillColor(...primaryColor); + doc.rect(0, yPos - 10, pageWidth, 15, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFontSize(16); + doc.setFont('helvetica', 'bold'); + doc.text(`${i + 1}. ${documento.titulo}`, margin, yPos + 3, { maxWidth: pageWidth - 2 * margin }); + yPos += 20; - // Metadados - doc.setFontSize(10); - doc.setTextColor(128, 128, 128); - doc.text(`Tipo: ${documento.tipo} | Versão: ${documento.versao}`, margin, yPos); - yPos += 8; + // Metadados em tabela + autoTable(doc, { + startY: yPos, + body: [ + ['Tipo', documento.tipo], + ['Versão', documento.versao], + ['Arquivo Origem', documento.arquivoOrigem || 'N/A'] + ], + theme: 'plain', + styles: { + fontSize: 9, + cellPadding: 4 + }, + columnStyles: { + 0: { fontStyle: 'bold', cellWidth: 50, fillColor: [240, 240, 240] }, + 1: { cellWidth: 'auto' } + } + }); + + yPos = (doc as any).lastAutoTable.finalY + 10; // Conteúdo - doc.setFontSize(11); + doc.setFontSize(10); doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'normal'); - // Converter Markdown para texto simples (remover formatação) + // Processar Markdown de forma mais inteligente let conteudoTexto = documento.conteudo; - // Remover markdown básico - conteudoTexto = conteudoTexto.replace(/#{1,6}\s+/g, ''); // Headers - conteudoTexto = conteudoTexto.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold - conteudoTexto = conteudoTexto.replace(/\*(.*?)\*/g, '$1'); // Italic - conteudoTexto = conteudoTexto.replace(/`(.*?)`/g, '$1'); // Code - conteudoTexto = conteudoTexto.replace(/\[(.*?)\]\(.*?\)/g, '$1'); // Links - - // Dividir em linhas e adicionar ao PDF - const linhas = doc.splitTextToSize(conteudoTexto, 170); - for (const linha of linhas) { - if (yPos > pageHeight - 20) { - doc.addPage(); - yPos = margin; + + // Processar seções + const secoes = conteudoTexto.split(/\n##\s+/); + + for (let j = 0; j < secoes.length; j++) { + let secao = secoes[j]; + if (j === 0) { + // Primeira seção (antes do primeiro ##) + secao = secao.replace(/^#\s+.*?\n\n/, ''); // Remove título principal } - doc.text(linha, margin, yPos); - yPos += 6; + + // Detectar título da seção + const tituloMatch = secao.match(/^(.+?)\n/); + if (tituloMatch && j > 0) { + // Adicionar título da seção + if (yPos > pageHeight - 30) { + doc.addPage(); + yPos = margin; + } + doc.setFontSize(12); + doc.setTextColor(...primaryColor); + doc.setFont('helvetica', 'bold'); + doc.text(tituloMatch[1], margin, yPos); + yPos += 8; + secao = secao.substring(tituloMatch[0].length); + } + + // Processar conteúdo da seção + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'normal'); + + // Remover markdown básico + secao = secao.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold + secao = secao.replace(/\*(.*?)\*/g, '$1'); // Italic + secao = secao.replace(/`(.*?)`/g, '$1'); // Code inline + secao = secao.replace(/\[(.*?)\]\(.*?\)/g, '$1'); // Links + + // Processar listas + const linhas = secao.split('\n'); + for (const linha of linhas) { + if (yPos > pageHeight - 20) { + doc.addPage(); + yPos = margin; + } + + if (linha.trim().startsWith('-') || linha.trim().startsWith('*')) { + // Item de lista + const texto = linha.replace(/^[\s\-*]+/, '• '); + const linhasTexto = doc.splitTextToSize(texto, pageWidth - 2 * margin - 10); + doc.text(linhasTexto[0], margin + 5, yPos); + yPos += 5; + for (let k = 1; k < linhasTexto.length; k++) { + if (yPos > pageHeight - 20) { + doc.addPage(); + yPos = margin; + } + doc.text(linhasTexto[k], margin + 10, yPos); + yPos += 5; + } + } else if (linha.trim().startsWith('###')) { + // Subtítulo + const subtitulo = linha.replace(/^###\s+/, ''); + doc.setFontSize(11); + doc.setTextColor(...primaryColor); + doc.setFont('helvetica', 'bold'); + doc.text(subtitulo, margin + 5, yPos); + yPos += 7; + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'normal'); + } else if (linha.trim()) { + // Texto normal + const linhasTexto = doc.splitTextToSize(linha.trim(), pageWidth - 2 * margin); + for (const linhaTexto of linhasTexto) { + if (yPos > pageHeight - 20) { + doc.addPage(); + yPos = margin; + } + doc.text(linhaTexto, margin, yPos); + yPos += 5; + } + } else { + // Linha vazia + yPos += 3; + } + } + + yPos += 5; } - - yPos += 10; } // Footer em todas as páginas const pageCount = doc.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { doc.setPage(i); + doc.setFillColor(240, 240, 240); + doc.rect(0, pageHeight - 15, pageWidth, 15, 'F'); doc.setFontSize(8); - doc.setTextColor(128, 128, 128); + doc.setTextColor(...secondaryColor); + doc.setFont('helvetica', 'normal'); doc.text( `SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`, - doc.internal.pageSize.getWidth() / 2, - doc.internal.pageSize.getHeight() - 10, + pageWidth / 2, + pageHeight - 7, { align: 'center' } ); } diff --git a/apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte b/apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte index 13878e7..d52750c 100644 --- a/apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte @@ -1,28 +1,15 @@ @@ -102,7 +105,9 @@ class="border-primary/25 from-primary/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl" >
-
+
- + Configuração @@ -137,7 +139,7 @@ -
+
@@ -150,9 +152,7 @@ Filtros {#if busca || categoriaSelecionada || tipoSelecionado || tagsSelecionadas.length > 0} - + {/if}
@@ -178,7 +178,7 @@ Documentos {#if documentosQuery} - ({documentosQuery.total}) + ({totalDocumentos}) {/if} @@ -189,7 +189,7 @@
{:else if documentos.length === 0} -
+

Nenhum documento encontrado

@@ -208,6 +208,7 @@ {documento} selecionado={documentosSelecionados.includes(documento._id)} onToggleSelecao={() => toggleSelecaoDocumento(documento._id)} + onVisualizar={() => abrirModal(documento._id)} /> {/each}

@@ -225,5 +226,7 @@ }} /> {/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 index 3f4eb29..90894ba 100644 --- a/apps/web/src/routes/(dashboard)/ti/documentacao/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/documentacao/[id]/+page.svelte @@ -7,8 +7,9 @@ import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; import { marked } from 'marked'; - import { ArrowLeft, Download, Calendar, User, Tag, FileText } from 'lucide-svelte'; + import { ArrowLeft, Download, Calendar, User, Tag, FileText, BookOpen } from 'lucide-svelte'; import jsPDF from 'jspdf'; + import autoTable from 'jspdf-autotable'; const documentoId = $derived(page.params.id); const documentoQuery = useQuery( @@ -18,58 +19,196 @@ let gerandoPdf = $state(false); + // Gerar índice a partir do conteúdo Markdown + const indice = $derived(() => { + if (!documentoQuery?.conteudo) return []; + + const linhas = documentoQuery.conteudo.split('\n'); + const indices: Array<{ nivel: number; titulo: string; id: string }> = []; + + linhas.forEach((linha) => { + const match = linha.match(/^(#{1,3})\s+(.+)$/); + if (match) { + const nivel = match[1].length; + const titulo = match[2].trim(); + const id = titulo + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + indices.push({ nivel, titulo, id }); + } + }); + + return indices; + }); + + function scrollParaSecao(id: string) { + const elemento = document.getElementById(id); + if (elemento) { + elemento.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + + // Processar HTML para adicionar IDs aos títulos + const conteudoHtml = $derived(() => { + if (!documentoQuery?.conteudo) return ''; + + let html = marked.parse(documentoQuery.conteudo); + + // Adicionar IDs aos títulos + html = html.replace(/(.+?)<\/h[1-3]>/g, (match, nivel, titulo) => { + const id = titulo + .replace(/<[^>]+>/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + return `${titulo}`; + }); + + return html; + }); + async function gerarPdfIndividual() { if (!documentoQuery) return; try { gerandoPdf = true; const doc = new jsPDF(); - let yPos = 20; + const pageHeight = doc.internal.pageSize.getHeight(); + const pageWidth = doc.internal.pageSize.getWidth(); const margin = 20; + const primaryColor = [102, 126, 234]; + const secondaryColor = [128, 128, 128]; - // Título - doc.setFontSize(20); - doc.setTextColor(102, 126, 234); - doc.text(documentoQuery.titulo, margin, yPos, { maxWidth: 170 }); - yPos += 15; + // Cabeçalho + doc.setFillColor(...primaryColor); + doc.rect(0, 0, pageWidth, 40, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text(documentoQuery.titulo, pageWidth / 2, 25, { align: 'center', maxWidth: pageWidth - 2 * margin }); - // Metadados + let yPos = 50; + + // Metadados em tabela + autoTable(doc, { + startY: yPos, + body: [ + ['Tipo', documentoQuery.tipo], + ['Versão', documentoQuery.versao], + ['Arquivo Origem', documentoQuery.arquivoOrigem || 'N/A'] + ], + theme: 'plain', + styles: { + fontSize: 9, + cellPadding: 4 + }, + columnStyles: { + 0: { fontStyle: 'bold', cellWidth: 50, fillColor: [240, 240, 240] }, + 1: { cellWidth: 'auto' } + } + }); + + yPos = (doc as any).lastAutoTable.finalY + 10; + + // Conteúdo processado doc.setFontSize(10); - doc.setTextColor(128, 128, 128); - doc.text(`Tipo: ${documentoQuery.tipo} | Versão: ${documentoQuery.versao}`, margin, yPos); - yPos += 10; - - // Conteúdo - doc.setFontSize(11); doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'normal'); let conteudoTexto = documentoQuery.conteudo; - conteudoTexto = conteudoTexto.replace(/#{1,6}\s+/g, ''); - conteudoTexto = conteudoTexto.replace(/\*\*(.*?)\*\*/g, '$1'); - conteudoTexto = conteudoTexto.replace(/\*(.*?)\*/g, '$1'); - conteudoTexto = conteudoTexto.replace(/`(.*?)`/g, '$1'); - conteudoTexto = conteudoTexto.replace(/\[(.*?)\]\(.*?\)/g, '$1'); + const secoes = conteudoTexto.split(/\n##\s+/); - const linhas = doc.splitTextToSize(conteudoTexto, 170); - for (const linha of linhas) { - if (yPos > doc.internal.pageSize.getHeight() - 20) { - doc.addPage(); - yPos = margin; + for (let j = 0; j < secoes.length; j++) { + let secao = secoes[j]; + if (j === 0) { + secao = secao.replace(/^#\s+.*?\n\n/, ''); } - doc.text(linha, margin, yPos); - yPos += 6; + + const tituloMatch = secao.match(/^(.+?)\n/); + if (tituloMatch && j > 0) { + if (yPos > pageHeight - 30) { + doc.addPage(); + yPos = margin; + } + doc.setFontSize(12); + doc.setTextColor(...primaryColor); + doc.setFont('helvetica', 'bold'); + doc.text(tituloMatch[1], margin, yPos); + yPos += 8; + secao = secao.substring(tituloMatch[0].length); + } + + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'normal'); + + secao = secao.replace(/\*\*(.*?)\*\*/g, '$1'); + secao = secao.replace(/\*(.*?)\*/g, '$1'); + secao = secao.replace(/`(.*?)`/g, '$1'); + secao = secao.replace(/\[(.*?)\]\(.*?\)/g, '$1'); + + const linhas = secao.split('\n'); + for (const linha of linhas) { + if (yPos > pageHeight - 20) { + doc.addPage(); + yPos = margin; + } + + if (linha.trim().startsWith('-') || linha.trim().startsWith('*')) { + const texto = linha.replace(/^[\s\-*]+/, '• '); + const linhasTexto = doc.splitTextToSize(texto, pageWidth - 2 * margin - 10); + doc.text(linhasTexto[0], margin + 5, yPos); + yPos += 5; + for (let k = 1; k < linhasTexto.length; k++) { + if (yPos > pageHeight - 20) { + doc.addPage(); + yPos = margin; + } + doc.text(linhasTexto[k], margin + 10, yPos); + yPos += 5; + } + } else if (linha.trim().startsWith('###')) { + const subtitulo = linha.replace(/^###\s+/, ''); + doc.setFontSize(11); + doc.setTextColor(...primaryColor); + doc.setFont('helvetica', 'bold'); + doc.text(subtitulo, margin + 5, yPos); + yPos += 7; + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'normal'); + } else if (linha.trim()) { + const linhasTexto = doc.splitTextToSize(linha.trim(), pageWidth - 2 * margin); + for (const linhaTexto of linhasTexto) { + if (yPos > pageHeight - 20) { + doc.addPage(); + yPos = margin; + } + doc.text(linhaTexto, margin, yPos); + yPos += 5; + } + } else { + yPos += 3; + } + } + + yPos += 5; } // Footer const pageCount = doc.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { doc.setPage(i); + doc.setFillColor(240, 240, 240); + doc.rect(0, pageHeight - 15, pageWidth, 15, 'F'); doc.setFontSize(8); - doc.setTextColor(128, 128, 128); + doc.setTextColor(...secondaryColor); + doc.setFont('helvetica', 'normal'); doc.text( `SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`, - doc.internal.pageSize.getWidth() / 2, - doc.internal.pageSize.getHeight() - 10, + pageWidth / 2, + pageHeight - 7, { align: 'center' } ); } @@ -112,80 +251,159 @@
{:else} -
- -
-

{documentoQuery.titulo}

-
-
- - {documentoQuery.tipo} +
+ + {#if indice().length > 0} + + {/if} + + +
+ +
+
+
+
+ + {documentoQuery.tipo}
- {/if} +

{documentoQuery.titulo}

+
+
+ + + Atualizado em {documentoQuery.atualizadoEm && !isNaN(new Date(documentoQuery.atualizadoEm).getTime()) + ? format(new Date(documentoQuery.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { + locale: ptBR + }) + : 'Data não disponível'} + +
+ {#if documentoQuery.criadoPorUsuario} +
+ + Por {documentoQuery.criadoPorUsuario.nome} +
+ {/if} +
+ + Versão {documentoQuery.versao} +
+
+
- - {#if documentoQuery.tags && documentoQuery.tags.length > 0} -
- - {#each documentoQuery.tags as tag} - {tag} - {/each} -
- {/if} + +
+ + {#if documentoQuery.tags && documentoQuery.tags.length > 0} +
+ + {#each documentoQuery.tags as tag} + {tag} + {/each} +
+ {/if} - -
- {@html marked.parse(documentoQuery.conteudo)} + +
+ {@html conteudoHtml()} +
+ + + {#if documentoQuery.arquivoOrigem} +
+

+ + Informações do Arquivo +

+
+
+ Arquivo Origem: + {documentoQuery.arquivoOrigem} +
+ {#if documentoQuery.funcaoOrigem} +
+ Função Origem: + {documentoQuery.funcaoOrigem} +
+ {/if} +
+ Hash: + {documentoQuery.hash} +
+
+
+ {/if} + + + {#if documentoQuery.metadados} +
+

Detalhes Técnicos

+
+ {#if documentoQuery.metadados.parametros && documentoQuery.metadados.parametros.length > 0} +
+

Parâmetros de Entrada:

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

Valor de Retorno:

+
+

{documentoQuery.metadados.retorno}

+
+
+ {/if} + {#if documentoQuery.metadados.dependencias && documentoQuery.metadados.dependencias.length > 0} +
+

Dependências:

+
+
    + {#each documentoQuery.metadados.dependencias as dep} +
  • + + {dep} +
  • + {/each} +
+
+
+ {/if} +
+
+ {/if}
- - - {#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} @@ -193,25 +411,121 @@ diff --git a/apps/web/src/routes/(dashboard)/ti/documentacao/configuracao/+page.svelte b/apps/web/src/routes/(dashboard)/ti/documentacao/configuracao/+page.svelte index 4fbc75f..5b3b800 100644 --- a/apps/web/src/routes/(dashboard)/ti/documentacao/configuracao/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/documentacao/configuracao/+page.svelte @@ -2,6 +2,7 @@ import { resolve } from '$app/paths'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; + import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte'; import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; @@ -13,7 +14,9 @@ Calendar, CheckCircle2, XCircle, - Loader2 + Loader2, + X, + CheckCircle } from 'lucide-svelte'; const client = useConvexClient(); @@ -22,17 +25,17 @@ type DiaSemana = 'domingo' | 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado'; type ConfigVarredura = { - _id: Id<'documentacaoConfig'>; + _id?: Id<'documentacaoConfig'>; ativo: boolean; diasSemana: DiaSemana[]; horario: string; fusoHorario?: string; ultimaExecucao?: number; proximaExecucao?: number; - configuradoPor: Id<'usuarios'>; - configuradoEm: number; + configuradoPor?: Id<'usuarios'>; + configuradoEm?: number; atualizadoPor?: Id<'usuarios'>; - atualizadoEm: number; + atualizadoEm?: number; }; type Varredura = { @@ -53,12 +56,29 @@ nome: string; email: string; } | null; + arquivosModificados?: Array<{ + arquivo: string; + versao: string; + funcoes: string[]; + }>; + totalArquivosModificados?: number; }; - // Estados - let config = $state(null); + // Estados - inicializar com valores padrão + let config = $state({ + ativo: false, + diasSemana: [], + horario: '08:00', + fusoHorario: 'America/Recife' + }); let executandoVarredura = $state(false); let historicoVarreduras = $state([]); + + // Estados para modais + let showSuccessModal = $state(false); + let showErrorModal = $state(false); + let modalMessage = $state(''); + let modalTitle = $state(''); // Queries const configQuery = useQuery(api.documentacao.obterConfigVarredura, {}); @@ -67,18 +87,33 @@ $effect(() => { if (configQuery) { config = { + _id: configQuery._id, ativo: configQuery.ativo ?? false, diasSemana: configQuery.diasSemana ?? [], horario: configQuery.horario ?? '08:00', - fusoHorario: configQuery.fusoHorario ?? 'America/Recife' + fusoHorario: configQuery.fusoHorario ?? 'America/Recife', + ultimaExecucao: configQuery.ultimaExecucao, + proximaExecucao: configQuery.proximaExecucao, + configuradoPor: configQuery.configuradoPor, + configuradoEm: configQuery.configuradoEm, + atualizadoPor: configQuery.atualizadoPor, + atualizadoEm: configQuery.atualizadoEm }; + } else if (configQuery === null) { + // Se não há configuração, criar uma padrão + if (!config || !config._id) { + config = { + ativo: false, + diasSemana: [], + horario: '08:00', + fusoHorario: 'America/Recife' + }; + } } }); // Funções async function salvarConfig() { - if (!config) return; - try { await client.mutation(api.documentacao.salvarConfigVarredura, { ativo: config.ativo, @@ -86,10 +121,14 @@ horario: config.horario, fusoHorario: config.fusoHorario }); - alert('Configuração salva com sucesso!'); + modalTitle = 'Sucesso'; + modalMessage = 'Configuração salva com sucesso!'; + showSuccessModal = true; } catch (error) { console.error('Erro ao salvar configuração:', error); - alert('Erro ao salvar configuração'); + modalTitle = 'Erro'; + modalMessage = 'Erro ao salvar configuração. Tente novamente.'; + showErrorModal = true; } } @@ -97,32 +136,57 @@ try { executandoVarredura = true; await client.mutation(api.documentacaoVarredura.executarVarreduraManual, {}); - alert('Varredura iniciada! Você será notificado quando concluir.'); - // Recarregar histórico após alguns segundos - setTimeout(() => { - carregarHistorico(); - }, 3000); + modalTitle = 'Varredura Iniciada'; + modalMessage = 'Varredura iniciada! Você será notificado quando concluir.'; + showSuccessModal = true; } catch (error) { console.error('Erro ao executar varredura:', error); - alert('Erro ao executar varredura'); + modalTitle = 'Erro'; + modalMessage = 'Erro ao executar varredura. Tente novamente.'; + showErrorModal = true; } finally { executandoVarredura = false; } } - - async function carregarHistorico() { - try { - const historico = await client.mutation(api.documentacaoVarredura.obterHistoricoVarreduras, { - limite: 20 - }); - historicoVarreduras = historico || []; - } catch (error) { - console.error('Erro ao carregar histórico:', error); - } + + function fecharModal() { + showSuccessModal = false; + showErrorModal = false; + modalMessage = ''; + modalTitle = ''; } + const historicoQuery = useQuery(api.documentacaoVarredura.obterHistoricoVarreduras, () => ({ + limite: 20 + })); + + // Extrair dados do histórico $effect(() => { - carregarHistorico(); + if (historicoQuery === undefined) { + historicoVarreduras = []; + return; + } + + if (historicoQuery === null) { + historicoVarreduras = []; + return; + } + + // Se é diretamente um array, usar ele (caso mais comum) + if (Array.isArray(historicoQuery)) { + historicoVarreduras = historicoQuery; + return; + } + + // Se tem propriedade data, usar os dados + if (typeof historicoQuery === 'object' && historicoQuery !== null && 'data' in historicoQuery) { + const data = (historicoQuery as any).data; + historicoVarreduras = Array.isArray(data) ? data : []; + return; + } + + // Caso padrão + historicoVarreduras = []; }); const diasSemana = [ @@ -136,7 +200,6 @@ ]; function toggleDiaSemana(dia: DiaSemana) { - if (!config) return; if (config.diasSemana.includes(dia)) { config.diasSemana = config.diasSemana.filter((d) => d !== dia); } else { @@ -186,10 +249,6 @@
- {:else if !config} -
-

Carregando configuração...

-
{:else}
@@ -199,6 +258,51 @@
+ + {#if config._id} +
+

Configuração Atual

+
+
+ Status: + + {config.ativo ? 'Ativa' : 'Inativa'} + +
+ {#if config.ultimaExecucao && !isNaN(new Date(config.ultimaExecucao).getTime())} +
+ Última execução: + + {format(new Date(config.ultimaExecucao), "dd/MM/yyyy 'às' HH:mm", { + locale: ptBR + })} + +
+ {/if} + {#if config.proximaExecucao && !isNaN(new Date(config.proximaExecucao).getTime())} +
+ Próxima execução: + + {format(new Date(config.proximaExecucao), "dd/MM/yyyy 'às' HH:mm", { + locale: ptBR + })} + +
+ {/if} + {#if config.atualizadoEm && !isNaN(new Date(config.atualizadoEm).getTime())} +
+ Atualizado em: + + {format(new Date(config.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { + locale: ptBR + })} + +
+ {/if} +
+
+ {/if} +
@@ -272,57 +376,90 @@ Histórico de Varreduras - {#if historicoVarreduras.length === 0} + {#if historicoQuery === undefined} +
+ +
+ {:else 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} - +
+ {#each historicoVarreduras as varredura} +
+
+
+ + {varredura.tipo === 'automatica' ? 'Automática' : 'Manual'} + + + {statusLabels[varredura.status] || varredura.status} + + + {varredura.iniciadoEm && !isNaN(new Date(varredura.iniciadoEm).getTime()) + ? format(new Date(varredura.iniciadoEm), "dd/MM/yyyy 'às' HH:mm", { + locale: ptBR + }) + : 'Data não disponível'} + +
+
+ {varredura.duracaoMs + ? `${(varredura.duracaoMs / 1000).toFixed(1)}s` + : '-'} +
+
+ +
+
+ Arquivos analisados: + {varredura.arquivosAnalisados} +
+
+ Documentos novos: + {varredura.documentosNovos} +
+
+ Documentos atualizados: + {varredura.documentosAtualizados} +
+
+ Executado por: + {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 varredura.totalArquivosModificados !== undefined && varredura.totalArquivosModificados > 0} +
+
+ + Arquivos modificados: {varredura.totalArquivosModificados} + +
+
+ {#each varredura.arquivosModificados || [] as arquivo} +
+
+ + {arquivo.arquivo} + + v{arquivo.versao} +
+ {#if arquivo.funcoes.length > 0} +
+ Funções: {arquivo.funcoes.join(', ')} +
+ {/if} +
+ {/each} +
+
+ {/if} +
+ {/each}
{/if} @@ -330,3 +467,55 @@ + +{#if showSuccessModal} + +{/if} + + +{#if showErrorModal} + +{/if} + diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 548bfea..f0e786d 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as acoes from "../acoes.js"; +import type * as actions_documentacaoVarredura from "../actions/documentacaoVarredura.js"; import type * as actions_email from "../actions/email.js"; import type * as actions_linkPreview from "../actions/linkPreview.js"; import type * as actions_pushNotifications from "../actions/pushNotifications.js"; @@ -100,6 +101,7 @@ import type { declare const fullApi: ApiFromModules<{ acoes: typeof acoes; + "actions/documentacaoVarredura": typeof actions_documentacaoVarredura; "actions/email": typeof actions_email; "actions/linkPreview": typeof actions_linkPreview; "actions/pushNotifications": typeof actions_pushNotifications; diff --git a/packages/backend/convex/actions/documentacaoVarredura.ts b/packages/backend/convex/actions/documentacaoVarredura.ts new file mode 100644 index 0000000..adab71a --- /dev/null +++ b/packages/backend/convex/actions/documentacaoVarredura.ts @@ -0,0 +1,137 @@ +'use node'; + +import { action } from '../_generated/server'; +import { v } from 'convex/values'; +import { internal } from '../_generated/api'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Action para ler arquivos do sistema de arquivos e analisar funções + */ +export const analisarArquivos = action({ + args: { + varreduraId: v.id('documentacaoVarredura'), + arquivos: v.array(v.string()), + executadoPor: v.id('usuarios') + }, + returns: v.object({ + arquivosAnalisados: v.number(), + documentosNovos: v.number(), + documentosAtualizados: v.number(), + erros: v.array(v.string()) + }), + handler: async (ctx, args) => { + 'use node'; + + const erros: string[] = []; + let arquivosAnalisados = 0; + let documentosNovos = 0; + let documentosAtualizados = 0; + + console.log('🔍 [analisarArquivos] Iniciando análise de arquivos...'); + console.log(`📁 Total de arquivos: ${args.arquivos.length}`); + console.log(`📋 Arquivos: ${args.arquivos.join(', ')}`); + + // Tentar diferentes caminhos possíveis + const caminhosPossiveis = [ + path.join(process.cwd(), 'packages/backend/convex'), + path.join(process.cwd(), 'convex'), + path.resolve(__dirname, '../../convex'), + path.resolve(__dirname, '../../../convex') + ]; + + let basePath: string | null = null; + for (const caminho of caminhosPossiveis) { + if (fs.existsSync(caminho)) { + basePath = caminho; + console.log(`✅ Caminho encontrado: ${basePath}`); + break; + } + } + + if (!basePath) { + const erro = `Nenhum caminho válido encontrado. Tentados: ${caminhosPossiveis.join(', ')}`; + console.error(`❌ ${erro}`); + erros.push(erro); + await ctx.runMutation(internal.documentacaoVarredura.atualizarVarreduraComResultados, { + varreduraId: args.varreduraId, + documentosNovos: 0, + documentosAtualizados: 0, + arquivosAnalisados: 0, + erros: [erro] + }); + return { + arquivosAnalisados: 0, + documentosNovos: 0, + documentosAtualizados: 0, + erros: [erro] + }; + } + + for (const nomeArquivo of args.arquivos) { + try { + const arquivoPath = path.join(basePath, nomeArquivo); + console.log(`📄 Processando: ${arquivoPath}`); + + // Verificar se o arquivo existe + if (!fs.existsSync(arquivoPath)) { + const erro = `Arquivo não encontrado: ${nomeArquivo} (caminho: ${arquivoPath})`; + console.error(`❌ ${erro}`); + erros.push(erro); + continue; + } + + // Ler conteúdo do arquivo + const conteudo = fs.readFileSync(arquivoPath, 'utf-8'); + console.log(`✅ Arquivo lido: ${nomeArquivo} (${conteudo.length} caracteres)`); + + // Analisar funções no arquivo usando a mutation interna + const resultado = await ctx.runMutation( + internal.documentacaoVarredura.processarArquivo, + { + varreduraId: args.varreduraId, + nomeArquivo, + conteudo, + executadoPor: args.executadoPor + } + ); + + console.log( + `✅ Processado ${nomeArquivo}: ${resultado.documentosNovos} novos, ${resultado.documentosAtualizados} atualizados` + ); + + arquivosAnalisados++; + documentosNovos += resultado.documentosNovos; + documentosAtualizados += resultado.documentosAtualizados; + } catch (error) { + const erroMsg = error instanceof Error ? error.message : 'Erro desconhecido'; + const erroStack = error instanceof Error ? error.stack : undefined; + const erroCompleto = `Erro ao processar ${nomeArquivo}: ${erroMsg}${erroStack ? `\n${erroStack}` : ''}`; + console.error(`❌ ${erroCompleto}`); + erros.push(erroCompleto); + } + } + + console.log( + `📊 Resumo: ${arquivosAnalisados} arquivos analisados, ${documentosNovos} novos, ${documentosAtualizados} atualizados` + ); + + // Atualizar varredura com os resultados + await ctx.runMutation(internal.documentacaoVarredura.atualizarVarreduraComResultados, { + varreduraId: args.varreduraId, + documentosNovos, + documentosAtualizados, + arquivosAnalisados, + erros + }); + + return { + arquivosAnalisados, + documentosNovos, + documentosAtualizados, + erros + }; + } +}); + diff --git a/packages/backend/convex/documentacaoVarredura.ts b/packages/backend/convex/documentacaoVarredura.ts index 209c9ee..599ac5b 100644 --- a/packages/backend/convex/documentacaoVarredura.ts +++ b/packages/backend/convex/documentacaoVarredura.ts @@ -1,11 +1,22 @@ import { v } from 'convex/values'; -import { internalMutation, mutation } from './_generated/server'; +import { internalMutation, mutation, query } from './_generated/server'; import { Doc, Id } from './_generated/dataModel'; import { internal, api } from './_generated/api'; import { getCurrentUserFunction } from './auth'; // ========== 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(); +} + /** * 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 @@ -34,6 +45,7 @@ function analisarFuncaoConvex( parametros: string[]; retorno: string; hash: string; + corpoFuncao: string; }> { const funcoes: Array<{ nome: string; @@ -42,6 +54,7 @@ function analisarFuncaoConvex( parametros: string[]; retorno: string; hash: string; + corpoFuncao: string; }> = []; // Padrões para detectar funções @@ -49,13 +62,13 @@ function analisarFuncaoConvex( 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 + // Extrair JSDoc comments com melhor parsing const padraoJSDoc = /\/\*\*([\s\S]*?)\*\//g; - const jsdocs: Map = new Map(); + const jsdocs: Map; retorno?: string }> = new Map(); let match; while ((match = padraoJSDoc.exec(conteudo)) !== null) { - const jsdoc = match[1].trim(); + const jsdocRaw = match[1].trim(); // Tentar encontrar a função seguinte const proximaFuncao = conteudo.indexOf('export', match.index + match[0].length); if (proximaFuncao !== -1) { @@ -63,7 +76,32 @@ function analisarFuncaoConvex( .substring(proximaFuncao, proximaFuncao + 200) .match(/export\s+const\s+(\w+)/); if (nomeFuncao) { - jsdocs.set(nomeFuncao[1], jsdoc); + // Parse JSDoc para extrair descrição, @param, @returns + const linhas = jsdocRaw.split('\n').map((l) => l.trim().replace(/^\*\s*/, '')); + let descricao = ''; + const parametros = new Map(); + let retorno: string | undefined; + + for (let i = 0; i < linhas.length; i++) { + const linha = linhas[i]; + if (linha.startsWith('@param')) { + const paramMatch = linha.match(/@param\s+(\w+)\s+(.+)/); + if (paramMatch) { + parametros.set(paramMatch[1], paramMatch[2]); + } + } else if (linha.startsWith('@returns') || linha.startsWith('@return')) { + retorno = linha.replace(/@returns?\s+/, ''); + } else if (!linha.startsWith('@') && linha.length > 0) { + if (descricao) descricao += ' '; + descricao += linha; + } + } + + jsdocs.set(nomeFuncao[1], { + descricao: descricao || jsdocRaw, + parametros, + retorno + }); } } } @@ -76,28 +114,50 @@ function analisarFuncaoConvex( const corpoFuncao = conteudo.substring(inicio, fim); const hash = gerarHash(corpoFuncao); - // Extrair args + // Extrair args com mais detalhes const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); const parametros: string[] = []; + const jsdocInfo = jsdocs.get(nome); 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 paramNome = paramMatch[1]; + const paramTipo = paramMatch[2]; + const paramDesc = jsdocInfo?.parametros.get(paramNome); + if (paramDesc) { + parametros.push(`${paramNome}: ${paramTipo} - ${paramDesc}`); + } else { + parametros.push(`${paramNome}: ${paramTipo}`); + } } } // Extrair returns const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); - const retorno = returnsMatch ? returnsMatch[1] : 'void'; + let retorno = returnsMatch ? returnsMatch[1] : 'void'; + if (jsdocInfo?.retorno) { + retorno = `${retorno} - ${jsdocInfo.retorno}`; + } + + let descricao = jsdocInfo?.descricao || `Query ${nome} do arquivo ${nomeArquivo}. Esta função realiza uma consulta no banco de dados.`; + + // Adicionar análise da lógica se não houver JSDoc detalhado + if (!jsdocInfo?.descricao || jsdocInfo.descricao.length < 50) { + const logica = analisarLogicaFuncao(corpoFuncao, 'query'); + if (logica) { + descricao += ' ' + logica; + } + } funcoes.push({ nome, tipo: 'query', - descricao: jsdocs.get(nome) || `Query ${nome} do arquivo ${nomeArquivo}`, + descricao, parametros, retorno, - hash + hash, + corpoFuncao }); } @@ -112,24 +172,46 @@ function analisarFuncaoConvex( const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); const parametros: string[] = []; + const jsdocInfo = jsdocs.get(nome); 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 paramNome = paramMatch[1]; + const paramTipo = paramMatch[2]; + const paramDesc = jsdocInfo?.parametros.get(paramNome); + if (paramDesc) { + parametros.push(`${paramNome}: ${paramTipo} - ${paramDesc}`); + } else { + parametros.push(`${paramNome}: ${paramTipo}`); + } } } const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); - const retorno = returnsMatch ? returnsMatch[1] : 'void'; + let retorno = returnsMatch ? returnsMatch[1] : 'void'; + if (jsdocInfo?.retorno) { + retorno = `${retorno} - ${jsdocInfo.retorno}`; + } + + let descricao = jsdocInfo?.descricao || `Mutation ${nome} do arquivo ${nomeArquivo}. Esta função modifica dados no banco de dados.`; + + // Adicionar análise da lógica se não houver JSDoc detalhado + if (!jsdocInfo?.descricao || jsdocInfo.descricao.length < 50) { + const logica = analisarLogicaFuncao(corpoFuncao, 'mutation'); + if (logica) { + descricao += ' ' + logica; + } + } funcoes.push({ nome, tipo: 'mutation', - descricao: jsdocs.get(nome) || `Mutation ${nome} do arquivo ${nomeArquivo}`, + descricao, parametros, retorno, - hash + hash, + corpoFuncao }); } @@ -144,30 +226,184 @@ function analisarFuncaoConvex( const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); const parametros: string[] = []; + const jsdocInfo = jsdocs.get(nome); 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 paramNome = paramMatch[1]; + const paramTipo = paramMatch[2]; + const paramDesc = jsdocInfo?.parametros.get(paramNome); + if (paramDesc) { + parametros.push(`${paramNome}: ${paramTipo} - ${paramDesc}`); + } else { + parametros.push(`${paramNome}: ${paramTipo}`); + } } } const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); - const retorno = returnsMatch ? returnsMatch[1] : 'void'; + let retorno = returnsMatch ? returnsMatch[1] : 'void'; + if (jsdocInfo?.retorno) { + retorno = `${retorno} - ${jsdocInfo.retorno}`; + } + + let descricao = jsdocInfo?.descricao || `Action ${nome} do arquivo ${nomeArquivo}. Esta função executa ações no servidor, podendo acessar APIs externas e realizar operações assíncronas.`; + + // Adicionar análise da lógica se não houver JSDoc detalhado + if (!jsdocInfo?.descricao || jsdocInfo.descricao.length < 50) { + const logica = analisarLogicaFuncao(corpoFuncao, 'action'); + if (logica) { + descricao += ' ' + logica; + } + } funcoes.push({ nome, tipo: 'action', - descricao: jsdocs.get(nome) || `Action ${nome} do arquivo ${nomeArquivo}`, + descricao, parametros, retorno, - hash + hash, + corpoFuncao }); } return funcoes; } +/** + * Analisa a lógica de uma função e gera uma explicação detalhada + */ +function analisarLogicaFuncao(corpoFuncao: string, tipo: 'query' | 'mutation' | 'action'): string { + const explicacoes: string[] = []; + + // Detectar operações de banco de dados + const operacoesDB = { + query: corpoFuncao.match(/ctx\.db\.query\(['"]([^'"]+)['"]\)/g) || [], + insert: corpoFuncao.match(/ctx\.db\.insert\(['"]([^'"]+)['"]/g) || [], + update: corpoFuncao.match(/ctx\.db\.patch\(/g) || [], + delete: corpoFuncao.match(/ctx\.db\.delete\(/g) || [], + replace: corpoFuncao.match(/ctx\.db\.replace\(/g) || [] + }; + + if (operacoesDB.query.length > 0) { + const tabelas = operacoesDB.query.map((q) => { + const match = q.match(/['"]([^'"]+)['"]/); + return match ? match[1] : ''; + }); + explicacoes.push( + `Esta função realiza consultas na(s) tabela(s): **${tabelas.filter((t) => t).join(', ')}**.` + ); + } + + if (operacoesDB.insert.length > 0) { + const tabelas = operacoesDB.insert.map((i) => { + const match = i.match(/['"]([^'"]+)['"]/); + return match ? match[1] : ''; + }); + explicacoes.push( + `A função **insere novos registros** na(s) tabela(s): **${tabelas.filter((t) => t).join(', ')}**.` + ); + } + + if (operacoesDB.update.length > 0) { + explicacoes.push(`A função **atualiza registros existentes** no banco de dados.`); + } + + if (operacoesDB.delete.length > 0) { + explicacoes.push(`A função **remove registros** do banco de dados.`); + } + + if (operacoesDB.replace.length > 0) { + explicacoes.push(`A função **substitui registros completos** no banco de dados.`); + } + + // Detectar uso de índices + if (corpoFuncao.includes('.withIndex(')) { + explicacoes.push(`A função utiliza **índices do banco de dados** para otimizar as consultas.`); + } + + // Detectar filtros + if (corpoFuncao.includes('.filter(')) { + explicacoes.push(`A função aplica **filtros** para restringir os resultados da consulta.`); + } + + // Detectar ordenação + if (corpoFuncao.includes('.order(')) { + explicacoes.push(`A função **ordena os resultados** de acordo com critérios específicos.`); + } + + // Detectar paginação + if (corpoFuncao.includes('.take(') || corpoFuncao.includes('.limit')) { + explicacoes.push(`A função implementa **paginação** para limitar a quantidade de resultados retornados.`); + } + + // Detectar validações + if (corpoFuncao.includes('if (') || corpoFuncao.includes('if(')) { + const condicoes = (corpoFuncao.match(/if\s*\([^)]+\)/g) || []).length; + if (condicoes > 0) { + explicacoes.push( + `A função contém **${condicoes} condição(ões) de validação** para garantir a integridade dos dados.` + ); + } + } + + // Detectar loops + if (corpoFuncao.includes('for (') || corpoFuncao.includes('forEach(') || corpoFuncao.includes('.map(')) { + explicacoes.push(`A função **processa múltiplos itens** iterando sobre coleções de dados.`); + } + + // Detectar chamadas a outras funções + const chamadasInternas = corpoFuncao.match(/ctx\.run(Query|Mutation|Action)\(/g) || []; + if (chamadasInternas.length > 0) { + explicacoes.push( + `A função **chama outras funções** do sistema (${chamadasInternas.length} chamada(s)) para realizar operações auxiliares.` + ); + } + + // Detectar tratamento de erros + if (corpoFuncao.includes('try {') || corpoFuncao.includes('catch')) { + explicacoes.push(`A função implementa **tratamento de erros** para garantir robustez.`); + } + + // Detectar operações assíncronas (para actions) + if (tipo === 'action') { + if (corpoFuncao.includes('fetch(') || corpoFuncao.includes('await fetch')) { + explicacoes.push(`A função realiza **chamadas HTTP** para APIs externas.`); + } + if (corpoFuncao.includes('fs.') || corpoFuncao.includes('readFile') || corpoFuncao.includes('writeFile')) { + explicacoes.push(`A função realiza **operações de arquivo** no sistema.`); + } + } + + // Detectar agregações + if (corpoFuncao.includes('.collect()')) { + explicacoes.push(`A função **coleta todos os resultados** da consulta em memória.`); + } + + // Detectar contagem + if (corpoFuncao.includes('.count()') || corpoFuncao.includes('length')) { + explicacoes.push(`A função **conta elementos** ou calcula quantidades.`); + } + + // Detectar transformações de dados + if (corpoFuncao.includes('.map(') || corpoFuncao.includes('.reduce(') || corpoFuncao.includes('.filter(')) { + explicacoes.push(`A função **transforma os dados** antes de retorná-los.`); + } + + // Detectar autenticação/autorização + if (corpoFuncao.includes('getCurrentUser') || corpoFuncao.includes('auth') || corpoFuncao.includes('usuario')) { + explicacoes.push(`A função verifica **autenticação e autorização** do usuário.`); + } + + if (explicacoes.length === 0) { + return 'A função executa operações básicas conforme sua implementação.'; + } + + return explicacoes.join(' '); +} + /** * Gera conteúdo Markdown para uma função */ @@ -179,27 +415,77 @@ function gerarMarkdownFuncao( parametros: string[]; retorno: string; hash: string; + corpoFuncao: 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`; + + // Descrição do tipo + let tipoDescricao = ''; + switch (funcao.tipo) { + case 'query': + tipoDescricao = 'Uma **Query** é uma função de leitura que busca dados do banco de dados. Queries são otimizadas para leitura e podem ser executadas múltiplas vezes sem efeitos colaterais.'; + break; + case 'mutation': + tipoDescricao = 'Uma **Mutation** é uma função de escrita que modifica dados no banco de dados. Mutations garantem consistência e podem ser executadas de forma transacional.'; + break; + case 'action': + tipoDescricao = 'Uma **Action** é uma função que pode executar operações no servidor, incluindo chamadas a APIs externas, operações de arquivo e outras tarefas assíncronas.'; + break; } - markdown += `## Retorno\n\n`; - markdown += `\`${funcao.retorno}\`\n\n`; + let markdown = `# ${funcao.nome}\n\n`; + markdown += `## Visão Geral\n\n`; + markdown += `${funcao.descricao}\n\n`; + + markdown += `## Tipo de Função\n\n`; + markdown += `**${tipoCapitalizado}**\n\n`; + markdown += `${tipoDescricao}\n\n`; + + // Adicionar seção de lógica implementada + const logicaDetalhada = analisarLogicaFuncao(funcao.corpoFuncao, funcao.tipo); + if (logicaDetalhada && logicaDetalhada !== 'A função executa operações básicas conforme sua implementação.') { + markdown += `## Lógica Implementada\n\n`; + markdown += `${logicaDetalhada}\n\n`; + } + + if (funcao.parametros.length > 0) { + markdown += `## Parâmetros de Entrada\n\n`; + markdown += `Esta função aceita os seguintes parâmetros:\n\n`; + for (const param of funcao.parametros) { + // Separar nome, tipo e descrição se houver + const paramMatch = param.match(/^(.+?):\s*(.+?)(?:\s*-\s*(.+))?$/); + if (paramMatch) { + const [, nome, tipo, desc] = paramMatch; + markdown += `### \`${nome}\`\n\n`; + markdown += `- **Tipo:** \`${tipo}\`\n`; + if (desc) { + markdown += `- **Descrição:** ${desc}\n`; + } + markdown += `\n`; + } else { + markdown += `- \`${param}\`\n`; + } + } + markdown += `\n`; + } else { + markdown += `## Parâmetros de Entrada\n\n`; + markdown += `Esta função não requer parâmetros.\n\n`; + } + + markdown += `## Valor de Retorno\n\n`; + const retornoMatch = funcao.retorno.match(/^(.+?)(?:\s*-\s*(.+))?$/); + if (retornoMatch) { + const [, tipo, desc] = retornoMatch; + markdown += `- **Tipo:** \`${tipo}\`\n`; + if (desc) { + markdown += `- **Descrição:** ${desc}\n`; + } + } else { + markdown += `\`${funcao.retorno}\`\n`; + } + markdown += `\n`; markdown += `## Arquivo Origem\n\n`; markdown += `\`${arquivoOrigem}\`\n\n`; @@ -286,44 +572,39 @@ export const executarVarredura = internalMutation({ } } - // 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() + // Chamar Action para ler e analisar arquivos do sistema de arquivos + // Usar scheduler para não bloquear, mas a Action atualizará a varredura quando terminar + console.log(`🚀 [executarVarredura] Iniciando análise de ${arquivosConvex.length} arquivos`); + + ctx.scheduler.runAfter( + 0, + api.actions.documentacaoVarredura.analisarArquivos, + { + varreduraId, + arquivos: arquivosConvex, + executadoPor: args.executadoPor + } + ).catch((error) => { + console.error('❌ [executarVarredura] Erro ao agendar Action:', error); + erros.push(`Erro ao agendar análise: ${error instanceof Error ? error.message : 'Erro desconhecido'}`); }); - // 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(); + // Atualizar status inicial (a Action atualizará quando terminar) + arquivosAnalisados = arquivosConvex.length; + const duracao = Date.now() - inicio; + await ctx.db.patch(varreduraId, { + status: 'em_andamento', // Manter como em_andamento até a Action terminar + documentosEncontrados: documentosExistentes.length, + documentosNovos: 0, + documentosAtualizados: 0, + arquivosAnalisados, + erros: erros.length > 0 ? erros : undefined, + duracaoMs: duracao + }); - const novosDocumentosIds = novosDocumentos.map((doc) => doc._id); + console.log(`✅ [executarVarredura] Varredura agendada, ID: ${varreduraId}`); - if (novosDocumentosIds.length > 0) { - // Notificar via scheduler para não bloquear - await ctx.scheduler.runAfter(0, api.documentacao.notificarNovosDocumentos, { - documentosIds: novosDocumentosIds, - quantidade: documentosNovos - }); - } - } + // A notificação será feita pela Action quando terminar return varreduraId; } catch (error) { @@ -342,6 +623,195 @@ export const executarVarredura = internalMutation({ } }); +/** + * Atualizar varredura com resultados da Action + */ +export const atualizarVarreduraComResultados = internalMutation({ + args: { + varreduraId: v.id('documentacaoVarredura'), + documentosNovos: v.number(), + documentosAtualizados: v.number(), + arquivosAnalisados: v.number(), + erros: v.array(v.string()) + }, + handler: async (ctx, args) => { + const varredura = await ctx.db.get(args.varreduraId); + if (!varredura) return; + + const duracao = Date.now() - varredura.iniciadoEm; + + // Buscar documentos encontrados + const documentosEncontrados = await ctx.db + .query('documentacao') + .filter((q) => q.eq(q.field('geradoAutomaticamente'), true)) + .collect(); + + await ctx.db.patch(args.varreduraId, { + status: 'concluida', + documentosEncontrados: documentosEncontrados.length, + documentosNovos: args.documentosNovos, + documentosAtualizados: args.documentosAtualizados, + arquivosAnalisados: args.arquivosAnalisados, + erros: args.erros.length > 0 ? args.erros : undefined, + duracaoMs: duracao, + concluidoEm: Date.now() + }); + + // Notificar TI_master se houver novos documentos + if (args.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'), varredura.iniciadoEm)) + .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: args.documentosNovos + }); + } + } + } +}); + +/** + * Processar um arquivo e criar/atualizar documentos (chamado pela Action) + */ +export const processarArquivo = internalMutation({ + args: { + varreduraId: v.id('documentacaoVarredura'), + nomeArquivo: v.string(), + conteudo: v.string(), + executadoPor: v.id('usuarios') + }, + returns: v.object({ + documentosNovos: v.number(), + documentosAtualizados: v.number() + }), + handler: async (ctx, args) => { + let documentosNovos = 0; + let documentosAtualizados = 0; + + console.log(`📝 [processarArquivo] Processando arquivo: ${args.nomeArquivo}`); + + try { + // Analisar funções no arquivo + const funcoes = analisarFuncaoConvex(args.conteudo, args.nomeArquivo); + console.log(`🔍 [processarArquivo] Encontradas ${funcoes.length} funções em ${args.nomeArquivo}`); + + if (funcoes.length === 0) { + console.log(`⚠️ [processarArquivo] Nenhuma função encontrada em ${args.nomeArquivo}`); + } + + // Processar cada função encontrada + for (const funcao of funcoes) { + console.log(` 📌 Processando função: ${funcao.nome} (${funcao.tipo})`); + const markdown = gerarMarkdownFuncao(funcao, args.nomeArquivo); + const hashOrigem = `${args.nomeArquivo}:${funcao.hash}`; + + // Verificar se já existe documento com este hash + const documentoExistente = await ctx.db + .query('documentacao') + .withIndex('by_hash_origem', (q) => q.eq('hashOrigem', hashOrigem)) + .first(); + + const conteudoBusca = normalizarTextoParaBusca( + `${funcao.nome} ${funcao.descricao} ${markdown}` + ); + + if (documentoExistente) { + // Verificar se houve mudança no conteúdo (comparar hash) + const hashAtual = funcao.hash; + const hashAnterior = documentoExistente.hashOrigem?.split(':')[1] || ''; + + // Calcular nova versão se houver mudança + let novaVersao = documentoExistente.versao; + if (hashAtual !== hashAnterior) { + // Incrementar versão (ex: 1.0.0 -> 1.0.1, 1.0.1 -> 1.0.2, etc) + const partes = documentoExistente.versao.split('.'); + const patch = parseInt(partes[2] || '0', 10) + 1; + novaVersao = `${partes[0]}.${partes[1]}.${patch}`; + console.log( + ` 🔄 Função ${funcao.nome} alterada: versão ${documentoExistente.versao} -> ${novaVersao}` + ); + } + + // Atualizar documento existente + await ctx.db.patch(documentoExistente._id, { + titulo: `${funcao.nome} (${funcao.tipo})`, + conteudo: markdown, + conteudoBusca, + versao: novaVersao, + metadados: { + parametros: funcao.parametros, + retorno: funcao.retorno, + algoritmo: funcao.descricao + }, + atualizadoEm: Date.now() + }); + console.log(` ✅ Documento atualizado: ${funcao.nome} (versão ${novaVersao})`); + documentosAtualizados++; + } else { + // Criar novo documento + const agora = Date.now(); + + // 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: `${funcao.nome} (${funcao.tipo})`, + conteudo: markdown, + conteudoBusca, + categoriaId, + tags: [funcao.tipo, 'automatico'], + tipo: funcao.tipo, + versao: '1.0.0', + arquivoOrigem: args.nomeArquivo, + funcaoOrigem: funcao.nome, + hashOrigem: hashOrigem, + metadados: { + parametros: funcao.parametros, + retorno: funcao.retorno, + algoritmo: funcao.descricao + }, + ativo: true, + criadoPor: args.executadoPor, + criadoEm: agora, + atualizadoEm: agora, + visualizacoes: 0, + geradoAutomaticamente: true + }); + console.log(` ✨ Novo documento criado: ${funcao.nome} (ID: ${documentoId})`); + documentosNovos++; + } + } + } catch (error) { + const erroMsg = error instanceof Error ? error.message : 'Erro desconhecido'; + console.error(`❌ [processarArquivo] Erro ao processar arquivo ${args.nomeArquivo}:`, erroMsg); + throw error; + } + + console.log( + `✅ [processarArquivo] Arquivo ${args.nomeArquivo} processado: ${documentosNovos} novos, ${documentosAtualizados} atualizados` + ); + + return { documentosNovos, documentosAtualizados }; + } +}); + /** * Criar documento a partir de função detectada */ @@ -469,7 +939,7 @@ export const executarVarreduraManual = mutation({ /** * Obter histórico de varreduras */ -export const obterHistoricoVarreduras = mutation({ +export const obterHistoricoVarreduras = query({ args: { limite: v.optional(v.number()) }, @@ -492,10 +962,56 @@ export const obterHistoricoVarreduras = mutation({ .order('desc') .take(limite); - // Enriquecer com informações do usuário + // Enriquecer com informações do usuário e arquivos modificados const varredurasEnriquecidas = await Promise.all( varreduras.map(async (varredura) => { const usuario = await ctx.db.get(varredura.executadoPor); + + // Buscar documentos atualizados durante esta varredura + const documentosAtualizados = await ctx.db + .query('documentacao') + .filter((q) => q.eq(q.field('geradoAutomaticamente'), true)) + .filter((q) => q.gte(q.field('atualizadoEm'), varredura.iniciadoEm)) + .filter((q) => q.lte(q.field('atualizadoEm'), varredura.concluidoEm || Date.now())) + .collect(); + + // Agrupar por arquivo e obter versões + const arquivosModificados = new Map(); + + for (const doc of documentosAtualizados) { + if (doc.arquivoOrigem) { + const arquivo = doc.arquivoOrigem; + if (!arquivosModificados.has(arquivo)) { + arquivosModificados.set(arquivo, { versao: doc.versao, funcoes: [] }); + } + const info = arquivosModificados.get(arquivo)!; + if (doc.funcaoOrigem) { + info.funcoes.push(doc.funcaoOrigem); + } + // Manter a versão mais recente (comparar numericamente) + const partesAtual = doc.versao.split('.').map(Number); + const partesInfo = info.versao.split('.').map(Number); + let versaoMaisNova = info.versao; + for (let i = 0; i < Math.max(partesAtual.length, partesInfo.length); i++) { + const atual = partesAtual[i] || 0; + const infoParte = partesInfo[i] || 0; + if (atual > infoParte) { + versaoMaisNova = doc.versao; + break; + } else if (atual < infoParte) { + break; + } + } + info.versao = versaoMaisNova; + } + } + + const arquivosModificadosArray = Array.from(arquivosModificados.entries()).map(([arquivo, info]) => ({ + arquivo, + versao: info.versao, + funcoes: info.funcoes + })); + return { ...varredura, executadoPorUsuario: usuario @@ -504,7 +1020,9 @@ export const obterHistoricoVarreduras = mutation({ nome: usuario.nome, email: usuario.email } - : null + : null, + arquivosModificados: arquivosModificadosArray, + totalArquivosModificados: arquivosModificados.size }; }) );