From 0b2319b11d42386f4de1778e49e3fe49da1608b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 8 Sep 2026 16:31:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(automations):=20ctx.tasks.sendMessage=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=20=D1=87=D0=B0=D1=82=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=20=D0=BE=D1=82=20=D0=A1=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B;=20userId=20=D0=B2=20triggerData=20status=5Fchange?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/FormAccessTab.tsx | 16 ++++++ client/src/components/UserModal.tsx | 53 +++++++++++------ .../components/profile/ProfileFieldRow.tsx | 10 ++++ client/src/pages/Automations.tsx | 11 +++- client/src/services/auth.service.ts | 57 +++++++++++++++---- client/src/types/auth.types.ts | 2 + server/mcp.ts | 4 +- server/routes/auth.users.routes.ts | 7 ++- server/routes/automation.routes.ts | 28 +++++++++ server/services/auth.service.ts | 4 ++ server/services/task-message.service.ts | 6 +- server/services/task-transition.service.ts | 1 + server/workers/automation-worker-code.ts | 4 ++ 13 files changed, 171 insertions(+), 32 deletions(-) diff --git a/client/src/components/FormAccessTab.tsx b/client/src/components/FormAccessTab.tsx index 078d40c..ce689ea 100644 --- a/client/src/components/FormAccessTab.tsx +++ b/client/src/components/FormAccessTab.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiRequest } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -40,11 +41,21 @@ const LEVEL_LABELS: Record = { export default function FormAccessTab({ formId, visibility, authorId, users, roles }: FormAccessTabProps) { const queryClient = useQueryClient(); + const { toast } = useToast(); const [targetType, setTargetType] = useState<'user' | 'role'>('user'); const [targetId, setTargetId] = useState(''); const [accessLevel, setAccessLevel] = useState('participate'); const [newAuthorId, setNewAuthorId] = useState(''); + // apiRequest при 400/409 бросает BadRequestError/ConflictError с текстом сервера в message; + // для сетевых сбоев (TypeError) текст не показываем — он технический + const showError = (title: string) => (error: unknown) => + toast({ + title, + description: error instanceof Error && !(error instanceof TypeError) ? error.message : undefined, + variant: 'destructive', + }); + const { data: rulesData } = useQuery<{ success: boolean; rules: FormAccessRule[] }>({ queryKey: ['/api/forms', formId, 'access-rules'], queryFn: () => apiRequest('GET', `/api/forms/${formId}/access-rules`).then(r => r.json()), @@ -56,24 +67,28 @@ export default function FormAccessTab({ formId, visibility, authorId, users, rol mutationFn: (data: Omit) => apiRequest('POST', `/api/forms/${formId}/access-rules`, data).then(r => r.json()), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'access-rules'] }), + onError: showError('Не удалось добавить правило доступа'), }); const updateRule = useMutation({ mutationFn: ({ id, accessLevel }: { id: number; accessLevel: string }) => apiRequest('PUT', `/api/forms/${formId}/access-rules/${id}`, { accessLevel }).then(r => r.json()), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'access-rules'] }), + onError: showError('Не удалось обновить правило доступа'), }); const deleteRule = useMutation({ mutationFn: (id: number) => apiRequest('DELETE', `/api/forms/${formId}/access-rules/${id}`).then(r => r.json()), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'access-rules'] }), + onError: showError('Не удалось удалить правило доступа'), }); const updateVisibility = useMutation({ mutationFn: (v: string) => apiRequest('PUT', `/api/forms/${formId}/visibility`, { visibility: v }).then(r => r.json()), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['/api/forms', formId] }), + onError: showError('Не удалось изменить видимость формы'), }); const transferOwnership = useMutation({ @@ -83,6 +98,7 @@ export default function FormAccessTab({ formId, visibility, authorId, users, rol queryClient.invalidateQueries({ queryKey: ['/api/forms', formId] }); setNewAuthorId(''); }, + onError: showError('Не удалось передать владение формой'), }); const author = users.find(u => u.id === authorId); diff --git a/client/src/components/UserModal.tsx b/client/src/components/UserModal.tsx index 2ed61db..4786be0 100644 --- a/client/src/components/UserModal.tsx +++ b/client/src/components/UserModal.tsx @@ -42,6 +42,19 @@ const createUserSchema = z.object({ organizationalRoleIds: z.array(z.number()).default([]), statusId: z.union([z.number(), z.null()]).optional(), sendInvite: z.boolean().default(true), + // Пустое значение = временный пароль генерируется на сервере + password: z.string() + .optional() + .refine((password) => { + if (!password || password.trim() === '') return true; + return password.length >= 8 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /\d/.test(password) && + /[!@#$%^&*(),.?":{}|<>]/.test(password); + }, { + message: 'Пароль должен содержать минимум 8 символов, заглавные и строчные буквы, цифры и специальные символы' + }), }); const editUserSchema = z.object({ @@ -156,13 +169,18 @@ const UserModal = ({ open, onOpenChange, editingUser }: UserModalProps) => { appRole: 'user', organizationalRoleIds: [], sendInvite: true, + password: '', }); } } }, [open, isEditing, editingUser, currentUserRoleIds, form]); const createUserMutation = useMutation({ - mutationFn: (data: CreateUserForm) => authService.createUser(data), + mutationFn: (data: CreateUserForm) => { + const { password, ...rest } = data; + // Пустой пароль не отправляем — сервер сгенерирует временный + return authService.createUser(password && password.trim() !== '' ? { ...rest, password } : rest); + }, onSuccess: (result) => { if (result.success) { toast({ title: 'Успешно', description: result.message || 'Пользователь создан' }); @@ -391,21 +409,24 @@ const UserModal = ({ open, onOpenChange, editingUser }: UserModalProps) => { - {isEditing && ( - ( - - Новый пароль - - - - - - )} - /> - )} + ( + + {isEditing ? 'Новый пароль' : 'Пароль'} + + + + + + )} + /> {!isEditing && ( { + // Правила совпадают с серверными (server/utils/password.ts): длина + состав if (password.length < 8) { setError('Пароль должен содержать минимум 8 символов'); return; } + if ( + !/[A-Z]/.test(password) || + !/[a-z]/.test(password) || + !/\d/.test(password) || + !/[!@#$%^&*(),.?":{}|<>]/.test(password) + ) { + setError('Пароль должен содержать заглавные и строчные буквы, цифры и специальные символы'); + return; + } setPending(true); const result = await authService.changeUserPassword(userId, password); setPending(false); diff --git a/client/src/pages/Automations.tsx b/client/src/pages/Automations.tsx index a0083b5..203ce51 100644 --- a/client/src/pages/Automations.tsx +++ b/client/src/pages/Automations.tsx @@ -75,6 +75,8 @@ const CODE_TEMPLATE = `// ====================================================== // ctx.tasks.getFieldValues(taskId) — значения полей задачи // ctx.tasks.getAssignees(taskId) — назначенные пользователи // ctx.tasks.setFieldValue(taskId, fieldCode, value) — установить значение поля +// ctx.tasks.sendMessage(taskId, message) — сообщение в чат задачи от «Система» +// (messageType 'status_change', бейдж «Статус», без push-уведомлений) // // Работа с пользователями: // ctx.users.list() — список пользователей организации @@ -198,6 +200,11 @@ export function AutomationsContent() { return res.json(); }, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['/api/automations'] }), + onError: (error) => toast({ + title: 'Ошибка переключения автоматизации', + description: error instanceof Error && !(error instanceof TypeError) ? error.message : undefined, + variant: 'destructive', + }), }); function openCreate() { @@ -306,7 +313,7 @@ export function AutomationsContent() { События ctx.triggerData: task.before_create: { formId, task: { formId, title, currentStatusId, dueDate, customFields } } task.created: { formId, taskId, task: { ... } } - task.status_changed:{ formId, taskId, oldStatusId, newStatusId, task: { ... } } + task.status_changed:{ formId, taskId, oldStatusId, newStatusId, task: { ... }, userId } qr.scan: { template, objectId, scannedByUserId } schedule: { trigger: 'schedule', scheduledAt } @@ -319,6 +326,8 @@ export function AutomationsContent() { ctx.tasks.update(taskId, updates) / ctx.tasks.delete(taskId) ctx.tasks.getFieldValues(taskId) / ctx.tasks.getAssignees(taskId) ctx.tasks.setFieldValue(taskId, fieldCode, value) + ctx.tasks.sendMessage(taskId, message) — сообщение в чат задачи от «Система» + (messageType 'status_change', бейдж «Статус», без push-уведомлений) Пользователи: ctx.users.list() / ctx.users.get(userId) diff --git a/client/src/services/auth.service.ts b/client/src/services/auth.service.ts index 3209469..5b6fa1a 100644 --- a/client/src/services/auth.service.ts +++ b/client/src/services/auth.service.ts @@ -1,8 +1,23 @@ -import { apiRequest } from '@/lib/queryClient'; +import { apiRequest, BadRequestError, ConflictError } from '@/lib/queryClient'; import type { User, LoginRequest, RegisterOrganizationRequest, CreateUserRequest, AuthResponse } from '@/types/auth.types'; const CACHED_USER_KEY = 'cachedUser'; +// При 400/409 apiRequest бросает BadRequestError/ConflictError с уже извлечённым +// текстом ошибки сервера — возвращаем его как { success: false, error }, +// чтобы вызывающий код не получал unhandled rejection. +// При сетевых и прочих сбоях показываем общий текст. +function toServiceError(error: unknown, fallback: string): { success: false; error: string } { + if ( + (error instanceof BadRequestError || error instanceof ConflictError) && + error.message !== 'bad_request' && + error.message !== 'conflict' + ) { + return { success: false, error: error.message }; + } + return { success: false, error: fallback }; +} + class AuthService { async login(data: LoginRequest): Promise { const response = await apiRequest('POST', '/api/auth/login', data); @@ -60,8 +75,12 @@ class AuthService { } async createUser(data: CreateUserRequest): Promise { - const response = await apiRequest('POST', '/api/users', data); - return await response.json(); + try { + const response = await apiRequest('POST', '/api/users', data); + return await response.json(); + } catch (error) { + return toServiceError(error, 'Не удалось создать пользователя. Проверьте соединение и попробуйте ещё раз'); + } } async getUsers(): Promise { @@ -80,23 +99,39 @@ class AuthService { } async updateUser(id: number, data: Partial): Promise<{ success: boolean; user?: User; message?: string; error?: string }> { - const response = await apiRequest('PUT', `/api/users/${id}`, data); - return await response.json(); + try { + const response = await apiRequest('PUT', `/api/users/${id}`, data); + return await response.json(); + } catch (error) { + return toServiceError(error, 'Не удалось обновить пользователя. Проверьте соединение и попробуйте ещё раз'); + } } async changeUserPassword(id: number, password: string): Promise<{ success: boolean; error?: string }> { - const response = await apiRequest('PUT', `/api/users/${id}/password`, { password }); - return await response.json(); + try { + const response = await apiRequest('PUT', `/api/users/${id}/password`, { password }); + return await response.json(); + } catch (error) { + return toServiceError(error, 'Не удалось изменить пароль. Проверьте соединение и попробуйте ещё раз'); + } } async changePassword(currentPassword: string, newPassword: string): Promise<{ success: boolean; message?: string; error?: string }> { - const response = await apiRequest('POST', '/api/auth/change-password', { currentPassword, newPassword }); - return await response.json(); + try { + const response = await apiRequest('POST', '/api/auth/change-password', { currentPassword, newPassword }); + return await response.json(); + } catch (error) { + return toServiceError(error, 'Не удалось изменить пароль. Проверьте соединение и попробуйте ещё раз'); + } } async deleteUser(id: number): Promise<{ success: boolean; message?: string; error?: string }> { - const response = await apiRequest('DELETE', `/api/users/${id}`); - return await response.json(); + try { + const response = await apiRequest('DELETE', `/api/users/${id}`); + return await response.json(); + } catch (error) { + return toServiceError(error, 'Не удалось удалить пользователя. Проверьте соединение и попробуйте ещё раз'); + } } setCachedUser(user: User | null): void { diff --git a/client/src/types/auth.types.ts b/client/src/types/auth.types.ts index 45af446..2725e6e 100644 --- a/client/src/types/auth.types.ts +++ b/client/src/types/auth.types.ts @@ -55,6 +55,8 @@ export interface CreateUserRequest { appRole: string; organizationalRoleIds?: number[]; sendInvite: boolean; + // Необязательный явный пароль; без него сервер генерирует временный + password?: string; } export interface AuthResponse { diff --git a/server/mcp.ts b/server/mcp.ts index f8267d5..582794f 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -690,6 +690,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco oldStatusId: task.currentStatusId, newStatusId: status_id, task: updated, + userId: actor.user.id, }).catch((e: unknown) => { console.error('Automation trigger error (MCP update_task_status):', e); }); } @@ -2410,7 +2411,7 @@ Variables: Trigger data shapes (ctx.triggerData): - task.before_create: { formId, task: { formId, title, currentStatusId, dueDate, customFields } } - task.created: { formId, taskId, task: { ... } } -- task.status_changed: { formId, taskId, oldStatusId, newStatusId, task: { ... } } +- task.status_changed: { formId, taskId, oldStatusId, newStatusId, task: { ... }, userId } - qr.scan: { template, objectId, scannedByUserId } - schedule: { trigger: 'schedule', scheduledAt } @@ -2432,6 +2433,7 @@ Tasks: - ctx.tasks.getFieldValues(taskId) — get task field values array - ctx.tasks.getAssignees(taskId) — get task assignees - ctx.tasks.setFieldValue(taskId, fieldCode, value) — set a field value +- ctx.tasks.sendMessage(taskId, message) — post a message to the task chat as 'Система' (messageType 'status_change', 'Статус' badge, no push notifications) Users: - ctx.users.list() — list organization users diff --git a/server/routes/auth.users.routes.ts b/server/routes/auth.users.routes.ts index 656539a..2553840 100644 --- a/server/routes/auth.users.routes.ts +++ b/server/routes/auth.users.routes.ts @@ -414,7 +414,9 @@ export function registerUserManagementRoutes(router: Router): void { appRole: z.string().min(1, 'Выберите роль доступа'), organizationalRoleIds: z.array(z.number()).optional(), sendInvite: z.boolean().default(true), - statusId: z.union([z.number(), z.null()]).optional() + statusId: z.union([z.number(), z.null()]).optional(), + // Необязательный явный пароль; состав дополнительно проверяет hashPassword (400 с текстом правила) + password: z.string().min(8, 'Пароль должен содержать минимум 8 символов').optional() })), async (req: AuthenticatedRequest, res) => { try { @@ -427,7 +429,8 @@ export function registerUserManagementRoutes(router: Router): void { appRole: req.body.appRole, organizationalRoleIds: req.body.organizationalRoleIds, sendInvite: req.body.sendInvite, - statusId: req.body.statusId ?? undefined + statusId: req.body.statusId ?? undefined, + password: req.body.password }); if (result.success && result.user) { diff --git a/server/routes/automation.routes.ts b/server/routes/automation.routes.ts index 5ed477b..7dfec76 100644 --- a/server/routes/automation.routes.ts +++ b/server/routes/automation.routes.ts @@ -6,6 +6,7 @@ import { authenticateToken, requirePermission, type AuthenticatedRequest } from import { tenantIsolation } from "../middleware/tenant.middleware"; import { logAudit } from "../utils/audit"; import { AUTOMATION_WORKER_CODE } from "../workers/automation-worker-code"; +import { sendTaskMessage } from "../services/task-message.service"; const router = Router(); @@ -31,6 +32,8 @@ const ALLOWED_STORAGE_METHODS = new Set([ 'updateUser', 'setUserProfileFieldValueFromAutomation', 'recalcUserFieldAverageFromAutomation', + // Не storage-метод: обрабатывается отдельно в обработчике storage-call (сервис сообщений) + 'sendTaskMessageFromAutomation', ]); interface WorkerStorageCall { @@ -96,6 +99,31 @@ export function runAutomationInWorker( return; } + // Сообщение в чат задачи из автоматизации: не storage-метод, + // идём через сервис сообщений (SSE, вложения, messageType 'status_change') + if (method === 'sendTaskMessageFromAutomation') { + try { + const [taskId, orgId, message] = args as [number, number, unknown]; + const task = await storage.getTask(Number(taskId), Number(orgId)); + if (!task) throw new Error(`Задача ${taskId} не найдена`); + const created = await sendTaskMessage({ + task, + organizationId: Number(orgId), + message: String(message ?? ''), + messageType: 'status_change', + }); + if (!settled) { + try { worker.postMessage({ type: 'storage-response', callId, result: created }); } catch { /* terminated */ } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (!settled) { + try { worker.postMessage({ type: 'storage-response', callId, error: message }); } catch { /* terminated */ } + } + } + return; + } + try { const fn = (storage as unknown as Record Promise>)[method]; if (typeof fn !== 'function') { diff --git a/server/services/auth.service.ts b/server/services/auth.service.ts index 131acac..bd1a7e9 100644 --- a/server/services/auth.service.ts +++ b/server/services/auth.service.ts @@ -290,6 +290,10 @@ export class AuthService { }; } catch (error) { console.error('Create user error:', error); + // Ошибки валидации пароля из hashPassword отдаём клиенту как есть + if (error instanceof Error && error.message.includes('Пароль должен')) { + return { success: false, error: error.message }; + } return { success: false, error: 'Ошибка при создании пользователя' }; } }); diff --git a/server/services/task-message.service.ts b/server/services/task-message.service.ts index c9ccba3..32744bc 100644 --- a/server/services/task-message.service.ts +++ b/server/services/task-message.service.ts @@ -47,7 +47,11 @@ export interface SendTaskMessageParams { export async function sendTaskMessage(params: SendTaskMessageParams): Promise { const { task, user, organizationId } = params; const botId = params.botId ?? null; - if (!user && !botId) { + // Системные сообщения ('system', 'status_change') могут быть без автора — + // их пишут автоматизации (ctx.tasks.sendMessage), автор в чате — «Система» + const isAuthorlessSystem = !user && !botId && + (params.messageType === 'system' || params.messageType === 'status_change'); + if (!user && !botId && !isAuthorlessSystem) { throw new SendTaskMessageError('Не указан автор сообщения (user или botId)', 500); } const taskId = task.id; diff --git a/server/services/task-transition.service.ts b/server/services/task-transition.service.ts index 9b1352f..22ae5fe 100644 --- a/server/services/task-transition.service.ts +++ b/server/services/task-transition.service.ts @@ -538,6 +538,7 @@ export async function executeTaskTransition(options: ExecuteTaskTransitionOption oldStatusId: transition.fromStatusId, newStatusId: targetStatusId, task: updatedTask, + userId: user!.id, }).catch(err => console.error('[automation] task.status_changed error:', err)); } diff --git a/server/workers/automation-worker-code.ts b/server/workers/automation-worker-code.ts index ecfa1cc..2a0522a 100644 --- a/server/workers/automation-worker-code.ts +++ b/server/workers/automation-worker-code.ts @@ -104,6 +104,10 @@ const ctx = { return callStorage('createTaskFieldValue', [{ taskId, fieldId: field.id, formId: task.formId, value: String(value) }]); } }, + // Сообщение в чат задачи от имени «Система» (messageType 'status_change', без уведомлений) + sendMessage: function(taskId, message) { + return callStorage('sendTaskMessageFromAutomation', [taskId, organizationId, String(message)]); + }, }, };