Outline requires collaboratorIds to be an array, not NULL. Error was: "TypeError: b.collaboratorIds is not iterable" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1385 lines
44 KiB
TypeScript
1385 lines
44 KiB
TypeScript
/**
|
|
* 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<DocumentArgs> = {
|
|
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<ToolResponse> => {
|
|
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.icon,
|
|
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<GetDocumentArgs> = {
|
|
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<ToolResponse> => {
|
|
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.icon,
|
|
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<CreateDocumentArgs> = {
|
|
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<ToolResponse> => {
|
|
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;
|
|
|
|
// Obter teamId da collection
|
|
const teamResult = await pgClient.query(
|
|
`SELECT "teamId" FROM collections WHERE id = $1`,
|
|
[args.collection_id]
|
|
);
|
|
const teamId = teamResult.rows[0]?.teamId;
|
|
|
|
// Use transaction to ensure both document and revision are created
|
|
await pgClient.query('BEGIN');
|
|
|
|
try {
|
|
const docQuery = `
|
|
INSERT INTO documents (
|
|
id, "urlId", title, text, "collectionId", "teamId", "parentDocumentId", "createdById",
|
|
"lastModifiedById", template, "publishedAt", "createdAt", "updatedAt", version,
|
|
"isWelcome", "fullWidth", "insightsEnabled", "collaboratorIds"
|
|
)
|
|
VALUES (
|
|
gen_random_uuid(),
|
|
substring(md5(random()::text) from 1 for 10),
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), 1, false, false, false, '{}'
|
|
)
|
|
RETURNING id, "urlId", title, "collectionId", "publishedAt", "createdAt"
|
|
`;
|
|
|
|
const docParams = [
|
|
title,
|
|
text,
|
|
args.collection_id,
|
|
teamId,
|
|
args.parent_document_id || null,
|
|
userId,
|
|
userId,
|
|
args.template || false,
|
|
publishedAt
|
|
];
|
|
|
|
const docResult = await pgClient.query(docQuery, docParams);
|
|
const newDoc = docResult.rows[0];
|
|
|
|
// Insert initial revision (required for Outline to display the document)
|
|
const revisionQuery = `
|
|
INSERT INTO revisions (
|
|
id, "documentId", "userId", title, text,
|
|
"createdAt", "updatedAt"
|
|
)
|
|
VALUES (
|
|
gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()
|
|
)
|
|
`;
|
|
|
|
await pgClient.query(revisionQuery, [
|
|
newDoc.id,
|
|
userId,
|
|
title,
|
|
text
|
|
]);
|
|
|
|
await pgClient.query('COMMIT');
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
success: true,
|
|
document: newDoc,
|
|
message: args.publish ? 'Documento criado e publicado' : 'Draft criado (não publicado)'
|
|
}, null, 2)
|
|
}]
|
|
};
|
|
} catch (txError) {
|
|
await pgClient.query('ROLLBACK');
|
|
throw txError;
|
|
}
|
|
} 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<UpdateDocumentArgs> = {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<SearchDocumentsArgs> = {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<MoveDocumentArgs> = {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
// 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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<ToolResponse> => {
|
|
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<any>[] = [
|
|
listDocuments,
|
|
getDocument,
|
|
createDocument,
|
|
updateDocument,
|
|
deleteDocument,
|
|
searchDocuments,
|
|
listDrafts,
|
|
listViewedDocuments,
|
|
archiveDocument,
|
|
restoreDocument,
|
|
moveDocument,
|
|
unpublishDocument,
|
|
templatizeDocument,
|
|
exportDocument,
|
|
importDocument,
|
|
listDocumentUsers,
|
|
listDocumentMemberships,
|
|
addUserToDocument,
|
|
removeUserFromDocument
|
|
];
|