import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import { Doc, Id } from './_generated/dataModel'; import type { QueryCtx, MutationCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import { api } from './_generated/api'; // ========== HELPERS ========== /** * Normaliza texto para busca (remove acentos, converte para lowercase) */ function normalizarTextoParaBusca(texto: string): string { return texto .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') // Remove diacríticos .trim(); } /** * Verifica se o usuário tem permissão de TI (nível 0 ou 1) */ async function verificarPermissaoTI(ctx: QueryCtx | MutationCtx): Promise> { const usuarioAtual = await getCurrentUserFunction(ctx); if (!usuarioAtual) { throw new Error('Não autenticado'); } // Verificar se é TI (nível 0 ou 1) const role = await ctx.db.get(usuarioAtual.roleId); if (!role || (role.nivel > 1 && role.nome !== 'ti_master' && role.nome !== 'ti_usuario')) { throw new Error('Acesso negado. Apenas usuários TI podem acessar a documentação.'); } return usuarioAtual; } // ========== QUERIES ========== /** * Listar todos os documentos (com filtros opcionais) */ export const listarDocumentos = query({ args: { categoriaId: v.optional(v.id('documentacaoCategorias')), tipo: v.optional( v.union( v.literal('query'), v.literal('mutation'), v.literal('action'), v.literal('component'), v.literal('route'), v.literal('modulo'), v.literal('manual'), v.literal('outro') ) ), tags: v.optional(v.array(v.string())), ativo: v.optional(v.boolean()), busca: v.optional(v.string()), limite: v.optional(v.number()), offset: v.optional(v.number()) }, handler: async (ctx, args) => { await verificarPermissaoTI(ctx); let documentos: Doc<'documentacao'>[] = []; // Aplicar filtros usando índices quando possível if (args.categoriaId) { documentos = await ctx.db .query('documentacao') .withIndex('by_categoria', (q) => q.eq('categoriaId', args.categoriaId)) .collect(); } else if (args.tipo) { documentos = await ctx.db .query('documentacao') .withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!)) .collect(); } else if (args.ativo !== undefined) { documentos = await ctx.db .query('documentacao') .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) .collect(); } else { documentos = await ctx.db.query('documentacao').collect(); } // Filtrar por tags (se fornecido) if (args.tags && args.tags.length > 0) { documentos = documentos.filter((doc) => args.tags!.some((tag) => doc.tags.includes(tag)) ); } // Filtrar por busca (full-text) if (args.busca && args.busca.trim().length > 0) { const buscaNormalizada = normalizarTextoParaBusca(args.busca); documentos = documentos.filter((doc) => { const tituloBusca = normalizarTextoParaBusca(doc.titulo); return ( doc.conteudoBusca.includes(buscaNormalizada) || tituloBusca.includes(buscaNormalizada) ); }); } // Ordenar por data de atualização (mais recentes primeiro) documentos.sort((a, b) => b.atualizadoEm - a.atualizadoEm); // Aplicar paginação const offset = args.offset || 0; const limite = args.limite || 50; const documentosPaginados = documentos.slice(offset, offset + limite); // Enriquecer com informações de categoria const documentosEnriquecidos = await Promise.all( documentosPaginados.map(async (doc) => { let categoria = null; if (doc.categoriaId) { categoria = await ctx.db.get(doc.categoriaId); } return { ...doc, categoria }; }) ); return { documentos: documentosEnriquecidos, total: documentos.length, offset, limite }; } }); /** * Obter um documento por ID */ export const obterDocumento = query({ args: { documentoId: v.id('documentacao') }, handler: async (ctx, args) => { await verificarPermissaoTI(ctx); const documento = await ctx.db.get(args.documentoId); if (!documento) { throw new Error('Documento não encontrado'); } // Nota: Não podemos fazer patch em uma query, então o contador de visualizações // será atualizado apenas quando o documento for atualizado via mutation // Enriquecer com informações let categoria = null; if (documento.categoriaId) { categoria = await ctx.db.get(documento.categoriaId); } const criadoPor = await ctx.db.get(documento.criadoPor); const atualizadoPor = documento.atualizadoPor ? await ctx.db.get(documento.atualizadoPor) : null; return { ...documento, categoria, criadoPorUsuario: criadoPor ? { _id: criadoPor._id, nome: criadoPor.nome, email: criadoPor.email } : null, atualizadoPorUsuario: atualizadoPor ? { _id: atualizadoPor._id, nome: atualizadoPor.nome, email: atualizadoPor.email } : null }; } }); /** * Buscar documentos (full-text search) */ export const buscarDocumentos = query({ args: { query: v.string(), categoriaId: v.optional(v.id('documentacaoCategorias')), tipo: v.optional( v.union( v.literal('query'), v.literal('mutation'), v.literal('action'), v.literal('component'), v.literal('route'), v.literal('modulo'), v.literal('manual'), v.literal('outro') ) ), tags: v.optional(v.array(v.string())), limite: v.optional(v.number()) }, handler: async (ctx, args) => { await verificarPermissaoTI(ctx); const queryNormalizada = normalizarTextoParaBusca(args.query); const limite = args.limite || 50; // Buscar todos os documentos ativos let documentos = await ctx.db .query('documentacao') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .collect(); // Filtrar por busca documentos = documentos.filter((doc) => { const tituloBusca = normalizarTextoParaBusca(doc.titulo); return ( doc.conteudoBusca.includes(queryNormalizada) || tituloBusca.includes(queryNormalizada) || doc.tags.some((tag) => normalizarTextoParaBusca(tag).includes(queryNormalizada)) ); }); // Aplicar filtros adicionais if (args.categoriaId) { documentos = documentos.filter((doc) => doc.categoriaId === args.categoriaId); } if (args.tipo) { documentos = documentos.filter((doc) => doc.tipo === args.tipo); } if (args.tags && args.tags.length > 0) { documentos = documentos.filter((doc) => args.tags!.some((tag) => doc.tags.includes(tag)) ); } // Ordenar por relevância (simples: documentos com mais matches primeiro) documentos.sort((a, b) => { const aMatches = (normalizarTextoParaBusca(a.titulo).includes(queryNormalizada) ? 2 : 0) + (a.conteudoBusca.includes(queryNormalizada) ? 1 : 0); const bMatches = (normalizarTextoParaBusca(b.titulo).includes(queryNormalizada) ? 2 : 0) + (b.conteudoBusca.includes(queryNormalizada) ? 1 : 0); return bMatches - aMatches; }); // Limitar resultados documentos = documentos.slice(0, limite); // Enriquecer com categoria const documentosEnriquecidos = await Promise.all( documentos.map(async (doc) => { let categoria = null; if (doc.categoriaId) { categoria = await ctx.db.get(doc.categoriaId); } return { ...doc, categoria }; }) ); return documentosEnriquecidos; } }); /** * Listar categorias */ export const listarCategorias = query({ args: { parentId: v.optional(v.id('documentacaoCategorias')), ativo: v.optional(v.boolean()) }, handler: async (ctx, args) => { await verificarPermissaoTI(ctx); let categorias: Doc<'documentacaoCategorias'>[] = []; if (args.parentId !== undefined) { categorias = await ctx.db .query('documentacaoCategorias') .withIndex('by_parent', (q) => q.eq('parentId', args.parentId)) .collect(); } else if (args.ativo !== undefined) { categorias = await ctx.db .query('documentacaoCategorias') .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) .collect(); } else { categorias = await ctx.db.query('documentacaoCategorias').collect(); } // Ordenar por ordem categorias.sort((a, b) => a.ordem - b.ordem); return categorias; } }); /** * Obter múltiplos documentos por IDs */ export const obterDocumentosPorIds = query({ args: { documentosIds: v.array(v.id('documentacao')) }, handler: async (ctx, args) => { await verificarPermissaoTI(ctx); const documentos = await Promise.all( args.documentosIds.map(async (id) => { const doc = await ctx.db.get(id); if (!doc) return null; let categoria = null; if (doc.categoriaId) { categoria = await ctx.db.get(doc.categoriaId); } return { ...doc, categoria }; }) ); return documentos.filter((doc): doc is NonNullable => doc !== null); } }); /** * Listar tags */ export const listarTags = query({ args: { ativo: v.optional(v.boolean()), limite: v.optional(v.number()) }, handler: async (ctx, args) => { await verificarPermissaoTI(ctx); let tags: Doc<'documentacaoTags'>[] = []; if (args.ativo !== undefined) { tags = await ctx.db .query('documentacaoTags') .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) .collect(); } else { tags = await ctx.db.query('documentacaoTags').collect(); } // Ordenar por uso (mais usadas primeiro) tags.sort((a, b) => b.usadoEm - a.usadoEm); // Limitar se necessário if (args.limite) { tags = tags.slice(0, args.limite); } return tags; } }); // ========== MUTATIONS ========== /** * Criar novo documento */ export const criarDocumento = mutation({ args: { titulo: v.string(), conteudo: v.string(), conteudoHtml: v.optional(v.string()), categoriaId: v.optional(v.id('documentacaoCategorias')), tags: v.array(v.string()), tipo: v.union( v.literal('query'), v.literal('mutation'), v.literal('action'), v.literal('component'), v.literal('route'), v.literal('modulo'), v.literal('manual'), v.literal('outro') ), versao: v.string(), arquivoOrigem: v.optional(v.string()), funcaoOrigem: v.optional(v.string()), hashOrigem: v.optional(v.string()), metadados: v.optional( v.object({ parametros: v.optional(v.array(v.string())), retorno: v.optional(v.string()), dependencias: v.optional(v.array(v.string())), exemplos: v.optional(v.array(v.string())), algoritmo: v.optional(v.string()) }) ), geradoAutomaticamente: v.optional(v.boolean()) }, handler: async (ctx, args) => { const usuarioAtual = await verificarPermissaoTI(ctx); const agora = Date.now(); const conteudoBusca = normalizarTextoParaBusca(args.titulo + ' ' + args.conteudo); const documentoId = await ctx.db.insert('documentacao', { titulo: args.titulo, conteudo: args.conteudo, conteudoHtml: args.conteudoHtml, conteudoBusca, categoriaId: args.categoriaId, tags: args.tags, tipo: args.tipo, versao: args.versao, arquivoOrigem: args.arquivoOrigem, funcaoOrigem: args.funcaoOrigem, hashOrigem: args.hashOrigem, metadados: args.metadados, ativo: true, criadoPor: usuarioAtual._id, criadoEm: agora, atualizadoEm: agora, visualizacoes: 0, geradoAutomaticamente: args.geradoAutomaticamente ?? false }); // Atualizar contadores de uso das tags for (const tagNome of args.tags) { const tag = await ctx.db .query('documentacaoTags') .withIndex('by_nome', (q) => q.eq('nome', tagNome)) .first(); if (tag) { await ctx.db.patch(tag._id, { usadoEm: tag.usadoEm + 1 }); } } return documentoId; } }); /** * Atualizar documento */ export const atualizarDocumento = mutation({ args: { documentoId: v.id('documentacao'), titulo: v.optional(v.string()), conteudo: v.optional(v.string()), conteudoHtml: v.optional(v.string()), categoriaId: v.optional(v.id('documentacaoCategorias')), tags: v.optional(v.array(v.string())), tipo: v.optional( v.union( v.literal('query'), v.literal('mutation'), v.literal('action'), v.literal('component'), v.literal('route'), v.literal('modulo'), v.literal('manual'), v.literal('outro') ) ), versao: v.optional(v.string()), metadados: v.optional( v.object({ parametros: v.optional(v.array(v.string())), retorno: v.optional(v.string()), dependencias: v.optional(v.array(v.string())), exemplos: v.optional(v.array(v.string())), algoritmo: v.optional(v.string()) }) ), ativo: v.optional(v.boolean()) }, handler: async (ctx, args) => { const usuarioAtual = await verificarPermissaoTI(ctx); const documento = await ctx.db.get(args.documentoId); if (!documento) { throw new Error('Documento não encontrado'); } const atualizacoes: Partial> = { atualizadoPor: usuarioAtual._id, atualizadoEm: Date.now() }; if (args.titulo !== undefined) atualizacoes.titulo = args.titulo; if (args.conteudo !== undefined) atualizacoes.conteudo = args.conteudo; if (args.conteudoHtml !== undefined) atualizacoes.conteudoHtml = args.conteudoHtml; if (args.categoriaId !== undefined) atualizacoes.categoriaId = args.categoriaId; if (args.tags !== undefined) atualizacoes.tags = args.tags; if (args.tipo !== undefined) atualizacoes.tipo = args.tipo; if (args.versao !== undefined) atualizacoes.versao = args.versao; if (args.metadados !== undefined) atualizacoes.metadados = args.metadados; if (args.ativo !== undefined) atualizacoes.ativo = args.ativo; // Atualizar conteúdo de busca se título ou conteúdo mudaram if (args.titulo !== undefined || args.conteudo !== undefined) { const novoTitulo = args.titulo ?? documento.titulo; const novoConteudo = args.conteudo ?? documento.conteudo; atualizacoes.conteudoBusca = normalizarTextoParaBusca(novoTitulo + ' ' + novoConteudo); } // Atualizar contadores de tags se tags mudaram if (args.tags !== undefined) { // Decrementar tags antigas for (const tagNome of documento.tags) { if (!args.tags.includes(tagNome)) { const tag = await ctx.db .query('documentacaoTags') .withIndex('by_nome', (q) => q.eq('nome', tagNome)) .first(); if (tag && tag.usadoEm > 0) { await ctx.db.patch(tag._id, { usadoEm: tag.usadoEm - 1 }); } } } // Incrementar tags novas for (const tagNome of args.tags) { if (!documento.tags.includes(tagNome)) { const tag = await ctx.db .query('documentacaoTags') .withIndex('by_nome', (q) => q.eq('nome', tagNome)) .first(); if (tag) { await ctx.db.patch(tag._id, { usadoEm: tag.usadoEm + 1 }); } } } } await ctx.db.patch(args.documentoId, atualizacoes); return { sucesso: true }; } }); /** * Deletar documento (soft delete) */ export const deletarDocumento = mutation({ args: { documentoId: v.id('documentacao') }, handler: async (ctx, args) => { const usuarioAtual = await verificarPermissaoTI(ctx); const documento = await ctx.db.get(args.documentoId); if (!documento) { throw new Error('Documento não encontrado'); } // Soft delete: apenas marcar como inativo await ctx.db.patch(args.documentoId, { ativo: false, atualizadoPor: usuarioAtual._id, atualizadoEm: Date.now() }); // Decrementar contadores de tags for (const tagNome of documento.tags) { const tag = await ctx.db .query('documentacaoTags') .withIndex('by_nome', (q) => q.eq('nome', tagNome)) .first(); if (tag && tag.usadoEm > 0) { await ctx.db.patch(tag._id, { usadoEm: tag.usadoEm - 1 }); } } return { sucesso: true }; } }); /** * Criar categoria */ export const criarCategoria = mutation({ args: { nome: v.string(), descricao: v.optional(v.string()), icone: v.optional(v.string()), cor: v.optional( v.union( v.literal('primary'), v.literal('secondary'), v.literal('accent'), v.literal('success'), v.literal('warning'), v.literal('error'), v.literal('info') ) ), parentId: v.optional(v.id('documentacaoCategorias')), ordem: v.number() }, handler: async (ctx, args) => { await verificarPermissaoTI(ctx); const usuarioAtual = await getCurrentUserFunction(ctx); if (!usuarioAtual) { throw new Error('Não autenticado'); } const agora = Date.now(); const categoriaId = await ctx.db.insert('documentacaoCategorias', { nome: args.nome, descricao: args.descricao, icone: args.icone, cor: args.cor, parentId: args.parentId, ordem: args.ordem, ativo: true, criadoPor: usuarioAtual._id, criadoEm: agora, atualizadoEm: agora }); return categoriaId; } }); /** * Criar tag */ export const criarTag = mutation({ args: { nome: v.string(), descricao: v.optional(v.string()), cor: v.optional( v.union( v.literal('primary'), v.literal('secondary'), v.literal('accent'), v.literal('success'), v.literal('warning'), v.literal('error'), v.literal('info') ) ) }, handler: async (ctx, args) => { await verificarPermissaoTI(ctx); // Verificar se tag já existe const tagExistente = await ctx.db .query('documentacaoTags') .withIndex('by_nome', (q) => q.eq('nome', args.nome)) .first(); if (tagExistente) { throw new Error('Tag já existe'); } const usuarioAtual = await getCurrentUserFunction(ctx); if (!usuarioAtual) { throw new Error('Não autenticado'); } const agora = Date.now(); const tagId = await ctx.db.insert('documentacaoTags', { nome: args.nome, descricao: args.descricao, cor: args.cor, usadoEm: 0, ativo: true, criadoPor: usuarioAtual._id, criadoEm: agora }); return tagId; } }); /** * Obter configuração de agendamento de varredura */ export const obterConfigVarredura = query({ args: {}, handler: async (ctx) => { await verificarPermissaoTI(ctx); const config = await ctx.db .query('documentacaoConfig') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); return config; } }); /** * Salvar configuração de agendamento de varredura */ export const salvarConfigVarredura = mutation({ args: { ativo: v.boolean(), diasSemana: v.array( v.union( v.literal('domingo'), v.literal('segunda'), v.literal('terca'), v.literal('quarta'), v.literal('quinta'), v.literal('sexta'), v.literal('sabado') ) ), horario: v.string(), // Formato "HH:MM" fusoHorario: v.optional(v.string()) }, handler: async (ctx, args) => { const usuarioAtual = await verificarPermissaoTI(ctx); // Buscar configuração existente const configExistente = await ctx.db .query('documentacaoConfig') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); const agora = Date.now(); if (configExistente) { // Atualizar configuração existente await ctx.db.patch(configExistente._id, { ativo: args.ativo, diasSemana: args.diasSemana, horario: args.horario, fusoHorario: args.fusoHorario, atualizadoPor: usuarioAtual._id, atualizadoEm: agora }); return configExistente._id; } else { // Criar nova configuração const configId = await ctx.db.insert('documentacaoConfig', { ativo: args.ativo, diasSemana: args.diasSemana, horario: args.horario, fusoHorario: args.fusoHorario || 'America/Recife', configuradoPor: usuarioAtual._id, configuradoEm: agora, atualizadoEm: agora }); return configId; } } }); /** * Notificar TI_master sobre novos documentos criados */ export const notificarNovosDocumentos = mutation({ args: { documentosIds: v.array(v.id('documentacao')), quantidade: v.number() }, handler: async (ctx, args) => { // Buscar usuários TI_master (nível 0) const roleTIMaster = await ctx.db .query('roles') .filter((q) => q.eq(q.field('nivel'), 0)) .first(); if (!roleTIMaster) { return; // Não há TI_master configurado } const usuariosTIMaster = await ctx.db .query('usuarios') .filter((q) => q.eq(q.field('roleId'), roleTIMaster._id)) .collect(); // Criar notificações no chat for (const usuario of usuariosTIMaster) { await ctx.db.insert('notificacoes', { usuarioId: usuario._id, tipo: 'nova_mensagem', titulo: '📚 Novos Documentos Criados', descricao: `${args.quantidade} novo(s) documento(s) de documentação foram criados automaticamente pela varredura do sistema.`, lida: false, criadaEm: Date.now() }); } // Enviar emails para TI_master (usando scheduler para não bloquear) for (const usuario of usuariosTIMaster) { if (usuario.email) { await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { destinatario: usuario.email, destinatarioId: usuario._id, templateCodigo: 'documentacao_novos_documentos', variaveis: { destinatarioNome: usuario.nome, quantidade: args.quantidade.toString(), urlSistema: process.env.FRONTEND_URL || 'http://localhost:5173' }, enviadoPor: usuario._id }); } } return { sucesso: true }; } });