fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
253
server/routes/task-bulk.routes.ts
Normal file
253
server/routes/task-bulk.routes.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken, 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 type { ReminderRecipient } from '@shared/schema';
|
||||
|
||||
const taskBulkRouter = Router();
|
||||
|
||||
const MAX_BULK_TASKS = 100;
|
||||
|
||||
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: 'Ошибка при массовой смене статуса' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default taskBulkRouter;
|
||||
Reference in New Issue
Block a user