Атрибуция от бота в MCP/REST + REST API по ключу (этап 2)
- MCP: getActor (владелец ключа + бот), аудит changedByName=бот/label ключа, botId, metadata.source='mcp'; аудит для update_task/update_task_status - sendTaskMessage: botId (messageType 'bot'), починен путь isBotToken без req.user - authenticateTokenOrApiKey: JWT или X-Api-Key с белым списком endpoint'ов (/api/upload, messages, field-values, create task), проверки скоупов, аудит source='api' - Лимиты файлов через env: UPLOAD_IMAGE_MAX_MB=25, UPLOAD_DOC_MAX_MB=100, UPLOAD_MAX_MB=100 - MCP upload-инструменты: параметр fileUrl для привязки уже загруженного файла; новый инструмент get_api_guide
This commit is contained in:
12
.env.example
12
.env.example
@@ -130,3 +130,15 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
|
||||
# DOC_WORKER_URL=http://document-worker:3000
|
||||
# For local development without Docker, you can point to an external service:
|
||||
# DOC_WORKER_URL=http://localhost:3000
|
||||
|
||||
# =============================================
|
||||
# File upload limits (optional, in megabytes)
|
||||
# =============================================
|
||||
# Per-type limits (extension-based, enforced after upload):
|
||||
# UPLOAD_IMAGE_MAX_MB=25
|
||||
# UPLOAD_DOC_MAX_MB=100
|
||||
# Hard cap for the multipart parser (multer fileSize):
|
||||
# UPLOAD_MAX_MB=100
|
||||
# Bot login token TTL (access / refresh) — см. этап bot API keys:
|
||||
# JWT_BOT_LOGIN_EXPIRES=30d
|
||||
# JWT_BOT_LOGIN_REFRESH_EXPIRES=90d
|
||||
|
||||
494
server/mcp.ts
494
server/mcp.ts
@@ -33,8 +33,8 @@ async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: stri
|
||||
}
|
||||
}
|
||||
import type { Request, Response } from "express";
|
||||
import type { Task, ApiKeyScopes } from "@shared/schema";
|
||||
import { normalizeApiKeyScopes } from "./utils/api-key";
|
||||
import type { Task, ApiKeyScopes, OrganizationApiKey, User, Bot } from "@shared/schema";
|
||||
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
|
||||
import beautify from "js-beautify";
|
||||
import {
|
||||
semanticSearch,
|
||||
@@ -147,6 +147,7 @@ const READ_TOOLS: readonly string[] = [
|
||||
'get_task_tree',
|
||||
'get_user_field_values',
|
||||
'dadata_suggest',
|
||||
'get_api_guide',
|
||||
];
|
||||
|
||||
// WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных.
|
||||
@@ -171,10 +172,12 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
|
||||
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
||||
// автоматизаций, таб-модулей, страниц, переиндексация) — только режим full.
|
||||
|
||||
// Результат разрешения API-ключа: организация + нормализованные скоупы доступа.
|
||||
// Результат разрешения API-ключа: организация + нормализованные скоупы доступа
|
||||
// + полная запись ключа (botId, createdBy, label) для атрибуции изменений.
|
||||
export interface ResolvedApiKey {
|
||||
organizationId: number;
|
||||
scopes: ApiKeyScopes;
|
||||
key: OrganizationApiKey;
|
||||
}
|
||||
|
||||
// Разрешает API-ключ из запроса: возвращает organizationId и нормализованные скоупы
|
||||
@@ -199,7 +202,7 @@ async function resolveApiKey(req: Request): Promise<ResolvedApiKey | null> {
|
||||
}
|
||||
if (!apiKey.isActive) return null;
|
||||
storage.touchApiKey(apiKey.id).catch(() => {});
|
||||
return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes) };
|
||||
return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes), key: apiKey };
|
||||
}
|
||||
|
||||
function taskToJson(t: Task) {
|
||||
@@ -216,7 +219,7 @@ function taskToJson(t: Task) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer {
|
||||
function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyRecord: OrganizationApiKey | null): McpServer {
|
||||
const server = new McpServer({ name: "iistwin-mcp", version: "1.0.0" });
|
||||
|
||||
// ── Фильтрация инструментов по режиму ключа (scopes.mode) ─────────────────
|
||||
@@ -239,12 +242,107 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
}) as typeof server.registerTool;
|
||||
|
||||
// ── Объектный доступ по scopes.formIds ────────────────────────────────────
|
||||
const isFormAllowed = (formId: number) => scopes.formIds === null || scopes.formIds.includes(formId);
|
||||
const isFormAllowed = (formId: number) => isFormAllowedByScopes(scopes, formId);
|
||||
const formDenied = (formId: number) => ({
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к форме ${formId} запрещён правами API-ключа` }) }],
|
||||
isError: true,
|
||||
});
|
||||
|
||||
// ── Актор изменений по API-ключу ───────────────────────────────────────────
|
||||
// user — владелец ключа (fallback: первый админ) для notNull FK-колонок;
|
||||
// bot — бот ключа (если привязан); displayName — имя для аудита
|
||||
// (имя бота или «Ключ "label"», чтобы в истории было видно, что это не человек).
|
||||
interface McpActor {
|
||||
user: User;
|
||||
bot: Bot | null;
|
||||
displayName: string;
|
||||
}
|
||||
let actorPromise: Promise<McpActor> | null = null;
|
||||
const getActor = (): Promise<McpActor> => {
|
||||
if (!actorPromise) {
|
||||
actorPromise = (async () => {
|
||||
let user: User | null | undefined = apiKeyRecord?.createdBy
|
||||
? await storage.getUser(apiKeyRecord.createdBy).catch(() => null)
|
||||
: null;
|
||||
if (!user || user.organizationId !== organizationId) {
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
user = orgUsers.find((u) => u.appRole === 'admin') ?? orgUsers[0] ?? null;
|
||||
}
|
||||
if (!user) throw new Error('В организации нет пользователей');
|
||||
const bot = apiKeyRecord?.botId
|
||||
? await storage.getBot(apiKeyRecord.botId, organizationId).catch(() => null) ?? null
|
||||
: null;
|
||||
const displayName = bot?.name
|
||||
?? (apiKeyRecord ? `Ключ «${apiKeyRecord.label}»` : null)
|
||||
?? (`${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email || 'MCP');
|
||||
return { user, bot, displayName };
|
||||
})();
|
||||
}
|
||||
return actorPromise;
|
||||
};
|
||||
|
||||
// Поля аудит-записи от актора: для бота changedBy=null и botId выставлен,
|
||||
// канал фиксируется в metadata.source='mcp'.
|
||||
const actorAudit = (actor: McpActor) => ({
|
||||
changedBy: actor.bot ? null : actor.user.id,
|
||||
changedByName: actor.displayName,
|
||||
botId: actor.bot?.id ?? null,
|
||||
});
|
||||
|
||||
// Источник файла для upload-инструментов: base64 (загрузка в хранилище)
|
||||
// или fileUrl (уже загруженный файл — только привязка, без повторной загрузки).
|
||||
// Ровно один источник обязателен.
|
||||
const resolveUploadSource = async (args: {
|
||||
fileName?: string;
|
||||
contentBase64?: string;
|
||||
fileUrl?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
taskId?: number | null;
|
||||
fieldId?: number | null;
|
||||
}): Promise<
|
||||
| { error: string }
|
||||
| { actor: McpActor; file: { key: string; url: string; name: string; size: number; mimeType: string } }
|
||||
> => {
|
||||
const hasBase64 = !!args.contentBase64;
|
||||
const hasUrl = !!args.fileUrl;
|
||||
if (hasBase64 === hasUrl) {
|
||||
return { error: 'Укажите ровно один источник файла: contentBase64 или fileUrl' };
|
||||
}
|
||||
const actor = await getActor();
|
||||
if (hasUrl) {
|
||||
const url = args.fileUrl!;
|
||||
if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) {
|
||||
return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/' };
|
||||
}
|
||||
const name = args.fileName || url.split('/').pop() || 'file';
|
||||
return {
|
||||
actor,
|
||||
file: {
|
||||
key: url.replace(/^\/api\/files\/|^\/uploads\//, ''),
|
||||
url,
|
||||
name,
|
||||
size: args.fileSize ?? 0,
|
||||
mimeType: args.mimeType ?? 'application/octet-stream',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!args.fileName) {
|
||||
return { error: 'fileName обязателен при загрузке через contentBase64' };
|
||||
}
|
||||
const file = await uploadFileFromBase64({
|
||||
organizationId,
|
||||
userId: actor.user.id,
|
||||
fileName: args.fileName,
|
||||
contentBase64: args.contentBase64!,
|
||||
mimeType: args.mimeType,
|
||||
taskId: args.taskId,
|
||||
fieldId: args.fieldId,
|
||||
botId: actor.bot?.id ?? null,
|
||||
});
|
||||
return { actor, file };
|
||||
};
|
||||
|
||||
// ── Объектный доступ по scopes.tableIds (справочники) ─────────────────────
|
||||
const isTableAllowed = (tableId: number) => scopes.tableIds === null || scopes.tableIds.includes(tableId);
|
||||
const tableDenied = (tableId: number) => ({
|
||||
@@ -494,11 +592,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
}
|
||||
}
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) {
|
||||
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
|
||||
}
|
||||
const actor = await getActor();
|
||||
|
||||
let parsedDueDate: Date | null = null;
|
||||
if (due_date) {
|
||||
@@ -513,7 +607,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
title,
|
||||
formId: form_id,
|
||||
organizationId,
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
assignedTo: assigned_to ?? null,
|
||||
currentStatusId: resolvedStatusId,
|
||||
description: description ?? null,
|
||||
@@ -523,14 +617,12 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
parentTaskId: null,
|
||||
});
|
||||
|
||||
const creatorName = `${adminUser.firstName || ""} ${adminUser.middleName || ""} ${adminUser.lastName || ""}`.trim() || adminUser.email || "MCP";
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task.id,
|
||||
organizationId,
|
||||
action: "task.created",
|
||||
changedBy: adminUser.id,
|
||||
changedByName: creatorName,
|
||||
metadata: { title: task.title },
|
||||
...actorAudit(actor),
|
||||
metadata: { title: task.title, source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error("Audit log error (MCP create_task):", e); });
|
||||
|
||||
if (field_values && typeof field_values === "object") {
|
||||
@@ -593,6 +685,23 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
isCompleted: isFinalStatus,
|
||||
completedAt: isFinalStatus ? (task.completedAt ?? new Date()) : null,
|
||||
});
|
||||
|
||||
// Аудит смены статуса с актором ключа (как REST: action 'status.changed')
|
||||
const oldStatus = statuses.find((s) => s.id === task.currentStatusId);
|
||||
if (task.currentStatusId !== status_id) {
|
||||
const actor = await getActor();
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task_id,
|
||||
organizationId,
|
||||
action: 'status.changed',
|
||||
fieldName: 'Статус',
|
||||
oldValue: oldStatus?.name ?? String(task.currentStatusId),
|
||||
newValue: validStatus.name,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error("Audit log error (MCP update_task_status):", e); });
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -655,6 +764,49 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
}
|
||||
|
||||
const updated = await storage.updateTask(task_id, organizationId, updates);
|
||||
|
||||
// Аудит изменённых полей с актором ключа (как REST PUT /api/tasks/:id)
|
||||
{
|
||||
const actor = await getActor();
|
||||
const resolveUserName = async (userId: number | null | undefined): Promise<string | null> => {
|
||||
if (!userId) return null;
|
||||
const u = await storage.getUser(userId).catch(() => null);
|
||||
return u ? (`${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email) : String(userId);
|
||||
};
|
||||
const trackedFields: Array<{ key: 'title' | 'description' | 'assignedTo' | 'dueDate'; label: string }> = [
|
||||
{ key: 'title', label: 'Заголовок' },
|
||||
{ key: 'description', label: 'Описание' },
|
||||
{ key: 'assignedTo', label: 'Исполнитель' },
|
||||
{ key: 'dueDate', label: 'Срок' },
|
||||
];
|
||||
for (const { key, label } of trackedFields) {
|
||||
if (!(key in updates)) continue;
|
||||
const oldVal = task[key];
|
||||
const newVal = updates[key];
|
||||
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
|
||||
const newStr = newVal === null || newVal === undefined ? '' : String(newVal);
|
||||
if (oldStr === newStr) continue;
|
||||
let auditOldValue: string | null = oldVal === null || oldVal === undefined ? null : String(oldVal);
|
||||
let auditNewValue: string | null = newVal === null || newVal === undefined ? null : String(newVal);
|
||||
if (key === 'assignedTo') {
|
||||
[auditOldValue, auditNewValue] = await Promise.all([
|
||||
resolveUserName(oldVal as number | null),
|
||||
resolveUserName(newVal as number | null),
|
||||
]);
|
||||
}
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task_id,
|
||||
organizationId,
|
||||
action: 'task.updated',
|
||||
fieldName: label,
|
||||
oldValue: auditOldValue,
|
||||
newValue: auditNewValue,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error("Audit log error (MCP update_task):", e); });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -823,17 +975,13 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
},
|
||||
},
|
||||
async ({ name, description }) => {
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) {
|
||||
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
|
||||
}
|
||||
const actor = await getActor();
|
||||
|
||||
const form = await storage.createForm({
|
||||
organizationId,
|
||||
name,
|
||||
description: description ?? null,
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
isActive: true,
|
||||
chatEnabled: true,
|
||||
chatLayout: "default",
|
||||
@@ -1103,11 +1251,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
if (existing) {
|
||||
return { content: [{ type: "text" as const, text: `A module with type "${type}" already exists (id=${existing.id})` }], isError: true };
|
||||
}
|
||||
const adminUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
|
||||
if (!createdBy) {
|
||||
return { content: [{ type: "text" as const, text: "No admin user found to assign createdBy" }], isError: true };
|
||||
}
|
||||
const createdBy = (await getActor()).user.id;
|
||||
const mod = await storage.createCustomTabModule({
|
||||
type,
|
||||
label,
|
||||
@@ -1179,11 +1323,7 @@ RULES for tab component code:
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const adminUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
|
||||
if (!createdBy) {
|
||||
return { content: [{ type: "text" as const, text: "No admin user found" }], isError: true };
|
||||
}
|
||||
const createdBy = (await getActor()).user.id;
|
||||
const mod = await storage.createCustomTabModule({
|
||||
type,
|
||||
label,
|
||||
@@ -1560,11 +1700,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules:
|
||||
if (existing) {
|
||||
return { content: [{ type: "text" as const, text: `A page with slug "${slug}" already exists` }], isError: true };
|
||||
}
|
||||
const adminUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
|
||||
if (!createdBy) {
|
||||
return { content: [{ type: "text" as const, text: "No admin user found to assign createdBy" }], isError: true };
|
||||
}
|
||||
const createdBy = (await getActor()).user.id;
|
||||
const createWarnings = validatePageCode(code);
|
||||
const formattedCode = formatPageCode(code);
|
||||
|
||||
@@ -2112,10 +2248,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
},
|
||||
},
|
||||
async ({ name, code, trigger, description, trigger_config, is_active, run_offline, client_compatible }) => {
|
||||
const users = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = users.find(u => u.appRole === 'admin');
|
||||
const createdBy = adminUser?.id;
|
||||
if (!createdBy) return { content: [{ type: "text" as const, text: "No admin user found" }], isError: true };
|
||||
const createdBy = (await getActor()).user.id;
|
||||
const item = await storage.createAutomation({
|
||||
organizationId,
|
||||
name,
|
||||
@@ -2596,11 +2729,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
};
|
||||
}
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) {
|
||||
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
|
||||
}
|
||||
const actor = await getActor();
|
||||
|
||||
const field = await storage.createFieldTemplate({
|
||||
code,
|
||||
@@ -2613,7 +2742,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
defaultValue: default_value ?? null,
|
||||
validationRules: null,
|
||||
organizationId,
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -2826,24 +2955,16 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
resolvedTabId = found.id;
|
||||
}
|
||||
|
||||
const adminUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
|
||||
if (!createdBy) {
|
||||
return { content: [{ type: "text" as const, text: "No user found to assign createdBy" }], isError: true };
|
||||
}
|
||||
const actor = await getActor();
|
||||
|
||||
const row = await storage.createRegularTableRow({
|
||||
taskId: task_id,
|
||||
tabId: resolvedTabId,
|
||||
data: data ?? {},
|
||||
createdBy,
|
||||
createdBy: actor.user.id,
|
||||
});
|
||||
|
||||
const tab = await storage.getFormTab(resolvedTabId, task.formId, organizationId);
|
||||
const adminUser = adminUsers.find((u) => u.id === createdBy);
|
||||
const editorName = adminUser
|
||||
? `${adminUser.firstName || ""} ${adminUser.middleName || ""} ${adminUser.lastName || ""}`.trim() || adminUser.email || "MCP"
|
||||
: "MCP";
|
||||
const persistedData = row.data && typeof row.data === "object" && Object.keys(row.data).length > 0 ? row.data : null;
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task_id,
|
||||
@@ -2852,8 +2973,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
fieldName: `Таблица «${tab?.name || resolvedTabId}»: добавлена строка`,
|
||||
oldValue: null,
|
||||
newValue: persistedData,
|
||||
changedBy: createdBy,
|
||||
changedByName: editorName,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error("Audit log error (MCP append_table_row):", e); });
|
||||
|
||||
return {
|
||||
@@ -3320,18 +3441,14 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
},
|
||||
},
|
||||
async ({ name, description, columns }) => {
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) {
|
||||
return directoryError("В организации нет пользователей для назначения createdBy");
|
||||
}
|
||||
const actor = await getActor();
|
||||
|
||||
const table = await storage.createDataTable({
|
||||
name,
|
||||
description: description ?? null,
|
||||
columns: columns.map((c) => ({ ...c, type: c.type ?? 'text' })),
|
||||
organizationId,
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
@@ -3640,14 +3757,13 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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("В организации нет пользователей для авторства сообщения");
|
||||
const actor = await getActor();
|
||||
|
||||
try {
|
||||
const created = await sendTaskMessage({
|
||||
task,
|
||||
user: adminUser,
|
||||
user: actor.bot ? undefined : actor.user,
|
||||
botId: actor.bot?.id ?? null,
|
||||
organizationId,
|
||||
message: content,
|
||||
});
|
||||
@@ -3737,7 +3853,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
return mcpError(`Пользователи не найдены в организации: ${invalid.join(', ')}`);
|
||||
}
|
||||
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
const actor = await getActor();
|
||||
|
||||
// Полная замена списка: удаляем лишних, добавляем недостающих
|
||||
const current = await storage.getTaskAssignees(taskId, organizationId);
|
||||
@@ -3755,11 +3871,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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 autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
|
||||
|
||||
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);
|
||||
@@ -3773,15 +3886,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
fieldName: 'Ответственные обновлены',
|
||||
oldValue: null,
|
||||
newValue: names || '(пусто)',
|
||||
changedBy: adminUser?.id ?? null,
|
||||
changedByName: editorName,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).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)
|
||||
notifyTaskAssigned(refreshedTask, newAssignedTo, actor.user.id, organizationId)
|
||||
.catch((err) => console.error('[MCP set_task_assignees] notifyTaskAssigned error:', err));
|
||||
}
|
||||
|
||||
@@ -3868,9 +3981,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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("В организации нет пользователей для генерации документа");
|
||||
const actor = await getActor();
|
||||
|
||||
// Сервис генерации собирается так же, как в server/documents/routes.ts
|
||||
const templateService = new DocumentTemplateService();
|
||||
@@ -3883,7 +3994,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
templateId,
|
||||
taskId,
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
userId: actor.user.id,
|
||||
outputFormat: format,
|
||||
});
|
||||
return {
|
||||
@@ -4094,13 +4205,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
if (!recipient) {
|
||||
return mcpError(`Пользователь ${userId} не принадлежит организации`);
|
||||
}
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||
const actor = await getActor();
|
||||
|
||||
const reminder = await storage.createTaskReminder({
|
||||
taskId,
|
||||
organizationId,
|
||||
createdByUserId: adminUser.id,
|
||||
createdByUserId: actor.user.id,
|
||||
remindAt: remindAtDate,
|
||||
note: message ?? null,
|
||||
recipients: [{ type: "user", userId }],
|
||||
@@ -4740,16 +4850,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
},
|
||||
},
|
||||
async ({ fileName, contentBase64, mimeType }) => {
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||
const actor = await getActor();
|
||||
try {
|
||||
const file = await uploadFileFromBase64({
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
userId: actor.user.id,
|
||||
fileName,
|
||||
contentBase64,
|
||||
mimeType,
|
||||
botId: actor.bot?.id ?? null,
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
@@ -4771,18 +4880,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
{
|
||||
title: "Upload Task Field File",
|
||||
description:
|
||||
"Upload a file (base64) and APPEND it to a file-type form field of a task. " +
|
||||
"Upload a file and APPEND it to a file-type form field of a task. " +
|
||||
"Source: contentBase64 (upload) OR fileUrl (already uploaded file, e.g. via POST /api/upload — binding only). " +
|
||||
"File fields are multiple: the value is an array of {url, name, size}. " +
|
||||
"Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
fieldId: z.number().int().describe("The numeric ID of the file-type form field"),
|
||||
fileName: z.string().min(1).describe("Original file name with extension"),
|
||||
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
|
||||
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64; for fileUrl defaults to the URL basename)"),
|
||||
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
|
||||
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
|
||||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
|
||||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, fieldId, fileName, contentBase64, mimeType }) => {
|
||||
async ({ taskId, fieldId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
@@ -4794,20 +4906,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
return mcpError(`Поле ${fieldId} имеет тип «${field.type}», а не file`);
|
||||
}
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||
|
||||
try {
|
||||
const file = await uploadFileFromBase64({
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
fileName,
|
||||
contentBase64,
|
||||
mimeType,
|
||||
taskId,
|
||||
fieldId,
|
||||
});
|
||||
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId, fieldId });
|
||||
if ('error' in source) return mcpError(source.error);
|
||||
const { actor, file } = source;
|
||||
|
||||
// File-поля множественные: значение = массив {url, name, size} — добавляем файл
|
||||
const existingValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||||
@@ -4832,7 +4934,6 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
await storage.createTaskFieldValue({ taskId, fieldId, formId: task.formId, value: newFiles });
|
||||
}
|
||||
|
||||
const editorName = `${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP';
|
||||
storage.addTaskAuditLog({
|
||||
taskId,
|
||||
organizationId,
|
||||
@@ -4841,12 +4942,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
fieldName: field.name,
|
||||
oldValue: existingValue?.value ?? null,
|
||||
newValue: newFiles,
|
||||
changedBy: adminUser.id,
|
||||
changedByName: editorName,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error('Audit log error (MCP upload_task_file):', e); });
|
||||
|
||||
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
|
||||
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser.id });
|
||||
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
|
||||
indexTaskAsync(taskId, organizationId).catch(() => {});
|
||||
const freshTask = await storage.getTask(taskId, organizationId);
|
||||
eventBus.publishEvent({
|
||||
@@ -4879,39 +4980,34 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
{
|
||||
title: "Upload Message Attachment",
|
||||
description:
|
||||
"Upload a file (base64) and post it as a task comment attachment. " +
|
||||
"Upload a file and post it as a task comment attachment. " +
|
||||
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
|
||||
"If content is omitted, the message text is generated as '📎 <file name>'. " +
|
||||
"Triggers the same side effects as send_task_message (notifications, SSE, webhooks).",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
fileName: z.string().min(1).describe("Original file name with extension"),
|
||||
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
|
||||
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
|
||||
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
|
||||
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
|
||||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
|
||||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||
content: z.string().optional().describe("Comment text (optional; default '📎 <file name>')"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, fileName, contentBase64, mimeType, content }) => {
|
||||
async ({ taskId, fileName, contentBase64, fileUrl, fileSize, mimeType, content }) => {
|
||||
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("В организации нет пользователей");
|
||||
|
||||
try {
|
||||
const file = await uploadFileFromBase64({
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
fileName,
|
||||
contentBase64,
|
||||
mimeType,
|
||||
taskId,
|
||||
});
|
||||
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId });
|
||||
if ('error' in source) return mcpError(source.error);
|
||||
const { actor, file } = source;
|
||||
|
||||
const created = await sendTaskMessage({
|
||||
task,
|
||||
user: adminUser,
|
||||
user: actor.bot ? undefined : actor.user,
|
||||
botId: actor.bot?.id ?? null,
|
||||
organizationId,
|
||||
message: content?.trim() || `📎 ${file.name}`,
|
||||
attachments: [{ url: file.url, name: file.name, size: file.size, mimeType: file.mimeType }],
|
||||
@@ -4942,18 +5038,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
{
|
||||
title: "Upload Directory Cell File",
|
||||
description:
|
||||
"Upload a file (base64) and write a link into a directory row cell. " +
|
||||
"Upload a file and write a link into a directory row cell. " +
|
||||
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
|
||||
"Directory columns have no file type, so the cell gets a markdown link: [file name](url).",
|
||||
inputSchema: {
|
||||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||||
rowId: z.number().int().describe("The numeric ID of the row"),
|
||||
columnIndex: z.number().int().min(0).describe("Column index (0-based)"),
|
||||
fileName: z.string().min(1).describe("Original file name with extension"),
|
||||
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
|
||||
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
|
||||
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
|
||||
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
|
||||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
|
||||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||
},
|
||||
},
|
||||
async ({ tableId, rowId, columnIndex, fileName, contentBase64, mimeType }) => {
|
||||
async ({ tableId, rowId, columnIndex, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
|
||||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||||
const table = await storage.getDataTable(tableId, organizationId);
|
||||
if (!table) return mcpError(`Справочник ${tableId} не найден`);
|
||||
@@ -4964,18 +5063,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
|
||||
if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||
|
||||
try {
|
||||
const file = await uploadFileFromBase64({
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
fileName,
|
||||
contentBase64,
|
||||
mimeType,
|
||||
});
|
||||
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType });
|
||||
if ('error' in source) return mcpError(source.error);
|
||||
const { file } = source;
|
||||
|
||||
// У колонок справочника нет file-типа: в ячейку пишем markdown-ссылку [имя](url)
|
||||
const values = Array.isArray(row.values) ? [...row.values] : [];
|
||||
@@ -5007,7 +5098,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
{
|
||||
title: "Upload Table Tab Cell File",
|
||||
description:
|
||||
"Upload a file (base64) and write a link into a cell of a task's 'table' tab (regular_table_rows). " +
|
||||
"Upload a file and write a link into a cell of a task's 'table' tab (regular_table_rows). " +
|
||||
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
|
||||
"The cell gets a markdown link: [file name](url). " +
|
||||
"If rowId is omitted, a new row is created (like append_table_row). Use get_form_tabs to discover tab and column IDs.",
|
||||
inputSchema: {
|
||||
@@ -5015,12 +5107,14 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
tabId: z.number().int().describe("The numeric ID of the table tab"),
|
||||
columnId: z.string().min(1).describe("Column ID (e.g. 'gf_12', 'ft_34')"),
|
||||
rowId: z.number().int().optional().describe("Row ID to update. Omit to create a new row."),
|
||||
fileName: z.string().min(1).describe("Original file name with extension"),
|
||||
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
|
||||
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
|
||||
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
|
||||
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
|
||||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
|
||||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, mimeType }) => {
|
||||
async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
@@ -5034,19 +5128,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
return mcpError(`Колонка "${columnId}" не найдена в табе ${tabId}. Доступные: ${columns.map((c) => c.id).join(', ') || '(нет)'}`);
|
||||
}
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||
|
||||
try {
|
||||
const file = await uploadFileFromBase64({
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
fileName,
|
||||
contentBase64,
|
||||
mimeType,
|
||||
taskId,
|
||||
});
|
||||
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId });
|
||||
if ('error' in source) return mcpError(source.error);
|
||||
const { actor, file } = source;
|
||||
|
||||
// Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст):
|
||||
// пишем markdown-ссылку [имя](url), как и в справочниках
|
||||
@@ -5063,7 +5148,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
taskId,
|
||||
tabId,
|
||||
data: { [columnId]: cellValue },
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5085,6 +5170,91 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
}
|
||||
);
|
||||
|
||||
// get_api_guide
|
||||
register(
|
||||
"get_api_guide",
|
||||
{
|
||||
title: "Get API Guide",
|
||||
description: "Returns a compact Russian-language guide for AI agents: how to upload files and create tasks/comments via REST with the same API key, limits, and file field value formats. Call this FIRST when you need to attach files to tasks.",
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const guide = `
|
||||
# Работа с API iistwin по ключу (гайд для ИИ-агента)
|
||||
|
||||
Авторизация везде: заголовок \`X-Api-Key: $KEY\` (или \`Authorization: Bearer $KEY\`).
|
||||
Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST.
|
||||
|
||||
## 1. Загрузка файла (multipart, НЕ base64)
|
||||
|
||||
\`\`\`bash
|
||||
curl -F "file=@/path/report.pdf" \\
|
||||
-H "X-Api-Key: $KEY" \\
|
||||
"https://iistwin.ru/api/upload?taskId=<taskId>&fieldId=<fieldId>"
|
||||
# → { "url": "/api/files/<key>", "name": "report.pdf", "size": 123456 }
|
||||
\`\`\`
|
||||
|
||||
- taskId/fieldId в query — необязательны, но при taskId проверяется доступ ключа к форме задачи.
|
||||
- Лимиты (дефолты, переопределяются env): изображения \`UPLOAD_IMAGE_MAX_MB=25\` МБ,
|
||||
документы \`UPLOAD_DOC_MAX_MB=100\` МБ, жёсткий потолок \`UPLOAD_MAX_MB=100\` МБ.
|
||||
- Расширения — whitelist: jpg/jpeg/png/gif/webp/svg, pdf, doc/docx, xls/xlsx, ppt/pptx, txt/csv, zip/rar.
|
||||
Для jpg/png/pdf проверяются magic bytes.
|
||||
- Для base64-загрузки больших файлов НЕ используй MCP upload_* с contentBase64 —
|
||||
грузи через REST /api/upload, а привязывай через fileUrl (п.2).
|
||||
|
||||
## 2. Привязка файла к задаче/справочнику
|
||||
|
||||
Проще всего — MCP-инструменты с fileUrl (без повторной загрузки):
|
||||
- \`upload_task_file({ taskId, fieldId, fileUrl, fileName?, fileSize? })\` — добавит файл в file-поле задачи.
|
||||
- \`upload_message_file({ taskId, fileUrl, content? })\` — комментарий с вложением.
|
||||
- \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl })\` — ссылка в ячейку справочника.
|
||||
- \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl })\` — ссылка в ячейку таб-таблицы.
|
||||
|
||||
Либо напрямую REST (file-поле — массив объектов {url, name, size}):
|
||||
\`\`\`bash
|
||||
# Прочитать текущее значение, ДОБАВИТЬ объект, записать назад:
|
||||
curl -X PATCH -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||||
-d '{"value":[{"url":"/api/files/<key>","name":"report.pdf","size":123456}]}' \\
|
||||
"https://iistwin.ru/api/tasks/<taskId>/field-values/<fieldId>"
|
||||
\`\`\`
|
||||
ВНИМАНИЕ: PATCH полностью заменяет значение поля — сначала прочитай задачу
|
||||
(\`get_task\` в MCP или GET /api/tasks/<id> в REST) и добавь файл к существующему массиву.
|
||||
|
||||
## 3. Создание задачи и комментария через REST
|
||||
|
||||
\`\`\`bash
|
||||
# Задача (customFields: { "customField_<fieldId>": значение })
|
||||
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||||
-d '{"title":"Новая задача","customFields":{"customField_12":"Текст"}}' \\
|
||||
"https://iistwin.ru/api/forms/<formId>/tasks"
|
||||
|
||||
# Комментарий (с вложением — attachments: [{url, name, size, mimeType?}])
|
||||
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||||
-d '{"message":"Готово","attachments":[{"url":"/api/files/<key>","name":"report.pdf","size":123456}]}' \\
|
||||
"https://iistwin.ru/api/tasks/<taskId>/messages"
|
||||
\`\`\`
|
||||
|
||||
## 4. Права ключа
|
||||
|
||||
- mode=read: только чтение (запись → 403). mode=write: чтение+создание. mode=full: всё.
|
||||
- formIds/tableIds ограничивают список доступных форм/справочников (null = все).
|
||||
- REST по ключу открыт только для: POST /api/upload, POST /api/tasks/:id/messages,
|
||||
PATCH /api/tasks/:id/field-values/:fieldId, POST /api/tasks/:id/field-values,
|
||||
POST /api/forms/:id/tasks. Остальное — через MCP-инструменты.
|
||||
- Атрибуция: действия по ключу бота записываются в историю от имени бота (bot_id),
|
||||
по обычному ключу — «Ключ "label"» (metadata.source = 'mcp' | 'api').
|
||||
|
||||
## 5. Форматы значений
|
||||
|
||||
- file-поле задачи: массив \`[{url, name, size}]\` (множественное — добавляй, не заменяй).
|
||||
- Вложение сообщения: \`{url, name, size, mimeType?}\`.
|
||||
- Ячейка справочника/таб-таблицы: markdown-ссылка \`[имя](url)\`.
|
||||
- url файла: \`/api/files/<key>\` (S3) или \`/uploads/<key>\` (локальный режим).
|
||||
`.trim();
|
||||
return { content: [{ type: "text" as const, text: guide }] };
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -5114,7 +5284,7 @@ export async function handleMcpRequest(req: Request, res: Response) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { organizationId, scopes } = resolved;
|
||||
const { organizationId, scopes, key } = resolved;
|
||||
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
|
||||
@@ -5126,7 +5296,7 @@ export async function handleMcpRequest(req: Request, res: Response) {
|
||||
mcpTransports.set(sid, { transport, server, organizationId, scopes });
|
||||
},
|
||||
});
|
||||
const server = buildMcpServer(organizationId, scopes);
|
||||
const server = buildMcpServer(organizationId, scopes, key);
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -5184,7 +5354,7 @@ export async function handleMcpSse(req: Request, res: Response) {
|
||||
res.status(401).json({ error: "Invalid or missing API key. Provide X-Api-Key header." });
|
||||
return;
|
||||
}
|
||||
const { organizationId, scopes } = resolved;
|
||||
const { organizationId, scopes, key } = resolved;
|
||||
|
||||
const transport = new SSEServerTransport("/mcp/messages", res);
|
||||
const sessionId = transport.sessionId;
|
||||
@@ -5195,7 +5365,7 @@ export async function handleMcpSse(req: Request, res: Response) {
|
||||
sseSessions.delete(sessionId);
|
||||
};
|
||||
|
||||
const server = buildMcpServer(organizationId, scopes);
|
||||
const server = buildMcpServer(organizationId, scopes, key);
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { storage } from '../storage';
|
||||
import { withTenant, openTenantCtx, openSuperAdminCtx, _tenantCtx } from '../db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { trackUserActivity } from '../utils/userActivity';
|
||||
import { normalizeApiKeyScopes } from '../utils/api-key';
|
||||
import type { ApiKeyScopes } from '@shared/schema';
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: any;
|
||||
@@ -11,6 +13,19 @@ export interface AuthenticatedRequest extends Request {
|
||||
/** True when the request was authenticated with a bot-service JWT (type: 'bot_service').
|
||||
* Routes should skip bot-trigger logic when this flag is set to prevent message loops. */
|
||||
isBotToken?: boolean;
|
||||
/** Контекст API-ключа (authenticateTokenOrApiKey), когда запрос авторизован ключом, а не JWT.
|
||||
* req.user при этом равен null. */
|
||||
apiKey?: RequestApiKeyContext;
|
||||
}
|
||||
|
||||
// Контекст авторизации по API-ключу организации
|
||||
export interface RequestApiKeyContext {
|
||||
id: number;
|
||||
organizationId: number;
|
||||
botId: number | null;
|
||||
createdBy: number;
|
||||
label: string;
|
||||
scopes: ApiKeyScopes;
|
||||
}
|
||||
|
||||
export interface SuperAdminRequest extends Request {
|
||||
@@ -111,6 +126,133 @@ export const authenticateToken = async (
|
||||
}
|
||||
};
|
||||
|
||||
// ── API-ключи: белый список endpoint'ов, доступных по ключу (REST) ───────────
|
||||
// Проверяется по originalUrl только когда запрос авторизован ключом (не JWT).
|
||||
const API_KEY_ALLOWED_ROUTES: Array<{ method: string; pattern: RegExp }> = [
|
||||
{ method: 'POST', pattern: /^\/api\/upload(\?|$)/ },
|
||||
{ method: 'POST', pattern: /^\/api\/tasks\/\d+\/messages(\?|$)/ },
|
||||
{ method: 'PATCH', pattern: /^\/api\/tasks\/\d+\/field-values\/\d+(\?|$)/ },
|
||||
{ method: 'POST', pattern: /^\/api\/tasks\/\d+\/field-values(\?|$)/ },
|
||||
{ method: 'POST', pattern: /^\/api\/forms\/\d+\/tasks(\?|$)/ },
|
||||
];
|
||||
|
||||
// Разрешает запрос по JWT пользователя (поведение authenticateToken не меняется)
|
||||
// либо по API-ключу организации (X-Api-Key или Authorization: Bearer <key>).
|
||||
// При авторизации ключом: req.user = null, req.apiKey заполнен,
|
||||
// req.organizationId = key.organizationId, tenant-контекст открывается так же,
|
||||
// как в authenticateToken. Legacy/неактивные ключи отклоняются.
|
||||
export const authenticateTokenOrApiKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (req.isBotToken) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const authHeader = req.headers['authorization'];
|
||||
const headerToken = authHeader && authHeader.split(' ')[1];
|
||||
const cookieToken = (req as any).cookies?.access_token;
|
||||
const apiKeyHeader = (req.headers['x-api-key'] as string | undefined)?.trim();
|
||||
|
||||
// 1. JWT пользователя (cookie или Bearer) — как в authenticateToken
|
||||
const userJwt = cookieToken || (!apiKeyHeader ? headerToken : null);
|
||||
if (userJwt) {
|
||||
try {
|
||||
const decoded = verifyAccessToken(userJwt);
|
||||
// Токены ботов не принимаются (та же проверка, что в authenticateToken)
|
||||
const payloadType = (decoded as { type?: string }).type;
|
||||
if (decoded.appRole === 'bot' || (typeof payloadType === 'string' && payloadType.startsWith('bot'))) {
|
||||
return res.status(401).json({ error: 'Токены ботов не принимаются на этом ресурсе' });
|
||||
}
|
||||
const user = await withTenant(decoded.organizationId, () =>
|
||||
storage.getUserWithOrganization(decoded.userId)
|
||||
);
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'Пользователь не найден или заблокирован' });
|
||||
}
|
||||
if (user.organization && !user.organization.isActive) {
|
||||
return res.status(403).json({ error: 'Доступ организации заблокирован' });
|
||||
}
|
||||
req.user = user;
|
||||
req.organizationId = user.organizationId;
|
||||
trackUserActivity(user.id);
|
||||
if (_tenantCtx.getStore()) return next();
|
||||
openTenantCtx(user.organizationId)
|
||||
.then((handle) => {
|
||||
handle.run(() => {
|
||||
const guard = setTimeout(() => {
|
||||
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
|
||||
handle.release();
|
||||
}, 30_000);
|
||||
res.once('finish', () => { clearTimeout(guard); handle.release(); });
|
||||
res.once('close', () => { clearTimeout(guard); handle.release(); });
|
||||
next();
|
||||
});
|
||||
})
|
||||
.catch((err) => next(err as Error));
|
||||
return;
|
||||
} catch {
|
||||
// JWT невалиден — если Bearer-токен задан, пробуем его как API-ключ
|
||||
if (!headerToken) {
|
||||
return res.status(403).json({ error: 'Недействительный токен' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. API-ключ организации
|
||||
const rawKey = apiKeyHeader || headerToken;
|
||||
if (!rawKey) {
|
||||
return res.status(401).json({ error: 'Токен доступа отсутствует' });
|
||||
}
|
||||
|
||||
try {
|
||||
// getApiKeyByHash сам фильтрует isActive и isLegacy=false
|
||||
const key = await storage.getApiKeyByHash(rawKey);
|
||||
if (!key || !key.isActive) {
|
||||
return res.status(401).json({ error: 'Недействительный API-ключ' });
|
||||
}
|
||||
|
||||
// Endpoint должен быть в белом списке для API-ключей
|
||||
const allowed = API_KEY_ALLOWED_ROUTES.some(
|
||||
(r) => r.method === req.method && r.pattern.test(req.originalUrl)
|
||||
);
|
||||
if (!allowed) {
|
||||
return res.status(403).json({ error: 'API-ключ не поддерживается на этом ресурсе' });
|
||||
}
|
||||
|
||||
storage.touchApiKey(key.id).catch(() => {});
|
||||
|
||||
req.apiKey = {
|
||||
id: key.id,
|
||||
organizationId: key.organizationId,
|
||||
botId: key.botId ?? null,
|
||||
createdBy: key.createdBy,
|
||||
label: key.label,
|
||||
scopes: normalizeApiKeyScopes(key.scopes),
|
||||
};
|
||||
req.user = null;
|
||||
req.organizationId = key.organizationId;
|
||||
|
||||
if (_tenantCtx.getStore()) return next();
|
||||
openTenantCtx(key.organizationId)
|
||||
.then((handle) => {
|
||||
handle.run(() => {
|
||||
const guard = setTimeout(() => {
|
||||
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
|
||||
handle.release();
|
||||
}, 30_000);
|
||||
res.once('finish', () => { clearTimeout(guard); handle.release(); });
|
||||
res.once('close', () => { clearTimeout(guard); handle.release(); });
|
||||
next();
|
||||
});
|
||||
})
|
||||
.catch((err) => next(err as Error));
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Недействительный API-ключ' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional pre-middleware: detects a bot-service JWT (type: 'bot_service') and sets
|
||||
* req.isBotToken = true when found. authenticateToken then skips its user-lookup step.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Router } from "express";
|
||||
import { z } from 'zod';
|
||||
import { storage } from "../storage";
|
||||
import { authenticateToken, tryBotServiceToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { authenticateToken, authenticateTokenOrApiKey, tryBotServiceToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { validateRequest } from "../middleware/validation.middleware";
|
||||
import { createTaskMessageSchema } from "@shared/schema";
|
||||
import { eventBus } from "./shared";
|
||||
import { sendTaskMessage, SendTaskMessageError } from "../services/task-message.service";
|
||||
import { isFormAllowedByScopes } from "../utils/api-key";
|
||||
|
||||
export function registerChatMessageRoutes(router: Router): void {
|
||||
// Get task messages
|
||||
@@ -40,10 +41,10 @@ export function registerChatMessageRoutes(router: Router): void {
|
||||
}
|
||||
);
|
||||
|
||||
// Create task message
|
||||
// Create task message (JWT пользователя или API-ключ организации)
|
||||
router.post('/api/tasks/:id/messages',
|
||||
tryBotServiceToken,
|
||||
authenticateToken,
|
||||
authenticateTokenOrApiKey,
|
||||
tenantIsolation,
|
||||
validateRequest(createTaskMessageSchema.omit({ taskId: true })),
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
@@ -65,9 +66,9 @@ export function registerChatMessageRoutes(router: Router): void {
|
||||
|
||||
{
|
||||
const taskRolesForGuard = await storage.getTaskRoles(taskId, req.organizationId!);
|
||||
if (taskRolesForGuard.length > 0) {
|
||||
if (req.user && taskRolesForGuard.length > 0) {
|
||||
const hasAccess = await storage.canUserAccessTask(
|
||||
taskId, req.user!.id, req.organizationId!, req.user!.appRole
|
||||
taskId, req.user.id, req.organizationId!, req.user.appRole
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
|
||||
@@ -75,12 +76,38 @@ export function registerChatMessageRoutes(router: Router): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
|
||||
if (req.apiKey) {
|
||||
if (req.apiKey.scopes.mode === 'read') {
|
||||
return res.status(403).json({ success: false, error: 'API-ключ в режиме read не может отправлять сообщения' });
|
||||
}
|
||||
if (!isFormAllowedByScopes(req.apiKey.scopes, task.formId)) {
|
||||
return res.status(403).json({ success: false, error: 'Доступ к форме этой задачи запрещён правами API-ключа' });
|
||||
}
|
||||
}
|
||||
|
||||
// Автор сообщения: пользователь (JWT); по ключу бота — сам бот;
|
||||
// по ключу без бота — владелец ключа
|
||||
let messageUser = req.user ?? null;
|
||||
let messageBotId: number | null = null;
|
||||
if (!messageUser && req.apiKey) {
|
||||
if (req.apiKey.botId) {
|
||||
messageBotId = req.apiKey.botId;
|
||||
} else {
|
||||
messageUser = await storage.getUser(req.apiKey.createdBy);
|
||||
}
|
||||
}
|
||||
if (!messageUser && !messageBotId) {
|
||||
return res.status(401).json({ success: false, error: 'Не удалось определить автора сообщения' });
|
||||
}
|
||||
|
||||
// Основная логика (сообщение + side-эффекты) вынесена в сервис —
|
||||
// переиспользуется также MCP-сервером (инструмент send_task_message).
|
||||
try {
|
||||
const createdMessage = await sendTaskMessage({
|
||||
task,
|
||||
user: req.user!,
|
||||
user: messageUser ?? undefined,
|
||||
botId: messageBotId,
|
||||
organizationId: req.organizationId!,
|
||||
message: req.body.message || '',
|
||||
messageType: req.body.messageType,
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Router } from "express";
|
||||
import fs from 'fs/promises';
|
||||
import multer from 'multer';
|
||||
import { storage } from "../storage";
|
||||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { authenticateTokenOrApiKey, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { isS3Enabled, uploadToS3FromFile } from "../utils/s3";
|
||||
import { isFormAllowedByScopes } from "../utils/api-key";
|
||||
import { upload, EXT_TO_MIME, getFileExt, checkMagicBytes, getSizeLimit, DOC_MAX_SIZE,
|
||||
getPendingKey, getActivePendingUploads, addPendingUpload,
|
||||
} from "./shared";
|
||||
@@ -11,8 +12,8 @@ import { db } from "../db";
|
||||
import { fileUploads } from "@shared/schema";
|
||||
|
||||
const router = Router();
|
||||
// File upload endpoint
|
||||
router.post('/api/upload', authenticateToken, async (req: AuthenticatedRequest, res) => {
|
||||
// File upload endpoint (JWT пользователя или API-ключ организации)
|
||||
router.post('/api/upload', authenticateTokenOrApiKey, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single('file')(req, res, (err: unknown) => {
|
||||
@@ -32,6 +33,22 @@ const router = Router();
|
||||
return res.status(400).json({ error: 'Файл не передан' });
|
||||
}
|
||||
|
||||
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
|
||||
if (req.apiKey) {
|
||||
if (req.apiKey.scopes.mode === 'read') {
|
||||
if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } }
|
||||
return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' });
|
||||
}
|
||||
const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN;
|
||||
if (!isNaN(taskIdForScope)) {
|
||||
const scopedTask = await storage.getTask(taskIdForScope, req.organizationId!);
|
||||
if (!scopedTask || !isFormAllowedByScopes(req.apiKey.scopes, scopedTask.formId)) {
|
||||
if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } }
|
||||
return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteNewFile — удаляем временный файл с диска если валидация не прошла
|
||||
// Теперь всегда disk-режим (S3 тоже пишет во tmpUploadDir до загрузки)
|
||||
const deleteNewFile = async () => {
|
||||
@@ -164,7 +181,9 @@ const router = Router();
|
||||
}
|
||||
|
||||
// Записываем файл в таблицу трекинга для последующей проверки доступа
|
||||
if (req.user?.id && req.organizationId) {
|
||||
// Автор: пользователь (JWT) или владелец API-ключа; botId — атрибуция бота
|
||||
const uploaderId = req.user?.id ?? req.apiKey?.createdBy;
|
||||
if (uploaderId && req.organizationId) {
|
||||
const fileKey = isS3Enabled
|
||||
? url.replace('/api/files/', '')
|
||||
: (req.file.filename ?? '');
|
||||
@@ -174,12 +193,13 @@ const router = Router();
|
||||
try {
|
||||
await db.insert(fileUploads).values({
|
||||
organizationId: req.organizationId,
|
||||
uploadedBy: req.user.id,
|
||||
uploadedBy: uploaderId,
|
||||
fileKey,
|
||||
originalName: name,
|
||||
sizeBytes: req.file.size,
|
||||
taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null,
|
||||
fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null,
|
||||
botId: req.apiKey?.botId ?? null,
|
||||
}).onConflictDoNothing();
|
||||
} catch (trackErr) {
|
||||
console.warn('[Upload] Failed to track file upload:', trackErr);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { validateRequiredFields } from "../utils/validate-required-fields";
|
||||
import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service";
|
||||
import { runAutomationsByTrigger, type AutomationRunResult } from "./automation.routes";
|
||||
import { normalizeFieldValueForStorage } from "../utils/normalize-field-value";
|
||||
import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor";
|
||||
import { db, withTenant } from "../db";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
|
||||
@@ -51,10 +52,17 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
return res.status(404).json({ success: false, error: 'Форма не найдена' });
|
||||
}
|
||||
|
||||
const canAccessForm = await storage.canUserAccessForm(req.user!.id, formId, req.organizationId!, 'participate');
|
||||
if (req.user) {
|
||||
const canAccessForm = await storage.canUserAccessForm(req.user.id, formId, req.organizationId!, 'participate');
|
||||
if (!canAccessForm) {
|
||||
return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' });
|
||||
}
|
||||
}
|
||||
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
|
||||
const apiKeyErrCreate = checkApiKeyWriteAccess(req, formId);
|
||||
if (apiKeyErrCreate) {
|
||||
return res.status(403).json({ success: false, error: apiKeyErrCreate });
|
||||
}
|
||||
|
||||
const { customFields, dueDate, ...baseTaskData } = req.body;
|
||||
|
||||
@@ -137,11 +145,17 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
}
|
||||
}
|
||||
|
||||
// Автор: пользователь (JWT) или владелец API-ключа
|
||||
const createdByUserId = req.user?.id ?? req.apiKey?.createdBy;
|
||||
if (!createdByUserId) {
|
||||
return res.status(401).json({ success: false, error: 'Не удалось определить автора задачи' });
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
...baseTaskData,
|
||||
formId,
|
||||
organizationId: req.organizationId!,
|
||||
createdBy: req.user!.id,
|
||||
createdBy: createdByUserId,
|
||||
dueDate: parsedDueDate,
|
||||
parentTaskId,
|
||||
};
|
||||
@@ -197,14 +211,15 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
}
|
||||
}
|
||||
|
||||
const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
|
||||
const creatorActor = await resolveRestActor(req, req.organizationId!);
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task.id,
|
||||
organizationId: req.organizationId!,
|
||||
action: 'task.created',
|
||||
changedBy: req.user?.id ?? null,
|
||||
changedByName: creatorName,
|
||||
metadata: { title: task.title },
|
||||
changedBy: creatorActor.changedBy,
|
||||
changedByName: creatorActor.changedByName,
|
||||
botId: creatorActor.botId,
|
||||
metadata: { title: task.title, ...(creatorActor.source ? { source: creatorActor.source } : {}) },
|
||||
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
|
||||
|
||||
if (customFields && typeof customFields === 'object') {
|
||||
@@ -307,8 +322,8 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
finalTask = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle });
|
||||
}
|
||||
|
||||
if (finalTask.assignedTo && finalTask.assignedTo !== req.user!.id) {
|
||||
notifyTaskAssigned(finalTask, finalTask.assignedTo, req.user!.id, req.organizationId!)
|
||||
if (finalTask.assignedTo && finalTask.assignedTo !== req.user?.id) {
|
||||
notifyTaskAssigned(finalTask, finalTask.assignedTo, req.user?.id ?? null, req.organizationId!)
|
||||
.catch((err) => console.error('[TaskCreate] notifyTaskAssigned error:', err));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Router } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { authenticateToken, authenticateTokenOrApiKey, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor";
|
||||
import { buildSystemFieldValues, buildTableRowMap, deleteRemovedFiles, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared";
|
||||
import { evaluateAutoTransitions } from "../utils/auto-transitions";
|
||||
import { tasksMinimalCache } from "../utils/cache";
|
||||
@@ -63,7 +64,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
|
||||
// Create/Update task field values (bulk)
|
||||
router.post('/api/tasks/:id/field-values',
|
||||
authenticateToken,
|
||||
authenticateTokenOrApiKey,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
@@ -77,14 +78,19 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
return res.status(404).json({ success: false, error: 'Задача не найдена' });
|
||||
}
|
||||
|
||||
{
|
||||
if (req.user) {
|
||||
const hasAccess = await storage.canUserAccessTask(
|
||||
taskId, req.user!.id, req.organizationId!, req.user!.appRole
|
||||
taskId, req.user.id, req.organizationId!, req.user.appRole
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
|
||||
}
|
||||
}
|
||||
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
|
||||
const apiKeyErrPost = checkApiKeyWriteAccess(req, existingTask.formId);
|
||||
if (apiKeyErrPost) {
|
||||
return res.status(403).json({ success: false, error: apiKeyErrPost });
|
||||
}
|
||||
|
||||
const { fieldValues } = req.body;
|
||||
if (!Array.isArray(fieldValues)) {
|
||||
@@ -226,7 +232,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
}
|
||||
}
|
||||
|
||||
const fieldEditorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
|
||||
const fieldActor = await resolveRestActor(req, req.organizationId!);
|
||||
const offlineEnqueuedAtPost = parseOfflineTimestamp(req);
|
||||
for (const fieldValue of fieldValues) {
|
||||
const field = fieldMap.get(fieldValue.fieldId);
|
||||
@@ -247,11 +253,14 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
fieldName: field.name,
|
||||
oldValue: oldVal ?? null,
|
||||
newValue: newVal ?? null,
|
||||
changedBy: req.user?.id ?? null,
|
||||
changedByName: fieldEditorName,
|
||||
changedBy: fieldActor.changedBy,
|
||||
changedByName: fieldActor.changedByName,
|
||||
botId: fieldActor.botId,
|
||||
metadata: {
|
||||
displayOldValue: displayOld || null,
|
||||
displayNewValue: displayNew || null,
|
||||
// Атрибуция канала: 'api' при изменении по API-ключу
|
||||
...(fieldActor.source ? { source: fieldActor.source } : {}),
|
||||
},
|
||||
...(offlineEnqueuedAtPost ? { createdAt: offlineEnqueuedAtPost } : {}),
|
||||
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
|
||||
@@ -365,7 +374,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
|
||||
// Update single field value (inline editing)
|
||||
router.patch('/api/tasks/:id/field-values/:fieldId',
|
||||
authenticateToken,
|
||||
authenticateTokenOrApiKey,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
@@ -380,14 +389,19 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
return res.status(404).json({ success: false, error: 'Задача не найдена' });
|
||||
}
|
||||
|
||||
{
|
||||
if (req.user) {
|
||||
const hasAccess = await storage.canUserAccessTask(
|
||||
taskId, req.user!.id, req.organizationId!, req.user!.appRole
|
||||
taskId, req.user.id, req.organizationId!, req.user.appRole
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
|
||||
}
|
||||
}
|
||||
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
|
||||
const apiKeyErrPatch = checkApiKeyWriteAccess(req, existingTask.formId);
|
||||
if (apiKeyErrPatch) {
|
||||
return res.status(403).json({ success: false, error: apiKeyErrPatch });
|
||||
}
|
||||
|
||||
const { value } = req.body;
|
||||
|
||||
@@ -473,9 +487,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
});
|
||||
}
|
||||
|
||||
const editorName = req.user
|
||||
? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email)
|
||||
: 'API';
|
||||
const patchActor = await resolveRestActor(req, req.organizationId!);
|
||||
const offlineEnqueuedAtPatch = parseOfflineTimestamp(req);
|
||||
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
|
||||
const newStr = normalizedValue === null || normalizedValue === undefined ? '' : String(normalizedValue);
|
||||
@@ -488,8 +500,10 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
fieldName: field.name,
|
||||
oldValue: oldVal ?? null,
|
||||
newValue: normalizedValue ?? null,
|
||||
changedBy: req.user?.id ?? null,
|
||||
changedByName: editorName,
|
||||
changedBy: patchActor.changedBy,
|
||||
changedByName: patchActor.changedByName,
|
||||
botId: patchActor.botId,
|
||||
...(patchActor.source ? { metadata: { source: patchActor.source } } : {}),
|
||||
...(offlineEnqueuedAtPatch ? { createdAt: offlineEnqueuedAtPatch } : {}),
|
||||
}).catch((auditErr: unknown) => { console.error('Audit log error (inline edit):', auditErr); });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/auth.middleware";
|
||||
import { authenticateToken, authenticateTokenOrApiKey } from "../middleware/auth.middleware";
|
||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { registerUserRoutes } from "./user.routes";
|
||||
import { registerFormRoutes } from "./form.routes";
|
||||
@@ -11,11 +11,13 @@ import { registerTaskRoleRoutes } from "./task-roles.routes";
|
||||
const router = Router();
|
||||
|
||||
// Global middleware for all protected route prefixes
|
||||
// Для /api/tasks и /api/forms — authenticateTokenOrApiKey: JWT-путь идентичен,
|
||||
// API-ключ допускается только на endpoint'ы из белого списка (см. auth.middleware).
|
||||
router.use('/api/users', authenticateToken, tenantIsolation);
|
||||
router.use('/api/user-statuses', authenticateToken, tenantIsolation);
|
||||
router.use('/api/organizations', authenticateToken, tenantIsolation);
|
||||
router.use('/api/forms', authenticateToken, tenantIsolation);
|
||||
router.use('/api/tasks', authenticateToken, tenantIsolation);
|
||||
router.use('/api/forms', authenticateTokenOrApiKey, tenantIsolation);
|
||||
router.use('/api/tasks', authenticateTokenOrApiKey, tenantIsolation);
|
||||
|
||||
// Register domain route handlers
|
||||
registerUserRoutes(router);
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface UploadFileFromBase64Params {
|
||||
mimeType?: string;
|
||||
taskId?: number | null; // опциональная привязка к задаче
|
||||
fieldId?: number | null; // опциональная привязка к полю формы
|
||||
botId?: number | null; // атрибуция загрузки ботом (file_uploads.bot_id)
|
||||
}
|
||||
|
||||
export interface UploadedFileInfo {
|
||||
@@ -50,7 +51,7 @@ const MAX_BASE64_LENGTH = Math.ceil(DOC_MAX_SIZE * 4 / 3) + 1024;
|
||||
// whitelist расширений, magic bytes, раздельные лимиты (10 МБ изображения / 50 МБ прочее).
|
||||
// Создаёт запись трекинга в file_uploads.
|
||||
export async function uploadFileFromBase64(params: UploadFileFromBase64Params): Promise<UploadedFileInfo> {
|
||||
const { organizationId, userId, taskId = null, fieldId = null } = params;
|
||||
const { organizationId, userId, taskId = null, fieldId = null, botId = null } = params;
|
||||
|
||||
// 1. Имя файла: whitelist расширений + запрет недопустимых символов (как в multer fileFilter)
|
||||
const name = path.basename(params.fileName || '');
|
||||
@@ -123,6 +124,7 @@ export async function uploadFileFromBase64(params: UploadFileFromBase64Params):
|
||||
sizeBytes: buffer.length,
|
||||
taskId,
|
||||
fieldId,
|
||||
botId,
|
||||
}).returning({ id: fileUploads.id });
|
||||
fileUploadId = row?.id ?? null;
|
||||
} catch (trackErr) {
|
||||
|
||||
@@ -23,7 +23,8 @@ type MessageAttachment = { url: string; name: string; size: number; mimeType?: s
|
||||
|
||||
export interface SendTaskMessageParams {
|
||||
task: Task; // задача (уже загружена и проверена вызывающим кодом)
|
||||
user: User; // автор сообщения
|
||||
user?: User; // автор сообщения; необязателен, если передан botId
|
||||
botId?: number | null; // сообщение от бота: authorId=null, botId, messageType='bot'
|
||||
organizationId: number;
|
||||
message: string;
|
||||
messageType?: string; // 'comment' (default), 'bot', 'system', ...
|
||||
@@ -41,8 +42,14 @@ export interface SendTaskMessageParams {
|
||||
// постановка embedding в очередь, запись взаимодействия с задачей.
|
||||
// Логика вынесена из POST /api/tasks/:id/messages (routes/chat.messages.routes.ts)
|
||||
// и переиспользуется MCP-сервером — поведение не менять.
|
||||
// При botId (сообщение от бота) авторство и пользовательские side-эффекты пропускаются,
|
||||
// как в POST /api/bot/message (bot-api.routes.ts).
|
||||
export async function sendTaskMessage(params: SendTaskMessageParams): Promise<TaskMessage> {
|
||||
const { task, user, organizationId } = params;
|
||||
const botId = params.botId ?? null;
|
||||
if (!user && !botId) {
|
||||
throw new SendTaskMessageError('Не указан автор сообщения (user или botId)', 500);
|
||||
}
|
||||
const taskId = task.id;
|
||||
|
||||
let replyToMessage = null;
|
||||
@@ -59,7 +66,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const orgUserIds = orgUsers.map(u => u.id);
|
||||
mentionedUserIds = params.mentionedUserIds.filter(id =>
|
||||
orgUserIds.includes(id) && id !== user.id
|
||||
orgUserIds.includes(id) && id !== user?.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,15 +74,16 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
|
||||
// 1. messageType is 'bot' or 'system' (messages authored by the bot service)
|
||||
// 2. authorId is null in the payload (another indicator of a bot/system message)
|
||||
// 3. Request was authenticated via a bot-service JWT (isBotToken = true)
|
||||
const messageType = params.messageType || (botId ? 'bot' : 'comment');
|
||||
const isBotOrSystemMessage =
|
||||
params.messageType === 'bot' ||
|
||||
params.messageType === 'system' ||
|
||||
messageType === 'bot' ||
|
||||
messageType === 'system' ||
|
||||
params.bodyAuthorId === null ||
|
||||
params.isBotToken === true;
|
||||
|
||||
let mentionedBotIds: number[] = [];
|
||||
let mentionedBotsMap = new Map<number, Bot>();
|
||||
if (!isBotOrSystemMessage && params.mentionedBotIds && params.mentionedBotIds.length > 0) {
|
||||
if (!isBotOrSystemMessage && user && params.mentionedBotIds && params.mentionedBotIds.length > 0) {
|
||||
const orgBots = await storage.getBotsByOrganization(organizationId);
|
||||
const activeBots = orgBots.filter(b => b.isActive);
|
||||
activeBots.forEach(bot => mentionedBotsMap.set(bot.id, bot));
|
||||
@@ -86,10 +94,11 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
|
||||
const messageData = {
|
||||
taskId,
|
||||
formId: task.formId,
|
||||
authorId: user.id,
|
||||
authorId: botId ? null : (user?.id ?? null),
|
||||
botId,
|
||||
replyToMessageId: params.replyToMessageId || null,
|
||||
message: params.message || '',
|
||||
messageType: params.messageType || 'comment',
|
||||
messageType,
|
||||
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : null,
|
||||
attachments: params.attachments?.length ? params.attachments : null,
|
||||
};
|
||||
@@ -127,7 +136,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
|
||||
}
|
||||
}
|
||||
|
||||
if (mentionedBotIds.length > 0) {
|
||||
if (mentionedBotIds.length > 0 && user) {
|
||||
const taskFieldValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||||
const form = await storage.getForm(task.formId, organizationId);
|
||||
const taskWithFields = { ...task, fieldValues: taskFieldValues };
|
||||
@@ -226,6 +235,9 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
|
||||
taskId: taskId
|
||||
});
|
||||
|
||||
// Уведомления и исходящие вебхуки — только для сообщений от пользователя
|
||||
// (для bot-сообщений — как в POST /api/bot/message: без notificationService).
|
||||
if (user) {
|
||||
const notificationEvent: NotificationEvent = {
|
||||
type: replyToMessage ? EVENT_TYPES.TASK_COMMENT_REPLIED : EVENT_TYPES.TASK_COMMENT_CREATED,
|
||||
organizationId,
|
||||
@@ -261,13 +273,14 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
|
||||
organizationId, taskId, task.formId, createdMessage, user.id
|
||||
).catch(err => console.error('Webhook dispatch error:', err));
|
||||
}
|
||||
}
|
||||
|
||||
if (messageData.messageType === 'comment') {
|
||||
storage.enqueueEmbedding(organizationId, 'task_message', createdMessage.id, 'upsert')
|
||||
.catch(err => console.error('[RAG] enqueue message embedding error:', err));
|
||||
}
|
||||
|
||||
if (user.id) {
|
||||
if (user?.id) {
|
||||
storage.recordTaskInteractionAuto(taskId, user.id, organizationId)
|
||||
.catch(err => console.error('recordTaskInteraction error:', err));
|
||||
}
|
||||
|
||||
44
server/utils/api-key-actor.ts
Normal file
44
server/utils/api-key-actor.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { storage } from '../storage';
|
||||
import type { AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import { isFormAllowedByScopes } from './api-key';
|
||||
|
||||
// Актор изменений для REST-хендлеров, поддерживающих API-ключ:
|
||||
// JWT — текущий пользователь; ключ бота — бот (changedBy=null); ключ без бота — label ключа.
|
||||
export interface RestActor {
|
||||
changedBy: number | null;
|
||||
changedByName: string;
|
||||
botId: number | null;
|
||||
source: 'api' | null; // 'api' при авторизации по ключу (идёт в metadata аудит-записей)
|
||||
}
|
||||
|
||||
export async function resolveRestActor(req: AuthenticatedRequest, organizationId: number): Promise<RestActor> {
|
||||
if (!req.apiKey) {
|
||||
const name = req.user
|
||||
? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email)
|
||||
: 'API';
|
||||
return { changedBy: req.user?.id ?? null, changedByName: name, botId: null, source: null };
|
||||
}
|
||||
const bot = req.apiKey.botId
|
||||
? await storage.getBot(req.apiKey.botId, organizationId).catch(() => null)
|
||||
: null;
|
||||
return {
|
||||
changedBy: bot ? null : req.apiKey.createdBy,
|
||||
changedByName: bot?.name ?? `Ключ «${req.apiKey.label}»`,
|
||||
botId: bot?.id ?? null,
|
||||
source: 'api',
|
||||
};
|
||||
}
|
||||
|
||||
// Проверки авторизации по API-ключу для write-endpoint'а (изменение данных формы/задачи).
|
||||
// Возвращает текст ошибки для 403 или null, если доступ разрешён.
|
||||
// Для JWT-запросов всегда null (их проверки — в хендлерах, как раньше).
|
||||
export function checkApiKeyWriteAccess(req: AuthenticatedRequest, formId: number): string | null {
|
||||
if (!req.apiKey) return null;
|
||||
if (req.apiKey.scopes.mode === 'read') {
|
||||
return 'API-ключ в режиме read не может изменять данные';
|
||||
}
|
||||
if (!isFormAllowedByScopes(req.apiKey.scopes, formId)) {
|
||||
return 'Доступ к этой форме запрещён правами API-ключа';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -35,6 +35,16 @@ export function legacySha256Hash(raw: string): string {
|
||||
// Полный доступ — используется как дефолт для legacy-ключей (scopes = NULL в БД)
|
||||
export const FULL_API_KEY_SCOPES: ApiKeyScopes = { mode: 'full', formIds: null, tableIds: null };
|
||||
|
||||
// Проверка доступа к форме по скоупам ключа (null = все формы)
|
||||
export function isFormAllowedByScopes(scopes: ApiKeyScopes, formId: number): boolean {
|
||||
return scopes.formIds === null || scopes.formIds.includes(formId);
|
||||
}
|
||||
|
||||
// Проверка доступа к справочнику по скоупам ключа (null = все справочники)
|
||||
export function isTableAllowedByScopes(scopes: ApiKeyScopes, tableId: number): boolean {
|
||||
return scopes.tableIds === null || scopes.tableIds.includes(tableId);
|
||||
}
|
||||
|
||||
// Нормализация скоупов из БД: NULL/отсутствующий или частично заполненный объект
|
||||
// приводится к полной структуре ApiKeyScopes (дефолты — полный доступ).
|
||||
export function normalizeApiKeyScopes(scopes: unknown): ApiKeyScopes {
|
||||
|
||||
@@ -53,10 +53,13 @@ export const EXT_MAGIC: Record<string, Buffer> = {
|
||||
pdf: Buffer.from([0x25, 0x50, 0x44, 0x46]), // %PDF
|
||||
};
|
||||
|
||||
// Image extensions → 10 MB limit; all others → 50 MB
|
||||
// Image extensions → лимит изображений; all others → документный лимит
|
||||
// Лимиты настраиваются через env (в МБ), дефолты: 25 МБ изображения, 100 МБ документы
|
||||
export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
|
||||
export const IMAGE_MAX_SIZE = 10 * 1024 * 1024; // 10 МБ
|
||||
export const DOC_MAX_SIZE = 50 * 1024 * 1024; // 50 МБ
|
||||
export const IMAGE_MAX_SIZE = Number(process.env.UPLOAD_IMAGE_MAX_MB || 25) * 1024 * 1024;
|
||||
export const DOC_MAX_SIZE = Number(process.env.UPLOAD_DOC_MAX_MB || 100) * 1024 * 1024;
|
||||
// Жёсткий потолок multer (fileSize) — отдельный env, дефолт 100 МБ
|
||||
export const UPLOAD_MAX_SIZE = Number(process.env.UPLOAD_MAX_MB || 100) * 1024 * 1024;
|
||||
|
||||
// Derives the lowercase extension from the original filename (trusted)
|
||||
export function getFileExt(originalname: string): string {
|
||||
@@ -127,7 +130,7 @@ const multerStorage = multer.diskStorage({
|
||||
|
||||
export const upload = multer({
|
||||
storage: multerStorage,
|
||||
limits: { fileSize: DOC_MAX_SIZE }, // hard cap 50 МБ; per-type check done in handler
|
||||
limits: { fileSize: UPLOAD_MAX_SIZE }, // жёсткий потолок (UPLOAD_MAX_MB); раздельная проверка по типам — в хендлере
|
||||
fileFilter: (_req, file, cb) => {
|
||||
// 1. Validate filename: strict regex + extension whitelist (extension is trusted)
|
||||
const { valid, reason } = validateFilename(file.originalname);
|
||||
|
||||
Reference in New Issue
Block a user