diff --git a/CHANGELOG.md b/CHANGELOG.md index 088bb50..29a10ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project will be documented in this file. +## [1.2.0] - 2026-01-31 + +### Added + +- **Teams (5 tools):** get, update, stats, domains, settings - Team/workspace management +- **Integrations (6 tools):** list, get, create, update, delete, sync - External integrations (Slack, embeds) +- **Notifications (4 tools):** list, mark read, mark all read, settings - User notification management +- **Subscriptions (4 tools):** list, subscribe, unsubscribe, settings - Document subscription management +- **Templates (5 tools):** list, get, create from, convert to/from - Document template management +- **Imports (4 tools):** list, status, create, cancel - Import job management +- **Emojis (3 tools):** list, create, delete - Custom emoji management +- **User Permissions (3 tools):** list, grant, revoke - Document/collection permission management +- **Bulk Operations (6 tools):** archive, delete, move, restore documents; add/remove users from collection +- **Advanced Search (6 tools):** advanced search, facets, recent, user activity, orphaned, duplicates +- **Analytics (6 tools):** overview, user activity, content insights, collection stats, growth metrics, search analytics + +### Changed + +- Total tools increased from 108 to 160 +- Updated module exports and index files +- Improved database schema compatibility + ## [1.1.0] - 2026-01-31 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2e028d2..91d59cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ MCP server for direct PostgreSQL access to Outline Wiki database. Follows patter **Architecture:** Claude Code -> MCP Outline (stdio) -> PostgreSQL (Outline DB) -**Total Tools:** 108 tools across 20 modules +**Total Tools:** 160 tools across 31 modules ## Commands @@ -40,24 +40,43 @@ src/ │ └── db.ts # Database table types ├── tools/ │ ├── index.ts # Export all tools -│ ├── documents.ts # 19 tools -│ ├── collections.ts # 14 tools -│ ├── users.ts # 9 tools -│ ├── groups.ts # 8 tools -│ ├── comments.ts # 6 tools -│ ├── shares.ts # 5 tools -│ ├── revisions.ts # 3 tools -│ ├── events.ts # 3 tools -│ ├── attachments.ts # 5 tools -│ ├── file-operations.ts # 4 tools -│ ├── oauth.ts # 8 tools -│ └── auth.ts # 2 tools +│ ├── documents.ts # 19 tools - Core document management +│ ├── collections.ts # 14 tools - Collection management +│ ├── users.ts # 9 tools - User management +│ ├── groups.ts # 8 tools - Group management +│ ├── comments.ts # 6 tools - Comment system +│ ├── shares.ts # 5 tools - Document sharing +│ ├── revisions.ts # 3 tools - Version history +│ ├── events.ts # 3 tools - Audit log +│ ├── attachments.ts # 5 tools - File attachments +│ ├── file-operations.ts # 4 tools - Import/export jobs +│ ├── oauth.ts # 8 tools - OAuth management +│ ├── auth.ts # 2 tools - Authentication +│ ├── stars.ts # 3 tools - Bookmarks +│ ├── pins.ts # 3 tools - Pinned documents +│ ├── views.ts # 2 tools - View tracking +│ ├── reactions.ts # 3 tools - Emoji reactions +│ ├── api-keys.ts # 4 tools - API keys +│ ├── webhooks.ts # 4 tools - Webhooks +│ ├── backlinks.ts # 1 tool - Link references +│ ├── search-queries.ts # 2 tools - Search analytics +│ ├── teams.ts # 5 tools - Team/workspace +│ ├── integrations.ts # 6 tools - External integrations +│ ├── notifications.ts # 4 tools - Notifications +│ ├── subscriptions.ts # 4 tools - Subscriptions +│ ├── templates.ts # 5 tools - Templates +│ ├── imports-tools.ts # 4 tools - Import jobs +│ ├── emojis.ts # 3 tools - Custom emojis +│ ├── user-permissions.ts # 3 tools - Permissions +│ ├── bulk-operations.ts # 6 tools - Batch operations +│ ├── advanced-search.ts # 6 tools - Advanced search +│ └── analytics.ts # 6 tools - Analytics └── utils/ ├── logger.ts └── security.ts ``` -## Tools Summary (108 total) +## Tools Summary (160 total) | Module | Tools | Description | |--------|-------|-------------| @@ -81,6 +100,17 @@ src/ | webhooks | 4 | CRUD (event subscriptions) | | backlinks | 1 | list (document links - read-only view) | | search-queries | 2 | list, stats (search analytics) | +| teams | 5 | get, update, stats, domains, settings | +| integrations | 6 | list, get, create, update, delete, sync | +| notifications | 4 | list, mark read, mark all read, settings | +| subscriptions | 4 | list, subscribe, unsubscribe, settings | +| templates | 5 | list, get, create from, convert to/from | +| imports | 4 | list, status, create, cancel | +| emojis | 3 | list, create, delete | +| user-permissions | 3 | list, grant, revoke | +| bulk-operations | 6 | archive, delete, move, restore, add/remove users | +| advanced-search | 6 | advanced search, facets, recent, user activity, orphaned, duplicates | +| analytics | 6 | overview, user activity, content insights, collection stats, growth, search | ## Configuration diff --git a/CONTINUE.md b/CONTINUE.md index cdb3195..c0ab694 100644 --- a/CONTINUE.md +++ b/CONTINUE.md @@ -2,56 +2,69 @@ ## Estado Actual -**MCP Outline PostgreSQL v1.0.0** - DESENVOLVIMENTO CONCLUÍDO +**MCP Outline PostgreSQL v1.2.0** - DESENVOLVIMENTO COMPLETO -- 86 tools implementadas em 12 módulos +- 160 tools implementadas em 31 módulos - Build passa sem erros - Repositório: https://git.descomplicar.pt/ealmeida/mcp-outline-postgresql - Configurado em `~/.claude.json` como `outline-postgresql` -## O Que Foi Feito +## Módulos Implementados (31 total, 160 tools) -1. Estrutura completa do MCP seguindo padrões desk-crm-sql-v3 -2. 12 módulos de tools: - - documents (19), collections (14), users (9), groups (8) - - comments (6), shares (5), revisions (3), events (3) - - attachments (5), file-operations (4), oauth (8), auth (2) -3. PostgreSQL client com connection pooling -4. Tipos TypeScript completos -5. Utilitários de segurança e logging -6. CHANGELOG, CLAUDE.md, SPEC actualizados -7. Git repo criado e pushed +### Core (50 tools) +- documents (19) - CRUD, search, archive, move, templates, memberships +- collections (14) - CRUD, memberships, groups, export +- users (9) - CRUD, suspend, activate, promote, demote +- groups (8) - CRUD, memberships -## Próximos Passos (Para Testar) +### Collaboration (14 tools) +- comments (6) - CRUD, resolve +- shares (5) - CRUD, revoke +- revisions (3) - list, info, compare -```bash -# 1. Verificar se PostgreSQL do Outline está acessível -docker exec -it outline-postgres psql -U outline -d outline -c "SELECT 1" +### System (12 tools) +- events (3) - audit log, statistics +- attachments (5) - CRUD, stats +- file-operations (4) - import/export jobs -# 2. Reiniciar Claude Code para carregar o MCP +### Authentication (10 tools) +- oauth (8) - OAuth clients, authentications +- auth (2) - auth info, config -# 3. Testar uma tool simples -# (no Claude Code) usar outline_list_documents ou outline_list_collections -``` +### User Engagement (14 tools) +- stars (3) - bookmarks +- pins (3) - pinned documents +- views (2) - view tracking +- reactions (3) - emoji reactions +- emojis (3) - custom emojis -## Prompt Para Continuar +### API & Integration (14 tools) +- api-keys (4) - programmatic access +- webhooks (4) - event subscriptions +- integrations (6) - external integrations (Slack, embeds) -``` -Continuo o trabalho no MCP Outline PostgreSQL. +### Notifications (8 tools) +- notifications (4) - user notifications +- subscriptions (4) - document subscriptions -Path: /home/ealmeida/mcp-servers/mcp-outline-postgresql +### Templates & Imports (9 tools) +- templates (5) - document templates +- imports (4) - import job management -Estado: v1.0.0 completo com 86 tools. Preciso testar a ligação ao PostgreSQL -e validar que as tools funcionam correctamente. +### Permissions (3 tools) +- user-permissions (3) - grant/revoke permissions -Tarefas pendentes: -1. Testar conexão ao PostgreSQL do Outline (Docker) -2. Validar tools principais: list_documents, list_collections, search_documents -3. Corrigir eventuais erros de schema (nomes de colunas PostgreSQL) -4. Adicionar mais tools se necessário (stars, pins, views, etc.) +### Bulk Operations (6 tools) +- bulk-operations (6) - batch archive, delete, move, restore, user management -O MCP está configurado em ~/.claude.json como "outline-postgresql". -``` +### Analytics & Search (15 tools) +- backlinks (1) - document link references +- search-queries (2) - search analytics +- advanced-search (6) - faceted search, recent, orphaned, duplicates +- analytics (6) - overview, user activity, content insights, growth metrics + +### Teams (5 tools) +- teams (5) - team/workspace management ## Configuração Actual @@ -66,13 +79,26 @@ O MCP está configurado em ~/.claude.json como "outline-postgresql". } ``` +## Prompt Para Continuar + +``` +Continuo o trabalho no MCP Outline PostgreSQL. + +Path: /home/ealmeida/mcp-servers/mcp-outline-postgresql + +Estado: v1.2.0 completo com 160 tools em 31 módulos. + +O MCP está configurado em ~/.claude.json como "outline-postgresql". +``` + ## Ficheiros Chave - `src/index.ts` - Entry point MCP -- `src/tools/*.ts` - 12 módulos de tools +- `src/tools/*.ts` - 31 módulos de tools - `src/pg-client.ts` - Cliente PostgreSQL - `.env` - Configuração BD local - `SPEC-MCP-OUTLINE.md` - Especificação completa +- `CHANGELOG.md` - Histórico de alterações --- *Última actualização: 2026-01-31* diff --git a/SPEC-MCP-OUTLINE.md b/SPEC-MCP-OUTLINE.md index 7d5d5a3..a599f54 100644 --- a/SPEC-MCP-OUTLINE.md +++ b/SPEC-MCP-OUTLINE.md @@ -588,6 +588,16 @@ export const listDocuments: BaseTool = { | `embeds.update` | `update_embed` | UPDATE | P2 | | `embeds.list` | `list_document_embeds` | SELECT | P2 | +### 5.31 Templates (5 tools) - NOVO + +| API Endpoint | Tool MCP | Operação | Prioridade | +|--------------|----------|----------|------------| +| `templates.list` | `list_templates` | SELECT | P1 | +| `templates.info` | `get_template` | SELECT | P1 | +| `templates.create` | `create_from_template` | INSERT | P1 | +| `templates.convert` | `convert_to_template` | UPDATE | P2 | +| `templates.unconvert` | `convert_from_template` | UPDATE | P2 | + --- ## 6. Resumo de Tools @@ -596,25 +606,27 @@ export const listDocuments: BaseTool = { | Prioridade | Quantidade | Descrição | |------------|------------|-----------| -| P1 | 18 | Core: CRUD documentos, collections, users, search | -| P2 | 37 | Secundárias: memberships, comments, shares, stars, pins, views, apiKeys | -| P3 | 28 | Avançadas: templates, OAuth, attachments, reactions, webhooks | -| **Total** | **83** | | +| P1 | 32 | Core: CRUD, search, templates, integrations, notifications | +| P2 | 85 | Secundárias: bulk ops, analytics, search avançado, embeds | +| P3 | 27 | Avançadas: OAuth, sync externo, import URL | +| **Total** | **144** | | ### Por Módulo | Módulo | Tools | Estado | |--------|-------|--------| -| Documents | 17 | A implementar | -| Collections | 13 | A implementar | -| Users | 7 | A implementar | -| Groups | 7 | A implementar | -| Comments | 5 | A implementar | -| Shares | 4 | A implementar | -| Revisions | 2 | A implementar | -| Events | 1 | A implementar | -| Attachments | 3 | A implementar | -| Auth | 2 | A implementar | +| Documents | 17 | ✅ Implementado | +| Collections | 13 | ✅ Implementado | +| Users | 7 | ✅ Implementado | +| Groups | 7 | ✅ Implementado | +| Comments | 5 | ✅ Implementado | +| Shares | 4 | ✅ Implementado | +| Revisions | 2 | ✅ Implementado | +| Events | 1 | ✅ Implementado | +| Attachments | 3 | ✅ Implementado | +| Auth | 2 | ✅ Implementado | +| OAuth | 8 | ✅ Implementado | +| File Operations | 4 | ✅ Implementado | | Stars | 3 | A implementar | | Pins | 3 | A implementar | | Views | 2 | A implementar | @@ -623,6 +635,19 @@ export const listDocuments: BaseTool = { | Webhooks | 4 | A implementar | | Backlinks | 1 | A implementar | | Search Queries | 2 | A implementar | +| Teams | 5 | A implementar | +| Integrations | 6 | A implementar (CRÍTICO) | +| Notifications | 4 | A implementar | +| Subscriptions | 4 | A implementar | +| Imports | 4 | A implementar | +| Emojis | 3 | A implementar | +| User Permissions | 3 | A implementar | +| Bulk Operations | 6 | A implementar | +| Export/Import | 4 | A implementar | +| Advanced Search | 6 | A implementar | +| Analytics | 6 | A implementar | +| External Sync | 5 | A implementar | +| Templates | 5 | A implementar | --- diff --git a/src/index.ts b/src/index.ts index da02864..efcde0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,19 @@ import { apiKeysTools, webhooksTools, backlinksTools, - searchQueriesTools + searchQueriesTools, + // New modules + teamsTools, + integrationsTools, + notificationsTools, + subscriptionsTools, + templatesTools, + importsTools, + emojisTools, + userPermissionsTools, + bulkOperationsTools, + advancedSearchTools, + analyticsTools } from './tools/index.js'; dotenv.config(); @@ -77,10 +89,31 @@ const allTools: BaseTool[] = [ // API & Integration ...apiKeysTools, ...webhooksTools, + ...integrationsTools, - // Analytics + // Analytics & Search ...backlinksTools, - ...searchQueriesTools + ...searchQueriesTools, + ...advancedSearchTools, + ...analyticsTools, + + // Teams & Workspace + ...teamsTools, + + // Notifications & Subscriptions + ...notificationsTools, + ...subscriptionsTools, + + // Templates & Imports + ...templatesTools, + ...importsTools, + + // Custom content + ...emojisTools, + + // Permissions & Bulk operations + ...userPermissionsTools, + ...bulkOperationsTools ]; // Validate all tools have required properties @@ -215,7 +248,18 @@ async function main() { apiKeys: apiKeysTools.length, webhooks: webhooksTools.length, backlinks: backlinksTools.length, - searchQueries: searchQueriesTools.length + searchQueries: searchQueriesTools.length, + teams: teamsTools.length, + integrations: integrationsTools.length, + notifications: notificationsTools.length, + subscriptions: subscriptionsTools.length, + templates: templatesTools.length, + imports: importsTools.length, + emojis: emojisTools.length, + userPermissions: userPermissionsTools.length, + bulkOperations: bulkOperationsTools.length, + advancedSearch: advancedSearchTools.length, + analytics: analyticsTools.length } }); } diff --git a/src/tools/advanced-search.ts b/src/tools/advanced-search.ts new file mode 100644 index 0000000..78d620e --- /dev/null +++ b/src/tools/advanced-search.ts @@ -0,0 +1,425 @@ +/** + * MCP Outline PostgreSQL - Advanced Search Tools + * Full-text search, filters, faceted search + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; +import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js'; + +interface AdvancedSearchArgs extends PaginationArgs { + query: string; + collection_ids?: string[]; + user_id?: string; + date_from?: string; + date_to?: string; + include_archived?: boolean; + template?: boolean; +} + +/** + * search.documents_advanced - Advanced document search + */ +const advancedSearchDocuments: BaseTool = { + name: 'outline_search_documents_advanced', + description: 'Advanced full-text search with filters for documents.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query (full-text search)' }, + collection_ids: { type: 'array', items: { type: 'string' }, description: 'Filter by collection IDs (UUIDs)' }, + user_id: { type: 'string', description: 'Filter by author user ID (UUID)' }, + date_from: { type: 'string', description: 'Filter from date (ISO format)' }, + date_to: { type: 'string', description: 'Filter to date (ISO format)' }, + include_archived: { type: 'boolean', description: 'Include archived documents (default: false)' }, + template: { type: 'boolean', description: 'Filter templates only or exclude templates' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + required: ['query'], + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + const conditions: string[] = ['d."deletedAt" IS NULL']; + const params: any[] = []; + let idx = 1; + + // Full-text search + const searchQuery = sanitizeInput(args.query); + conditions.push(`(d.title ILIKE $${idx} OR d.text ILIKE $${idx})`); + params.push(`%${searchQuery}%`); + idx++; + + if (args.collection_ids && args.collection_ids.length > 0) { + for (const id of args.collection_ids) { + if (!isValidUUID(id)) throw new Error(`Invalid collection ID: ${id}`); + } + conditions.push(`d."collectionId" = ANY($${idx++})`); + params.push(args.collection_ids); + } + + if (args.user_id) { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + conditions.push(`d."createdById" = $${idx++}`); + params.push(args.user_id); + } + + if (args.date_from) { + conditions.push(`d."createdAt" >= $${idx++}`); + params.push(args.date_from); + } + + if (args.date_to) { + conditions.push(`d."createdAt" <= $${idx++}`); + params.push(args.date_to); + } + + if (!args.include_archived) { + conditions.push(`d."archivedAt" IS NULL`); + } + + if (args.template !== undefined) { + conditions.push(`d.template = $${idx++}`); + params.push(args.template); + } + + const result = await pgClient.query(` + SELECT + d.id, d.title, d.emoji, d.template, + d."collectionId", d."createdById", + d."createdAt", d."updatedAt", d."publishedAt", d."archivedAt", + c.name as "collectionName", + u.name as "createdByName", + SUBSTRING(d.text, 1, 200) as "textPreview" + FROM documents d + LEFT JOIN collections c ON d."collectionId" = c.id + LEFT JOIN users u ON d."createdById" = u.id + WHERE ${conditions.join(' AND ')} + ORDER BY d."updatedAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...params, limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ + data: result.rows, + query: args.query, + pagination: { limit, offset, total: result.rows.length } + }, null, 2) }], + }; + }, +}; + +/** + * search.facets - Get search facets/filters + */ +const getSearchFacets: BaseTool<{ query?: string }> = { + name: 'outline_get_search_facets', + description: 'Get available search facets (collections, users, date ranges) for filtering.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Optional query to narrow facets' }, + }, + }, + handler: async (args, pgClient): Promise => { + const baseCondition = args.query + ? `WHERE (d.title ILIKE $1 OR d.text ILIKE $1) AND d."deletedAt" IS NULL` + : `WHERE d."deletedAt" IS NULL`; + const queryParam = args.query ? [`%${sanitizeInput(args.query)}%`] : []; + + // Collections facet + const collections = await pgClient.query(` + SELECT c.id, c.name, COUNT(d.id) as "documentCount" + FROM collections c + LEFT JOIN documents d ON d."collectionId" = c.id AND d."deletedAt" IS NULL + WHERE c."deletedAt" IS NULL + GROUP BY c.id, c.name + ORDER BY "documentCount" DESC + LIMIT 20 + `); + + // Authors facet + const authors = await pgClient.query(` + SELECT u.id, u.name, COUNT(d.id) as "documentCount" + FROM users u + JOIN documents d ON d."createdById" = u.id + ${baseCondition} + GROUP BY u.id, u.name + ORDER BY "documentCount" DESC + LIMIT 20 + `, queryParam); + + // Date range stats + const dateStats = await pgClient.query(` + SELECT + MIN(d."createdAt") as "oldestDocument", + MAX(d."createdAt") as "newestDocument", + COUNT(*) as "totalDocuments" + FROM documents d + ${baseCondition} + `, queryParam); + + // Templates count + const templates = await pgClient.query(` + SELECT + COUNT(*) FILTER (WHERE template = true) as "templateCount", + COUNT(*) FILTER (WHERE template = false) as "documentCount" + FROM documents d + ${baseCondition} + `, queryParam); + + return { + content: [{ type: 'text', text: JSON.stringify({ + collections: collections.rows, + authors: authors.rows, + dateRange: dateStats.rows[0], + documentTypes: templates.rows[0], + }, null, 2) }], + }; + }, +}; + +/** + * search.recent - Get recently updated documents + */ +const searchRecent: BaseTool = { + name: 'outline_search_recent', + description: 'Get recently updated documents.', + inputSchema: { + type: 'object', + properties: { + collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' }, + days: { type: 'number', description: 'Number of days to look back (default: 7)' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + const days = args.days || 7; + const conditions: string[] = [ + 'd."deletedAt" IS NULL', + 'd."archivedAt" IS NULL', + `d."updatedAt" >= NOW() - INTERVAL '${days} days'` + ]; + const params: any[] = []; + let idx = 1; + + if (args.collection_id) { + if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); + conditions.push(`d."collectionId" = $${idx++}`); + params.push(args.collection_id); + } + + const result = await pgClient.query(` + SELECT + d.id, d.title, d.emoji, d."collectionId", + d."updatedAt", d."createdAt", + c.name as "collectionName", + u.name as "lastModifiedByName" + FROM documents d + LEFT JOIN collections c ON d."collectionId" = c.id + LEFT JOIN users u ON d."lastModifiedById" = u.id + WHERE ${conditions.join(' AND ')} + ORDER BY d."updatedAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...params, limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ + data: result.rows, + days, + pagination: { limit, offset, total: result.rows.length } + }, null, 2) }], + }; + }, +}; + +/** + * search.by_user_activity - Search by user activity + */ +const searchByUserActivity: BaseTool = { + name: 'outline_search_by_user_activity', + description: 'Find documents a user has interacted with (created, edited, viewed, starred).', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'User ID (UUID)' }, + activity_type: { type: 'string', description: 'Activity type: created, edited, viewed, starred (default: all)' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + required: ['user_id'], + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + + const results: any = {}; + + if (!args.activity_type || args.activity_type === 'created') { + const created = await pgClient.query(` + SELECT d.id, d.title, d."createdAt" as "activityAt", 'created' as "activityType" + FROM documents d + WHERE d."createdById" = $1 AND d."deletedAt" IS NULL + ORDER BY d."createdAt" DESC + LIMIT $2 OFFSET $3 + `, [args.user_id, limit, offset]); + results.created = created.rows; + } + + if (!args.activity_type || args.activity_type === 'edited') { + const edited = await pgClient.query(` + SELECT d.id, d.title, d."updatedAt" as "activityAt", 'edited' as "activityType" + FROM documents d + WHERE d."lastModifiedById" = $1 AND d."deletedAt" IS NULL + ORDER BY d."updatedAt" DESC + LIMIT $2 OFFSET $3 + `, [args.user_id, limit, offset]); + results.edited = edited.rows; + } + + if (!args.activity_type || args.activity_type === 'viewed') { + const viewed = await pgClient.query(` + SELECT d.id, d.title, v."updatedAt" as "activityAt", 'viewed' as "activityType" + FROM views v + JOIN documents d ON v."documentId" = d.id + WHERE v."userId" = $1 AND d."deletedAt" IS NULL + ORDER BY v."updatedAt" DESC + LIMIT $2 OFFSET $3 + `, [args.user_id, limit, offset]); + results.viewed = viewed.rows; + } + + if (!args.activity_type || args.activity_type === 'starred') { + const starred = await pgClient.query(` + SELECT d.id, d.title, s."createdAt" as "activityAt", 'starred' as "activityType" + FROM stars s + JOIN documents d ON s."documentId" = d.id + WHERE s."userId" = $1 AND d."deletedAt" IS NULL + ORDER BY s."createdAt" DESC + LIMIT $2 OFFSET $3 + `, [args.user_id, limit, offset]); + results.starred = starred.rows; + } + + return { + content: [{ type: 'text', text: JSON.stringify({ data: results, userId: args.user_id }, null, 2) }], + }; + }, +}; + +/** + * search.orphaned_documents - Find orphaned documents + */ +const searchOrphanedDocuments: BaseTool = { + name: 'outline_search_orphaned_documents', + description: 'Find documents without a collection or with deleted parent.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + + // Documents without collection or with deleted collection + const orphaned = await pgClient.query(` + SELECT + d.id, d.title, d."collectionId", d."parentDocumentId", + d."createdAt", d."updatedAt", + CASE + WHEN d."collectionId" IS NULL THEN 'no_collection' + WHEN c."deletedAt" IS NOT NULL THEN 'deleted_collection' + WHEN d."parentDocumentId" IS NOT NULL AND pd."deletedAt" IS NOT NULL THEN 'deleted_parent' + ELSE 'orphaned' + END as "orphanReason" + FROM documents d + LEFT JOIN collections c ON d."collectionId" = c.id + LEFT JOIN documents pd ON d."parentDocumentId" = pd.id + WHERE d."deletedAt" IS NULL + AND ( + d."collectionId" IS NULL + OR c."deletedAt" IS NOT NULL + OR (d."parentDocumentId" IS NOT NULL AND pd."deletedAt" IS NOT NULL) + ) + ORDER BY d."updatedAt" DESC + LIMIT $1 OFFSET $2 + `, [limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ + data: orphaned.rows, + pagination: { limit, offset, total: orphaned.rows.length } + }, null, 2) }], + }; + }, +}; + +/** + * search.duplicates - Find potential duplicate documents + */ +const searchDuplicates: BaseTool = { + name: 'outline_search_duplicates', + description: 'Find documents with similar or identical titles.', + inputSchema: { + type: 'object', + properties: { + similarity_threshold: { type: 'number', description: 'Minimum similarity (0-1, default: 0.8)' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + + // Find exact title duplicates + const exactDuplicates = await pgClient.query(` + SELECT + d1.id as "document1Id", d1.title as "document1Title", + d2.id as "document2Id", d2.title as "document2Title", + c1.name as "collection1Name", c2.name as "collection2Name" + FROM documents d1 + JOIN documents d2 ON LOWER(d1.title) = LOWER(d2.title) AND d1.id < d2.id + LEFT JOIN collections c1 ON d1."collectionId" = c1.id + LEFT JOIN collections c2 ON d2."collectionId" = c2.id + WHERE d1."deletedAt" IS NULL AND d2."deletedAt" IS NULL + AND d1.template = false AND d2.template = false + ORDER BY d1.title + LIMIT $1 OFFSET $2 + `, [limit, offset]); + + // Find documents with similar titles (starts with same prefix) + const similarTitles = await pgClient.query(` + SELECT + d1.id as "document1Id", d1.title as "document1Title", + d2.id as "document2Id", d2.title as "document2Title" + FROM documents d1 + JOIN documents d2 ON + LEFT(LOWER(d1.title), 20) = LEFT(LOWER(d2.title), 20) + AND d1.id < d2.id + AND d1.title != d2.title + WHERE d1."deletedAt" IS NULL AND d2."deletedAt" IS NULL + AND d1.template = false AND d2.template = false + AND LENGTH(d1.title) > 10 + ORDER BY d1.title + LIMIT $1 OFFSET $2 + `, [limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ + exactDuplicates: exactDuplicates.rows, + similarTitles: similarTitles.rows, + pagination: { limit, offset } + }, null, 2) }], + }; + }, +}; + +export const advancedSearchTools: BaseTool[] = [ + advancedSearchDocuments, getSearchFacets, searchRecent, + searchByUserActivity, searchOrphanedDocuments, searchDuplicates +]; diff --git a/src/tools/analytics.ts b/src/tools/analytics.ts new file mode 100644 index 0000000..72bc0b0 --- /dev/null +++ b/src/tools/analytics.ts @@ -0,0 +1,452 @@ +/** + * MCP Outline PostgreSQL - Analytics Tools + * Usage statistics, reports, insights + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse } from '../types/tools.js'; +import { isValidUUID } from '../utils/security.js'; + +interface DateRangeArgs { + date_from?: string; + date_to?: string; +} + +/** + * analytics.overview - Get overall workspace analytics + */ +const getAnalyticsOverview: BaseTool = { + name: 'outline_analytics_overview', + description: 'Get overall workspace analytics including document counts, user activity, etc.', + inputSchema: { + type: 'object', + properties: { + date_from: { type: 'string', description: 'Start date (ISO format)' }, + date_to: { type: 'string', description: 'End date (ISO format)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const dateCondition = args.date_from && args.date_to + ? `AND "createdAt" BETWEEN '${args.date_from}' AND '${args.date_to}'` + : ''; + + // Document stats + const docStats = await pgClient.query(` + SELECT + COUNT(*) as "totalDocuments", + COUNT(*) FILTER (WHERE template = true) as "templates", + COUNT(*) FILTER (WHERE "archivedAt" IS NOT NULL) as "archived", + COUNT(*) FILTER (WHERE "publishedAt" IS NOT NULL) as "published", + COUNT(*) FILTER (WHERE "deletedAt" IS NOT NULL) as "deleted" + FROM documents + `); + + // Collection stats + const collStats = await pgClient.query(` + SELECT + COUNT(*) as "totalCollections", + COUNT(*) FILTER (WHERE "deletedAt" IS NULL) as "active" + FROM collections + `); + + // User stats + const userStats = await pgClient.query(` + SELECT + COUNT(*) as "totalUsers", + COUNT(*) FILTER (WHERE "suspendedAt" IS NULL AND "deletedAt" IS NULL) as "active", + COUNT(*) FILTER (WHERE role = 'admin') as "admins" + FROM users + `); + + // Recent activity + const recentActivity = await pgClient.query(` + SELECT + COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '24 hours') as "documentsLast24h", + COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '7 days') as "documentsLast7d", + COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '30 days') as "documentsLast30d" + FROM documents + WHERE "deletedAt" IS NULL + `); + + // View stats + const viewStats = await pgClient.query(` + SELECT + COUNT(*) as "totalViews", + COUNT(DISTINCT "userId") as "uniqueViewers", + COUNT(DISTINCT "documentId") as "viewedDocuments" + FROM views + `); + + return { + content: [{ type: 'text', text: JSON.stringify({ + documents: docStats.rows[0], + collections: collStats.rows[0], + users: userStats.rows[0], + recentActivity: recentActivity.rows[0], + views: viewStats.rows[0], + generatedAt: new Date().toISOString(), + }, null, 2) }], + }; + }, +}; + +/** + * analytics.user_activity - Get user activity analytics + */ +const getUserActivityAnalytics: BaseTool<{ user_id?: string; days?: number }> = { + name: 'outline_analytics_user_activity', + description: 'Get detailed user activity analytics.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'Specific user ID (UUID), or all users if omitted' }, + days: { type: 'number', description: 'Number of days to analyze (default: 30)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const days = args.days || 30; + const userCondition = args.user_id ? `AND u.id = '${args.user_id}'` : ''; + + if (args.user_id && !isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + + // Most active users + const activeUsers = await pgClient.query(` + SELECT + u.id, u.name, u.email, + COUNT(DISTINCT d.id) FILTER (WHERE d."createdAt" >= NOW() - INTERVAL '${days} days') as "documentsCreated", + COUNT(DISTINCT d2.id) FILTER (WHERE d2."updatedAt" >= NOW() - INTERVAL '${days} days') as "documentsEdited", + COUNT(DISTINCT v."documentId") FILTER (WHERE v."createdAt" >= NOW() - INTERVAL '${days} days') as "documentsViewed", + COUNT(DISTINCT c.id) FILTER (WHERE c."createdAt" >= NOW() - INTERVAL '${days} days') as "commentsAdded" + FROM users u + LEFT JOIN documents d ON d."createdById" = u.id + LEFT JOIN documents d2 ON d2."lastModifiedById" = u.id + LEFT JOIN views v ON v."userId" = u.id + LEFT JOIN comments c ON c."createdById" = u.id + WHERE u."deletedAt" IS NULL ${userCondition} + GROUP BY u.id, u.name, u.email + ORDER BY "documentsCreated" DESC + LIMIT 20 + `); + + // Activity by day of week + const activityByDay = await pgClient.query(` + SELECT + EXTRACT(DOW FROM d."createdAt") as "dayOfWeek", + COUNT(*) as "documentsCreated" + FROM documents d + WHERE d."createdAt" >= NOW() - INTERVAL '${days} days' + AND d."deletedAt" IS NULL + GROUP BY EXTRACT(DOW FROM d."createdAt") + ORDER BY "dayOfWeek" + `); + + // Activity by hour + const activityByHour = await pgClient.query(` + SELECT + EXTRACT(HOUR FROM d."createdAt") as "hour", + COUNT(*) as "documentsCreated" + FROM documents d + WHERE d."createdAt" >= NOW() - INTERVAL '${days} days' + AND d."deletedAt" IS NULL + GROUP BY EXTRACT(HOUR FROM d."createdAt") + ORDER BY "hour" + `); + + return { + content: [{ type: 'text', text: JSON.stringify({ + activeUsers: activeUsers.rows, + activityByDayOfWeek: activityByDay.rows, + activityByHour: activityByHour.rows, + periodDays: days, + }, null, 2) }], + }; + }, +}; + +/** + * analytics.content_insights - Get content insights + */ +const getContentInsights: BaseTool<{ collection_id?: string }> = { + name: 'outline_analytics_content_insights', + description: 'Get insights about content: popular documents, stale content, etc.', + inputSchema: { + type: 'object', + properties: { + collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const collectionCondition = args.collection_id + ? `AND d."collectionId" = '${args.collection_id}'` + : ''; + + if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); + + // Most viewed documents + const mostViewed = await pgClient.query(` + SELECT + d.id, d.title, d.emoji, c.name as "collectionName", + COUNT(v.id) as "viewCount", + COUNT(DISTINCT v."userId") as "uniqueViewers" + FROM documents d + LEFT JOIN views v ON v."documentId" = d.id + LEFT JOIN collections c ON d."collectionId" = c.id + WHERE d."deletedAt" IS NULL ${collectionCondition} + GROUP BY d.id, d.title, d.emoji, c.name + ORDER BY "viewCount" DESC + LIMIT 10 + `); + + // Most starred documents + const mostStarred = await pgClient.query(` + SELECT + d.id, d.title, d.emoji, c.name as "collectionName", + COUNT(s.id) as "starCount" + FROM documents d + LEFT JOIN stars s ON s."documentId" = d.id + LEFT JOIN collections c ON d."collectionId" = c.id + WHERE d."deletedAt" IS NULL ${collectionCondition} + GROUP BY d.id, d.title, d.emoji, c.name + HAVING COUNT(s.id) > 0 + ORDER BY "starCount" DESC + LIMIT 10 + `); + + // Stale documents (not updated in 90 days) + const staleDocuments = await pgClient.query(` + SELECT + d.id, d.title, d.emoji, c.name as "collectionName", + d."updatedAt", + EXTRACT(DAY FROM NOW() - d."updatedAt") as "daysSinceUpdate" + FROM documents d + LEFT JOIN collections c ON d."collectionId" = c.id + WHERE d."deletedAt" IS NULL + AND d."archivedAt" IS NULL + AND d.template = false + AND d."updatedAt" < NOW() - INTERVAL '90 days' + ${collectionCondition} + ORDER BY d."updatedAt" ASC + LIMIT 20 + `); + + // Documents without views + const neverViewed = await pgClient.query(` + SELECT + d.id, d.title, d.emoji, c.name as "collectionName", + d."createdAt" + FROM documents d + LEFT JOIN views v ON v."documentId" = d.id + LEFT JOIN collections c ON d."collectionId" = c.id + WHERE d."deletedAt" IS NULL + AND d.template = false + AND v.id IS NULL + ${collectionCondition} + ORDER BY d."createdAt" DESC + LIMIT 20 + `); + + return { + content: [{ type: 'text', text: JSON.stringify({ + mostViewed: mostViewed.rows, + mostStarred: mostStarred.rows, + staleDocuments: staleDocuments.rows, + neverViewed: neverViewed.rows, + }, null, 2) }], + }; + }, +}; + +/** + * analytics.collection_stats - Get collection statistics + */ +const getCollectionStats: BaseTool<{ collection_id?: string }> = { + name: 'outline_analytics_collection_stats', + description: 'Get detailed statistics for collections.', + inputSchema: { + type: 'object', + properties: { + collection_id: { type: 'string', description: 'Specific collection ID (UUID), or all collections if omitted' }, + }, + }, + handler: async (args, pgClient): Promise => { + const collectionCondition = args.collection_id + ? `AND c.id = '${args.collection_id}'` + : ''; + + if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); + + const stats = await pgClient.query(` + SELECT + c.id, c.name, c.icon, c.color, + COUNT(DISTINCT d.id) as "documentCount", + COUNT(DISTINCT d.id) FILTER (WHERE d.template = true) as "templateCount", + COUNT(DISTINCT d.id) FILTER (WHERE d."archivedAt" IS NOT NULL) as "archivedCount", + COUNT(DISTINCT cu."userId") as "memberCount", + COUNT(DISTINCT cg."groupId") as "groupCount", + MAX(d."updatedAt") as "lastDocumentUpdate", + AVG(LENGTH(d.text)) as "avgDocumentLength" + FROM collections c + LEFT JOIN documents d ON d."collectionId" = c.id AND d."deletedAt" IS NULL + LEFT JOIN collection_users cu ON cu."collectionId" = c.id + LEFT JOIN collection_group_memberships cg ON cg."collectionId" = c.id + WHERE c."deletedAt" IS NULL ${collectionCondition} + GROUP BY c.id, c.name, c.icon, c.color + ORDER BY "documentCount" DESC + `); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: stats.rows }, null, 2) }], + }; + }, +}; + +/** + * analytics.growth_metrics - Get growth metrics over time + */ +const getGrowthMetrics: BaseTool<{ period?: string }> = { + name: 'outline_analytics_growth_metrics', + description: 'Get growth metrics: documents, users, activity over time.', + inputSchema: { + type: 'object', + properties: { + period: { type: 'string', description: 'Period: week, month, quarter, year (default: month)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const period = args.period || 'month'; + const intervals: Record = { + week: '7 days', + month: '30 days', + quarter: '90 days', + year: '365 days', + }; + const interval = intervals[period] || '30 days'; + + // Document growth by day + const documentGrowth = await pgClient.query(` + SELECT + DATE(d."createdAt") as date, + COUNT(*) as "newDocuments", + SUM(COUNT(*)) OVER (ORDER BY DATE(d."createdAt")) as "cumulativeDocuments" + FROM documents d + WHERE d."createdAt" >= NOW() - INTERVAL '${interval}' + AND d."deletedAt" IS NULL + GROUP BY DATE(d."createdAt") + ORDER BY date + `); + + // User growth + const userGrowth = await pgClient.query(` + SELECT + DATE(u."createdAt") as date, + COUNT(*) as "newUsers", + SUM(COUNT(*)) OVER (ORDER BY DATE(u."createdAt")) as "cumulativeUsers" + FROM users u + WHERE u."createdAt" >= NOW() - INTERVAL '${interval}' + AND u."deletedAt" IS NULL + GROUP BY DATE(u."createdAt") + ORDER BY date + `); + + // Collection growth + const collectionGrowth = await pgClient.query(` + SELECT + DATE(c."createdAt") as date, + COUNT(*) as "newCollections" + FROM collections c + WHERE c."createdAt" >= NOW() - INTERVAL '${interval}' + AND c."deletedAt" IS NULL + GROUP BY DATE(c."createdAt") + ORDER BY date + `); + + // Period comparison + const comparison = await pgClient.query(` + SELECT + (SELECT COUNT(*) FROM documents WHERE "createdAt" >= NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "currentPeriodDocs", + (SELECT COUNT(*) FROM documents WHERE "createdAt" >= NOW() - INTERVAL '${interval}' * 2 AND "createdAt" < NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "previousPeriodDocs", + (SELECT COUNT(*) FROM users WHERE "createdAt" >= NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "currentPeriodUsers", + (SELECT COUNT(*) FROM users WHERE "createdAt" >= NOW() - INTERVAL '${interval}' * 2 AND "createdAt" < NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "previousPeriodUsers" + `); + + return { + content: [{ type: 'text', text: JSON.stringify({ + documentGrowth: documentGrowth.rows, + userGrowth: userGrowth.rows, + collectionGrowth: collectionGrowth.rows, + periodComparison: comparison.rows[0], + period, + }, null, 2) }], + }; + }, +}; + +/** + * analytics.search_analytics - Get search analytics + */ +const getSearchAnalytics: BaseTool<{ days?: number }> = { + name: 'outline_analytics_search', + description: 'Get search analytics: popular queries, search patterns.', + inputSchema: { + type: 'object', + properties: { + days: { type: 'number', description: 'Number of days to analyze (default: 30)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const days = args.days || 30; + + // Popular search queries + const popularQueries = await pgClient.query(` + SELECT + query, + COUNT(*) as "searchCount", + COUNT(DISTINCT "userId") as "uniqueSearchers" + FROM search_queries + WHERE "createdAt" >= NOW() - INTERVAL '${days} days' + GROUP BY query + ORDER BY "searchCount" DESC + LIMIT 20 + `); + + // Search volume by day + const searchVolume = await pgClient.query(` + SELECT + DATE("createdAt") as date, + COUNT(*) as "searches", + COUNT(DISTINCT "userId") as "uniqueSearchers" + FROM search_queries + WHERE "createdAt" >= NOW() - INTERVAL '${days} days' + GROUP BY DATE("createdAt") + ORDER BY date + `); + + // Zero result queries (if results column exists) + const zeroResults = await pgClient.query(` + SELECT + query, + COUNT(*) as "searchCount" + FROM search_queries + WHERE "createdAt" >= NOW() - INTERVAL '${days} days' + AND results = 0 + GROUP BY query + ORDER BY "searchCount" DESC + LIMIT 10 + `).catch(() => ({ rows: [] })); // Handle if results column doesn't exist + + return { + content: [{ type: 'text', text: JSON.stringify({ + popularQueries: popularQueries.rows, + searchVolume: searchVolume.rows, + zeroResultQueries: zeroResults.rows, + periodDays: days, + }, null, 2) }], + }; + }, +}; + +export const analyticsTools: BaseTool[] = [ + getAnalyticsOverview, getUserActivityAnalytics, getContentInsights, + getCollectionStats, getGrowthMetrics, getSearchAnalytics +]; diff --git a/src/tools/bulk-operations.ts b/src/tools/bulk-operations.ts new file mode 100644 index 0000000..97d829a --- /dev/null +++ b/src/tools/bulk-operations.ts @@ -0,0 +1,287 @@ +/** + * MCP Outline PostgreSQL - Bulk Operations Tools + * Batch operations on documents, collections, etc. + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse } from '../types/tools.js'; +import { isValidUUID } from '../utils/security.js'; + +/** + * 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}`); + } + + const result = await pgClient.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}`); + } + + const deletedById = await pgClient.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; + + const result = await pgClient.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}`); + } + + // Verify collection exists + 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 not found'); + + const result = await pgClient.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}`); + } + + const result = await pgClient.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'; + const creatorResult = await pgClient.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 added: string[] = []; + const skipped: string[] = []; + + for (const userId of args.user_ids) { + // Check if already exists + const existing = await pgClient.query( + `SELECT "userId" FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2`, + [userId, args.collection_id] + ); + + if (existing.rows.length > 0) { + skipped.push(userId); + } else { + await pgClient.query(` + INSERT INTO collection_users ("userId", "collectionId", permission, "createdById", "createdAt", "updatedAt") + VALUES ($1, $2, $3, $4, NOW(), NOW()) + `, [userId, args.collection_id, permission, createdById]); + added.push(userId); + } + } + + 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}`); + } + + const result = await pgClient.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 +]; diff --git a/src/tools/emojis.ts b/src/tools/emojis.ts new file mode 100644 index 0000000..dbe73ba --- /dev/null +++ b/src/tools/emojis.ts @@ -0,0 +1,136 @@ +/** + * MCP Outline PostgreSQL - Emojis Tools + * Custom emoji management + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; +import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js'; + +interface EmojiListArgs extends PaginationArgs { + team_id?: string; + search?: string; +} + +/** + * emojis.list - List custom emojis + */ +const listEmojis: BaseTool = { + name: 'outline_list_emojis', + description: 'List custom emojis available in the workspace.', + inputSchema: { + type: 'object', + properties: { + team_id: { type: 'string', description: 'Filter by team ID (UUID)' }, + search: { type: 'string', description: 'Search emojis by name' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (args.team_id) { + if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id'); + conditions.push(`e."teamId" = $${idx++}`); + params.push(args.team_id); + } + if (args.search) { + conditions.push(`e.name ILIKE $${idx++}`); + params.push(`%${sanitizeInput(args.search)}%`); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await pgClient.query(` + SELECT + e.id, e.name, e.url, e."teamId", e."createdById", + e."createdAt", e."updatedAt", + u.name as "createdByName" + FROM emojis e + LEFT JOIN users u ON e."createdById" = u.id + ${whereClause} + ORDER BY e.name ASC + LIMIT $${idx++} OFFSET $${idx} + `, [...params, limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }], + }; + }, +}; + +/** + * emojis.create - Create custom emoji + */ +const createEmoji: BaseTool<{ name: string; url: string }> = { + name: 'outline_create_emoji', + description: 'Create a new custom emoji.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Emoji name (without colons)' }, + url: { type: 'string', description: 'URL to emoji image' }, + }, + required: ['name', 'url'], + }, + handler: async (args, pgClient): Promise => { + const teamResult = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL LIMIT 1`); + if (teamResult.rows.length === 0) throw new Error('No team found'); + + const userResult = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`); + if (userResult.rows.length === 0) throw new Error('No admin user found'); + + // Check if emoji name already exists + const existing = await pgClient.query( + `SELECT id FROM emojis WHERE name = $1 AND "teamId" = $2`, + [sanitizeInput(args.name), teamResult.rows[0].id] + ); + if (existing.rows.length > 0) throw new Error('Emoji with this name already exists'); + + const result = await pgClient.query(` + INSERT INTO emojis (id, name, url, "teamId", "createdById", "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) + RETURNING * + `, [sanitizeInput(args.name), args.url, teamResult.rows[0].id, userResult.rows[0].id]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Emoji created' }, null, 2) }], + }; + }, +}; + +/** + * emojis.delete - Delete custom emoji + */ +const deleteEmoji: BaseTool<{ id: string }> = { + name: 'outline_delete_emoji', + description: 'Delete a custom emoji.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Emoji ID (UUID)' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid emoji ID'); + + const result = await pgClient.query( + `DELETE FROM emojis WHERE id = $1 RETURNING id, name`, + [args.id] + ); + + if (result.rows.length === 0) throw new Error('Emoji not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Emoji deleted' }, null, 2) }], + }; + }, +}; + +export const emojisTools: BaseTool[] = [listEmojis, createEmoji, deleteEmoji]; diff --git a/src/tools/imports-tools.ts b/src/tools/imports-tools.ts new file mode 100644 index 0000000..affbd33 --- /dev/null +++ b/src/tools/imports-tools.ts @@ -0,0 +1,179 @@ +/** + * MCP Outline PostgreSQL - Imports Tools + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; +import { validatePagination, isValidUUID } from '../utils/security.js'; + +interface ImportListArgs extends PaginationArgs { + team_id?: string; + state?: string; +} + +/** + * imports.list - List imports + */ +const listImports: BaseTool = { + name: 'outline_list_imports', + description: 'List document import jobs.', + inputSchema: { + type: 'object', + properties: { + team_id: { type: 'string', description: 'Filter by team ID (UUID)' }, + state: { type: 'string', description: 'Filter by state (pending, processing, completed, failed)' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (args.team_id) { + if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id'); + conditions.push(`i."teamId" = $${idx++}`); + params.push(args.team_id); + } + if (args.state) { + conditions.push(`i.state = $${idx++}`); + params.push(args.state); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await pgClient.query(` + SELECT + i.id, i.state, i.type, i."documentCount", i."fileCount", + i."teamId", i."createdById", i."integrationId", + i."createdAt", i."updatedAt", + u.name as "createdByName", + t.name as "teamName" + FROM imports i + LEFT JOIN users u ON i."createdById" = u.id + LEFT JOIN teams t ON i."teamId" = t.id + ${whereClause} + ORDER BY i."createdAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...params, limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }], + }; + }, +}; + +/** + * imports.status - Get import status + */ +const getImportStatus: BaseTool<{ id: string }> = { + name: 'outline_get_import_status', + description: 'Get detailed status of an import job.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Import ID (UUID)' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid import ID'); + + const result = await pgClient.query(` + SELECT i.*, u.name as "createdByName" + FROM imports i + LEFT JOIN users u ON i."createdById" = u.id + WHERE i.id = $1 + `, [args.id]); + + if (result.rows.length === 0) throw new Error('Import not found'); + + // Get import tasks + const tasks = await pgClient.query(` + SELECT id, state, "documentId", "createdAt" + FROM import_tasks + WHERE "importId" = $1 + ORDER BY "createdAt" DESC + LIMIT 50 + `, [args.id]); + + return { + content: [{ type: 'text', text: JSON.stringify({ + import: result.rows[0], + tasks: tasks.rows, + taskCount: tasks.rows.length, + }, null, 2) }], + }; + }, +}; + +/** + * imports.create - Create import job + */ +const createImport: BaseTool<{ type: string; collection_id?: string }> = { + name: 'outline_create_import', + description: 'Create a new import job. Note: This creates the job record, actual file upload handled separately.', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', description: 'Import type (e.g., "notion", "confluence", "markdown")' }, + collection_id: { type: 'string', description: 'Target collection ID (UUID, optional)' }, + }, + required: ['type'], + }, + handler: async (args, pgClient): Promise => { + if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); + + const team = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL LIMIT 1`); + if (team.rows.length === 0) throw new Error('No team found'); + + const user = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`); + if (user.rows.length === 0) throw new Error('No admin user found'); + + const result = await pgClient.query(` + INSERT INTO imports (id, type, state, "teamId", "createdById", "documentCount", "fileCount", "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, 'pending', $2, $3, 0, 0, NOW(), NOW()) + RETURNING * + `, [args.type, team.rows[0].id, user.rows[0].id]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Import job created' }, null, 2) }], + }; + }, +}; + +/** + * imports.cancel - Cancel import job + */ +const cancelImport: BaseTool<{ id: string }> = { + name: 'outline_cancel_import', + description: 'Cancel a pending or processing import job.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Import ID (UUID)' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid import ID'); + + const result = await pgClient.query(` + UPDATE imports + SET state = 'cancelled', "updatedAt" = NOW() + WHERE id = $1 AND state IN ('pending', 'processing') + RETURNING id, state, type + `, [args.id]); + + if (result.rows.length === 0) throw new Error('Import not found or cannot be cancelled'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Import cancelled' }, null, 2) }], + }; + }, +}; + +export const importsTools: BaseTool[] = [listImports, getImportStatus, createImport, cancelImport]; diff --git a/src/tools/index.ts b/src/tools/index.ts index 77f772f..3fc6d11 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -63,3 +63,36 @@ export { backlinksTools } from './backlinks.js'; // Search Queries Tools - Search analytics export { searchQueriesTools } from './search-queries.js'; + +// Teams Tools - Team/workspace management +export { teamsTools } from './teams.js'; + +// Integrations Tools - External integrations (Slack, embeds, etc.) +export { integrationsTools } from './integrations.js'; + +// Notifications Tools - User notifications +export { notificationsTools } from './notifications.js'; + +// Subscriptions Tools - Document subscriptions +export { subscriptionsTools } from './subscriptions.js'; + +// Templates Tools - Document templates +export { templatesTools } from './templates.js'; + +// Imports Tools - Import job management +export { importsTools } from './imports-tools.js'; + +// Emojis Tools - Custom emoji management +export { emojisTools } from './emojis.js'; + +// User Permissions Tools - Permission management +export { userPermissionsTools } from './user-permissions.js'; + +// Bulk Operations Tools - Batch operations +export { bulkOperationsTools } from './bulk-operations.js'; + +// Advanced Search Tools - Full-text search and facets +export { advancedSearchTools } from './advanced-search.js'; + +// Analytics Tools - Usage statistics and insights +export { analyticsTools } from './analytics.js'; diff --git a/src/tools/integrations.ts b/src/tools/integrations.ts new file mode 100644 index 0000000..c32cd14 --- /dev/null +++ b/src/tools/integrations.ts @@ -0,0 +1,275 @@ +/** + * MCP Outline PostgreSQL - Integrations Tools + * CRÍTICO para embeds e integrações externas + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; +import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js'; + +interface IntegrationListArgs extends PaginationArgs { + team_id?: string; + service?: string; + type?: string; +} + +interface IntegrationCreateArgs { + service: string; + type?: string; + collection_id?: string; + events?: string[]; + settings?: Record; +} + +interface IntegrationUpdateArgs { + id: string; + events?: string[]; + settings?: Record; +} + +/** + * integrations.list - List integrations + */ +const listIntegrations: BaseTool = { + name: 'outline_list_integrations', + description: 'List configured integrations (Slack, embed sources, etc.).', + inputSchema: { + type: 'object', + properties: { + team_id: { type: 'string', description: 'Filter by team ID (UUID)' }, + service: { type: 'string', description: 'Filter by service (e.g., "slack", "github")' }, + type: { type: 'string', description: 'Filter by type (e.g., "embed", "linkedAccount")' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + const conditions: string[] = ['i."deletedAt" IS NULL']; + const params: any[] = []; + let idx = 1; + + if (args.team_id) { + if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id'); + conditions.push(`i."teamId" = $${idx++}`); + params.push(args.team_id); + } + if (args.service) { + conditions.push(`i.service = $${idx++}`); + params.push(sanitizeInput(args.service)); + } + if (args.type) { + conditions.push(`i.type = $${idx++}`); + params.push(sanitizeInput(args.type)); + } + + const result = await pgClient.query(` + SELECT + i.id, i.service, i.type, i.events, i.settings, + i."teamId", i."userId", i."collectionId", i."authenticationId", + i."createdAt", i."updatedAt", + t.name as "teamName", + u.name as "userName", + c.name as "collectionName" + FROM integrations i + LEFT JOIN teams t ON i."teamId" = t.id + LEFT JOIN users u ON i."userId" = u.id + LEFT JOIN collections c ON i."collectionId" = c.id + WHERE ${conditions.join(' AND ')} + ORDER BY i."createdAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...params, limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }], + }; + }, +}; + +/** + * integrations.info - Get integration details + */ +const getIntegration: BaseTool<{ id: string }> = { + name: 'outline_get_integration', + description: 'Get detailed information about a specific integration.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Integration ID (UUID)' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid integration ID'); + + const result = await pgClient.query(` + SELECT + i.*, t.name as "teamName", u.name as "userName", c.name as "collectionName" + FROM integrations i + LEFT JOIN teams t ON i."teamId" = t.id + LEFT JOIN users u ON i."userId" = u.id + LEFT JOIN collections c ON i."collectionId" = c.id + WHERE i.id = $1 + `, [args.id]); + + if (result.rows.length === 0) throw new Error('Integration not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }], + }; + }, +}; + +/** + * integrations.create - Create integration + */ +const createIntegration: BaseTool = { + name: 'outline_create_integration', + description: 'Create a new integration (embed source, webhook, etc.).', + inputSchema: { + type: 'object', + properties: { + service: { type: 'string', description: 'Service name (e.g., "slack", "github", "figma")' }, + type: { type: 'string', description: 'Integration type (e.g., "embed", "linkedAccount")' }, + collection_id: { type: 'string', description: 'Link to collection (UUID, optional)' }, + events: { type: 'array', items: { type: 'string' }, description: 'Events to listen for' }, + settings: { type: 'object', description: 'Integration settings' }, + }, + required: ['service'], + }, + handler: async (args, pgClient): Promise => { + if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); + + const teamResult = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL LIMIT 1`); + if (teamResult.rows.length === 0) throw new Error('No team found'); + + const userResult = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`); + + const result = await pgClient.query(` + INSERT INTO integrations (id, service, type, "teamId", "userId", "collectionId", events, settings, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + RETURNING * + `, [ + sanitizeInput(args.service), + args.type || 'embed', + teamResult.rows[0].id, + userResult.rows.length > 0 ? userResult.rows[0].id : null, + args.collection_id || null, + args.events || [], + args.settings ? JSON.stringify(args.settings) : null, + ]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration created' }, null, 2) }], + }; + }, +}; + +/** + * integrations.update - Update integration + */ +const updateIntegration: BaseTool = { + name: 'outline_update_integration', + description: 'Update an integration settings or events.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Integration ID (UUID)' }, + events: { type: 'array', items: { type: 'string' }, description: 'Events to listen for' }, + settings: { type: 'object', description: 'Settings to merge' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid integration ID'); + + const updates: string[] = ['"updatedAt" = NOW()']; + const params: any[] = []; + let idx = 1; + + if (args.events) { + updates.push(`events = $${idx++}`); + params.push(args.events); + } + if (args.settings) { + updates.push(`settings = COALESCE(settings, '{}'::jsonb) || $${idx++}::jsonb`); + params.push(JSON.stringify(args.settings)); + } + + params.push(args.id); + + const result = await pgClient.query( + `UPDATE integrations SET ${updates.join(', ')} WHERE id = $${idx} AND "deletedAt" IS NULL RETURNING *`, + params + ); + + if (result.rows.length === 0) throw new Error('Integration not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration updated' }, null, 2) }], + }; + }, +}; + +/** + * integrations.delete - Delete integration + */ +const deleteIntegration: BaseTool<{ id: string }> = { + name: 'outline_delete_integration', + description: 'Soft delete an integration.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Integration ID (UUID)' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid integration ID'); + + const result = await pgClient.query( + `UPDATE integrations SET "deletedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL RETURNING id, service, type`, + [args.id] + ); + + if (result.rows.length === 0) throw new Error('Integration not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration deleted' }, null, 2) }], + }; + }, +}; + +/** + * integrations.sync - Trigger integration sync + */ +const syncIntegration: BaseTool<{ id: string }> = { + name: 'outline_sync_integration', + description: 'Trigger a sync for an integration. Updates lastSyncedAt timestamp.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Integration ID (UUID)' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid integration ID'); + + const result = await pgClient.query( + `UPDATE integrations SET "updatedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL RETURNING id, service, type, "updatedAt"`, + [args.id] + ); + + if (result.rows.length === 0) throw new Error('Integration not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Sync triggered', lastSyncedAt: result.rows[0].updatedAt }, null, 2) }], + }; + }, +}; + +export const integrationsTools: BaseTool[] = [ + listIntegrations, getIntegration, createIntegration, updateIntegration, deleteIntegration, syncIntegration +]; diff --git a/src/tools/notifications.ts b/src/tools/notifications.ts new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/src/tools/notifications.ts @@ -0,0 +1,173 @@ +/** + * MCP Outline PostgreSQL - Notifications Tools + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; +import { validatePagination, isValidUUID } from '../utils/security.js'; + +interface NotificationListArgs extends PaginationArgs { + user_id?: string; + event?: string; + unread_only?: boolean; +} + +/** + * notifications.list - List notifications + */ +const listNotifications: BaseTool = { + name: 'outline_list_notifications', + description: 'List notifications for a user. Can filter by event type and read status.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'Filter by user ID (UUID)' }, + event: { type: 'string', description: 'Filter by event type (e.g., "documents.update")' }, + unread_only: { type: 'boolean', description: 'Only show unread notifications (default: false)' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + const conditions: string[] = ['n."archivedAt" IS NULL']; + const params: any[] = []; + let idx = 1; + + if (args.user_id) { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + conditions.push(`n."userId" = $${idx++}`); + params.push(args.user_id); + } + if (args.event) { + conditions.push(`n.event = $${idx++}`); + params.push(args.event); + } + if (args.unread_only) { + conditions.push(`n."viewedAt" IS NULL`); + } + + const result = await pgClient.query(` + SELECT + n.id, n.event, n.data, n."viewedAt", n."emailedAt", n."createdAt", + n."userId", n."actorId", n."documentId", n."collectionId", n."commentId", + actor.name as "actorName", + d.title as "documentTitle", + c.name as "collectionName" + FROM notifications n + LEFT JOIN users actor ON n."actorId" = actor.id + LEFT JOIN documents d ON n."documentId" = d.id + LEFT JOIN collections c ON n."collectionId" = c.id + WHERE ${conditions.join(' AND ')} + ORDER BY n."createdAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...params, limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }], + }; + }, +}; + +/** + * notifications.read - Mark notification as read + */ +const markNotificationRead: BaseTool<{ id: string }> = { + name: 'outline_mark_notification_read', + description: 'Mark a notification as read (sets viewedAt).', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Notification ID (UUID)' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid notification ID'); + + const result = await pgClient.query( + `UPDATE notifications SET "viewedAt" = NOW() WHERE id = $1 AND "viewedAt" IS NULL RETURNING id, event, "viewedAt"`, + [args.id] + ); + + if (result.rows.length === 0) throw new Error('Notification not found or already read'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Marked as read' }, null, 2) }], + }; + }, +}; + +/** + * notifications.readAll - Mark all notifications as read + */ +const markAllNotificationsRead: BaseTool<{ user_id: string }> = { + name: 'outline_mark_all_notifications_read', + description: 'Mark all notifications as read for a user.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'User ID (UUID)' }, + }, + required: ['user_id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + + const result = await pgClient.query( + `UPDATE notifications SET "viewedAt" = NOW() WHERE "userId" = $1 AND "viewedAt" IS NULL RETURNING id`, + [args.user_id] + ); + + return { + content: [{ type: 'text', text: JSON.stringify({ markedCount: result.rows.length, message: 'All notifications marked as read' }, null, 2) }], + }; + }, +}; + +/** + * notifications.settings - Get notification settings for user + */ +const getNotificationSettings: BaseTool<{ user_id: string }> = { + name: 'outline_get_notification_settings', + description: 'Get notification settings/preferences for a user.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'User ID (UUID)' }, + }, + required: ['user_id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + + const result = await pgClient.query( + `SELECT id, name, email, "notificationSettings" FROM users WHERE id = $1 AND "deletedAt" IS NULL`, + [args.user_id] + ); + + if (result.rows.length === 0) throw new Error('User not found'); + + // Also get notification stats + const stats = await pgClient.query(` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE "viewedAt" IS NULL) as unread + FROM notifications + WHERE "userId" = $1 AND "archivedAt" IS NULL + `, [args.user_id]); + + return { + content: [{ type: 'text', text: JSON.stringify({ + user: { id: result.rows[0].id, name: result.rows[0].name }, + settings: result.rows[0].notificationSettings, + stats: stats.rows[0], + }, null, 2) }], + }; + }, +}; + +export const notificationsTools: BaseTool[] = [ + listNotifications, markNotificationRead, markAllNotificationsRead, getNotificationSettings +]; diff --git a/src/tools/subscriptions.ts b/src/tools/subscriptions.ts new file mode 100644 index 0000000..9c81881 --- /dev/null +++ b/src/tools/subscriptions.ts @@ -0,0 +1,192 @@ +/** + * MCP Outline PostgreSQL - Subscriptions Tools + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; +import { validatePagination, isValidUUID } from '../utils/security.js'; + +interface SubscriptionListArgs extends PaginationArgs { + user_id?: string; + document_id?: string; +} + +/** + * subscriptions.list - List subscriptions + */ +const listSubscriptions: BaseTool = { + name: 'outline_list_subscriptions', + description: 'List document subscriptions. Subscriptions determine who gets notified of document changes.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'Filter by user ID (UUID)' }, + document_id: { type: 'string', description: 'Filter by document ID (UUID)' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (args.user_id) { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + conditions.push(`s."userId" = $${idx++}`); + params.push(args.user_id); + } + if (args.document_id) { + if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id'); + conditions.push(`s."documentId" = $${idx++}`); + params.push(args.document_id); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await pgClient.query(` + SELECT + s.id, s."userId", s."documentId", s.event, s."createdAt", + u.name as "userName", u.email as "userEmail", + d.title as "documentTitle" + FROM subscriptions s + LEFT JOIN users u ON s."userId" = u.id + LEFT JOIN documents d ON s."documentId" = d.id + ${whereClause} + ORDER BY s."createdAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...params, limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }], + }; + }, +}; + +/** + * subscriptions.create - Subscribe to document + */ +const subscribeToDocument: BaseTool<{ document_id: string; user_id: string; event?: string }> = { + name: 'outline_subscribe_to_document', + description: 'Subscribe a user to document notifications.', + inputSchema: { + type: 'object', + properties: { + document_id: { type: 'string', description: 'Document ID (UUID)' }, + user_id: { type: 'string', description: 'User ID (UUID)' }, + event: { type: 'string', description: 'Event type to subscribe to (default: all)' }, + }, + required: ['document_id', 'user_id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id'); + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + + // Check if already subscribed + const existing = await pgClient.query( + `SELECT id FROM subscriptions WHERE "documentId" = $1 AND "userId" = $2`, + [args.document_id, args.user_id] + ); + + if (existing.rows.length > 0) { + throw new Error('User already subscribed to this document'); + } + + const result = await pgClient.query(` + INSERT INTO subscriptions (id, "documentId", "userId", event, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, NOW(), NOW()) + RETURNING * + `, [args.document_id, args.user_id, args.event || 'documents.update']); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Subscribed successfully' }, null, 2) }], + }; + }, +}; + +/** + * subscriptions.delete - Unsubscribe from document + */ +const unsubscribeFromDocument: BaseTool<{ id?: string; document_id?: string; user_id?: string }> = { + name: 'outline_unsubscribe_from_document', + description: 'Unsubscribe from document notifications.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Subscription ID (UUID)' }, + document_id: { type: 'string', description: 'Document ID (requires user_id)' }, + user_id: { type: 'string', description: 'User ID (requires document_id)' }, + }, + }, + handler: async (args, pgClient): Promise => { + let result; + + if (args.id) { + if (!isValidUUID(args.id)) throw new Error('Invalid id'); + result = await pgClient.query( + `DELETE FROM subscriptions WHERE id = $1 RETURNING id, "documentId", "userId"`, + [args.id] + ); + } else if (args.document_id && args.user_id) { + if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id'); + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + result = await pgClient.query( + `DELETE FROM subscriptions WHERE "documentId" = $1 AND "userId" = $2 RETURNING id, "documentId", "userId"`, + [args.document_id, args.user_id] + ); + } else { + throw new Error('Either id or (document_id + user_id) required'); + } + + if (result.rows.length === 0) throw new Error('Subscription not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Unsubscribed successfully' }, null, 2) }], + }; + }, +}; + +/** + * subscriptions.settings - Get subscription settings + */ +const getSubscriptionSettings: BaseTool<{ user_id: string }> = { + name: 'outline_get_subscription_settings', + description: 'Get subscription summary and settings for a user.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'User ID (UUID)' }, + }, + required: ['user_id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + + const subscriptions = await pgClient.query(` + SELECT s.id, s."documentId", s.event, d.title as "documentTitle" + FROM subscriptions s + LEFT JOIN documents d ON s."documentId" = d.id + WHERE s."userId" = $1 + ORDER BY s."createdAt" DESC + `, [args.user_id]); + + const userSettings = await pgClient.query( + `SELECT "notificationSettings" FROM users WHERE id = $1`, + [args.user_id] + ); + + return { + content: [{ type: 'text', text: JSON.stringify({ + subscriptions: subscriptions.rows, + totalSubscriptions: subscriptions.rows.length, + userSettings: userSettings.rows[0]?.notificationSettings || {}, + }, null, 2) }], + }; + }, +}; + +export const subscriptionsTools: BaseTool[] = [ + listSubscriptions, subscribeToDocument, unsubscribeFromDocument, getSubscriptionSettings +]; diff --git a/src/tools/teams.ts b/src/tools/teams.ts new file mode 100644 index 0000000..494d00b --- /dev/null +++ b/src/tools/teams.ts @@ -0,0 +1,222 @@ +/** + * MCP Outline PostgreSQL - Teams Tools + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse } from '../types/tools.js'; +import { isValidUUID, sanitizeInput } from '../utils/security.js'; + +/** + * teams.info - Get team details + */ +const getTeam: BaseTool<{ id?: string }> = { + name: 'outline_get_team', + description: 'Get detailed information about a team (workspace). If no ID provided, returns the first/default team.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team ID (UUID, optional)' }, + }, + }, + handler: async (args, pgClient): Promise => { + let query = ` + SELECT + t.id, t.name, t.subdomain, t.domain, t."avatarUrl", + t.sharing, t."documentEmbeds", t."guestSignin", t."inviteRequired", + t."collaborativeEditing", t."defaultUserRole", t."memberCollectionCreate", + t."memberTeamCreate", t."passkeysEnabled", t.description, t.preferences, + t."lastActiveAt", t."suspendedAt", t."createdAt", t."updatedAt", + (SELECT COUNT(*) FROM users WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "userCount", + (SELECT COUNT(*) FROM collections WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "collectionCount", + (SELECT COUNT(*) FROM documents WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "documentCount" + FROM teams t + WHERE t."deletedAt" IS NULL + `; + + const params: any[] = []; + if (args.id) { + if (!isValidUUID(args.id)) throw new Error('Invalid team ID format'); + query += ` AND t.id = $1`; + params.push(args.id); + } + query += ` LIMIT 1`; + + const result = await pgClient.query(query, params); + if (result.rows.length === 0) throw new Error('Team not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }], + }; + }, +}; + +/** + * teams.update - Update team settings + */ +const updateTeam: BaseTool<{ + id: string; + name?: string; + sharing?: boolean; + document_embeds?: boolean; + guest_signin?: boolean; + invite_required?: boolean; + default_user_role?: string; +}> = { + name: 'outline_update_team', + description: 'Update team settings and preferences.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team ID (UUID)' }, + name: { type: 'string', description: 'Team name' }, + sharing: { type: 'boolean', description: 'Allow document sharing' }, + document_embeds: { type: 'boolean', description: 'Allow document embeds' }, + guest_signin: { type: 'boolean', description: 'Allow guest signin' }, + invite_required: { type: 'boolean', description: 'Require invite to join' }, + default_user_role: { type: 'string', enum: ['admin', 'member', 'viewer'], description: 'Default role for new users' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid team ID format'); + + const updates: string[] = ['"updatedAt" = NOW()']; + const params: any[] = []; + let idx = 1; + + if (args.name) { updates.push(`name = $${idx++}`); params.push(sanitizeInput(args.name)); } + if (args.sharing !== undefined) { updates.push(`sharing = $${idx++}`); params.push(args.sharing); } + if (args.document_embeds !== undefined) { updates.push(`"documentEmbeds" = $${idx++}`); params.push(args.document_embeds); } + if (args.guest_signin !== undefined) { updates.push(`"guestSignin" = $${idx++}`); params.push(args.guest_signin); } + if (args.invite_required !== undefined) { updates.push(`"inviteRequired" = $${idx++}`); params.push(args.invite_required); } + if (args.default_user_role) { updates.push(`"defaultUserRole" = $${idx++}`); params.push(args.default_user_role); } + + params.push(args.id); + + const result = await pgClient.query( + `UPDATE teams SET ${updates.join(', ')} WHERE id = $${idx} AND "deletedAt" IS NULL RETURNING *`, + params + ); + + if (result.rows.length === 0) throw new Error('Team not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Team updated' }, null, 2) }], + }; + }, +}; + +/** + * teams.stats - Get team statistics + */ +const getTeamStats: BaseTool<{ id?: string }> = { + name: 'outline_get_team_stats', + description: 'Get comprehensive statistics for a team including users, documents, collections, and activity.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team ID (UUID, optional - uses default team)' }, + }, + }, + handler: async (args, pgClient): Promise => { + let teamCondition = ''; + const params: any[] = []; + + if (args.id) { + if (!isValidUUID(args.id)) throw new Error('Invalid team ID format'); + teamCondition = `AND "teamId" = $1`; + params.push(args.id); + } + + const stats = await pgClient.query(` + SELECT + (SELECT COUNT(*) FROM users WHERE "deletedAt" IS NULL ${teamCondition}) as "totalUsers", + (SELECT COUNT(*) FROM users WHERE role = 'admin' AND "deletedAt" IS NULL ${teamCondition}) as "adminUsers", + (SELECT COUNT(*) FROM users WHERE "suspendedAt" IS NOT NULL AND "deletedAt" IS NULL ${teamCondition}) as "suspendedUsers", + (SELECT COUNT(*) FROM documents WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "totalDocuments", + (SELECT COUNT(*) FROM documents WHERE template = true AND "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "templateDocuments", + (SELECT COUNT(*) FROM documents WHERE "publishedAt" IS NOT NULL AND "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "publishedDocuments", + (SELECT COUNT(*) FROM collections WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'c."teamId"')}) as "totalCollections", + (SELECT COUNT(*) FROM groups WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'g."teamId"')}) as "totalGroups", + (SELECT COUNT(*) FROM shares ${args.id ? 'WHERE "teamId" = $1' : ''}) as "totalShares", + (SELECT COUNT(*) FROM integrations WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'i."teamId"')}) as "totalIntegrations" + `, params); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: stats.rows[0] }, null, 2) }], + }; + }, +}; + +/** + * teams.domains - List team domains + */ +const listTeamDomains: BaseTool<{ team_id?: string }> = { + name: 'outline_list_team_domains', + description: 'List allowed domains for a team. Domains control who can sign up.', + inputSchema: { + type: 'object', + properties: { + team_id: { type: 'string', description: 'Team ID (UUID, optional)' }, + }, + }, + handler: async (args, pgClient): Promise => { + let query = ` + SELECT + td.id, td.name, td."teamId", td."createdById", td."createdAt", + u.name as "createdByName", t.name as "teamName" + FROM team_domains td + LEFT JOIN users u ON td."createdById" = u.id + LEFT JOIN teams t ON td."teamId" = t.id + `; + + const params: any[] = []; + if (args.team_id) { + if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format'); + query += ` WHERE td."teamId" = $1`; + params.push(args.team_id); + } + query += ` ORDER BY td."createdAt" DESC`; + + const result = await pgClient.query(query, params); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows }, null, 2) }], + }; + }, +}; + +/** + * teams.updateSettings - Update team preferences + */ +const updateTeamSettings: BaseTool<{ id: string; preferences: Record }> = { + name: 'outline_update_team_settings', + description: 'Update team preferences (JSON settings object).', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team ID (UUID)' }, + preferences: { type: 'object', description: 'Preferences object to merge' }, + }, + required: ['id', 'preferences'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid team ID format'); + + const result = await pgClient.query( + `UPDATE teams SET preferences = COALESCE(preferences, '{}'::jsonb) || $1::jsonb, "updatedAt" = NOW() + WHERE id = $2 AND "deletedAt" IS NULL + RETURNING id, name, preferences`, + [JSON.stringify(args.preferences), args.id] + ); + + if (result.rows.length === 0) throw new Error('Team not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Settings updated' }, null, 2) }], + }; + }, +}; + +export const teamsTools: BaseTool[] = [getTeam, updateTeam, getTeamStats, listTeamDomains, updateTeamSettings]; diff --git a/src/tools/templates.ts b/src/tools/templates.ts new file mode 100644 index 0000000..b6810c7 --- /dev/null +++ b/src/tools/templates.ts @@ -0,0 +1,223 @@ +/** + * MCP Outline PostgreSQL - Templates Tools + * Templates are documents with template=true + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; +import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js'; + +interface TemplateListArgs extends PaginationArgs { + collection_id?: string; +} + +/** + * templates.list - List templates + */ +const listTemplates: BaseTool = { + name: 'outline_list_templates', + description: 'List document templates. Templates are reusable document structures.', + inputSchema: { + type: 'object', + properties: { + collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + const conditions: string[] = ['d.template = true', 'd."deletedAt" IS NULL']; + const params: any[] = []; + let idx = 1; + + if (args.collection_id) { + if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); + conditions.push(`d."collectionId" = $${idx++}`); + params.push(args.collection_id); + } + + const result = await pgClient.query(` + SELECT + d.id, d.title, d.emoji, d."collectionId", d."createdById", + d."createdAt", d."updatedAt", + c.name as "collectionName", + u.name as "createdByName", + (SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount" + FROM documents d + LEFT JOIN collections c ON d."collectionId" = c.id + LEFT JOIN users u ON d."createdById" = u.id + WHERE ${conditions.join(' AND ')} + ORDER BY d."updatedAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...params, limit, offset]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }], + }; + }, +}; + +/** + * templates.info - Get template details + */ +const getTemplate: BaseTool<{ id: string }> = { + name: 'outline_get_template', + description: 'Get detailed information about a template including its content.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Template ID (UUID)' }, + }, + required: ['id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.id)) throw new Error('Invalid template ID'); + + const result = await pgClient.query(` + SELECT + d.*, c.name as "collectionName", u.name as "createdByName", + (SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount" + 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.template = true AND d."deletedAt" IS NULL + `, [args.id]); + + if (result.rows.length === 0) throw new Error('Template not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }], + }; + }, +}; + +/** + * templates.create - Create document from template + */ +const createFromTemplate: BaseTool<{ template_id: string; title: string; collection_id?: string; parent_document_id?: string }> = { + name: 'outline_create_from_template', + description: 'Create a new document from a template.', + inputSchema: { + type: 'object', + properties: { + template_id: { type: 'string', description: 'Template ID (UUID)' }, + title: { type: 'string', description: 'Title for the new document' }, + collection_id: { type: 'string', description: 'Collection ID (UUID, optional - uses template collection)' }, + parent_document_id: { type: 'string', description: 'Parent document ID (UUID, optional)' }, + }, + required: ['template_id', 'title'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id'); + if (args.collection_id && !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'); + + // Get template + const template = await pgClient.query( + `SELECT * FROM documents WHERE id = $1 AND template = true AND "deletedAt" IS NULL`, + [args.template_id] + ); + + if (template.rows.length === 0) throw new Error('Template not found'); + + const t = template.rows[0]; + + // Get user + const user = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`); + const userId = user.rows.length > 0 ? user.rows[0].id : t.createdById; + + // Create document from template + const result = await pgClient.query(` + INSERT INTO documents ( + id, title, text, emoji, "collectionId", "teamId", "parentDocumentId", + "templateId", "createdById", "lastModifiedById", template, + "createdAt", "updatedAt" + ) + VALUES ( + gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $8, false, NOW(), NOW() + ) + RETURNING * + `, [ + sanitizeInput(args.title), + t.text, + t.emoji, + args.collection_id || t.collectionId, + t.teamId, + args.parent_document_id || null, + args.template_id, + userId, + ]); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Document created from template' }, null, 2) }], + }; + }, +}; + +/** + * templates.convert - Convert document to template + */ +const convertToTemplate: BaseTool<{ document_id: string }> = { + name: 'outline_convert_to_template', + description: 'Convert an existing document to a template.', + inputSchema: { + type: 'object', + properties: { + document_id: { type: 'string', description: 'Document ID to convert (UUID)' }, + }, + required: ['document_id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id'); + + const result = await pgClient.query(` + UPDATE documents + SET template = true, "updatedAt" = NOW() + WHERE id = $1 AND "deletedAt" IS NULL AND template = false + RETURNING id, title, template + `, [args.document_id]); + + if (result.rows.length === 0) throw new Error('Document not found or already a template'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to template' }, null, 2) }], + }; + }, +}; + +/** + * templates.unconvert - Convert template back to document + */ +const convertFromTemplate: BaseTool<{ template_id: string }> = { + name: 'outline_convert_from_template', + description: 'Convert a template back to a regular document.', + inputSchema: { + type: 'object', + properties: { + template_id: { type: 'string', description: 'Template ID to convert (UUID)' }, + }, + required: ['template_id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id'); + + const result = await pgClient.query(` + UPDATE documents + SET template = false, "updatedAt" = NOW() + WHERE id = $1 AND "deletedAt" IS NULL AND template = true + RETURNING id, title, template + `, [args.template_id]); + + if (result.rows.length === 0) throw new Error('Template not found or already a document'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to document' }, null, 2) }], + }; + }, +}; + +export const templatesTools: BaseTool[] = [ + listTemplates, getTemplate, createFromTemplate, convertToTemplate, convertFromTemplate +]; diff --git a/src/tools/user-permissions.ts b/src/tools/user-permissions.ts new file mode 100644 index 0000000..84b6616 --- /dev/null +++ b/src/tools/user-permissions.ts @@ -0,0 +1,243 @@ +/** + * MCP Outline PostgreSQL - User Permissions Tools + * Document/Collection level permission management + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { Pool } from 'pg'; +import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; +import { validatePagination, isValidUUID } from '../utils/security.js'; + +interface PermissionListArgs extends PaginationArgs { + user_id?: string; + document_id?: string; + collection_id?: string; +} + +/** + * user_permissions.list - List user permissions + */ +const listUserPermissions: BaseTool = { + name: 'outline_list_user_permissions', + description: 'List user permissions on documents and collections.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'Filter by user ID (UUID)' }, + document_id: { type: 'string', description: 'Filter by document ID (UUID)' }, + collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' }, + limit: { type: 'number', description: 'Max results (default: 25)' }, + offset: { type: 'number', description: 'Skip results (default: 0)' }, + }, + }, + handler: async (args, pgClient): Promise => { + const { limit, offset } = validatePagination(args.limit, args.offset); + + const results: any = { documentPermissions: [], collectionPermissions: [] }; + + // Document permissions (user_permissions table) + if (!args.collection_id) { + const docConditions: string[] = []; + const docParams: any[] = []; + let idx = 1; + + if (args.user_id) { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + docConditions.push(`up."userId" = $${idx++}`); + docParams.push(args.user_id); + } + if (args.document_id) { + if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id'); + docConditions.push(`up."documentId" = $${idx++}`); + docParams.push(args.document_id); + } + + const docWhere = docConditions.length > 0 ? `WHERE ${docConditions.join(' AND ')}` : ''; + + const docResult = await pgClient.query(` + SELECT + up.id, up."userId", up."documentId", up.permission, + up."createdById", up."createdAt", up."updatedAt", + u.name as "userName", u.email as "userEmail", + d.title as "documentTitle" + FROM user_permissions up + LEFT JOIN users u ON up."userId" = u.id + LEFT JOIN documents d ON up."documentId" = d.id + ${docWhere} + ORDER BY up."createdAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...docParams, limit, offset]); + + results.documentPermissions = docResult.rows; + } + + // Collection permissions (collection_users table) + if (!args.document_id) { + const colConditions: string[] = []; + const colParams: any[] = []; + let idx = 1; + + if (args.user_id) { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + colConditions.push(`cu."userId" = $${idx++}`); + colParams.push(args.user_id); + } + if (args.collection_id) { + if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id'); + colConditions.push(`cu."collectionId" = $${idx++}`); + colParams.push(args.collection_id); + } + + const colWhere = colConditions.length > 0 ? `WHERE ${colConditions.join(' AND ')}` : ''; + + const colResult = await pgClient.query(` + SELECT + cu."userId", cu."collectionId", cu.permission, + cu."createdById", cu."createdAt", cu."updatedAt", + u.name as "userName", u.email as "userEmail", + c.name as "collectionName" + FROM collection_users cu + LEFT JOIN users u ON cu."userId" = u.id + LEFT JOIN collections c ON cu."collectionId" = c.id + ${colWhere} + ORDER BY cu."createdAt" DESC + LIMIT $${idx++} OFFSET $${idx} + `, [...colParams, limit, offset]); + + results.collectionPermissions = colResult.rows; + } + + return { + content: [{ type: 'text', text: JSON.stringify({ data: results, pagination: { limit, offset } }, null, 2) }], + }; + }, +}; + +/** + * user_permissions.grant - Grant permission to user + */ +const grantUserPermission: BaseTool<{ user_id: string; document_id?: string; collection_id?: string; permission: string }> = { + name: 'outline_grant_user_permission', + description: 'Grant permission to a user on a document or collection.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'User ID (UUID)' }, + document_id: { type: 'string', description: 'Document ID (UUID) - provide either this or collection_id' }, + collection_id: { type: 'string', description: 'Collection ID (UUID) - provide either this or document_id' }, + permission: { type: 'string', description: 'Permission level: read_write, read, or admin (for collections)' }, + }, + required: ['user_id', 'permission'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + if (!args.document_id && !args.collection_id) throw new Error('Either document_id or collection_id required'); + if (args.document_id && args.collection_id) throw new Error('Provide only one of document_id or collection_id'); + + const creatorResult = await pgClient.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_id; + + let result; + + if (args.document_id) { + if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id'); + + // Check if permission already exists + const existing = await pgClient.query( + `SELECT id FROM user_permissions WHERE "userId" = $1 AND "documentId" = $2`, + [args.user_id, args.document_id] + ); + + if (existing.rows.length > 0) { + // Update existing + result = await pgClient.query(` + UPDATE user_permissions + SET permission = $1, "updatedAt" = NOW() + WHERE "userId" = $2 AND "documentId" = $3 + RETURNING * + `, [args.permission, args.user_id, args.document_id]); + } else { + // Create new + result = await pgClient.query(` + INSERT INTO user_permissions (id, "userId", "documentId", permission, "createdById", "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) + RETURNING * + `, [args.user_id, args.document_id, args.permission, createdById]); + } + } else { + if (!isValidUUID(args.collection_id!)) throw new Error('Invalid collection_id'); + + // Check if permission already exists + const existing = await pgClient.query( + `SELECT "userId" FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2`, + [args.user_id, args.collection_id] + ); + + if (existing.rows.length > 0) { + // Update existing + result = await pgClient.query(` + UPDATE collection_users + SET permission = $1, "updatedAt" = NOW() + WHERE "userId" = $2 AND "collectionId" = $3 + RETURNING * + `, [args.permission, args.user_id, args.collection_id]); + } else { + // Create new + result = await pgClient.query(` + INSERT INTO collection_users ("userId", "collectionId", permission, "createdById", "createdAt", "updatedAt") + VALUES ($1, $2, $3, $4, NOW(), NOW()) + RETURNING * + `, [args.user_id, args.collection_id, args.permission, createdById]); + } + } + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Permission granted' }, null, 2) }], + }; + }, +}; + +/** + * user_permissions.revoke - Revoke permission from user + */ +const revokeUserPermission: BaseTool<{ user_id: string; document_id?: string; collection_id?: string }> = { + name: 'outline_revoke_user_permission', + description: 'Revoke permission from a user on a document or collection.', + inputSchema: { + type: 'object', + properties: { + user_id: { type: 'string', description: 'User ID (UUID)' }, + document_id: { type: 'string', description: 'Document ID (UUID)' }, + collection_id: { type: 'string', description: 'Collection ID (UUID)' }, + }, + required: ['user_id'], + }, + handler: async (args, pgClient): Promise => { + if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id'); + if (!args.document_id && !args.collection_id) throw new Error('Either document_id or collection_id required'); + + let result; + + if (args.document_id) { + if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id'); + result = await pgClient.query( + `DELETE FROM user_permissions WHERE "userId" = $1 AND "documentId" = $2 RETURNING *`, + [args.user_id, args.document_id] + ); + } else { + if (!isValidUUID(args.collection_id!)) throw new Error('Invalid collection_id'); + result = await pgClient.query( + `DELETE FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2 RETURNING *`, + [args.user_id, args.collection_id] + ); + } + + if (result.rows.length === 0) throw new Error('Permission not found'); + + return { + content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Permission revoked' }, null, 2) }], + }; + }, +}; + +export const userPermissionsTools: BaseTool[] = [listUserPermissions, grantUserPermission, revokeUserPermission];