feat(automations): ctx.tasks.sendMessage — сообщение в чат задачи от Системы; userId в triggerData status_changed

This commit is contained in:
2026-09-08 16:31:59 +03:00
parent 163351b345
commit 0b2319b11d
13 changed files with 171 additions and 32 deletions

View File

@@ -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<string, string> = {
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<string>('');
const [accessLevel, setAccessLevel] = useState<string>('participate');
const [newAuthorId, setNewAuthorId] = useState<string>('');
// 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<FormAccessRule, 'id' | 'formId'>) =>
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);

View File

@@ -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) => {
</div>
</FormItem>
{isEditing && (
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Новый пароль</FormLabel>
<FormControl>
<Input type="password" placeholder="Оставьте пустым для сохранения текущего" {...field} data-testid="input-password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{isEditing ? 'Новый пароль' : 'Пароль'}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={isEditing ? 'Оставьте пустым для сохранения текущего' : 'Оставьте пустым — сгенерируется временный'}
{...field}
data-testid="input-password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!isEditing && (
<FormField

View File

@@ -537,10 +537,20 @@ export function ProfilePasswordRow({ userId, canEdit }: { userId: number; canEdi
};
const apply = async () => {
// Правила совпадают с серверными (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);

View File

@@ -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)

View File

@@ -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<AuthResponse> {
const response = await apiRequest('POST', '/api/auth/login', data);
@@ -60,8 +75,12 @@ class AuthService {
}
async createUser(data: CreateUserRequest): Promise<AuthResponse> {
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<User[]> {
@@ -80,23 +99,39 @@ class AuthService {
}
async updateUser(id: number, data: Partial<User>): 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 {

View File

@@ -55,6 +55,8 @@ export interface CreateUserRequest {
appRole: string;
organizationalRoleIds?: number[];
sendInvite: boolean;
// Необязательный явный пароль; без него сервер генерирует временный
password?: string;
}
export interface AuthResponse {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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<string, (...a: unknown[]) => Promise<unknown>>)[method];
if (typeof fn !== 'function') {

View File

@@ -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: 'Ошибка при создании пользователя' };
}
});

View File

@@ -47,7 +47,11 @@ export interface SendTaskMessageParams {
export async function sendTaskMessage(params: SendTaskMessageParams): Promise<TaskMessage> {
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;

View File

@@ -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));
}

View File

@@ -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)]);
},
},
};