MCP: напоминания, уведомления, inbox, агрегации, update_form, дерево задач, поля пользователей, DaData — этап 3
- read: list_task_reminders, list_notifications, get_inbox, aggregate_tasks, get_task_tree, get_user_field_values, dadata_suggest - write: set_task_reminder, send_notification, mark_notifications_read - full: delete_task_reminder, update_form, set_user_field_value
This commit is contained in:
714
server/mcp.ts
714
server/mcp.ts
@@ -44,18 +44,20 @@ import {
|
||||
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 { tasksMinimalCache, formsCache } from "./utils/cache";
|
||||
import { evaluateAutoTransitions } from "./utils/auto-transitions";
|
||||
import { notifyTaskAssigned } from "./utils/notifyAssignee";
|
||||
import { eventBus } from "./routes/shared";
|
||||
import { eventBus, publishNotificationSSE } from "./routes/shared";
|
||||
import { indexFormAsync } from "./routes/task-helpers";
|
||||
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";
|
||||
import { db, pool } from "./db";
|
||||
import { fileUploads, taskReminders } from "@shared/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { decrypt as decryptSecret } from "./crypto";
|
||||
|
||||
/** Format JS page code with consistent indentation before storing in DB. */
|
||||
function formatPageCode(code: string): string {
|
||||
@@ -137,6 +139,13 @@ const READ_TOOLS: readonly string[] = [
|
||||
'list_document_templates',
|
||||
'get_task_file',
|
||||
'get_task_audit_log',
|
||||
'list_task_reminders',
|
||||
'list_notifications',
|
||||
'get_inbox',
|
||||
'aggregate_tasks',
|
||||
'get_task_tree',
|
||||
'get_user_field_values',
|
||||
'dadata_suggest',
|
||||
];
|
||||
|
||||
// WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных.
|
||||
@@ -148,6 +157,9 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
|
||||
'bulk_create_directory_rows',
|
||||
'send_task_message',
|
||||
'generate_document',
|
||||
'set_task_reminder',
|
||||
'send_notification',
|
||||
'mark_notifications_read',
|
||||
];
|
||||
|
||||
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
||||
@@ -4012,6 +4024,698 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
}
|
||||
);
|
||||
|
||||
// ── Напоминания по задачам ─────────────────────────────────────────────────
|
||||
|
||||
// list_task_reminders
|
||||
register(
|
||||
"list_task_reminders",
|
||||
{
|
||||
title: "List Task Reminders",
|
||||
description: "List pending (not yet sent) reminders of a task",
|
||||
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 reminders = await storage.getTaskReminders(taskId, organizationId);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(reminders.map((r) => ({
|
||||
id: r.id,
|
||||
taskId: r.taskId,
|
||||
remindAt: r.remindAt,
|
||||
note: r.note,
|
||||
recipients: r.recipients,
|
||||
isSent: r.isSent,
|
||||
createdByUserId: r.createdByUserId,
|
||||
createdAt: r.createdAt,
|
||||
})), null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// set_task_reminder
|
||||
register(
|
||||
"set_task_reminder",
|
||||
{
|
||||
title: "Set Task Reminder",
|
||||
description: "Create a reminder for a task addressed to a single user. remindAt must be an ISO 8601 date-time.",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
userId: z.number().int().describe("Recipient user ID (must belong to the organization)"),
|
||||
remindAt: z.string().describe("Reminder date-time in ISO 8601 format"),
|
||||
message: z.string().optional().describe("Optional note shown with the reminder"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, userId, remindAt, message }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
|
||||
const remindAtDate = new Date(remindAt);
|
||||
if (isNaN(remindAtDate.getTime())) {
|
||||
return mcpError("Неверный формат даты напоминания");
|
||||
}
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const recipient = orgUsers.find((u) => u.id === userId);
|
||||
if (!recipient) {
|
||||
return mcpError(`Пользователь ${userId} не принадлежит организации`);
|
||||
}
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||
|
||||
const reminder = await storage.createTaskReminder({
|
||||
taskId,
|
||||
organizationId,
|
||||
createdByUserId: adminUser.id,
|
||||
remindAt: remindAtDate,
|
||||
note: message ?? null,
|
||||
recipients: [{ type: "user", userId }],
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ success: true, reminder }, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// delete_task_reminder
|
||||
register(
|
||||
"delete_task_reminder",
|
||||
{
|
||||
title: "Delete Task Reminder",
|
||||
description: "Delete a task reminder by its ID",
|
||||
inputSchema: {
|
||||
reminderId: z.number().int().describe("The numeric ID of the reminder"),
|
||||
},
|
||||
},
|
||||
async ({ reminderId }) => {
|
||||
// Напоминание → задача → проверка доступа к форме задачи
|
||||
const [reminder] = await db
|
||||
.select()
|
||||
.from(taskReminders)
|
||||
.where(and(eq(taskReminders.id, reminderId), eq(taskReminders.organizationId, organizationId)))
|
||||
.limit(1);
|
||||
if (!reminder) return mcpError(`Напоминание ${reminderId} не найдено`);
|
||||
|
||||
const task = await storage.getTask(reminder.taskId, organizationId);
|
||||
if (task && !isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
|
||||
await storage.deleteTaskReminder(reminderId, organizationId);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: reminderId } }, null, 2) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Уведомления ────────────────────────────────────────────────────────────
|
||||
|
||||
// list_notifications
|
||||
register(
|
||||
"list_notifications",
|
||||
{
|
||||
title: "List Notifications",
|
||||
description: "List notifications of a user, newest first",
|
||||
inputSchema: {
|
||||
userId: z.number().int().describe("The numeric ID of the user"),
|
||||
limit: z.number().int().min(1).max(200).optional().describe("Max notifications to return (default 50)"),
|
||||
},
|
||||
},
|
||||
async ({ userId, limit }) => {
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user || user.organizationId !== organizationId) {
|
||||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||||
}
|
||||
const items = await storage.getUserNotifications(userId, organizationId);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(items.slice(0, limit ?? 50).map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
taskId: n.taskId,
|
||||
messageId: n.messageId,
|
||||
isRead: n.isRead,
|
||||
createdAt: n.createdAt,
|
||||
})), null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// send_notification
|
||||
register(
|
||||
"send_notification",
|
||||
{
|
||||
title: "Send Notification",
|
||||
description:
|
||||
"Create an in-app notification for a user (type 'system') and push a realtime SSE event, " +
|
||||
"so the user's notification badge updates immediately. " +
|
||||
"Note: the schema has no link field — the optional link is appended to the message text.",
|
||||
inputSchema: {
|
||||
userId: z.number().int().describe("Recipient user ID"),
|
||||
title: z.string().min(1).describe("Notification title"),
|
||||
message: z.string().min(1).describe("Notification text"),
|
||||
link: z.string().optional().describe("Optional URL (appended to the message text)"),
|
||||
},
|
||||
},
|
||||
async ({ userId, title, message, link }) => {
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user || user.organizationId !== organizationId) {
|
||||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||||
}
|
||||
// В схеме user_notifications нет поля link — добавляем ссылку в текст
|
||||
const notification = await storage.createUserNotification({
|
||||
userId,
|
||||
organizationId,
|
||||
type: "system",
|
||||
title,
|
||||
message: link ? `${message}\n${link}` : message,
|
||||
isRead: false,
|
||||
});
|
||||
// Realtime-обновление бейджа уведомлений у пользователя (как воркер напоминаний в index.ts)
|
||||
publishNotificationSSE(userId, organizationId, { type: "system", notificationId: notification.id });
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ success: true, notification: { id: notification.id, userId, title: notification.title, isRead: notification.isRead, createdAt: notification.createdAt } }, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// mark_notifications_read
|
||||
register(
|
||||
"mark_notifications_read",
|
||||
{
|
||||
title: "Mark Notifications Read",
|
||||
description: "Mark a single notification (by notificationId) or all notifications of a user as read",
|
||||
inputSchema: {
|
||||
userId: z.number().int().describe("The numeric ID of the user"),
|
||||
notificationId: z.number().int().optional().describe("Notification ID to mark as read. Omit to mark ALL user notifications as read."),
|
||||
},
|
||||
},
|
||||
async ({ userId, notificationId }) => {
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user || user.organizationId !== organizationId) {
|
||||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||||
}
|
||||
if (notificationId !== undefined) {
|
||||
const updated = await storage.markNotificationAsRead(notificationId, userId, organizationId);
|
||||
if (!updated) return mcpError(`Уведомление ${notificationId} не найдено у пользователя ${userId}`);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, marked: 1 }, null, 2) }],
|
||||
};
|
||||
}
|
||||
await storage.markAllNotificationsAsRead(userId, organizationId);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, marked: "all" }, null, 2) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Входящие (inbox) ───────────────────────────────────────────────────────
|
||||
|
||||
// get_inbox
|
||||
register(
|
||||
"get_inbox",
|
||||
{
|
||||
title: "Get Inbox",
|
||||
description:
|
||||
"Get the inbox of a user: uncompleted tasks requiring their attention " +
|
||||
"(assigned, mentioned in unread notifications, or via task roles), with reasons and unread counts. " +
|
||||
"Mirrors GET /api/home/inbox with default filters. total is computed after API-key form filtering.",
|
||||
inputSchema: {
|
||||
userId: z.number().int().describe("The numeric ID of the user"),
|
||||
limit: z.number().int().min(1).max(100).optional().describe("Max tasks to return (default 50)"),
|
||||
},
|
||||
},
|
||||
async ({ userId, limit }) => {
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user || user.organizationId !== organizationId) {
|
||||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||||
}
|
||||
const limitVal = Math.min(limit ?? 50, 100);
|
||||
|
||||
// SQL повторяет GET /api/home/inbox (home.routes.ts) с фильтрами по умолчанию:
|
||||
// без search/status/assignee/overdue, сортировка t.updated_at DESC.
|
||||
// Выбираем с запасом (500), затем фильтруем по scopes.formIds и режем до limit.
|
||||
const result = await pool.query(`
|
||||
WITH inbox_sources AS (
|
||||
SELECT t.id AS task_id, 'assignee' AS reason, NULL::text AS role_name
|
||||
FROM tasks t
|
||||
WHERE t.organization_id = $1
|
||||
AND t.assigned_to = $2
|
||||
AND t.is_completed = false
|
||||
|
||||
UNION
|
||||
|
||||
SELECT ta.task_id, 'assignee' AS reason, NULL::text AS role_name
|
||||
FROM task_assignees ta
|
||||
JOIN tasks t ON t.id = ta.task_id AND t.organization_id = $1 AND t.is_completed = false
|
||||
WHERE ta.user_id = $2 AND ta.organization_id = $1
|
||||
|
||||
UNION
|
||||
|
||||
SELECT un.task_id, 'notification' AS reason, NULL::text
|
||||
FROM user_notifications un
|
||||
JOIN tasks t2 ON t2.id = un.task_id AND t2.organization_id = $1 AND t2.is_completed = false
|
||||
WHERE un.organization_id = $1
|
||||
AND un.user_id = $2
|
||||
AND un.is_read = false
|
||||
AND un.task_id IS NOT NULL
|
||||
|
||||
UNION
|
||||
|
||||
SELECT tr.task_id, 'role' AS reason, r.name AS role_name
|
||||
FROM task_roles tr
|
||||
JOIN role_members rm ON rm.role_id = tr.role_id AND rm.user_id = $2
|
||||
JOIN roles r ON r.id = tr.role_id
|
||||
JOIN tasks t3 ON t3.id = tr.task_id AND t3.organization_id = $1 AND t3.is_completed = false
|
||||
WHERE tr.organization_id = $1
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT
|
||||
task_id,
|
||||
ARRAY_AGG(DISTINCT reason) AS reasons,
|
||||
ARRAY_AGG(DISTINCT role_name) FILTER (WHERE role_name IS NOT NULL) AS role_names
|
||||
FROM inbox_sources
|
||||
GROUP BY task_id
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
t.title,
|
||||
t.form_id AS "formId",
|
||||
f.name AS "formName",
|
||||
fs.name AS "statusName",
|
||||
fs.color AS "statusColor",
|
||||
fs.is_final AS "statusIsFinal",
|
||||
t.current_status_id AS "statusId",
|
||||
COALESCE(
|
||||
CASE WHEN u.id IS NOT NULL THEN CONCAT(u.first_name, ' ', u.last_name) ELSE NULL END,
|
||||
(SELECT STRING_AGG(CONCAT(ua.first_name, ' ', ua.last_name), ', ' ORDER BY ta2.id)
|
||||
FROM task_assignees ta2 JOIN users ua ON ua.id = ta2.user_id
|
||||
WHERE ta2.task_id = t.id)
|
||||
) AS "assigneeName",
|
||||
t.assigned_to AS "assignedTo",
|
||||
t.created_at AS "createdAt",
|
||||
t.updated_at AS "updatedAt",
|
||||
t.due_date AS "dueDate",
|
||||
t.is_completed AS "isCompleted",
|
||||
a.reasons,
|
||||
a.role_names AS "roleNames",
|
||||
(
|
||||
SELECT COUNT(*) FROM user_notifications un2
|
||||
WHERE un2.task_id = t.id AND un2.user_id = $2 AND un2.is_read = false
|
||||
)::int AS "unreadNotifications"
|
||||
FROM aggregated a
|
||||
JOIN tasks t ON t.id = a.task_id AND t.organization_id = $1
|
||||
JOIN forms f ON f.id = t.form_id
|
||||
JOIN form_statuses fs ON fs.id = t.current_status_id
|
||||
LEFT JOIN users u ON u.id = t.assigned_to
|
||||
WHERE t.is_completed = false
|
||||
AND ($3 = '' OR t.title ILIKE '%' || $3 || '%')
|
||||
AND ($4 = 0 OR t.form_id = $4)
|
||||
AND ($7 = '' OR fs.name ILIKE '%' || $7 || '%')
|
||||
AND ($8 = '' OR COALESCE(CONCAT(u.first_name, ' ', u.last_name), '') ILIKE '%' || $8 || '%'
|
||||
OR EXISTS (SELECT 1 FROM task_assignees ta_f JOIN users ua_f ON ua_f.id = ta_f.user_id
|
||||
WHERE ta_f.task_id = t.id AND CONCAT(ua_f.first_name, ' ', ua_f.last_name) ILIKE '%' || $8 || '%'))
|
||||
AND (NOT $9 OR (t.due_date IS NOT NULL AND t.due_date < NOW()))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_reminders tr_snooze
|
||||
WHERE tr_snooze.task_id = t.id
|
||||
AND tr_snooze.created_by_user_id = $2
|
||||
AND tr_snooze.remind_at > NOW()
|
||||
AND tr_snooze.is_sent = false
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_inbox_hidden tih
|
||||
WHERE tih.task_id = t.id AND tih.user_id = $2
|
||||
)
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT $5 OFFSET $6
|
||||
`, [organizationId, userId, '', 0, 500, 0, '', '', false]);
|
||||
|
||||
// Фильтруем выдачу по scopes.formIds (null = все формы)
|
||||
const rows = (result.rows as Array<{ formId: number }>).filter((r) => isFormAllowed(r.formId));
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
tasks: rows.slice(0, limitVal),
|
||||
total: rows.length,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Агрегации задач ────────────────────────────────────────────────────────
|
||||
|
||||
// aggregate_tasks
|
||||
register(
|
||||
"aggregate_tasks",
|
||||
{
|
||||
title: "Aggregate Tasks",
|
||||
description:
|
||||
"Compute aggregations over a numeric task field of a form (mirrors POST /api/forms/:id/tasks/aggregate). " +
|
||||
"Each aggregation: { fieldId, fn } where fn is count|sum|avg|min|max. " +
|
||||
"count = number of non-empty numeric values. Optionally filter tasks by statusId.",
|
||||
inputSchema: {
|
||||
formId: z.number().int().describe("The numeric ID of the form"),
|
||||
aggregations: z.array(z.object({
|
||||
fieldId: z.number().int().describe("Field ID whose numeric values are aggregated"),
|
||||
fn: z.enum(["count", "sum", "avg", "min", "max"]).describe("Aggregation function"),
|
||||
})).min(1).describe("Aggregations to compute"),
|
||||
statusId: z.number().int().optional().describe("Only tasks in this status (optional)"),
|
||||
},
|
||||
},
|
||||
async ({ formId, aggregations, statusId }) => {
|
||||
if (!isFormAllowed(formId)) return formDenied(formId);
|
||||
const form = await storage.getForm(formId, organizationId);
|
||||
if (!form) return mcpError(`Форма ${formId} не найдена`);
|
||||
|
||||
// Фильтр доступа как в endpoint — от имени первого админа (админ видит все задачи)
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
const [result, accessibleIds] = await Promise.all([
|
||||
storage.getTasksWithFieldsByFormOptimized(formId, organizationId, {}),
|
||||
adminUser
|
||||
? storage.getAccessibleTaskIds(adminUser.id, organizationId, adminUser.appRole)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
type TaskWithFields = (typeof result.tasks)[number] & { fieldValues?: Record<number, string | number | null> };
|
||||
let tasks = result.tasks as TaskWithFields[];
|
||||
if (accessibleIds !== null) {
|
||||
tasks = tasks.filter((t) => accessibleIds.has(t.id));
|
||||
}
|
||||
if (statusId) {
|
||||
tasks = tasks.filter((t) => t.currentStatusId === statusId);
|
||||
}
|
||||
|
||||
const results = aggregations.map(({ fieldId, fn }) => {
|
||||
const values: number[] = [];
|
||||
for (const task of tasks) {
|
||||
const fieldValue = task.fieldValues?.[fieldId];
|
||||
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
|
||||
const numValue = parseFloat(String(fieldValue));
|
||||
if (!isNaN(numValue)) values.push(numValue);
|
||||
}
|
||||
}
|
||||
let value: number | null = null;
|
||||
switch (fn) {
|
||||
case 'sum':
|
||||
value = values.reduce((a, b) => a + b, 0);
|
||||
break;
|
||||
case 'avg':
|
||||
value = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||
break;
|
||||
case 'count':
|
||||
value = values.length;
|
||||
break;
|
||||
case 'min':
|
||||
value = values.length > 0 ? Math.min(...values) : null;
|
||||
break;
|
||||
case 'max':
|
||||
value = values.length > 0 ? Math.max(...values) : null;
|
||||
break;
|
||||
}
|
||||
return { fieldId, fn, value, valuesCount: values.length };
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ success: true, formId, taskCount: tasks.length, results }, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Обновление формы ───────────────────────────────────────────────────────
|
||||
|
||||
// update_form
|
||||
register(
|
||||
"update_form",
|
||||
{
|
||||
title: "Update Form",
|
||||
description:
|
||||
"Update safe form properties: name, description, visibility, qrGenerationEnabled, folderId. " +
|
||||
"Does NOT touch fields, statuses or title template.",
|
||||
inputSchema: {
|
||||
formId: z.number().int().describe("The numeric ID of the form"),
|
||||
name: z.string().min(1).optional().describe("New form name"),
|
||||
description: z.string().nullable().optional().describe("New description (null to clear)"),
|
||||
visibility: z.enum(["organization", "restricted"]).optional().describe("'organization' = visible to all org members, 'restricted' = access rules only"),
|
||||
qrGenerationEnabled: z.boolean().optional().describe("Show QR generation button in the form registry"),
|
||||
folderId: z.number().int().nullable().optional().describe("Move form to folder (null = root)"),
|
||||
},
|
||||
},
|
||||
async ({ formId, name, description, visibility, qrGenerationEnabled, folderId }) => {
|
||||
if (!isFormAllowed(formId)) return formDenied(formId);
|
||||
const existingForm = await storage.getForm(formId, organizationId);
|
||||
if (!existingForm) return mcpError(`Форма ${formId} не найдена`);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (visibility !== undefined) updates.visibility = visibility;
|
||||
if (qrGenerationEnabled !== undefined) updates.qrGenerationEnabled = qrGenerationEnabled;
|
||||
if (folderId !== undefined) updates.folderId = folderId;
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return mcpError("Не переданы данные для обновления");
|
||||
}
|
||||
|
||||
const updatedForm = await storage.updateForm(formId, organizationId, updates);
|
||||
// Кэш и RAG-переиндексация — как в PUT /api/forms/:id
|
||||
formsCache.invalidatePrefix(`forms:${organizationId}`);
|
||||
indexFormAsync(formId, organizationId).catch(() => {});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
form: {
|
||||
id: updatedForm.id,
|
||||
name: updatedForm.name,
|
||||
description: updatedForm.description,
|
||||
visibility: updatedForm.visibility,
|
||||
qrGenerationEnabled: updatedForm.qrGenerationEnabled,
|
||||
folderId: updatedForm.folderId,
|
||||
},
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Дерево задач ───────────────────────────────────────────────────────────
|
||||
|
||||
// get_task_tree
|
||||
register(
|
||||
"get_task_tree",
|
||||
{
|
||||
title: "Get Task Tree",
|
||||
description:
|
||||
"Get the hierarchy of a task: parent chain (via parentTaskId, up to 10 levels) and the recursive subtasks tree. " +
|
||||
"Nodes from forms not allowed by the API key are filtered out.",
|
||||
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 tree = await storage.getTaskTree(taskId, organizationId);
|
||||
|
||||
// Компактный маппинг узлов с отсечением поддеревьев недоступных форм
|
||||
const mapNode = (node: Record<string, unknown> | null): unknown => {
|
||||
if (!node || typeof node.formId !== 'number' || !isFormAllowed(node.formId)) return null;
|
||||
const subtasks = Array.isArray(node.subtasks) ? node.subtasks : [];
|
||||
return {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
formId: node.formId,
|
||||
currentStatusId: node.currentStatusId,
|
||||
isCompleted: node.isCompleted,
|
||||
dueDate: node.dueDate,
|
||||
subtasks: subtasks.map((s) => mapNode(s as Record<string, unknown>)).filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
// Цепочка родителей вверх по parentTaskId (с защитой от циклов)
|
||||
const parents: Array<Record<string, unknown>> = [];
|
||||
const seen = new Set<number>([task.id]);
|
||||
let cursor = task.parentTaskId ?? null;
|
||||
while (cursor !== null && !seen.has(cursor) && parents.length < 10) {
|
||||
seen.add(cursor);
|
||||
const parent = await storage.getTask(cursor, organizationId);
|
||||
if (!parent) break;
|
||||
if (isFormAllowed(parent.formId)) {
|
||||
parents.push({
|
||||
id: parent.id,
|
||||
title: parent.title,
|
||||
formId: parent.formId,
|
||||
currentStatusId: parent.currentStatusId,
|
||||
isCompleted: parent.isCompleted,
|
||||
});
|
||||
}
|
||||
cursor = parent.parentTaskId ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ success: true, task: mapNode(tree), parents }, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Кастомные поля пользователей ───────────────────────────────────────────
|
||||
|
||||
// get_user_field_values
|
||||
register(
|
||||
"get_user_field_values",
|
||||
{
|
||||
title: "Get User Field Values",
|
||||
description: "Get custom field values of a user, enriched with field codes and names",
|
||||
inputSchema: {
|
||||
userId: z.number().int().describe("The numeric ID of the user"),
|
||||
},
|
||||
},
|
||||
async ({ userId }) => {
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user || user.organizationId !== organizationId) {
|
||||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||||
}
|
||||
const [fields, values] = await Promise.all([
|
||||
storage.getUserCustomFields(organizationId),
|
||||
storage.getUserCustomValues(userId, organizationId),
|
||||
]);
|
||||
const fieldMap = new Map(fields.map((f) => [f.id, f]));
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
userId,
|
||||
values: values.map((v) => ({
|
||||
fieldId: v.fieldId,
|
||||
fieldCode: fieldMap.get(v.fieldId)?.code ?? null,
|
||||
fieldName: fieldMap.get(v.fieldId)?.name ?? null,
|
||||
value: v.value,
|
||||
})),
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// set_user_field_value
|
||||
register(
|
||||
"set_user_field_value",
|
||||
{
|
||||
title: "Set User Field Value",
|
||||
description: "Set a custom field value for a user by field code. Use list_users or get_user_field_values to discover field codes.",
|
||||
inputSchema: {
|
||||
userId: z.number().int().describe("The numeric ID of the user"),
|
||||
fieldCode: z.string().min(1).describe("Custom field code"),
|
||||
value: z.string().nullable().describe("New value (null to clear)"),
|
||||
},
|
||||
},
|
||||
async ({ userId, fieldCode, value }) => {
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user || user.organizationId !== organizationId) {
|
||||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||||
}
|
||||
const fields = await storage.getUserCustomFields(organizationId);
|
||||
const field = fields.find((f) => f.code === fieldCode);
|
||||
if (!field) {
|
||||
return mcpError(`Поле с кодом "${fieldCode}" не найдено. Доступные коды: ${fields.map((f) => f.code).join(', ') || '(нет)'}`);
|
||||
}
|
||||
await storage.setUserCustomValues(userId, [{ fieldId: field.id, value }], organizationId);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ success: true, userId, fieldCode, value }, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── DaData подсказки ───────────────────────────────────────────────────────
|
||||
|
||||
// dadata_suggest
|
||||
register(
|
||||
"dadata_suggest",
|
||||
{
|
||||
title: "DaData Suggest",
|
||||
description:
|
||||
"Get DaData suggestions for organizations ('party') or addresses ('address'). " +
|
||||
"Uses the organization DaData API key, falling back to the global DADATA_API_KEY env variable.",
|
||||
inputSchema: {
|
||||
type: z.enum(["party", "address"]).describe("'party' = organizations, 'address' = addresses"),
|
||||
query: z.string().min(1).describe("Search query"),
|
||||
count: z.number().int().min(1).max(20).optional().describe("Max suggestions (default 10, max 20)"),
|
||||
},
|
||||
},
|
||||
async ({ type, query, count }) => {
|
||||
// Ключ: сначала org-specific сервис, затем глобальный env (как в external.routes.ts)
|
||||
const orgService = await storage.getExternalServiceByType('dadata', organizationId);
|
||||
let apiKey: string | null = null;
|
||||
if (orgService && orgService.apiKey) {
|
||||
apiKey = decryptSecret(orgService.apiKey);
|
||||
} else {
|
||||
apiKey = process.env.DADATA_API_KEY || null;
|
||||
}
|
||||
if (!apiKey) {
|
||||
return mcpError('API-ключ DaData не настроен. Добавьте его в разделе "Доступы к сервисам".');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/${type}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Token ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ query, count: Math.min(count ?? 10, 20) }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Dadata API error (MCP):', errorText);
|
||||
return mcpError("Ошибка API DaData");
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ success: true, suggestions: data.suggestions }, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return mcpError(`Ошибка при запросе к DaData: ${msg}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user