feat: add marked library for markdown parsing and enhance documentation handling with new cron job for scheduled checks
This commit is contained in:
@@ -57,6 +57,7 @@
|
|||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lib-jitsi-meet": "^1.0.6",
|
"lib-jitsi-meet": "^1.0.6",
|
||||||
"lucide-svelte": "^0.552.0",
|
"lucide-svelte": "^0.552.0",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"svelte-sonner": "^1.0.5",
|
"svelte-sonner": "^1.0.5",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
|
|||||||
145
apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte
Normal file
145
apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
|
||||||
|
type TipoDocumento =
|
||||||
|
| 'query'
|
||||||
|
| 'mutation'
|
||||||
|
| 'action'
|
||||||
|
| 'component'
|
||||||
|
| 'route'
|
||||||
|
| 'modulo'
|
||||||
|
| 'manual'
|
||||||
|
| 'outro';
|
||||||
|
|
||||||
|
type Documento = {
|
||||||
|
_id: Id<'documentacao'>;
|
||||||
|
titulo: string;
|
||||||
|
conteudo: string;
|
||||||
|
conteudoHtml?: string;
|
||||||
|
categoriaId?: Id<'documentacaoCategorias'>;
|
||||||
|
tags: string[];
|
||||||
|
tipo: TipoDocumento;
|
||||||
|
versao: string;
|
||||||
|
ativo: boolean;
|
||||||
|
visualizacoes: number;
|
||||||
|
geradoAutomaticamente: boolean;
|
||||||
|
atualizadoEm: number;
|
||||||
|
categoria?: Doc<'documentacaoCategorias'> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
documento: Documento;
|
||||||
|
selecionado?: boolean;
|
||||||
|
onToggleSelecao?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { documento, selecionado = false, onToggleSelecao }: Props = $props();
|
||||||
|
|
||||||
|
const tipoLabels: Record<string, string> = {
|
||||||
|
query: 'Query',
|
||||||
|
mutation: 'Mutation',
|
||||||
|
action: 'Action',
|
||||||
|
component: 'Componente',
|
||||||
|
route: 'Rota',
|
||||||
|
modulo: 'Módulo',
|
||||||
|
manual: 'Manual',
|
||||||
|
outro: 'Outro'
|
||||||
|
};
|
||||||
|
|
||||||
|
const tipoColors: Record<string, string> = {
|
||||||
|
query: 'badge-info',
|
||||||
|
mutation: 'badge-warning',
|
||||||
|
action: 'badge-error',
|
||||||
|
component: 'badge-success',
|
||||||
|
route: 'badge-primary',
|
||||||
|
modulo: 'badge-secondary',
|
||||||
|
manual: 'badge-accent',
|
||||||
|
outro: 'badge-ghost'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article
|
||||||
|
class="group relative flex cursor-pointer flex-col gap-4 overflow-hidden rounded-2xl border border-base-300 bg-base-100 p-6 shadow-lg transition-all duration-300 hover:scale-[1.02] hover:shadow-xl"
|
||||||
|
onclick={() => {
|
||||||
|
if (onToggleSelecao) {
|
||||||
|
onToggleSelecao();
|
||||||
|
} else {
|
||||||
|
window.location.href = resolve(`/ti/documentacao/${documento._id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- Checkbox de seleção -->
|
||||||
|
{#if onToggleSelecao}
|
||||||
|
<button
|
||||||
|
class="absolute right-4 top-4 z-10"
|
||||||
|
onclick|stopPropagation={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleSelecao();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if selecionado}
|
||||||
|
<CheckCircle2 class="text-primary h-6 w-6" />
|
||||||
|
{:else}
|
||||||
|
<Circle class="text-base-content/30 h-6 w-6" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="bg-primary/15 text-primary flex h-12 w-12 shrink-0 items-center justify-center rounded-xl">
|
||||||
|
<FileText class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-base-content mb-1 text-lg font-semibold line-clamp-2">
|
||||||
|
{documento.titulo}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="badge badge-sm {tipoColors[documento.tipo] || 'badge-ghost'}">
|
||||||
|
{tipoLabels[documento.tipo] || documento.tipo}
|
||||||
|
</span>
|
||||||
|
{#if documento.geradoAutomaticamente}
|
||||||
|
<span class="badge badge-sm badge-outline">Auto</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Descrição -->
|
||||||
|
{#if documento.conteudo}
|
||||||
|
<p class="text-base-content/70 line-clamp-3 text-sm">
|
||||||
|
{documento.conteudo.substring(0, 150)}...
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
{#if documento.tags && documento.tags.length > 0}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Tag class="text-base-content/50 h-4 w-4" />
|
||||||
|
{#each documento.tags.slice(0, 3) as tag}
|
||||||
|
<span class="badge badge-xs badge-outline">{tag}</span>
|
||||||
|
{/each}
|
||||||
|
{#if documento.tags.length > 3}
|
||||||
|
<span class="text-base-content/50 text-xs">+{documento.tags.length - 3}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-between border-t border-base-300 pt-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs text-base-content/50">
|
||||||
|
<Calendar class="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{format(new Date(documento.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if documento.visualizacoes !== undefined}
|
||||||
|
<span class="text-base-content/50 text-xs">{documento.visualizacoes} visualizações</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Search, X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
busca: string;
|
||||||
|
mostrarFiltros: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { busca = $bindable(''), mostrarFiltros = $bindable(false) }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="text-base-content/50 absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar documentos..."
|
||||||
|
class="input input-bordered w-full pl-12 pr-10"
|
||||||
|
bind:value={busca}
|
||||||
|
/>
|
||||||
|
{#if busca}
|
||||||
|
<button
|
||||||
|
class="text-base-content/50 absolute right-4 top-1/2 -translate-y-1/2 hover:text-base-content"
|
||||||
|
onclick={() => (busca = '')}
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Folder, Tag, Filter } from 'lucide-svelte';
|
||||||
|
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
|
||||||
|
type TipoDocumento =
|
||||||
|
| 'query'
|
||||||
|
| 'mutation'
|
||||||
|
| 'action'
|
||||||
|
| 'component'
|
||||||
|
| 'route'
|
||||||
|
| 'modulo'
|
||||||
|
| 'manual'
|
||||||
|
| 'outro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
categoriaSelecionada: Id<'documentacaoCategorias'> | null;
|
||||||
|
tipoSelecionado: TipoDocumento | null;
|
||||||
|
tagsSelecionadas: string[];
|
||||||
|
categorias: Doc<'documentacaoCategorias'>[];
|
||||||
|
tags: Doc<'documentacaoTags'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
categoriaSelecionada = $bindable(null),
|
||||||
|
tipoSelecionado = $bindable(null),
|
||||||
|
tagsSelecionadas = $bindable([]),
|
||||||
|
categorias = [],
|
||||||
|
tags = []
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const tipos: Array<{ value: TipoDocumento; label: string }> = [
|
||||||
|
{ value: 'query', label: 'Query' },
|
||||||
|
{ value: 'mutation', label: 'Mutation' },
|
||||||
|
{ value: 'action', label: 'Action' },
|
||||||
|
{ value: 'component', label: 'Componente' },
|
||||||
|
{ value: 'route', label: 'Rota' },
|
||||||
|
{ value: 'modulo', label: 'Módulo' },
|
||||||
|
{ value: 'manual', label: 'Manual' },
|
||||||
|
{ value: 'outro', label: 'Outro' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggleTag(tagNome: string) {
|
||||||
|
if (tagsSelecionadas.includes(tagNome)) {
|
||||||
|
tagsSelecionadas = tagsSelecionadas.filter((t) => t !== tagNome);
|
||||||
|
} else {
|
||||||
|
tagsSelecionadas = [...tagsSelecionadas, tagNome];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
<!-- Categorias -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base-content mb-3 flex items-center gap-2 font-semibold">
|
||||||
|
<Folder class="h-5 w-5" />
|
||||||
|
Categorias
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each categorias as categoria}
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="categoria"
|
||||||
|
checked={categoriaSelecionada === categoria._id}
|
||||||
|
onchange={() => {
|
||||||
|
categoriaSelecionada = categoriaSelecionada === categoria._id ? null : categoria._id;
|
||||||
|
}}
|
||||||
|
class="radio radio-sm"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">{categoria.nome}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
{#if categorias.length === 0}
|
||||||
|
<p class="text-base-content/50 text-sm">Nenhuma categoria disponível</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tipos -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base-content mb-3 flex items-center gap-2 font-semibold">
|
||||||
|
<Filter class="h-5 w-5" />
|
||||||
|
Tipos
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each tipos as tipo}
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tipo"
|
||||||
|
checked={tipoSelecionado === tipo.value}
|
||||||
|
onchange={() => {
|
||||||
|
tipoSelecionado = tipoSelecionado === tipo.value ? null : tipo.value;
|
||||||
|
}}
|
||||||
|
class="radio radio-sm"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">{tipo.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base-content mb-3 flex items-center gap-2 font-semibold">
|
||||||
|
<Tag class="h-5 w-5" />
|
||||||
|
Tags
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each tags.slice(0, 20) as tag}
|
||||||
|
<label class="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tagsSelecionadas.includes(tag.nome)}
|
||||||
|
onchange={() => toggleTag(tag.nome)}
|
||||||
|
class="checkbox checkbox-sm hidden"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="badge {tagsSelecionadas.includes(tag.nome)
|
||||||
|
? 'badge-primary'
|
||||||
|
: 'badge-outline'} cursor-pointer"
|
||||||
|
>
|
||||||
|
{tag.nome}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
{#if tags.length === 0}
|
||||||
|
<p class="text-base-content/50 text-sm">Nenhuma tag disponível</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
182
apps/web/src/lib/components/documentacao/PdfGenerator.svelte
Normal file
182
apps/web/src/lib/components/documentacao/PdfGenerator.svelte
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 jsPDF from 'jspdf';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { X, Download, Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
documentosIds: Id<'documentacao'>[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { documentosIds, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
let gerando = $state(false);
|
||||||
|
|
||||||
|
async function gerarPDF() {
|
||||||
|
try {
|
||||||
|
gerando = true;
|
||||||
|
|
||||||
|
// Buscar documentos
|
||||||
|
const documentos = await client.query(api.documentacao.obterDocumentosPorIds, {
|
||||||
|
documentosIds
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!documentos || documentos.length === 0) {
|
||||||
|
alert('Nenhum documento encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new jsPDF();
|
||||||
|
let yPos = 20;
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
const margin = 20;
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Biblioteca de Documentação SGSE', margin, yPos);
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, margin, yPos);
|
||||||
|
yPos += 10;
|
||||||
|
doc.text(`Total de documentos: ${documentos.length}`, margin, yPos);
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
// Índice
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
doc.text(`${index + 1}. ${documento.titulo}`, margin + 5, yPos);
|
||||||
|
yPos += 7;
|
||||||
|
});
|
||||||
|
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Metadados
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(`Tipo: ${documento.tipo} | Versão: ${documento.versao}`, margin, yPos);
|
||||||
|
yPos += 8;
|
||||||
|
|
||||||
|
// Conteúdo
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
|
||||||
|
// Converter Markdown para texto simples (remover formatação)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
doc.text(linha, margin, yPos);
|
||||||
|
yPos += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPos += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer em todas as páginas
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvar
|
||||||
|
doc.save(`documentacao-sgse-${new Date().toISOString().split('T')[0]}.pdf`);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
alert('Erro ao gerar PDF. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-bold">Gerar PDF</h2>
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm" onclick={onClose}>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
Você selecionou <strong>{documentosIds.length}</strong> documento(s) para gerar PDF.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-base-200 rounded-lg p-4">
|
||||||
|
<p class="text-base-content/70 text-sm">
|
||||||
|
O PDF será gerado com todos os documentos selecionados, incluindo um índice e formatação
|
||||||
|
apropriada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button class="btn btn-ghost" onclick={onClose} disabled={gerando}>Cancelar</button>
|
||||||
|
<button class="btn btn-primary gap-2" onclick={gerarPDF} disabled={gerando}>
|
||||||
|
{#if gerando}
|
||||||
|
<Loader2 class="h-5 w-5 animate-spin" />
|
||||||
|
Gerando...
|
||||||
|
{:else}
|
||||||
|
<Download class="h-5 w-5" />
|
||||||
|
Gerar PDF
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" onclick={onClose}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -432,10 +432,11 @@
|
|||||||
title: 'Documentação',
|
title: 'Documentação',
|
||||||
description:
|
description:
|
||||||
'Manuais, guias e documentação técnica do sistema para usuários e administradores.',
|
'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',
|
palette: 'primary',
|
||||||
icon: 'document',
|
icon: 'document',
|
||||||
disabled: true
|
disabled: false
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
229
apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte
Normal file
229
apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Settings,
|
||||||
|
Download,
|
||||||
|
BookOpen,
|
||||||
|
Tag,
|
||||||
|
Folder,
|
||||||
|
ChevronRight
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import DocumentacaoCard from '$lib/components/documentacao/DocumentacaoCard.svelte';
|
||||||
|
import DocumentacaoSearch from '$lib/components/documentacao/DocumentacaoSearch.svelte';
|
||||||
|
import DocumentacaoSidebar from '$lib/components/documentacao/DocumentacaoSidebar.svelte';
|
||||||
|
import PdfGenerator from '$lib/components/documentacao/PdfGenerator.svelte';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
// Estados
|
||||||
|
let busca = $state('');
|
||||||
|
let categoriaSelecionada = $state<Id<'documentacaoCategorias'> | null>(null);
|
||||||
|
let tipoSelecionado = $state<
|
||||||
|
'query' | 'mutation' | 'action' | 'component' | 'route' | 'modulo' | 'manual' | 'outro' | null
|
||||||
|
>(null);
|
||||||
|
let tagsSelecionadas = $state<string[]>([]);
|
||||||
|
let mostrarFiltros = $state(false);
|
||||||
|
let mostrarPdfGenerator = $state(false);
|
||||||
|
let documentosSelecionados = $state<Id<'documentacao'>[]>([]);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const documentosQuery = useQuery(api.documentacao.listarDocumentos, {
|
||||||
|
categoriaId: categoriaSelecionada || undefined,
|
||||||
|
tipo: tipoSelecionado || undefined,
|
||||||
|
tags: tagsSelecionadas.length > 0 ? tagsSelecionadas : undefined,
|
||||||
|
busca: busca.trim() || undefined,
|
||||||
|
ativo: true,
|
||||||
|
limite: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoriasQuery = useQuery(api.documentacao.listarCategorias, {
|
||||||
|
ativo: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagsQuery = useQuery(api.documentacao.listarTags, {
|
||||||
|
ativo: true,
|
||||||
|
limite: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dados derivados
|
||||||
|
const documentos = $derived.by(() => {
|
||||||
|
if (!documentosQuery) return [];
|
||||||
|
return documentosQuery.documentos || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const categorias = $derived.by(() => {
|
||||||
|
if (!categoriasQuery) return [];
|
||||||
|
return categoriasQuery || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags = $derived.by(() => {
|
||||||
|
if (!tagsQuery) return [];
|
||||||
|
return tagsQuery || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Funções
|
||||||
|
function limparFiltros() {
|
||||||
|
busca = '';
|
||||||
|
categoriaSelecionada = null;
|
||||||
|
tipoSelecionado = null;
|
||||||
|
tagsSelecionadas = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelecaoDocumento(documentoId: Id<'documentacao'>) {
|
||||||
|
if (documentosSelecionados.includes(documentoId)) {
|
||||||
|
documentosSelecionados = documentosSelecionados.filter((id) => id !== documentoId);
|
||||||
|
} else {
|
||||||
|
documentosSelecionados = [...documentosSelecionados, documentoId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirPdfGenerator() {
|
||||||
|
if (documentosSelecionados.length === 0) {
|
||||||
|
alert('Selecione pelo menos um documento para gerar PDF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mostrarPdfGenerator = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProtectedRoute allowedRoles={['ti_master', 'ti_usuario', 'admin']} maxLevel={1}>
|
||||||
|
<main class="mx-auto w-full max-w-7xl space-y-6 px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<section
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||||
|
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||||
|
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="max-w-3xl space-y-4">
|
||||||
|
<span
|
||||||
|
class="border-primary/40 bg-primary/10 text-primary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
|
||||||
|
>
|
||||||
|
Biblioteca de Documentação
|
||||||
|
</span>
|
||||||
|
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
|
||||||
|
Documentação Técnica do SGSE
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
||||||
|
Biblioteca completa com todas as funcionalidades, recursos, manuais técnicos e
|
||||||
|
explicações detalhadas dos algoritmos do sistema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a
|
||||||
|
href={resolve('/ti/documentacao/configuracao')}
|
||||||
|
class="btn btn-primary gap-2"
|
||||||
|
>
|
||||||
|
<Settings class="h-5 w-5" />
|
||||||
|
Configuração
|
||||||
|
</a>
|
||||||
|
{#if documentosSelecionados.length > 0}
|
||||||
|
<button class="btn btn-success gap-2" onclick={abrirPdfGenerator}>
|
||||||
|
<Download class="h-5 w-5" />
|
||||||
|
Gerar PDF ({documentosSelecionados.length})
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Busca e Filtros -->
|
||||||
|
<section class="bg-base-100 rounded-2xl border border-base-300 p-6 shadow-lg">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<DocumentacaoSearch bind:busca bind:mostrarFiltros />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
onclick={() => (mostrarFiltros = !mostrarFiltros)}
|
||||||
|
>
|
||||||
|
<Filter class="h-4 w-4" />
|
||||||
|
Filtros
|
||||||
|
</button>
|
||||||
|
{#if busca || categoriaSelecionada || tipoSelecionado || tagsSelecionadas.length > 0}
|
||||||
|
<button class="btn btn-ghost btn-sm gap-2" onclick={limparFiltros}>
|
||||||
|
Limpar
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros Expandidos -->
|
||||||
|
{#if mostrarFiltros}
|
||||||
|
<div class="border-base-300 mt-4 border-t pt-4">
|
||||||
|
<DocumentacaoSidebar
|
||||||
|
bind:categoriaSelecionada
|
||||||
|
bind:tipoSelecionado
|
||||||
|
bind:tagsSelecionadas
|
||||||
|
{categorias}
|
||||||
|
{tags}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Lista de Documentos -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-base-content text-2xl font-bold">
|
||||||
|
Documentos
|
||||||
|
{#if documentosQuery}
|
||||||
|
<span class="text-base-content/50 text-lg font-normal">
|
||||||
|
({documentosQuery.total})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if documentosQuery === undefined}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if documentos.length === 0}
|
||||||
|
<div class="bg-base-200 rounded-2xl border border-base-300 p-12 text-center">
|
||||||
|
<BookOpen class="text-base-content/30 mx-auto mb-4 h-16 w-16" />
|
||||||
|
<h3 class="text-base-content mb-2 text-xl font-semibold">Nenhum documento encontrado</h3>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
{#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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each documentos as documento (documento._id)}
|
||||||
|
<DocumentacaoCard
|
||||||
|
{documento}
|
||||||
|
selecionado={documentosSelecionados.includes(documento._id)}
|
||||||
|
onToggleSelecao={() => toggleSelecaoDocumento(documento._id)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Modal de Geração de PDF -->
|
||||||
|
{#if mostrarPdfGenerator}
|
||||||
|
<PdfGenerator
|
||||||
|
documentosIds={documentosSelecionados}
|
||||||
|
onClose={() => {
|
||||||
|
mostrarPdfGenerator = false;
|
||||||
|
documentosSelecionados = [];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</ProtectedRoute>
|
||||||
|
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve, page } from '$app/paths';
|
||||||
|
import { useQuery } 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';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { ArrowLeft, Download, Calendar, User, Tag, FileText } from 'lucide-svelte';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
|
||||||
|
const documentoId = $derived(page.params.id);
|
||||||
|
const documentoQuery = useQuery(
|
||||||
|
api.documentacao.obterDocumento,
|
||||||
|
documentoId ? { documentoId: documentoId as Id<'documentacao'> } : 'skip'
|
||||||
|
);
|
||||||
|
|
||||||
|
let gerandoPdf = $state(false);
|
||||||
|
|
||||||
|
async function gerarPdfIndividual() {
|
||||||
|
if (!documentoQuery) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
gerandoPdf = true;
|
||||||
|
const doc = new jsPDF();
|
||||||
|
let yPos = 20;
|
||||||
|
const margin = 20;
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text(documentoQuery.titulo, margin, yPos, { maxWidth: 170 });
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
// Metadados
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 linhas = doc.splitTextToSize(conteudoTexto, 170);
|
||||||
|
for (const linha of linhas) {
|
||||||
|
if (yPos > doc.internal.pageSize.getHeight() - 20) {
|
||||||
|
doc.addPage();
|
||||||
|
yPos = margin;
|
||||||
|
}
|
||||||
|
doc.text(linha, margin, yPos);
|
||||||
|
yPos += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save(`${documentoQuery.titulo.replace(/[^a-z0-9]/gi, '_')}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
alert('Erro ao gerar PDF');
|
||||||
|
} finally {
|
||||||
|
gerandoPdf = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProtectedRoute allowedRoles={['ti_master', 'ti_usuario', 'admin']} maxLevel={1}>
|
||||||
|
<main class="mx-auto w-full max-w-5xl space-y-6 px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a
|
||||||
|
href={resolve('/ti/documentacao')}
|
||||||
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Voltar
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-primary btn-sm gap-2" onclick={gerarPdfIndividual} disabled={gerandoPdf}>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
Gerar PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if documentoQuery === undefined}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if !documentoQuery}
|
||||||
|
<div class="bg-base-200 rounded-2xl border border-base-300 p-12 text-center">
|
||||||
|
<h3 class="text-base-content mb-2 text-xl font-semibold">Documento não encontrado</h3>
|
||||||
|
<p class="text-base-content/70">O documento solicitado não existe ou foi removido.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Documento -->
|
||||||
|
<article class="bg-base-100 rounded-2xl border border-base-300 p-8 shadow-lg">
|
||||||
|
<!-- Título -->
|
||||||
|
<header class="mb-6">
|
||||||
|
<h1 class="text-base-content mb-4 text-4xl font-bold">{documentoQuery.titulo}</h1>
|
||||||
|
<div class="flex flex-wrap items-center gap-4 text-sm text-base-content/70">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
<span class="badge badge-primary">{documentoQuery.tipo}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Calendar class="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{format(new Date(documentoQuery.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", {
|
||||||
|
locale: ptBR
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if documentoQuery.criadoPorUsuario}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
<span>{documentoQuery.criadoPorUsuario.nome}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
{#if documentoQuery.tags && documentoQuery.tags.length > 0}
|
||||||
|
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||||
|
<Tag class="text-base-content/50 h-5 w-5" />
|
||||||
|
{#each documentoQuery.tags as tag}
|
||||||
|
<span class="badge badge-outline">{tag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Conteúdo -->
|
||||||
|
<div class="prose prose-slate max-w-none">
|
||||||
|
{@html marked.parse(documentoQuery.conteudo)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadados -->
|
||||||
|
{#if documentoQuery.metadados}
|
||||||
|
<div class="bg-base-200 mt-8 rounded-lg p-6">
|
||||||
|
<h3 class="text-base-content mb-4 text-lg font-semibold">Informações Técnicas</h3>
|
||||||
|
{#if documentoQuery.metadados.parametros}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-base-content mb-2 font-medium">Parâmetros:</h4>
|
||||||
|
<ul class="list-inside list-disc">
|
||||||
|
{#each documentoQuery.metadados.parametros as param}
|
||||||
|
<li class="text-base-content/70">{param}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if documentoQuery.metadados.retorno}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-base-content mb-2 font-medium">Retorno:</h4>
|
||||||
|
<p class="text-base-content/70">{documentoQuery.metadados.retorno}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if documentoQuery.metadados.dependencias}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-base-content mb-2 font-medium">Dependências:</h4>
|
||||||
|
<ul class="list-inside list-disc">
|
||||||
|
{#each documentoQuery.metadados.dependencias as dep}
|
||||||
|
<li class="text-base-content/70">{dep}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</ProtectedRoute>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.prose) {
|
||||||
|
color: hsl(var(--bc));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.prose h1),
|
||||||
|
:global(.prose h2),
|
||||||
|
:global(.prose h3) {
|
||||||
|
color: hsl(var(--bc));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.prose code) {
|
||||||
|
background-color: hsl(var(--b2));
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.prose pre) {
|
||||||
|
background-color: hsl(var(--b2));
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ptBR } from 'date-fns/locale';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Settings,
|
||||||
|
Play,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
// Tipos
|
||||||
|
type DiaSemana = 'domingo' | 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado';
|
||||||
|
|
||||||
|
type ConfigVarredura = {
|
||||||
|
_id: Id<'documentacaoConfig'>;
|
||||||
|
ativo: boolean;
|
||||||
|
diasSemana: DiaSemana[];
|
||||||
|
horario: string;
|
||||||
|
fusoHorario?: string;
|
||||||
|
ultimaExecucao?: number;
|
||||||
|
proximaExecucao?: number;
|
||||||
|
configuradoPor: Id<'usuarios'>;
|
||||||
|
configuradoEm: number;
|
||||||
|
atualizadoPor?: Id<'usuarios'>;
|
||||||
|
atualizadoEm: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Varredura = {
|
||||||
|
_id: Id<'documentacaoVarredura'>;
|
||||||
|
tipo: 'automatica' | 'manual';
|
||||||
|
status: 'em_andamento' | 'concluida' | 'erro' | 'cancelada';
|
||||||
|
documentosEncontrados: number;
|
||||||
|
documentosNovos: number;
|
||||||
|
documentosAtualizados: number;
|
||||||
|
arquivosAnalisados: number;
|
||||||
|
erros?: string[];
|
||||||
|
duracaoMs?: number;
|
||||||
|
executadoPor: Id<'usuarios'>;
|
||||||
|
iniciadoEm: number;
|
||||||
|
concluidoEm?: number;
|
||||||
|
executadoPorUsuario: {
|
||||||
|
_id: Id<'usuarios'>;
|
||||||
|
nome: string;
|
||||||
|
email: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Estados
|
||||||
|
let config = $state<ConfigVarredura | null>(null);
|
||||||
|
let executandoVarredura = $state(false);
|
||||||
|
let historicoVarreduras = $state<Varredura[]>([]);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const configQuery = useQuery(api.documentacao.obterConfigVarredura, {});
|
||||||
|
|
||||||
|
// Dados derivados
|
||||||
|
$effect(() => {
|
||||||
|
if (configQuery) {
|
||||||
|
config = {
|
||||||
|
ativo: configQuery.ativo ?? false,
|
||||||
|
diasSemana: configQuery.diasSemana ?? [],
|
||||||
|
horario: configQuery.horario ?? '08:00',
|
||||||
|
fusoHorario: configQuery.fusoHorario ?? 'America/Recife'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Funções
|
||||||
|
async function salvarConfig() {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.documentacao.salvarConfigVarredura, {
|
||||||
|
ativo: config.ativo,
|
||||||
|
diasSemana: config.diasSemana,
|
||||||
|
horario: config.horario,
|
||||||
|
fusoHorario: config.fusoHorario
|
||||||
|
});
|
||||||
|
alert('Configuração salva com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar configuração:', error);
|
||||||
|
alert('Erro ao salvar configuração');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executarVarreduraManual() {
|
||||||
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao executar varredura:', error);
|
||||||
|
alert('Erro ao executar varredura');
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
carregarHistorico();
|
||||||
|
});
|
||||||
|
|
||||||
|
const diasSemana = [
|
||||||
|
{ value: 'domingo', label: 'Domingo' },
|
||||||
|
{ value: 'segunda', label: 'Segunda-feira' },
|
||||||
|
{ value: 'terca', label: 'Terça-feira' },
|
||||||
|
{ value: 'quarta', label: 'Quarta-feira' },
|
||||||
|
{ value: 'quinta', label: 'Quinta-feira' },
|
||||||
|
{ value: 'sexta', label: 'Sexta-feira' },
|
||||||
|
{ value: 'sabado', label: 'Sábado' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggleDiaSemana(dia: DiaSemana) {
|
||||||
|
if (!config) return;
|
||||||
|
if (config.diasSemana.includes(dia)) {
|
||||||
|
config.diasSemana = config.diasSemana.filter((d) => d !== dia);
|
||||||
|
} else {
|
||||||
|
config.diasSemana = [...config.diasSemana, dia];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
em_andamento: 'Em andamento',
|
||||||
|
concluida: 'Concluída',
|
||||||
|
erro: 'Erro',
|
||||||
|
cancelada: 'Cancelada'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
em_andamento: 'badge-warning',
|
||||||
|
concluida: 'badge-success',
|
||||||
|
erro: 'badge-error',
|
||||||
|
cancelada: 'badge-ghost'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProtectedRoute allowedRoles={['ti_master', 'ti_usuario', 'admin']} maxLevel={1}>
|
||||||
|
<main class="mx-auto w-full max-w-5xl space-y-6 px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a href={resolve('/ti/documentacao')} class="btn btn-ghost btn-sm gap-2">
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Voltar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||||
|
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<h1 class="text-base-content mb-2 text-4xl font-black">Configuração de Varredura</h1>
|
||||||
|
<p class="text-base-content/70 text-lg">
|
||||||
|
Configure o agendamento automático de varredura de documentação
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if configQuery === undefined}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if !config}
|
||||||
|
<div class="bg-base-200 rounded-2xl border border-base-300 p-12 text-center">
|
||||||
|
<p class="text-base-content/70">Carregando configuração...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Configuração -->
|
||||||
|
<section class="bg-base-100 rounded-2xl border border-base-300 p-6 shadow-lg">
|
||||||
|
<h2 class="text-base-content mb-6 flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<Settings class="h-6 w-6" />
|
||||||
|
Agendamento
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Ativar/Desativar -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text text-base font-semibold">Ativar varredura automática</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-primary"
|
||||||
|
bind:checked={config.ativo}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dias da Semana -->
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-base font-semibold">Dias da semana</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
{#each diasSemana as dia}
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
checked={config.diasSemana.includes(dia.value)}
|
||||||
|
onchange={() => toggleDiaSemana(dia.value)}
|
||||||
|
/>
|
||||||
|
<span>{dia.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horário -->
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-base font-semibold">Horário</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="input input-bordered w-full max-w-xs"
|
||||||
|
bind:value={config.horario}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botões -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="btn btn-primary" onclick={salvarConfig}>
|
||||||
|
Salvar Configuração
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-success gap-2"
|
||||||
|
onclick={executarVarreduraManual}
|
||||||
|
disabled={executandoVarredura}
|
||||||
|
>
|
||||||
|
{#if executandoVarredura}
|
||||||
|
<Loader2 class="h-5 w-5 animate-spin" />
|
||||||
|
Executando...
|
||||||
|
{:else}
|
||||||
|
<Play class="h-5 w-5" />
|
||||||
|
Executar Varredura Agora
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Histórico -->
|
||||||
|
<section class="bg-base-100 rounded-2xl border border-base-300 p-6 shadow-lg">
|
||||||
|
<h2 class="text-base-content mb-6 flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<Calendar class="h-6 w-6" />
|
||||||
|
Histórico de Varreduras
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if historicoVarreduras.length === 0}
|
||||||
|
<div class="bg-base-200 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-base-content/70">Nenhuma varredura executada ainda</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Documentos</th>
|
||||||
|
<th>Executado por</th>
|
||||||
|
<th>Iniciado em</th>
|
||||||
|
<th>Duração</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each historicoVarreduras as varredura}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-outline">
|
||||||
|
{varredura.tipo === 'automatica' ? 'Automática' : 'Manual'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {statusColors[varredura.status] || 'badge-ghost'}">
|
||||||
|
{statusLabels[varredura.status] || varredura.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
Novos: {varredura.documentosNovos} | Atualizados:{' '}
|
||||||
|
{varredura.documentosAtualizados}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{varredura.executadoPorUsuario?.nome || 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{format(new Date(varredura.iniciadoEm), "dd/MM/yyyy 'às' HH:mm", {
|
||||||
|
locale: ptBR
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{varredura.duracaoMs
|
||||||
|
? `${(varredura.duracaoMs / 1000).toFixed(1)}s`
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</ProtectedRoute>
|
||||||
|
|
||||||
3
bun.lock
3
bun.lock
@@ -53,6 +53,7 @@
|
|||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lib-jitsi-meet": "^1.0.6",
|
"lib-jitsi-meet": "^1.0.6",
|
||||||
"lucide-svelte": "^0.552.0",
|
"lucide-svelte": "^0.552.0",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"svelte-sonner": "^1.0.5",
|
"svelte-sonner": "^1.0.5",
|
||||||
"xlsx": "^0.18.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=="],
|
"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=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
|
|||||||
6
packages/backend/convex/_generated/api.d.ts
vendored
6
packages/backend/convex/_generated/api.d.ts
vendored
@@ -31,6 +31,8 @@ import type * as contratos from "../contratos.js";
|
|||||||
import type * as crons from "../crons.js";
|
import type * as crons from "../crons.js";
|
||||||
import type * as cursos from "../cursos.js";
|
import type * as cursos from "../cursos.js";
|
||||||
import type * as dashboard from "../dashboard.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 documentos from "../documentos.js";
|
||||||
import type * as email from "../email.js";
|
import type * as email from "../email.js";
|
||||||
import type * as empresas from "../empresas.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_chat from "../tables/chat.js";
|
||||||
import type * as tables_contratos from "../tables/contratos.js";
|
import type * as tables_contratos from "../tables/contratos.js";
|
||||||
import type * as tables_cursos from "../tables/cursos.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_empresas from "../tables/empresas.js";
|
||||||
import type * as tables_enderecos from "../tables/enderecos.js";
|
import type * as tables_enderecos from "../tables/enderecos.js";
|
||||||
import type * as tables_ferias from "../tables/ferias.js";
|
import type * as tables_ferias from "../tables/ferias.js";
|
||||||
@@ -119,6 +122,8 @@ declare const fullApi: ApiFromModules<{
|
|||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
cursos: typeof cursos;
|
cursos: typeof cursos;
|
||||||
dashboard: typeof dashboard;
|
dashboard: typeof dashboard;
|
||||||
|
documentacao: typeof documentacao;
|
||||||
|
documentacaoVarredura: typeof documentacaoVarredura;
|
||||||
documentos: typeof documentos;
|
documentos: typeof documentos;
|
||||||
email: typeof email;
|
email: typeof email;
|
||||||
empresas: typeof empresas;
|
empresas: typeof empresas;
|
||||||
@@ -152,6 +157,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
"tables/chat": typeof tables_chat;
|
"tables/chat": typeof tables_chat;
|
||||||
"tables/contratos": typeof tables_contratos;
|
"tables/contratos": typeof tables_contratos;
|
||||||
"tables/cursos": typeof tables_cursos;
|
"tables/cursos": typeof tables_cursos;
|
||||||
|
"tables/documentacao": typeof tables_documentacao;
|
||||||
"tables/empresas": typeof tables_empresas;
|
"tables/empresas": typeof tables_empresas;
|
||||||
"tables/enderecos": typeof tables_enderecos;
|
"tables/enderecos": typeof tables_enderecos;
|
||||||
"tables/ferias": typeof tables_ferias;
|
"tables/ferias": typeof tables_ferias;
|
||||||
|
|||||||
@@ -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;
|
export default crons;
|
||||||
|
|||||||
842
packages/backend/convex/documentacao.ts
Normal file
842
packages/backend/convex/documentacao.ts
Normal file
@@ -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<Doc<'usuarios'>> {
|
||||||
|
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<typeof doc> => 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<Doc<'documentacao'>> = {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
634
packages/backend/convex/documentacaoVarredura.ts
Normal file
634
packages/backend/convex/documentacaoVarredura.ts
Normal file
@@ -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<string, string> = 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<string, Id<'documentacao'>>();
|
||||||
|
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()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ import { pontoTables } from './tables/ponto';
|
|||||||
import { pedidosTables } from './tables/pedidos';
|
import { pedidosTables } from './tables/pedidos';
|
||||||
import { produtosTables } from './tables/produtos';
|
import { produtosTables } from './tables/produtos';
|
||||||
import { lgpdTables } from './tables/lgpdTables';
|
import { lgpdTables } from './tables/lgpdTables';
|
||||||
|
import { documentacaoTables } from './tables/documentacao';
|
||||||
|
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
...setoresTables,
|
...setoresTables,
|
||||||
@@ -42,5 +43,6 @@ export default defineSchema({
|
|||||||
...pontoTables,
|
...pontoTables,
|
||||||
...pedidosTables,
|
...pedidosTables,
|
||||||
...produtosTables,
|
...produtosTables,
|
||||||
...lgpdTables
|
...lgpdTables,
|
||||||
|
...documentacaoTables
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1804,7 +1804,7 @@ export const enviarMensagemChatSistema = internalMutation({
|
|||||||
conteudo: args.mensagem,
|
conteudo: args.mensagem,
|
||||||
conteudoBusca: args.mensagem.toLowerCase(),
|
conteudoBusca: args.mensagem.toLowerCase(),
|
||||||
tipo: 'texto',
|
tipo: 'texto',
|
||||||
criadaEm: Date.now()
|
enviadaEm: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Atualizar última mensagem da conversa
|
// 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({
|
export const expirarBloqueiosIpAutomaticos = internalMutation({
|
||||||
args: {},
|
args: {},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
@@ -1961,6 +2054,24 @@ async function aplicarRateLimit(
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const retryAfter = result.retryAfter ?? periodo;
|
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') {
|
if (config.acaoExcedido === 'bloquear') {
|
||||||
return {
|
return {
|
||||||
permitido: false,
|
permitido: false,
|
||||||
|
|||||||
152
packages/backend/convex/tables/documentacao.ts
Normal file
152
packages/backend/convex/tables/documentacao.ts
Normal file
@@ -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'])
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user