feat(automations): ctx.tasks.sendMessage — сообщение в чат задачи от Системы; userId в triggerData status_changed
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface CreateUserRequest {
|
||||
appRole: string;
|
||||
organizationalRoleIds?: number[];
|
||||
sendInvite: boolean;
|
||||
// Необязательный явный пароль; без него сервер генерирует временный
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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: 'Ошибка при создании пользователя' };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user