Files
iistwin/server/routes/task-bulk.routes.ts

301 lines
11 KiB
TypeScript

import { Router } from 'express';
import { authenticateToken, requirePermission, type AuthenticatedRequest } from '../middleware/auth.middleware';
import { tenantIsolation } from '../middleware/tenant.middleware';
import { storage } from '../storage';
import { pool } from '../db';
import { executeTaskTransition } from '../services/task-transition.service';
import { tasksMinimalCache } from '../utils/cache';
import type { ReminderRecipient } from '@shared/schema';
const taskBulkRouter = Router();
const MAX_BULK_TASKS = 1000;
function validateTaskIds(body: unknown): number[] | null {
if (!body || typeof body !== 'object') return null;
const { taskIds } = body as { taskIds?: unknown };
if (!Array.isArray(taskIds)) return null;
const ids = taskIds
.map((id) => typeof id === 'number' ? id : (typeof id === 'string' ? parseInt(id, 10) : NaN))
.filter((id) => !isNaN(id));
if (ids.length === 0 || ids.length > MAX_BULK_TASKS) return null;
return ids;
}
// Mark notifications as read for multiple tasks
// POST /api/tasks/bulk/notifications/read
// Body: { taskIds: number[] }
taskBulkRouter.post('/api/tasks/bulk/notifications/read',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskIds = validateTaskIds(req.body);
if (!taskIds) {
return res.status(400).json({ success: false, error: 'Укажите от 1 до 100 ID задач' });
}
const userId = req.user!.id;
const organizationId = req.user!.organizationId;
await pool.query(
`UPDATE user_notifications
SET is_read = true
WHERE task_id = ANY($1)
AND user_id = $2
AND organization_id = $3
AND is_read = false`,
[taskIds, userId, organizationId]
);
res.json({ success: true, processed: taskIds.length });
} catch (err) {
console.error('[bulk/notifications/read]', err);
res.status(500).json({ success: false, error: 'Ошибка при отметке уведомлений' });
}
}
);
// Hide multiple tasks from the current user's inbox
// POST /api/tasks/bulk/hide-inbox
// Body: { taskIds: number[] }
taskBulkRouter.post('/api/tasks/bulk/hide-inbox',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskIds = validateTaskIds(req.body);
if (!taskIds) {
return res.status(400).json({ success: false, error: 'Укажите от 1 до 100 ID задач' });
}
const userId = req.user!.id;
const organizationId = req.user!.organizationId;
await pool.query(
`INSERT INTO task_inbox_hidden (task_id, user_id, organization_id)
SELECT unnest($1::int[]), $2, $3
ON CONFLICT (task_id, user_id) DO NOTHING`,
[taskIds, userId, organizationId]
);
res.json({ success: true, hidden: taskIds.length });
} catch (err) {
console.error('[bulk/hide-inbox]', err);
res.status(500).json({ success: false, error: 'Ошибка при скрытии задач' });
}
}
);
// Unhide multiple tasks for the current user
// DELETE /api/tasks/bulk/hide-inbox
// Body: { taskIds: number[] }
taskBulkRouter.delete('/api/tasks/bulk/hide-inbox',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskIds = validateTaskIds(req.body);
if (!taskIds) {
return res.status(400).json({ success: false, error: 'Укажите от 1 до 100 ID задач' });
}
const userId = req.user!.id;
const organizationId = req.user!.organizationId;
await pool.query(
`DELETE FROM task_inbox_hidden
WHERE task_id = ANY($1)
AND user_id = $2
AND organization_id = $3`,
[taskIds, userId, organizationId]
);
res.json({ success: true, restored: taskIds.length });
} catch (err) {
console.error('[bulk/unhide-inbox]', err);
res.status(500).json({ success: false, error: 'Ошибка при возврате задач' });
}
}
);
// Create reminders for multiple tasks
// POST /api/tasks/bulk/reminders
// Body: { taskIds: number[], remindAt: string, note?: string, recipients: ReminderRecipient[] }
taskBulkRouter.post('/api/tasks/bulk/reminders',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskIds = validateTaskIds(req.body);
if (!taskIds) {
return res.status(400).json({ success: false, error: 'Укажите от 1 до 100 ID задач' });
}
const { remindAt, note, recipients } = req.body as {
remindAt?: string;
note?: string;
recipients?: ReminderRecipient[];
};
if (!remindAt) {
return res.status(400).json({ success: false, error: 'Укажите дату и время напоминания' });
}
const remindAtDate = new Date(remindAt);
if (isNaN(remindAtDate.getTime())) {
return res.status(400).json({ success: false, error: 'Неверный формат даты напоминания' });
}
if (!Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ success: false, error: 'Укажите хотя бы одного получателя' });
}
const organizationId = req.user!.organizationId;
const createdByUserId = req.user!.id;
// Re-use single-task validation logic from mcp-rag.routes.ts
const ALLOWED_SYSTEM_ROLES = new Set(['admin', 'user']);
const validatedRecipients: ReminderRecipient[] = [];
const hasUserRecipients = recipients.some((rec) => rec.type === 'user');
const orgUsersMap = hasUserRecipients
? new Map((await storage.getUsersByOrganization(organizationId)).map((u) => [u.id, u]))
: new Map();
for (const rec of recipients) {
if (rec.type === 'user') {
if (typeof rec.userId !== 'number') {
return res.status(400).json({ success: false, error: 'Неверный формат получателя' });
}
const orgUser = orgUsersMap.get(rec.userId);
if (!orgUser) {
return res.status(400).json({ success: false, error: `Пользователь ${rec.userId} не принадлежит организации` });
}
validatedRecipients.push({ type: 'user', userId: rec.userId });
} else if (rec.type === 'role') {
if (typeof rec.role !== 'string' || !ALLOWED_SYSTEM_ROLES.has(rec.role)) {
return res.status(400).json({ success: false, error: `Недопустимая системная роль: ${rec.role}` });
}
validatedRecipients.push({ type: 'role', role: rec.role });
} else if (rec.type === 'orgRole') {
if (typeof rec.roleId !== 'number') {
return res.status(400).json({ success: false, error: 'Неверный формат роли организации' });
}
await storage.getUsersByOrgRoleId(rec.roleId, organizationId);
validatedRecipients.push({ type: 'orgRole', roleId: rec.roleId });
} else {
return res.status(400).json({ success: false, error: 'Неверный тип получателя' });
}
}
let created = 0;
for (const taskId of taskIds) {
await storage.createTaskReminder({
taskId,
organizationId,
createdByUserId,
remindAt: remindAtDate,
note: note || null,
recipients: validatedRecipients,
});
created++;
}
res.json({ success: true, created });
} catch (err) {
console.error('[bulk/reminders]', err);
res.status(500).json({ success: false, error: 'Ошибка при создании напоминаний' });
}
}
);
// Apply a status transition to multiple tasks
// POST /api/tasks/bulk/transition
// Body: { taskIds: number[], transitionId: number, action?: 'approve' | 'reject' }
taskBulkRouter.post('/api/tasks/bulk/transition',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskIds = validateTaskIds(req.body);
if (!taskIds) {
return res.status(400).json({ success: false, error: 'Укажите от 1 до 100 ID задач' });
}
const { transitionId, action } = req.body as { transitionId?: number; action?: 'approve' | 'reject' };
if (!transitionId) {
return res.status(400).json({ success: false, error: 'Укажите transitionId' });
}
const user = req.user!;
const organizationId = user.organizationId;
const results: Array<{ taskId: number; success: boolean; error?: string }> = [];
for (const taskId of taskIds) {
const result = await executeTaskTransition({
taskId,
transitionId,
action,
user,
organizationId,
});
results.push({ taskId, success: result.success, error: result.success ? undefined : result.error });
}
const processed = results.filter(r => r.success).length;
const errors = results.filter(r => !r.success).map(r => ({ taskId: r.taskId, error: r.error }));
res.json({ success: true, processed, total: taskIds.length, errors });
} catch (err) {
console.error('[bulk/transition]', err);
res.status(500).json({ success: false, error: 'Ошибка при массовой смене статуса' });
}
}
);
// Delete multiple tasks (admin only)
// DELETE /api/tasks/bulk
// Body: { taskIds: number[] }
taskBulkRouter.delete('/api/tasks/bulk',
authenticateToken,
tenantIsolation,
requirePermission('tasks.edit_all'),
async (req: AuthenticatedRequest, res) => {
try {
const taskIds = validateTaskIds(req.body);
if (!taskIds) {
return res.status(400).json({ success: false, error: 'Укажите от 1 до 100 ID задач' });
}
const organizationId = req.user!.organizationId;
let deleted = 0;
const errors: Array<{ taskId: number; error: string }> = [];
for (const taskId of taskIds) {
const existingTask = await storage.getTask(taskId, organizationId);
if (!existingTask) {
errors.push({ taskId, error: 'Задача не найдена' });
continue;
}
try {
await storage.deleteTask(taskId, organizationId);
deleted++;
} catch (err: any) {
console.error(`[bulk/delete] Failed to delete task ${taskId}:`, err);
errors.push({ taskId, error: err?.message || 'Ошибка удаления' });
}
}
if (deleted > 0) {
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
}
res.json({ success: true, deleted, total: taskIds.length, errors: errors.length > 0 ? errors : undefined });
} catch (err) {
console.error('[bulk/delete]', err);
res.status(500).json({ success: false, error: 'Ошибка при массовом удалении задач' });
}
}
);
export default taskBulkRouter;