/** * MCP Outline PostgreSQL - Bulk Operations Tools * Batch operations on documents, collections, etc. * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { Pool, PoolClient } from 'pg'; import { BaseTool, ToolResponse } from '../types/tools.js'; import { isValidUUID } from '../utils/security.js'; /** * Execute operations within a transaction */ async function withTransaction(pool: Pool, callback: (client: PoolClient) => Promise): Promise { const client = await pool.connect(); try { await client.query('BEGIN'); const result = await callback(client); await client.query('COMMIT'); return result; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } /** * bulk.archive_documents - Archive multiple documents */ const bulkArchiveDocuments: BaseTool<{ document_ids: string[] }> = { name: 'outline_bulk_archive_documents', description: 'Archive multiple documents at once.', inputSchema: { type: 'object', properties: { document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' }, }, required: ['document_ids'], }, handler: async (args, pgClient): Promise => { if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required'); if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation'); // Validate all IDs for (const id of args.document_ids) { if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`); } // Use transaction for atomic operation const result = await withTransaction(pgClient, async (client) => { return await client.query(` UPDATE documents SET "archivedAt" = NOW(), "updatedAt" = NOW() WHERE id = ANY($1) AND "archivedAt" IS NULL AND "deletedAt" IS NULL RETURNING id, title `, [args.document_ids]); }); return { content: [{ type: 'text', text: JSON.stringify({ archived: result.rows, archivedCount: result.rows.length, requestedCount: args.document_ids.length, message: `${result.rows.length} documents archived` }, null, 2) }], }; }, }; /** * bulk.delete_documents - Soft delete multiple documents */ const bulkDeleteDocuments: BaseTool<{ document_ids: string[] }> = { name: 'outline_bulk_delete_documents', description: 'Soft delete multiple documents at once.', inputSchema: { type: 'object', properties: { document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' }, }, required: ['document_ids'], }, handler: async (args, pgClient): Promise => { if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required'); if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation'); for (const id of args.document_ids) { if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`); } // Use transaction for atomic operation const result = await withTransaction(pgClient, async (client) => { const deletedById = await client.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`); const userId = deletedById.rows.length > 0 ? deletedById.rows[0].id : null; return await client.query(` UPDATE documents SET "deletedAt" = NOW(), "deletedById" = $2, "updatedAt" = NOW() WHERE id = ANY($1) AND "deletedAt" IS NULL RETURNING id, title `, [args.document_ids, userId]); }); return { content: [{ type: 'text', text: JSON.stringify({ deleted: result.rows, deletedCount: result.rows.length, requestedCount: args.document_ids.length, message: `${result.rows.length} documents deleted` }, null, 2) }], }; }, }; /** * bulk.move_documents - Move multiple documents to collection */ const bulkMoveDocuments: BaseTool<{ document_ids: string[]; collection_id: string; parent_document_id?: string }> = { name: 'outline_bulk_move_documents', description: 'Move multiple documents to a collection or under a parent document.', inputSchema: { type: 'object', properties: { document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' }, collection_id: { type: 'string', description: 'Target collection ID (UUID)' }, parent_document_id: { type: 'string', description: 'Optional parent document ID (UUID)' }, }, required: ['document_ids', 'collection_id'], }, handler: async (args, pgClient): Promise => { if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required'); if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation'); if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); if (args.parent_document_id && !isValidUUID(args.parent_document_id)) throw new Error('Invalid parent_document_id'); for (const id of args.document_ids) { if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`); } // Use transaction for atomic operation const result = await withTransaction(pgClient, async (client) => { // Verify collection exists const collectionCheck = await client.query( `SELECT id FROM collections WHERE id = $1 AND "deletedAt" IS NULL`, [args.collection_id] ); if (collectionCheck.rows.length === 0) throw new Error('Collection not found'); return await client.query(` UPDATE documents SET "collectionId" = $2, "parentDocumentId" = $3, "updatedAt" = NOW() WHERE id = ANY($1) AND "deletedAt" IS NULL RETURNING id, title, "collectionId" `, [args.document_ids, args.collection_id, args.parent_document_id || null]); }); return { content: [{ type: 'text', text: JSON.stringify({ moved: result.rows, movedCount: result.rows.length, targetCollectionId: args.collection_id, message: `${result.rows.length} documents moved` }, null, 2) }], }; }, }; /** * bulk.restore_documents - Restore multiple deleted documents */ const bulkRestoreDocuments: BaseTool<{ document_ids: string[] }> = { name: 'outline_bulk_restore_documents', description: 'Restore multiple soft-deleted documents.', inputSchema: { type: 'object', properties: { document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' }, }, required: ['document_ids'], }, handler: async (args, pgClient): Promise => { if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required'); if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation'); for (const id of args.document_ids) { if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`); } // Use transaction for atomic operation const result = await withTransaction(pgClient, async (client) => { return await client.query(` UPDATE documents SET "deletedAt" = NULL, "deletedById" = NULL, "updatedAt" = NOW() WHERE id = ANY($1) AND "deletedAt" IS NOT NULL RETURNING id, title `, [args.document_ids]); }); return { content: [{ type: 'text', text: JSON.stringify({ restored: result.rows, restoredCount: result.rows.length, requestedCount: args.document_ids.length, message: `${result.rows.length} documents restored` }, null, 2) }], }; }, }; /** * bulk.add_users_to_collection - Add multiple users to collection */ const bulkAddUsersToCollection: BaseTool<{ user_ids: string[]; collection_id: string; permission?: string }> = { name: 'outline_bulk_add_users_to_collection', description: 'Add multiple users to a collection with specified permission.', inputSchema: { type: 'object', properties: { user_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs (UUIDs)' }, collection_id: { type: 'string', description: 'Collection ID (UUID)' }, permission: { type: 'string', description: 'Permission level: read_write, read, admin (default: read_write)' }, }, required: ['user_ids', 'collection_id'], }, handler: async (args, pgClient): Promise => { if (!args.user_ids || args.user_ids.length === 0) throw new Error('At least one user_id required'); if (args.user_ids.length > 50) throw new Error('Maximum 50 users per operation'); if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); for (const id of args.user_ids) { if (!isValidUUID(id)) throw new Error(`Invalid user ID: ${id}`); } const permission = args.permission || 'read_write'; // Use transaction for atomic operation const { added, skipped } = await withTransaction(pgClient, async (client) => { const creatorResult = await client.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`); const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_ids[0]; const addedList: string[] = []; const skippedList: string[] = []; for (const userId of args.user_ids) { // Check if already exists const existing = await client.query( `SELECT "userId" FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2`, [userId, args.collection_id] ); if (existing.rows.length > 0) { skippedList.push(userId); } else { await client.query(` INSERT INTO collection_users ("userId", "collectionId", permission, "createdById", "createdAt", "updatedAt") VALUES ($1, $2, $3, $4, NOW(), NOW()) `, [userId, args.collection_id, permission, createdById]); addedList.push(userId); } } return { added: addedList, skipped: skippedList }; }); return { content: [{ type: 'text', text: JSON.stringify({ addedUserIds: added, skippedUserIds: skipped, addedCount: added.length, skippedCount: skipped.length, permission, message: `${added.length} users added, ${skipped.length} already existed` }, null, 2) }], }; }, }; /** * bulk.remove_users_from_collection - Remove multiple users from collection */ const bulkRemoveUsersFromCollection: BaseTool<{ user_ids: string[]; collection_id: string }> = { name: 'outline_bulk_remove_users_from_collection', description: 'Remove multiple users from a collection.', inputSchema: { type: 'object', properties: { user_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs (UUIDs)' }, collection_id: { type: 'string', description: 'Collection ID (UUID)' }, }, required: ['user_ids', 'collection_id'], }, handler: async (args, pgClient): Promise => { if (!args.user_ids || args.user_ids.length === 0) throw new Error('At least one user_id required'); if (args.user_ids.length > 50) throw new Error('Maximum 50 users per operation'); if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); for (const id of args.user_ids) { if (!isValidUUID(id)) throw new Error(`Invalid user ID: ${id}`); } // Use transaction for atomic operation const result = await withTransaction(pgClient, async (client) => { return await client.query(` DELETE FROM collection_users WHERE "userId" = ANY($1) AND "collectionId" = $2 RETURNING "userId" `, [args.user_ids, args.collection_id]); }); return { content: [{ type: 'text', text: JSON.stringify({ removedUserIds: result.rows.map(r => r.userId), removedCount: result.rows.length, requestedCount: args.user_ids.length, message: `${result.rows.length} users removed from collection` }, null, 2) }], }; }, }; export const bulkOperationsTools: BaseTool[] = [ bulkArchiveDocuments, bulkDeleteDocuments, bulkMoveDocuments, bulkRestoreDocuments, bulkAddUsersToCollection, bulkRemoveUsersFromCollection ];