/** * MCP Outline PostgreSQL - Documents Tools * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { Pool } from 'pg'; import { BaseTool, ToolResponse, DocumentArgs, GetDocumentArgs, CreateDocumentArgs, UpdateDocumentArgs, SearchDocumentsArgs, MoveDocumentArgs } from '../types/tools.js'; import { validatePagination, validateSortDirection, validateSortField, isValidUUID, sanitizeInput } from '../utils/security.js'; /** * 1. list_documents - Lista documentos publicados e drafts com filtros e paginação */ const listDocuments: BaseTool = { name: 'list_documents', description: 'Lista documentos publicados e drafts com filtros e paginação. Suporta ordenação e filtros por collection, utilizador, templates e estado de publicação.', inputSchema: { type: 'object', properties: { collection_id: { type: 'string', description: 'UUID da collection para filtrar' }, user_id: { type: 'string', description: 'UUID do utilizador (autor) para filtrar' }, limit: { type: 'number', description: 'Máximo de resultados (default: 25, max: 100)' }, offset: { type: 'number', description: 'Offset para paginação' }, sort: { type: 'string', enum: ['updatedAt', 'createdAt', 'title', 'publishedAt'], description: 'Campo de ordenação' }, direction: { type: 'string', enum: ['ASC', 'DESC'], description: 'Direcção de ordenação' }, template: { type: 'boolean', description: 'Filtrar apenas templates' }, archived: { type: 'boolean', description: 'Incluir documentos arquivados (default: false)' }, published: { type: 'boolean', description: 'Filtrar apenas publicados (default: true)' } } }, handler: async (args, pgClient): Promise => { try { const { limit, offset } = validatePagination(args.limit, args.offset); const sort = validateSortField(args.sort, ['updatedAt', 'createdAt', 'title', 'publishedAt'], 'updatedAt'); const direction = validateSortDirection(args.direction); let query = ` SELECT d.id, d."urlId", d.title, d.text, d.emoji, d."collectionId", d."parentDocumentId", d."createdById", d."lastModifiedById", d."publishedAt", d."createdAt", d."updatedAt", d."archivedAt", d.template, d."templateId", d."fullWidth", d.version, c.name as "collectionName", c.color as "collectionColor", u.name as "createdByName", u.email as "createdByEmail", lm.name as "lastModifiedByName" FROM documents d LEFT JOIN collections c ON d."collectionId" = c.id LEFT JOIN users u ON d."createdById" = u.id LEFT JOIN users lm ON d."lastModifiedById" = lm.id WHERE d."deletedAt" IS NULL `; const params: any[] = []; let paramIndex = 1; // Filtros if (args.collection_id) { if (!isValidUUID(args.collection_id)) throw new Error('collection_id inválido (deve ser UUID)'); query += ` AND d."collectionId" = $${paramIndex++}`; params.push(args.collection_id); } if (args.user_id) { if (!isValidUUID(args.user_id)) throw new Error('user_id inválido (deve ser UUID)'); query += ` AND d."createdById" = $${paramIndex++}`; params.push(args.user_id); } if (args.template !== undefined) { query += ` AND d.template = $${paramIndex++}`; params.push(args.template); } if (!args.archived) { query += ` AND d."archivedAt" IS NULL`; } if (args.published !== false) { query += ` AND d."publishedAt" IS NOT NULL`; } else if (args.published === false) { query += ` AND d."publishedAt" IS NULL`; } // Ordenação e paginação query += ` ORDER BY d."${sort}" ${direction} LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; params.push(limit, offset); const result = await pgClient.query(query, params); return { content: [{ type: 'text', text: JSON.stringify({ documents: result.rows, pagination: { limit, offset, count: result.rows.length } }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 2. get_document - Obter documento por ID, urlId ou shareId */ const getDocument: BaseTool = { name: 'get_document', description: 'Obter detalhes completos de um documento por ID (UUID), urlId ou shareId. Retorna texto completo, metadata e informações de colaboradores.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' }, share_id: { type: 'string', description: 'ID de partilha pública (alternativa ao id)' } } }, handler: async (args, pgClient): Promise => { try { if (!args.id && !args.share_id) { throw new Error('É necessário fornecer id ou share_id'); } let query = ` SELECT d.id, d."urlId", d.title, d.text, d.emoji, d."collectionId", d."parentDocumentId", d."createdById", d."lastModifiedById", d."publishedAt", d."createdAt", d."updatedAt", d."archivedAt", d."deletedAt", d.template, d."templateId", d."fullWidth", d.version, c.name as "collectionName", c.color as "collectionColor", u.name as "createdByName", u.email as "createdByEmail", lm.name as "lastModifiedByName" FROM documents d LEFT JOIN collections c ON d."collectionId" = c.id LEFT JOIN users u ON d."createdById" = u.id LEFT JOIN users lm ON d."lastModifiedById" = lm.id `; const params: any[] = []; if (args.id) { if (!isValidUUID(args.id)) throw new Error('id inválido (deve ser UUID)'); query += ` WHERE d.id = $1`; params.push(args.id); } else if (args.share_id) { query += ` INNER JOIN shares s ON d.id = s."documentId" WHERE s.id = $1`; params.push(args.share_id); } const result = await pgClient.query(query, params); if (result.rows.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Documento não encontrado' }, null, 2) }] }; } return { content: [{ type: 'text', text: JSON.stringify({ document: result.rows[0] }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 3. create_document - Criar novo documento */ const createDocument: BaseTool = { name: 'create_document', description: 'Criar novo documento numa collection. Pode ser draft (não publicado) ou publicado imediatamente. Suporta criação de documentos hierárquicos (parent_document_id).', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Título do documento' }, text: { type: 'string', description: 'Conteúdo em Markdown (opcional para drafts)' }, collection_id: { type: 'string', description: 'UUID da collection' }, parent_document_id: { type: 'string', description: 'UUID do documento pai (opcional, para hierarquia)' }, template: { type: 'boolean', description: 'Marcar como template (default: false)' }, publish: { type: 'boolean', description: 'Publicar imediatamente (default: false, cria draft)' } }, required: ['title', 'collection_id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.collection_id)) { throw new Error('collection_id inválido (deve ser UUID)'); } if (args.parent_document_id && !isValidUUID(args.parent_document_id)) { throw new Error('parent_document_id inválido (deve ser UUID)'); } // Verificar que a collection existe const collectionCheck = await pgClient.query( `SELECT id FROM collections WHERE id = $1 AND "deletedAt" IS NULL`, [args.collection_id] ); if (collectionCheck.rows.length === 0) { throw new Error('Collection não encontrada ou foi eliminada'); } // Obter primeiro utilizador activo como createdById (necessário) const userResult = await pgClient.query( `SELECT id FROM users WHERE "deletedAt" IS NULL AND "suspendedAt" IS NULL LIMIT 1` ); if (userResult.rows.length === 0) { throw new Error('Nenhum utilizador activo encontrado'); } const userId = userResult.rows[0].id; const title = sanitizeInput(args.title); const text = args.text ? sanitizeInput(args.text) : ''; const publishedAt = args.publish ? new Date().toISOString() : null; const query = ` INSERT INTO documents ( title, text, "collectionId", "parentDocumentId", "createdById", "lastModifiedById", template, "publishedAt", "createdAt", "updatedAt", version ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW(), 1) RETURNING id, title, "collectionId", "publishedAt", "createdAt" `; const params = [ title, text, args.collection_id, args.parent_document_id || null, userId, userId, args.template || false, publishedAt ]; const result = await pgClient.query(query, params); return { content: [{ type: 'text', text: JSON.stringify({ success: true, document: result.rows[0], message: args.publish ? 'Documento criado e publicado' : 'Draft criado (não publicado)' }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 4. update_document - Actualizar documento existente */ const updateDocument: BaseTool = { name: 'update_document', description: 'Actualizar título e/ou conteúdo de um documento. Suporta substituição completa ou append ao texto existente.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' }, title: { type: 'string', description: 'Novo título (opcional)' }, text: { type: 'string', description: 'Novo conteúdo (opcional)' }, done: { type: 'boolean', description: 'Marcar como concluído (actualiza publishedAt se draft)' }, append: { type: 'boolean', description: 'Adicionar texto ao final em vez de substituir (default: false)' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } // Verificar que documento existe const docCheck = await pgClient.query( `SELECT id, text, "publishedAt" FROM documents WHERE id = $1 AND "deletedAt" IS NULL`, [args.id] ); if (docCheck.rows.length === 0) { throw new Error('Documento não encontrado ou foi eliminado'); } const updates: string[] = []; const params: any[] = []; let paramIndex = 1; if (args.title) { updates.push(`title = $${paramIndex++}`); params.push(sanitizeInput(args.title)); } if (args.text !== undefined) { if (args.append) { const currentText = docCheck.rows[0].text || ''; updates.push(`text = $${paramIndex++}`); params.push(currentText + '\n\n' + sanitizeInput(args.text)); } else { updates.push(`text = $${paramIndex++}`); params.push(sanitizeInput(args.text)); } } if (args.done && !docCheck.rows[0].publishedAt) { updates.push(`"publishedAt" = $${paramIndex++}`); params.push(new Date().toISOString()); } if (updates.length === 0) { throw new Error('Nenhum campo para actualizar fornecido'); } updates.push(`"updatedAt" = NOW()`); updates.push(`version = version + 1`); params.push(args.id); const query = ` UPDATE documents SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, title, "updatedAt", version, "publishedAt" `; const result = await pgClient.query(query, params); return { content: [{ type: 'text', text: JSON.stringify({ success: true, document: result.rows[0] }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 5. delete_document - Soft delete ou permanente */ const deleteDocument: BaseTool<{ id: string; permanent?: boolean }> = { name: 'delete_document', description: 'Eliminar documento (soft delete por default, permanente se especificado). Soft delete permite restaurar depois.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' }, permanent: { type: 'boolean', description: 'Eliminação permanente (irreversível, default: false)' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } let query: string; if (args.permanent) { query = `DELETE FROM documents WHERE id = $1 RETURNING id`; } else { query = `UPDATE documents SET "deletedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL RETURNING id, "deletedAt"`; } const result = await pgClient.query(query, [args.id]); if (result.rows.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Documento não encontrado ou já eliminado' }, null, 2) }] }; } return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: args.permanent ? 'Documento eliminado permanentemente' : 'Documento eliminado (soft delete)', id: result.rows[0].id }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 6. search_documents - Full-text search usando PostgreSQL tsvector */ const searchDocuments: BaseTool = { name: 'search_documents', description: 'Pesquisa full-text em documentos usando PostgreSQL tsvector. Pesquisa em título e conteúdo. Suporta filtros por collection e utilizador.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Termo de pesquisa' }, collection_id: { type: 'string', description: 'Filtrar por collection (opcional)' }, user_id: { type: 'string', description: 'Filtrar por autor (opcional)' }, include_archived: { type: 'boolean', description: 'Incluir arquivados (default: false)' }, include_drafts: { type: 'boolean', description: 'Incluir drafts não publicados (default: false)' }, limit: { type: 'number', description: 'Máximo de resultados (default: 25, max: 100)' }, offset: { type: 'number', description: 'Offset para paginação' } }, required: ['query'] }, handler: async (args, pgClient): Promise => { try { const { limit, offset } = validatePagination(args.limit, args.offset); const searchTerm = sanitizeInput(args.query); let query = ` SELECT d.id, d.title, d.text, d."collectionId", d."publishedAt", d."createdAt", c.name as "collectionName", u.name as "createdByName", ts_rank(to_tsvector('english', d.title || ' ' || d.text), plainto_tsquery('english', $1)) as rank FROM documents d LEFT JOIN collections c ON d."collectionId" = c.id LEFT JOIN users u ON d."createdById" = u.id WHERE d."deletedAt" IS NULL AND to_tsvector('english', d.title || ' ' || d.text) @@ plainto_tsquery('english', $1) `; const params: any[] = [searchTerm]; let paramIndex = 2; if (args.collection_id) { if (!isValidUUID(args.collection_id)) throw new Error('collection_id inválido'); query += ` AND d."collectionId" = $${paramIndex++}`; params.push(args.collection_id); } if (args.user_id) { if (!isValidUUID(args.user_id)) throw new Error('user_id inválido'); query += ` AND d."createdById" = $${paramIndex++}`; params.push(args.user_id); } if (!args.include_archived) { query += ` AND d."archivedAt" IS NULL`; } if (!args.include_drafts) { query += ` AND d."publishedAt" IS NOT NULL`; } query += ` ORDER BY rank DESC, d."updatedAt" DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; params.push(limit, offset); const result = await pgClient.query(query, params); return { content: [{ type: 'text', text: JSON.stringify({ query: searchTerm, results: result.rows, pagination: { limit, offset, count: result.rows.length } }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 7. list_drafts - Listar drafts (documentos não publicados) */ const listDrafts: BaseTool<{ collection_id?: string; limit?: number; offset?: number }> = { name: 'list_drafts', description: 'Listar documentos em draft (não publicados, publishedAt IS NULL). Útil para ver trabalho em curso.', inputSchema: { type: 'object', properties: { collection_id: { type: 'string', description: 'Filtrar por collection (opcional)' }, limit: { type: 'number', description: 'Máximo de resultados (default: 25)' }, offset: { type: 'number', description: 'Offset para paginação' } } }, handler: async (args, pgClient): Promise => { try { const { limit, offset } = validatePagination(args.limit, args.offset); let query = ` SELECT d.id, d.title, d."collectionId", d."createdById", d."createdAt", d."updatedAt", c.name as "collectionName", u.name as "createdByName" FROM documents d LEFT JOIN collections c ON d."collectionId" = c.id LEFT JOIN users u ON d."createdById" = u.id WHERE d."deletedAt" IS NULL AND d."publishedAt" IS NULL AND d."archivedAt" IS NULL `; const params: any[] = []; let paramIndex = 1; if (args.collection_id) { if (!isValidUUID(args.collection_id)) throw new Error('collection_id inválido'); query += ` AND d."collectionId" = $${paramIndex++}`; params.push(args.collection_id); } query += ` ORDER BY d."updatedAt" DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; params.push(limit, offset); const result = await pgClient.query(query, params); return { content: [{ type: 'text', text: JSON.stringify({ drafts: result.rows, pagination: { limit, offset, count: result.rows.length } }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 8. list_viewed_documents - Documentos visualizados (JOIN com views) */ const listViewedDocuments: BaseTool<{ user_id?: string; limit?: number; offset?: number }> = { name: 'list_viewed_documents', description: 'Listar documentos visualizados recentemente com estatísticas de views. Opcionalmente filtrar por utilizador.', inputSchema: { type: 'object', properties: { user_id: { type: 'string', description: 'UUID do utilizador (opcional, mostra views de todos se omitido)' }, limit: { type: 'number', description: 'Máximo de resultados (default: 25)' }, offset: { type: 'number', description: 'Offset para paginação' } } }, handler: async (args, pgClient): Promise => { try { const { limit, offset } = validatePagination(args.limit, args.offset); let query = ` SELECT d.id, d.title, d."collectionId", c.name as "collectionName", COUNT(v.id) as view_count, MAX(v."updatedAt") as last_viewed FROM views v INNER JOIN documents d ON v."documentId" = d.id LEFT JOIN collections c ON d."collectionId" = c.id WHERE d."deletedAt" IS NULL `; const params: any[] = []; let paramIndex = 1; if (args.user_id) { if (!isValidUUID(args.user_id)) throw new Error('user_id inválido'); query += ` AND v."userId" = $${paramIndex++}`; params.push(args.user_id); } query += ` GROUP BY d.id, d.title, d."collectionId", c.name ORDER BY last_viewed DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; params.push(limit, offset); const result = await pgClient.query(query, params); return { content: [{ type: 'text', text: JSON.stringify({ viewed_documents: result.rows, pagination: { limit, offset, count: result.rows.length } }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 9. archive_document - Arquivar documento */ const archiveDocument: BaseTool<{ id: string }> = { name: 'archive_document', description: 'Arquivar documento (define archivedAt). Documentos arquivados não aparecem em listagens normais mas podem ser restaurados.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } const result = await pgClient.query( `UPDATE documents SET "archivedAt" = NOW(), "updatedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL AND "archivedAt" IS NULL RETURNING id, title, "archivedAt"`, [args.id] ); if (result.rows.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Documento não encontrado, já arquivado ou eliminado' }, null, 2) }] }; } return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Documento arquivado', document: result.rows[0] }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 10. restore_document - Restaurar documento arquivado ou eliminado */ const restoreDocument: BaseTool<{ id: string }> = { name: 'restore_document', description: 'Restaurar documento arquivado ou eliminado (soft delete). Limpa archivedAt e deletedAt.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } const result = await pgClient.query( `UPDATE documents SET "archivedAt" = NULL, "deletedAt" = NULL, "updatedAt" = NOW() WHERE id = $1 AND ("archivedAt" IS NOT NULL OR "deletedAt" IS NOT NULL) RETURNING id, title, "updatedAt"`, [args.id] ); if (result.rows.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Documento não encontrado ou não estava arquivado/eliminado' }, null, 2) }] }; } return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Documento restaurado', document: result.rows[0] }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 11. move_document - Mover documento (mudar collection ou parent) */ const moveDocument: BaseTool = { name: 'move_document', description: 'Mover documento para outra collection ou mudar parent (hierarquia). Pelo menos um de collection_id ou parent_document_id deve ser fornecido.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento a mover' }, collection_id: { type: 'string', description: 'Nova collection (opcional)' }, parent_document_id: { type: 'string', description: 'Novo parent (opcional, null remove parent)' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } if (!args.collection_id && !args.parent_document_id) { throw new Error('É necessário fornecer collection_id ou parent_document_id'); } const updates: string[] = []; const params: any[] = []; let paramIndex = 1; if (args.collection_id) { if (!isValidUUID(args.collection_id)) throw new Error('collection_id inválido'); updates.push(`"collectionId" = $${paramIndex++}`); params.push(args.collection_id); } if (args.parent_document_id !== undefined) { if (args.parent_document_id && !isValidUUID(args.parent_document_id)) { throw new Error('parent_document_id inválido'); } updates.push(`"parentDocumentId" = $${paramIndex++}`); params.push(args.parent_document_id || null); } updates.push(`"updatedAt" = NOW()`); params.push(args.id); const query = ` UPDATE documents SET ${updates.join(', ')} WHERE id = $${paramIndex} AND "deletedAt" IS NULL RETURNING id, title, "collectionId", "parentDocumentId", "updatedAt" `; const result = await pgClient.query(query, params); if (result.rows.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Documento não encontrado ou foi eliminado' }, null, 2) }] }; } return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Documento movido', document: result.rows[0] }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 12. unpublish_document - Despublicar documento (volta a draft) */ const unpublishDocument: BaseTool<{ id: string }> = { name: 'unpublish_document', description: 'Despublicar documento (limpa publishedAt, voltando a draft). Útil para retirar documentos de visualização pública.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } const result = await pgClient.query( `UPDATE documents SET "publishedAt" = NULL, "updatedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL AND "publishedAt" IS NOT NULL RETURNING id, title, "updatedAt"`, [args.id] ); if (result.rows.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Documento não encontrado, foi eliminado ou já era draft' }, null, 2) }] }; } return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Documento despublicado (agora é draft)', document: result.rows[0] }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 13. templatize_document - Criar template a partir de documento */ const templatizeDocument: BaseTool<{ id: string }> = { name: 'templatize_document', description: 'Criar template a partir de documento existente. O documento original mantém-se, é criada uma cópia marcada como template.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento origem' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } // Obter documento origem const docResult = await pgClient.query( `SELECT * FROM documents WHERE id = $1 AND "deletedAt" IS NULL`, [args.id] ); if (docResult.rows.length === 0) { throw new Error('Documento não encontrado ou foi eliminado'); } const doc = docResult.rows[0]; // Criar template const templateResult = await pgClient.query( `INSERT INTO documents ( title, text, "collectionId", "createdById", "lastModifiedById", template, "templateId", "publishedAt", "createdAt", "updatedAt", version ) VALUES ($1, $2, $3, $4, $5, true, $6, NOW(), NOW(), NOW(), 1) RETURNING id, title, template, "templateId"`, [ `${doc.title} (Template)`, doc.text, doc.collectionId, doc.createdById, doc.lastModifiedById, args.id ] ); return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Template criado a partir do documento', template: templateResult.rows[0], original_document_id: args.id }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 14. export_document - Retornar texto markdown */ const exportDocument: BaseTool<{ id: string }> = { name: 'export_document', description: 'Exportar documento em formato Markdown. Retorna título, conteúdo e metadata.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } const result = await pgClient.query( `SELECT d.id, d.title, d.text, d."createdAt", d."updatedAt", d."publishedAt", c.name as "collectionName", u.name as "createdByName" FROM documents d LEFT JOIN collections c ON d."collectionId" = c.id LEFT JOIN users u ON d."createdById" = u.id WHERE d.id = $1 AND d."deletedAt" IS NULL`, [args.id] ); if (result.rows.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Documento não encontrado ou foi eliminado' }, null, 2) }] }; } const doc = result.rows[0]; const markdown = `# ${doc.title} **Collection:** ${doc.collectionName || 'N/A'} **Autor:** ${doc.createdByName || 'N/A'} **Criado:** ${doc.createdAt} **Actualizado:** ${doc.updatedAt} ${doc.publishedAt ? `**Publicado:** ${doc.publishedAt}` : '**Estado:** Draft'} --- ${doc.text || ''} `; return { content: [{ type: 'text', text: JSON.stringify({ id: doc.id, title: doc.title, markdown, metadata: { collectionName: doc.collectionName, createdByName: doc.createdByName, createdAt: doc.createdAt, updatedAt: doc.updatedAt, publishedAt: doc.publishedAt } }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 15. import_document - Criar documento a partir de texto/markdown */ const importDocument: BaseTool<{ title: string; text: string; collection_id: string; publish?: boolean }> = { name: 'import_document', description: 'Importar documento a partir de texto/Markdown. Cria novo documento numa collection.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Título do documento' }, text: { type: 'string', description: 'Conteúdo em Markdown' }, collection_id: { type: 'string', description: 'UUID da collection destino' }, publish: { type: 'boolean', description: 'Publicar imediatamente (default: true)' } }, required: ['title', 'text', 'collection_id'] }, handler: async (args, pgClient): Promise => { // Reutilizar create_document return createDocument.handler({ title: args.title, text: args.text, collection_id: args.collection_id, publish: args.publish !== false }, pgClient); } }; /** * 16. list_document_users - Utilizadores com acesso ao documento */ const listDocumentUsers: BaseTool<{ id: string }> = { name: 'list_document_users', description: 'Listar utilizadores com acesso a um documento (via collection permissions ou memberships directas).', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } // Obter utilizadores via collection_users const result = await pgClient.query( `SELECT DISTINCT u.id, u.name, u.email, cu.permission FROM documents d INNER JOIN collection_users cu ON d."collectionId" = cu."collectionId" INNER JOIN users u ON cu."userId" = u.id WHERE d.id = $1 AND d."deletedAt" IS NULL AND u."deletedAt" IS NULL ORDER BY u.name`, [args.id] ); return { content: [{ type: 'text', text: JSON.stringify({ document_id: args.id, users: result.rows }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 17. list_document_memberships - Memberships directas do documento */ const listDocumentMemberships: BaseTool<{ id: string }> = { name: 'list_document_memberships', description: 'Listar memberships directas do documento (via collection_users da collection do documento).', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' } }, required: ['id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } const result = await pgClient.query( `SELECT cu.id, cu."userId", cu."collectionId", cu.permission, cu."createdAt", u.name as "userName", u.email as "userEmail", c.name as "collectionName" FROM documents d INNER JOIN collection_users cu ON d."collectionId" = cu."collectionId" INNER JOIN users u ON cu."userId" = u.id INNER JOIN collections c ON cu."collectionId" = c.id WHERE d.id = $1 AND d."deletedAt" IS NULL ORDER BY cu."createdAt" DESC`, [args.id] ); return { content: [{ type: 'text', text: JSON.stringify({ document_id: args.id, memberships: result.rows }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 18. add_user_to_document - Adicionar permissão de utilizador ao documento */ const addUserToDocument: BaseTool<{ id: string; user_id: string; permission?: 'read' | 'read_write' }> = { name: 'add_user_to_document', description: 'Adicionar utilizador com permissão ao documento (via collection_users da collection do documento).', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' }, user_id: { type: 'string', description: 'UUID do utilizador' }, permission: { type: 'string', enum: ['read', 'read_write'], description: 'Tipo de permissão (default: read)' } }, required: ['id', 'user_id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } if (!isValidUUID(args.user_id)) { throw new Error('user_id inválido (deve ser UUID)'); } // Obter collectionId do documento const docResult = await pgClient.query( `SELECT "collectionId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL`, [args.id] ); if (docResult.rows.length === 0) { throw new Error('Documento não encontrado ou foi eliminado'); } const collectionId = docResult.rows[0].collectionId; const permission = args.permission || 'read'; // Inserir ou actualizar membership const result = await pgClient.query( `INSERT INTO collection_users ("userId", "collectionId", permission, "createdAt", "updatedAt") VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT ("userId", "collectionId") DO UPDATE SET permission = $3, "updatedAt" = NOW() RETURNING id, "userId", "collectionId", permission`, [args.user_id, collectionId, permission] ); return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Utilizador adicionado ao documento (via collection)', membership: result.rows[0] }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; /** * 19. remove_user_from_document - Remover permissão de utilizador do documento */ const removeUserFromDocument: BaseTool<{ id: string; user_id: string }> = { name: 'remove_user_from_document', description: 'Remover utilizador do documento (via collection_users da collection do documento).', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'UUID do documento' }, user_id: { type: 'string', description: 'UUID do utilizador' } }, required: ['id', 'user_id'] }, handler: async (args, pgClient): Promise => { try { if (!isValidUUID(args.id)) { throw new Error('id inválido (deve ser UUID)'); } if (!isValidUUID(args.user_id)) { throw new Error('user_id inválido (deve ser UUID)'); } // Obter collectionId do documento const docResult = await pgClient.query( `SELECT "collectionId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL`, [args.id] ); if (docResult.rows.length === 0) { throw new Error('Documento não encontrado ou foi eliminado'); } const collectionId = docResult.rows[0].collectionId; const result = await pgClient.query( `DELETE FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2 RETURNING id, "userId", "collectionId"`, [args.user_id, collectionId] ); if (result.rows.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Membership não encontrada' }, null, 2) }] }; } return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Utilizador removido do documento (via collection)', removed: result.rows[0] }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) }] }; } } }; // Export all document tools export const documentsTools: BaseTool[] = [ listDocuments, getDocument, createDocument, updateDocument, deleteDocument, searchDocuments, listDrafts, listViewedDocuments, archiveDocument, restoreDocument, moveDocument, unpublishDocument, templatizeDocument, exportDocument, importDocument, listDocumentUsers, listDocumentMemberships, addUserToDocument, removeUserFromDocument ];