Files
iistwin/server/routes/task-crud-list.routes.ts
Ильяс Султанов 9528a8123a perf(directories): SQL-фильтрация/пагинация справочников, N+1, лимиты column-values
Шаги 1.1 и 1.2 плана production-готовности:
- getDataTableRowsPaged: фильтры/поиск/сортировка/пагинация в SQL (values->>N)
- getDataTablesWithAccess и list_directories без N+1
- /column-values: limit (дефолт 20, кап 1000), убраны debug-логи с горячего пути
- tree-режим и legacy-путь TableEditor сохранены 1:1
- 10 новых тестов (88/88)
2026-09-07 23:09:15 +03:00

831 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Router } from "express";
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { type Task, type FormStatus, type SafeUser, roles, tasks } from "@shared/schema";
import { db } from "../db";
import { eq, inArray } from "drizzle-orm";
import { tasksMinimalCache } from "../utils/cache";
import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination";
import { clampDirectoryRowsLimit } from "../utils/data-table-rows-query";
// Лимиты выдачи уникальных значений в /column-values (автокомплит фильтров)
const COLUMN_VALUES_DEFAULT_LIMIT = 20;
const COLUMN_VALUES_MAX_LIMIT = 1000;
function safeParseOptions(opts: unknown): unknown {
if (Array.isArray(opts)) return opts;
if (typeof opts === 'string') {
try { return JSON.parse(opts); } catch { return undefined; }
}
if (typeof opts === 'object' && opts !== null) return opts;
return undefined;
}
/** Сканирует fieldValues задач на поля типа 'task', собирает referenced task IDs и возвращает их titles */
async function buildRelatedTaskTitles(
tasksList: Array<{ fieldValues?: { [fieldId: number]: unknown } } | any>,
formFields: Array<{ id: number; type: string }>,
): Promise<Record<number, string>> {
const taskFieldIds = new Set<number>();
for (const f of formFields) {
if (f.type === 'task') taskFieldIds.add(f.id);
}
if (taskFieldIds.size === 0) return {};
const referencedIds = new Set<number>();
for (const t of tasksList) {
if (!t.fieldValues) continue;
for (const fid of taskFieldIds) {
const val = t.fieldValues[fid];
if (val == null) continue;
let taskId: number | undefined;
if (typeof val === 'number') taskId = val;
else if (typeof val === 'string') {
const n = Number(val);
if (!isNaN(n)) {
taskId = n;
} else {
try {
const parsed = JSON.parse(val);
if (parsed && typeof parsed.taskId === 'number') taskId = parsed.taskId;
else if (parsed && typeof parsed.id === 'number') taskId = parsed.id;
} catch { /* ignore */ }
}
} else if (typeof val === 'object' && val !== null) {
const obj = val as any;
if (typeof obj.taskId === 'number') taskId = obj.taskId;
else if (typeof obj.id === 'number') taskId = obj.id;
}
if (taskId !== undefined) referencedIds.add(taskId);
}
}
if (referencedIds.size === 0) return {};
const relatedTasks = await db
.select({ id: tasks.id, title: tasks.title })
.from(tasks)
.where(inArray(tasks.id, Array.from(referencedIds)));
const map: Record<number, string> = {};
for (const t of relatedTasks) {
if (t.id) map[t.id] = t.title || `#${t.id}`;
}
return map;
}
export function registerTaskCrudListRoutes(router: ReturnType<typeof import("express").Router>): void {
router.get('/api/tasks', async (req: AuthenticatedRequest, res) => {
try {
const requestedLimit = req.query.limit ? parseInt(req.query.limit as string) : undefined;
// Дефолт 50, жёсткий кап 200 — без limit больше не уходит до 10000 полных строк
const effectiveLimit = clampTasksLimit(requestedLimit);
const sortBy = (req.query.sortBy as 'updatedAt' | 'createdAt') || 'updatedAt';
const order = (req.query.order as 'asc' | 'desc') || 'desc';
const statusId = req.query.statusId ? parseInt(req.query.statusId as string) : undefined;
const assignedTo = req.query.assignedTo ? parseInt(req.query.assignedTo as string) : undefined;
const minimal = req.query.minimal === 'true';
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
// Курсорная пагинация: cursor = "<updatedAt ISO>_<id>" (невалидный — игнорируется)
const cursor = parseTasksCursor(typeof req.query.cursor === 'string' ? req.query.cursor : null);
const appRole = req.user!.appRole;
const accessibleIds = await storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole);
if (search) {
const searchLimit = requestedLimit && requestedLimit > 0 ? Math.min(requestedLimit, 100) : 50;
let tasks = await storage.searchTasksByKeyword(req.organizationId!, search, searchLimit);
if (accessibleIds !== null) {
tasks = tasks.filter((t: any) => accessibleIds.has(t.id));
}
return res.json({ success: true, tasks });
}
// nextCursor выдаём только для дефолтной сортировки (updatedAt DESC) — режима курсора
const computeNextCursor = (list: Array<{ id: number; updatedAt?: Date | string | null }>): string | null => {
if (sortBy !== 'updatedAt' || order !== 'desc' || list.length < effectiveLimit) return null;
const last = list[list.length - 1];
return last && last.updatedAt ? buildTasksNextCursor({ id: last.id, updatedAt: last.updatedAt }) : null;
};
const cacheKey = (minimal && accessibleIds === null)
? `tasks:${req.organizationId}:minimal:${effectiveLimit}:${sortBy}:${order}:${statusId ?? ''}:${assignedTo ?? ''}:${cursor ? buildTasksNextCursor(cursor) ?? '' : ''}`
: null;
if (cacheKey) {
const cached = tasksMinimalCache.get<Task[]>(cacheKey);
if (cached) {
return res.json({ success: true, tasks: cached, nextCursor: computeNextCursor(cached) });
}
}
let tasks = await storage.getTasksByOrganization(req.organizationId!, {
limit: effectiveLimit,
sortBy,
order,
statusId: statusId && !isNaN(statusId) ? statusId : undefined,
assignedTo: assignedTo && !isNaN(assignedTo) ? assignedTo : undefined,
minimal,
// Списковый ответ без description: тяжёлая text-колонка в реестрах не нужна.
// Поиск (выше) и деталь задачи (/api/tasks/:id) description сохраняют.
excludeDescription: true,
cursor,
});
if (accessibleIds !== null) {
tasks = (tasks as Task[]).filter((t: Task) => accessibleIds.has(t.id));
}
if (cacheKey) {
tasksMinimalCache.set(cacheKey, tasks);
}
res.json({ success: true, tasks, nextCursor: computeNextCursor(tasks as Array<{ id: number; updatedAt?: Date | string | null }>) });
} catch (error) {
console.error('Get all tasks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении задач' });
}
});
router.get('/api/forms/:id/tasks', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const appRole = req.user!.appRole;
const [tasks, accessibleIds] = await Promise.all([
storage.getTasksWithFieldsByForm(formId, req.organizationId!),
storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole),
]);
const filteredTasks = accessibleIds !== null
? tasks.filter((t: Task) => accessibleIds.has(t.id))
: tasks;
const formFields = await storage.getFormFields(formId, req.organizationId!);
const relatedTaskTitles = await buildRelatedTaskTitles(filteredTasks, formFields);
res.json({ success: true, tasks: filteredTasks, relatedTaskTitles });
} catch (error) {
console.error('Get tasks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении задач' });
}
});
router.get('/api/forms/:id/tasks/with-fields', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const appRole = req.user!.appRole;
const [result, accessibleIds] = await Promise.all([
storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!, { limit, offset }),
storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole),
]);
let tasks = result.tasks as Task[];
if (accessibleIds !== null) {
tasks = tasks.filter((t: Task) => accessibleIds.has(t.id));
}
// Build taskTitleMap early for filtering/sorting and field resolution
const taskTitleMap = new Map<number, string>();
for (const t of tasks) {
if (t.id) taskTitleMap.set(t.id, t.title);
}
const filtersStr = req.query.filters as string;
if (filtersStr) {
try {
const filters: Record<string, string> = JSON.parse(filtersStr);
if (Object.keys(filters).length > 0) {
const [statuses, users, formFields, orgRoles] = await Promise.all([
storage.getFormStatuses(formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getFormFields(formId, req.organizationId!),
db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)),
]);
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: SafeUser) => [u.id, u]));
const roleMap = new Map(orgRoles.map((r) => [r.id, r]));
const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId);
const legalEntityMap = new Map<number, string>();
for (const f of contractNumberFields) {
const leFormId = (f as any).options.contractNumber.legalEntityFormId as number;
try {
const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!);
for (const t of leTasks) {
legalEntityMap.set(t.id, t.title);
}
} catch {}
}
Object.entries(filters).forEach(([col, val]) => {
if (!val) return;
if (col === 'overdue' && val === 'true') {
const today = new Date();
today.setHours(0, 0, 0, 0);
tasks = tasks.filter((t: Task) => {
const isCompleted = t.isCompleted || statusMap.get(t.currentStatusId)?.isFinal;
return !isCompleted && t.dueDate && new Date(t.dueDate) < today;
});
return;
}
const valLower = val.toLowerCase();
tasks = tasks.filter((t: Task) => {
const tv = getTaskColumnValue(t, col, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap);
return tv.toLowerCase().includes(valLower);
});
});
}
} catch {}
}
const sortColumn = req.query.sortColumn as string;
const sortDir = req.query.sortDirection as string;
if (sortColumn && (sortDir === 'asc' || sortDir === 'desc')) {
const [statuses, users, formFields, orgRoles] = await Promise.all([
storage.getFormStatuses(formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getFormFields(formId, req.organizationId!),
db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)),
]);
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: SafeUser) => [u.id, u]));
const roleMap = new Map(orgRoles.map((r) => [r.id, r]));
const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId);
const legalEntityMap = new Map<number, string>();
for (const f of contractNumberFields) {
const leFormId = (f as any).options.contractNumber.legalEntityFormId as number;
try {
const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!);
for (const t of leTasks) {
legalEntityMap.set(t.id, t.title);
}
} catch {}
}
const DATE_COLS = new Set(['createdAt', 'updatedAt', 'dueDate', 'completedAt']);
// Числовые и датовые кастомные поля сортируем по сырому значению,
// а не по отображаемой строке (иначе «01.12.2025» < «02.01.2024» и «10» < «9»)
type TaskWithFV = Task & { fieldValues?: Record<number, unknown> };
let numericFieldId: number | null = null;
let numericFieldIsDate = false;
if (sortColumn.startsWith('field_')) {
const fieldId = parseInt(sortColumn.replace('field_', ''));
const field = formFields.find((f) => f.id === fieldId);
if (field && ['date', 'datetime', 'number'].includes(field.type)) {
numericFieldId = fieldId;
numericFieldIsDate = field.type === 'date' || field.type === 'datetime';
}
}
const toSortNumber = (t: TaskWithFV): number | null => {
const v = t.fieldValues?.[numericFieldId!];
if (v === undefined || v === null || v === '') return null;
if (numericFieldIsDate) {
const ts = new Date(String(v)).getTime();
return isNaN(ts) ? null : ts;
}
const n = Number(String(v).replace(/\s/g, ''));
return isNaN(n) ? null : n;
};
tasks = [...tasks].sort((a, b) => {
if (DATE_COLS.has(sortColumn)) {
const _a = (a as Record<string, unknown>)[sortColumn];
const _b = (b as Record<string, unknown>)[sortColumn];
const av = _a ? new Date(String(_a)).getTime() : 0;
const bv = _b ? new Date(String(_b)).getTime() : 0;
return sortDir === 'asc' ? av - bv : bv - av;
}
if (numericFieldId !== null) {
const av = toSortNumber(a as TaskWithFV);
const bv = toSortNumber(b as TaskWithFV);
// Пустые значения всегда в конец независимо от направления
if (av === null && bv === null) return 0;
if (av === null) return 1;
if (bv === null) return -1;
return sortDir === 'asc' ? av - bv : bv - av;
}
const av = getTaskColumnValue(a, sortColumn, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap).toLowerCase();
const bv = getTaskColumnValue(b, sortColumn, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap).toLowerCase();
const cmp = av.localeCompare(bv, 'ru');
return sortDir === 'asc' ? cmp : -cmp;
});
}
const formFields = await storage.getFormFields(formId, req.organizationId!);
const relatedTaskTitles = await buildRelatedTaskTitles(tasks, formFields);
res.json({ success: true, tasks, total: tasks.length, limit, offset, relatedTaskTitles });
} catch (error) {
console.error('Get tasks with fields error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении задач' });
}
});
router.get('/api/forms/:id/tasks/status-counts', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const data = await storage.getTaskCountsByStatus(formId, req.organizationId!);
res.json({ success: true, data });
} catch (error) {
console.error('Get status counts error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении количества задач по статусам' });
}
});
// Отображаемое имя одного элемента user-поля (`user:<id>` / `role:<id>` / legacy-число)
function resolveUserColumnEntry(
entry: string,
userMap: Map<number, SafeUser>,
roleMap?: Map<number, { name?: string }>
): string {
if (entry.startsWith('user:')) {
const uid = Number(entry.replace('user:', ''));
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry;
}
if (entry.startsWith('role:')) {
const rid = Number(entry.replace('role:', ''));
const r = roleMap?.get(rid);
return r?.name ? `Роль: ${r.name}` : entry;
}
const uid = Number(entry);
if (!isNaN(uid)) {
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry;
}
return entry;
}
function getTaskColumnValue(
task: Task & { fieldValues?: Record<number, unknown>; assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> },
column: string,
statusMap: Map<number, FormStatus>,
userMap: Map<number, SafeUser>,
fields?: Array<{ id: number; type: string; options?: unknown }>,
roleMap?: Map<number, { name?: string }>,
tableRowMap?: Map<number, Map<number, string>>,
legalEntityMap?: Map<number, string>,
taskTitleMap?: Map<number, string>
): string {
if (column === 'title') return task.title || '';
if (column === 'status') {
const s = statusMap.get(task.currentStatusId);
return s ? s.name : '';
}
if (column === 'currentStatusId') {
return String(task.currentStatusId || '');
}
if (column === 'assignedTo') {
const taskAny = task as Task & { assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> };
if (taskAny.assignees && Array.isArray(taskAny.assignees) && taskAny.assignees.length > 0) {
return taskAny.assignees
.map((a: { userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }) => {
const u = a.user ?? userMap.get(a.userId);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : '';
})
.filter(Boolean)
.join(', ');
}
if (!task.assignedTo) return '';
const u = userMap.get(task.assignedTo);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : '';
}
if (column === 'createdBy') {
if (!task.createdBy) return '';
const u = userMap.get(task.createdBy);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : '';
}
if (column === 'dueDate') {
return task.dueDate ? new Date(task.dueDate).toLocaleDateString('ru-RU') : '';
}
if (column === 'createdAt') {
return task.createdAt ? new Date(task.createdAt).toLocaleDateString('ru-RU') : '';
}
if (column === 'updatedAt') {
return task.updatedAt ? new Date(task.updatedAt).toLocaleDateString('ru-RU') : '';
}
if (column === 'completedAt') {
return task.completedAt ? new Date(task.completedAt).toLocaleDateString('ru-RU') : '';
}
if (column.startsWith('field_')) {
const fieldId = parseInt(column.replace('field_', ''));
const field = fields?.find(f => f.id === fieldId);
const fv = task.fieldValues;
if (fv && typeof fv === 'object') {
const v = fv[fieldId];
if (v !== undefined && v !== null) {
const str = String(v);
switch (field?.type) {
case 'user': {
// Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую
let parsedUserVal: unknown = v;
if (typeof v === 'string' && v.startsWith('[')) {
try { parsedUserVal = JSON.parse(v); } catch { parsedUserVal = v; }
}
if (Array.isArray(parsedUserVal)) {
return parsedUserVal
.map((entry) => resolveUserColumnEntry(String(entry), userMap, roleMap))
.filter(Boolean)
.join(', ');
}
return resolveUserColumnEntry(str, userMap, roleMap);
}
case 'role': {
if (str.startsWith('role:')) {
const rid = Number(str.replace('role:', ''));
const r = roleMap?.get(rid);
return r?.name || str;
}
const rid = Number(str);
if (!isNaN(rid)) {
const r = roleMap?.get(rid);
return r?.name || str;
}
return str;
}
case 'contract-number': {
try {
const parsed = typeof v === 'string' ? JSON.parse(v) : v;
const num = parsed?.value ? String(parsed.value) : str;
const entityTitle = parsed?.legalEntityTaskId ? legalEntityMap?.get(Number(parsed.legalEntityTaskId)) : undefined;
const result = entityTitle ? `${num} — ${entityTitle}` : num;
return result;
} catch {
return str;
}
}
case 'company': {
try {
const parsed = typeof v === 'string' ? JSON.parse(v) : v;
return parsed?.name || parsed?.shortName || parsed?.fullName || str;
} catch {
return str;
}
}
case 'select':
case 'radio-group': {
const parsedOpts = safeParseOptions(field.options);
const opts = Array.isArray(parsedOpts) ? parsedOpts as Array<{ label?: string; value?: string; id?: string }> : [];
const opt = opts.find(o => String(o.value ?? o.id) === str);
return opt?.label || str;
}
case 'table': {
const parsedOpts = safeParseOptions(field.options);
const tableOpts = (typeof parsedOpts === 'object' && parsedOpts !== null && !Array.isArray(parsedOpts))
? parsedOpts as { tableId?: number; displayColumn?: string }
: undefined;
const tableId = tableOpts?.tableId;
// Значение может быть числом/строкой или JSON-объектом {rowId: n}
let rowId = Number(str);
if (isNaN(rowId) && typeof v === 'object' && v !== null) {
try {
const obj = typeof v === 'string' ? JSON.parse(v) : v;
if (obj && typeof obj.rowId === 'number') {
rowId = obj.rowId;
}
} catch { /* ignore */ }
}
if (tableId && !isNaN(rowId) && tableRowMap?.has(tableId)) {
const displayValue = tableRowMap.get(tableId)!.get(rowId);
if (displayValue !== undefined) return displayValue;
}
return str;
}
case 'toggle':
case 'checkbox': {
if (v === true || str === 'true') return 'Да';
if (v === false || str === 'false') return 'Нет';
return str;
}
case 'checklist': {
if (Array.isArray(v)) {
const checkedCount = v.filter((it: any) => it?.checked).length;
const totalCount = Array.isArray(field.options) ? field.options.length : 0;
return `Выполнено ${checkedCount} из ${totalCount}`;
}
return str;
}
case 'file': {
if (Array.isArray(v)) {
return v
.map(f => typeof f === 'object' && f !== null ? (f as { name?: string }).name || '' : String(f))
.filter(Boolean)
.join(', ');
}
if (typeof v === 'object' && v !== null) {
return (v as { name?: string }).name || str;
}
return str;
}
case 'geo': {
try {
const parsed = typeof v === 'string' ? JSON.parse(v) : v;
return parsed?.address || `${parsed?.lat || ''}, ${parsed?.lng || ''}` || str;
} catch {
return str;
}
}
case 'date':
case 'datetime': {
const dateValue = typeof v === 'string' ? v : str;
if (!dateValue) return '';
const date = new Date(dateValue);
if (!isNaN(date.getTime())) {
return date.toLocaleDateString('ru-RU');
}
return dateValue;
}
case 'task': {
const taskId = typeof v === 'number' ? v : Number(str);
if (!isNaN(taskId)) {
const title = taskTitleMap?.get(taskId);
if (title) return title;
return `#${taskId}`;
}
return str;
}
}
// Fallback для file-like объектов
if (Array.isArray(v)) {
return v
.filter((f: unknown): f is { name: string } => typeof f === 'object' && f !== null && typeof (f as { name?: unknown }).name === 'string')
.map((f: { name: string }) => f.name)
.join(', ');
}
if (typeof v === 'object' && v !== null && 'name' in v && typeof (v as Record<string, unknown>).name === 'string') {
return (v as Record<string, string>).name;
}
return String(v);
}
}
return '';
}
return '';
}
router.get('/api/forms/:id/tasks/column-values', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) return res.status(400).json({ error: 'Неверный ID формы' });
const column = (req.query.column as string) || '';
const search = ((req.query.search as string) || '').toLowerCase();
const contextStr = req.query.context as string;
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) return res.status(404).json({ error: 'Форма не найдена' });
const appRole = req.user!.appRole;
const [result, accessibleIds] = await Promise.all([
storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!),
storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole),
]);
type TaskWithFieldValues = Task & { fieldValues?: Record<number, unknown> };
let taskList = result.tasks as TaskWithFieldValues[];
if (accessibleIds !== null) {
taskList = taskList.filter((t: TaskWithFieldValues) => accessibleIds.has(t.id));
}
const [formFields, orgRoles] = await Promise.all([
storage.getFormFields(formId, req.organizationId!),
db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)),
]);
const roleMap = new Map(orgRoles.map((r) => [r.id, r]));
// Build taskTitleMap for task field resolution
const taskTitleMap = new Map<number, string>();
for (const t of taskList) {
if (t.id) taskTitleMap.set(t.id, t.title);
}
const relatedTaskTitles = await buildRelatedTaskTitles(taskList, formFields);
for (const [id, title] of Object.entries(relatedTaskTitles)) {
const taskId = Number(id);
if (!taskTitleMap.has(taskId)) {
taskTitleMap.set(taskId, title);
}
}
// Загружаем юрлица для полей типа 'contract-number'
const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId);
const legalEntityMap = new Map<number, string>();
for (const f of contractNumberFields) {
const leFormId = (f as any).options.contractNumber.legalEntityFormId as number;
try {
const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!);
for (const t of leTasks) {
legalEntityMap.set(t.id, t.title);
}
} catch (err) {
console.error('[column-values] error loading legal entities for formId:', leFormId, err);
}
}
// Загружаем справочники для полей типа 'table' чтобы резолвить ID в названия
const tableRowMap = new Map<number, Map<number, string>>();
const tableFields = formFields.filter((f: any) => {
if (f.type !== 'table') return false;
const parsed = safeParseOptions(f.options);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && (parsed as any).tableId;
});
const uniqueTableIds = new Set<number>();
for (const f of tableFields) {
const parsed = safeParseOptions(f.options);
const tableId = (parsed as any).tableId as number;
if (tableId) uniqueTableIds.add(tableId);
}
for (const tableId of uniqueTableIds) {
const fullTable = await storage.getDataTableFull(tableId, req.organizationId!);
if (!fullTable) continue;
const columns = (fullTable.columns as Array<{ name: string }>) || [];
const rows = (fullTable.rows as Array<{ id: number; values: string[] }>) || [];
const displayColumn = (tableFields.find((f: any) => (f.options as any).tableId === tableId)?.options as any)?.displayColumn || columns[0]?.name;
const colIndex = columns.findIndex((c) => c.name === displayColumn);
const rowMap = new Map<number, string>();
for (const row of rows) {
const displayValue = colIndex >= 0 ? row.values[colIndex] : row.values[0];
rowMap.set(row.id, displayValue || `#${row.id}`);
}
tableRowMap.set(tableId, rowMap);
}
if (contextStr) {
try {
const context: Record<string, string> = JSON.parse(contextStr);
const statuses = await storage.getFormStatuses(formId, req.organizationId!);
const users = await storage.getUsersByOrganization(req.organizationId!);
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: SafeUser) => [u.id, u]));
Object.entries(context).forEach(([col, val]) => {
if (!val) return;
if (col === 'overdue' && val === 'true') {
const today = new Date();
today.setHours(0, 0, 0, 0);
taskList = taskList.filter((t: Task) => {
const isCompleted = t.isCompleted || statusMap.get(t.currentStatusId)?.isFinal;
return !isCompleted && t.dueDate && new Date(t.dueDate) < today;
});
return;
}
const valLower = val.toLowerCase();
taskList = taskList.filter((t: Task) => {
const tv = getTaskColumnValue(t, col, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap);
return tv.toLowerCase().includes(valLower);
});
});
} catch {}
}
const statuses = await storage.getFormStatuses(formId, req.organizationId!);
const users = await storage.getUsersByOrganization(req.organizationId!);
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: SafeUser) => [u.id, u]));
// Кап на количество уникальных значений: параметр limit (дефолт 20, максимум 1000)
const limit = clampDirectoryRowsLimit(req.query.limit, COLUMN_VALUES_DEFAULT_LIMIT, COLUMN_VALUES_MAX_LIMIT);
const seen = new Set<string>();
const values: string[] = [];
for (const t of taskList) {
const val = getTaskColumnValue(t, column, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap).trim();
if (!val || seen.has(val)) continue;
if (search && !val.toLowerCase().includes(search)) continue;
seen.add(val);
values.push(val);
if (values.length >= limit) break;
}
res.json({ values });
} catch (error) {
console.error('Task column values error:', error);
res.status(500).json({ error: 'Ошибка получения значений' });
}
});
router.post('/api/forms/:id/tasks/aggregate', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const { fieldCode, operation, statusId } = req.body;
if (!fieldCode || !operation) {
return res.status(400).json({ success: false, error: 'Требуется fieldCode и operation' });
}
const allowedOperations = ['sum', 'avg', 'count', 'min', 'max'];
if (!allowedOperations.includes(operation)) {
return res.status(400).json({
success: false,
error: `Неверная операция. Допустимые: ${allowedOperations.join(', ')}`
});
}
const appRole = req.user!.appRole;
const [result, accessibleIds] = await Promise.all([
storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!, {}),
storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole),
]);
type TaskWithFields = (typeof result.tasks)[0] & { fieldValues?: Record<number, string | number | null> };
let tasks = result.tasks as TaskWithFields[];
if (accessibleIds !== null) {
tasks = tasks.filter((t: Task) => accessibleIds.has(t.id));
}
let filteredTasks = tasks;
if (statusId) {
filteredTasks = tasks.filter(t => t.currentStatusId === statusId);
}
const values: number[] = [];
for (const task of filteredTasks) {
const fieldValue = task.fieldValues?.[fieldCode];
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
const numValue = parseFloat(String(fieldValue));
if (!isNaN(numValue)) {
values.push(numValue);
}
}
}
let aggregatedValue: number | null = null;
switch (operation) {
case 'sum':
aggregatedValue = values.reduce((a, b) => a + b, 0);
break;
case 'avg':
aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case 'count':
aggregatedValue = values.length;
break;
case 'min':
aggregatedValue = values.length > 0 ? Math.min(...values) : null;
break;
case 'max':
aggregatedValue = values.length > 0 ? Math.max(...values) : null;
break;
}
res.json({
success: true,
result: {
formId,
fieldCode,
operation,
value: aggregatedValue,
taskCount: filteredTasks.length,
valuesCount: values.length
}
});
} catch (error) {
console.error('Aggregate tasks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при агрегации данных' });
}
});
}