MCP: сообщения задач, исполнители, документы, файлы, аудит — этап 2

- list_task_messages, get_task_assignees, list_document_templates, get_task_file, get_task_audit_log (read)
- send_task_message, generate_document (write); set_task_assignees (full)
- Логика POST /api/tasks/:id/messages вынесена в server/services/task-message.service.ts (переиспользуется route и MCP)
- get_task_file: нативный S3 presigned URL или локальный путь с пояснением
This commit is contained in:
2026-07-21 17:19:22 +03:00
parent 002ce152be
commit 05e09fa08b
3 changed files with 778 additions and 242 deletions

View File

@@ -43,6 +43,19 @@ import {
} from "./services/embedding.service";
import { formatUserName } from "./utils/formatUserName";
import { buildDataTableTree } from "./utils/data-table-tree";
import { sendTaskMessage, SendTaskMessageError } from "./services/task-message.service";
import { tasksMinimalCache } from "./utils/cache";
import { evaluateAutoTransitions } from "./utils/auto-transitions";
import { notifyTaskAssigned } from "./utils/notifyAssignee";
import { eventBus } from "./routes/shared";
import { DocumentTemplateService } from "./documents/template.service";
import { DocumentGenerationService } from "./documents/generation.service";
import { DataResolutionService } from "./documents/data-resolution.service";
import { AssetService } from "./documents/asset.service";
import { isS3Enabled, getPresignedUrl } from "./utils/s3";
import { db } from "./db";
import { fileUploads } from "@shared/schema";
import { eq } from "drizzle-orm";
/** Format JS page code with consistent indentation before storing in DB. */
function formatPageCode(code: string): string {
@@ -119,6 +132,11 @@ const READ_TOOLS: readonly string[] = [
'list_directory_rows',
'get_directory_row',
'get_directory_column_values',
'list_task_messages',
'get_task_assignees',
'list_document_templates',
'get_task_file',
'get_task_audit_log',
];
// WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных.
@@ -128,6 +146,8 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
'link_tasks',
'create_directory_row',
'bulk_create_directory_rows',
'send_task_message',
'generate_document',
];
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
@@ -220,6 +240,9 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
isError: true,
});
// Универсальная ошибка MCP-инструмента в формате { error } (русский текст)
const mcpError = directoryError;
// Нормализация values строки справочника до длины массива columns:
// лишние значения обрезаются, недостающие дополняются пустыми строками
// (как normalizeValues в server/routes/data-tables-sync.routes.ts).
@@ -3537,6 +3560,458 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
}
);
// ── Сообщения задач ────────────────────────────────────────────────────────
// list_task_messages
register(
"list_task_messages",
{
title: "List Task Messages",
description: "List messages (chat) of a task, oldest first. Use afterId for incremental polling.",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
afterId: z.number().int().optional().describe("Only messages with ID greater than this (incremental polling)"),
limit: z.number().int().min(1).max(500).optional().describe("Max messages to return (default 50)"),
},
},
async ({ taskId, afterId, limit }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const messages = await storage.getTaskMessages(taskId, organizationId, afterId);
const limited = messages.slice(0, limit ?? 50);
return {
content: [{
type: "text" as const,
text: JSON.stringify(limited.map((m) => ({
id: m.id,
message: m.message,
messageType: m.messageType,
authorId: m.authorId,
author: m.author
? (`${m.author.firstName || ''} ${m.author.middleName || ''} ${m.author.lastName || ''}`.trim() || null)
: null,
bot: m.bot ?? null,
replyToMessageId: m.replyToMessageId,
mentionedUserIds: m.mentionedUserIds,
attachments: m.attachments,
createdAt: m.createdAt,
})), null, 2),
}],
};
}
);
// send_task_message
register(
"send_task_message",
{
title: "Send Task Message",
description:
"Post a comment message to a task chat. Triggers the same side effects as the web UI: " +
"notifications, SSE events, webhooks, embeddings. The message is authored by the first admin user of the organization.",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
content: z.string().min(1).describe("Message text"),
},
},
async ({ taskId, content }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
if (!content.trim()) return mcpError("Текст сообщения обязателен");
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей для авторства сообщения");
try {
const created = await sendTaskMessage({
task,
user: adminUser,
organizationId,
message: content,
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
message: {
id: created.id,
taskId: created.taskId,
authorId: created.authorId,
message: created.message,
messageType: created.messageType,
createdAt: created.createdAt,
},
}, null, 2),
}],
};
} catch (err: unknown) {
if (err instanceof SendTaskMessageError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err);
return mcpError(`Ошибка отправки сообщения: ${msg}`);
}
}
);
// ── Исполнители задачи ─────────────────────────────────────────────────────
// get_task_assignees
register(
"get_task_assignees",
{
title: "Get Task Assignees",
description: "List assignees (исполнители) of a task with user id, name and email",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
},
},
async ({ taskId }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const assignees = await storage.getTaskAssignees(taskId, organizationId);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
taskId,
assignedTo: task.assignedTo,
assignees: assignees.map((a) => ({
userId: a.userId,
name: (`${a.user.firstName || ''} ${a.user.lastName || ''}`.trim()) || null,
email: a.user.email,
})),
}, null, 2),
}],
};
}
);
// set_task_assignees
register(
"set_task_assignees",
{
title: "Set Task Assignees",
description:
"Replace the full list of task assignees (исполнители). " +
"The legacy assignedTo field is synced to the first user in the list (or null when empty). " +
"Triggers auto-transitions, audit log entry and notifications, like the web UI.",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
userIds: z.array(z.number().int()).describe("Full new list of assignee user IDs (empty array to clear all)"),
},
},
async ({ taskId, userIds }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const orgUsers = await storage.getUsersByOrganization(organizationId);
const orgUserIds = new Set(orgUsers.map((u) => u.id));
const uniqueIds = [...new Set(userIds)];
const invalid = uniqueIds.filter((id) => !orgUserIds.has(id));
if (invalid.length > 0) {
return mcpError(`Пользователи не найдены в организации: ${invalid.join(', ')}`);
}
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
// Полная замена списка: удаляем лишних, добавляем недостающих
const current = await storage.getTaskAssignees(taskId, organizationId);
const currentIds = current.map((a) => a.userId);
const toAdd = uniqueIds.filter((id) => !currentIds.includes(id));
const toRemove = currentIds.filter((id) => !uniqueIds.includes(id));
for (const id of toRemove) {
await storage.removeTaskAssignee(taskId, id, organizationId);
}
for (const id of toAdd) {
await storage.addTaskAssignee(taskId, id, organizationId);
}
// Синхронизация legacy-поля assignedTo (как в route assignees: первый из списка или null)
const newAssignedTo = uniqueIds[0] ?? null;
await storage.updateTask(taskId, organizationId, { assignedTo: newAssignedTo });
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser?.id ?? null });
const editorName = adminUser
? (`${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP')
: 'MCP';
const names = uniqueIds
.map((id) => {
const u = orgUsers.find((x) => x.id === id);
return u ? (`${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email) : String(id);
})
.join(', ');
storage.addTaskAuditLog({
taskId,
organizationId,
action: 'task.updated',
fieldName: 'Ответственные обновлены',
oldValue: null,
newValue: names || '(пусто)',
changedBy: adminUser?.id ?? null,
changedByName: editorName,
}).catch((e: unknown) => { console.error('Audit log error (MCP set_task_assignees):', e); });
const refreshedTask = await storage.getTask(taskId, organizationId);
// Уведомление новому основному ответственному (если сменился)
if (refreshedTask && newAssignedTo && newAssignedTo !== task.assignedTo) {
notifyTaskAssigned(refreshedTask, newAssignedTo, adminUser?.id ?? null, organizationId)
.catch((err) => console.error('[MCP set_task_assignees] notifyTaskAssigned error:', err));
}
eventBus.publishEvent({
type: 'task_updated',
organizationId,
data: { taskId, formId: refreshedTask?.formId, task: refreshedTask },
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
taskId,
assignedTo: newAssignedTo,
assignees: uniqueIds,
added: toAdd,
removed: toRemove,
autoTransition: autoResult.changed,
}, null, 2),
}],
};
}
);
// ── Документы ──────────────────────────────────────────────────────────────
// list_document_templates
register(
"list_document_templates",
{
title: "List Document Templates",
description: "List document templates of the organization with their variables. Optionally filter by folder.",
inputSchema: {
folderId: z.number().int().optional().describe("Filter by template folder ID (optional)"),
},
},
async ({ folderId }) => {
const templateService = new DocumentTemplateService();
const templates = await templateService.list({ organizationId, folderId });
const result = await Promise.all(
templates.map(async (t) => {
const full = await templateService.getById(t.id, organizationId);
return {
id: t.id,
name: t.name,
description: t.description,
folderId: t.folderId,
categoryId: t.categoryId,
formId: t.formId,
status: t.status,
variables: (full?.variables ?? []).map((v) => ({
id: v.id,
code: v.code,
label: v.label,
source: v.source,
})),
};
})
);
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
}
);
// generate_document
register(
"generate_document",
{
title: "Generate Document",
description:
"Generate a document (docx or pdf) from a template for a given task. " +
"Returns the generation ID and a download URL (requires user authorization to download).",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task to fill the template with"),
templateId: z.number().int().describe("The numeric ID of the document template"),
format: z.enum(["docx", "pdf"]).describe("Output format"),
},
},
async ({ taskId, templateId, format }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей для генерации документа");
// Сервис генерации собирается так же, как в server/documents/routes.ts
const templateService = new DocumentTemplateService();
const dataResolution = new DataResolutionService();
const assetService = new AssetService();
const generationService = new DocumentGenerationService(templateService, dataResolution, assetService);
try {
const result = await generationService.generate({
templateId,
taskId,
organizationId,
userId: adminUser.id,
outputFormat: format,
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
generationId: result.generationId,
downloadUrl: `/api/documents/generations/${result.generationId}/download/${format}`,
pdfUrl: result.pdfUrl ?? null,
docxUrl: result.docxUrl ?? null,
status: result.status,
unresolvedVariables: result.unresolvedVariables,
}, null, 2),
}],
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return mcpError(`Ошибка генерации документа: ${msg}`);
}
}
);
// ── Файлы ──────────────────────────────────────────────────────────────────
// get_task_file
register(
"get_task_file",
{
title: "Get Task File",
description:
"Get a download URL for a file attached to a task (by fileKey from message attachments or file field values). " +
"In S3/MinIO mode returns a presigned URL (valid ~5 minutes). In local mode returns a direct path that requires user authorization.",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
fileKey: z.string().min(1).describe("File key (the part after /api/files/ or /uploads/ in the attachment URL)"),
},
},
async ({ taskId, fileKey }) => {
if (!fileKey || fileKey.includes('..') || fileKey.includes('/')) {
return mcpError("Неверный ключ файла");
}
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
// Файл должен отслеживаться в file_uploads и принадлежать организации (как canAccessFile в index.ts)
const [upload] = await db
.select()
.from(fileUploads)
.where(eq(fileUploads.fileKey, fileKey))
.limit(1);
if (!upload) return mcpError("Файл не найден или не отслеживается");
if (upload.organizationId !== organizationId) {
return mcpError("Файл принадлежит другой организации");
}
// Проверка принадлежности файла именно этой задаче:
// напрямую (file_uploads.taskId), через вложения сообщений или через значения file-полей
let belongsToTask = upload.taskId === taskId;
if (!belongsToTask) {
const messages = await storage.getTaskMessages(taskId, organizationId);
belongsToTask = messages.some((m) =>
Array.isArray(m.attachments) &&
m.attachments.some((a) => typeof a?.url === 'string' && a.url.includes(fileKey))
);
}
if (!belongsToTask) {
const fieldValues = await storage.getTaskFieldValues(taskId, organizationId);
belongsToTask = fieldValues.some((fv) => typeof fv.value === 'string' && fv.value.includes(fileKey));
}
if (!belongsToTask) {
return mcpError(`Файл не относится к задаче ${taskId}`);
}
if (isS3Enabled) {
const presignedUrl = await getPresignedUrl(fileKey, 300);
if (presignedUrl) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
fileKey,
originalName: upload.originalName,
downloadUrl: presignedUrl,
type: "presigned",
expiresInSeconds: 300,
}, null, 2),
}],
};
}
}
// Локальный режим (или ошибка presigned): отдаём прямой путь с пояснением
return {
content: [{
type: "text" as const,
text: JSON.stringify({
fileKey,
originalName: upload.originalName,
downloadUrl: isS3Enabled ? `/api/files/${fileKey}` : `/uploads/${fileKey}`,
type: "direct",
note: "Presigned URL недоступен (локальный режим хранения). Ссылка требует авторизации пользователя (JWT/сессия); временную ссылку выдаёт GET /api/files/:key/presigned.",
}, null, 2),
}],
};
}
);
// ── Аудит задачи ───────────────────────────────────────────────────────────
// get_task_audit_log
register(
"get_task_audit_log",
{
title: "Get Task Audit Log",
description: "Get the audit log entries of a task (field changes, status changes, etc.), newest first",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
limit: z.number().int().min(1).max(500).optional().describe("Max entries to return (default 50)"),
},
},
async ({ taskId, limit }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const entries = await storage.getTaskAuditLog(taskId, organizationId);
return {
content: [{
type: "text" as const,
text: JSON.stringify(entries.slice(0, limit ?? 50).map((e) => ({
id: e.id,
action: e.action,
fieldName: e.fieldName,
oldValue: e.oldValue,
newValue: e.newValue,
changedBy: e.changedBy,
changedByName: e.changedByName,
createdAt: e.createdAt,
})), null, 2),
}],
};
}
);
return server;
}