import { Router } from "express"; import { db } from "../db"; import { storage } from "../storage"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { tasks, forms, formStatuses, taskAssignees, taskFieldValues, taskRelations, users } from "@shared/schema"; import { eq, and, or, inArray, sql, isNotNull, asc, exists } from "drizzle-orm"; import { getGanttFieldsByForm, getGanttTaskDates, getRelatedTaskIds, calculateDateShift, shiftRelatedTasks, type GanttFieldInfo, type GanttTaskDates, } from "../services/gantt-shift.service"; export const ganttRouter = Router(); interface GanttTask { id: number; title: string; description?: string | null; formId: number; currentStatusId: number; statusName: string | null; statusColor: string | null; statusIsFinal: boolean; assignedTo?: number | null; assignees?: Array<{ userId: number; user: { firstName: string | null; lastName: string | null; email: string } }>; createdBy: number; createdAt: string; updatedAt: string; completedAt?: string | null; dueDate?: string | null; isCompleted: boolean; parentTaskId?: number | null; fieldValues?: Record; start: string | null; end: string | null; milestones: Array<{ fieldId: number; date: string }>; } interface GanttFormMeta { formId: number; startFieldId?: number; endFieldId?: number; milestoneFieldIds: number[]; } function parseFormIds(value: unknown): number[] | undefined { if (typeof value !== "string" || !value) return undefined; const ids = value .split(",") .map((s) => parseInt(s.trim(), 10)) .filter((n) => !isNaN(n)); return ids.length > 0 ? ids : undefined; } // GET /api/gantt/tasks?formIds=1,2,3&filters={...}&sortColumn=...&sortDirection=... ganttRouter.get("/api/gantt/tasks", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const formIds = parseFormIds(req.query.formIds); if (!formIds || formIds.length === 0) { return res.json({ success: true, tasks: [], meta: [], relatedTaskTitles: {} }); } const organizationId = req.organizationId!; const userId = req.user!.id; const appRole = req.user!.appRole; // Verify forms belong to organization and user can access them const formRows = await db .select({ id: forms.id, name: forms.name }) .from(forms) .where(and(eq(forms.organizationId, organizationId), inArray(forms.id, formIds))); const validFormIds = formRows.map((f) => f.id); if (validFormIds.length === 0) { return res.status(403).json({ success: false, error: "Нет доступа к выбранным формам" }); } const accessibleIds = await storage.getAccessibleTaskIds(userId, organizationId, appRole); // Load tasks with fields from selected forms const taskRows = await db .select({ id: tasks.id, title: tasks.title, description: tasks.description, formId: tasks.formId, currentStatusId: tasks.currentStatusId, assignedTo: tasks.assignedTo, createdBy: tasks.createdBy, createdAt: tasks.createdAt, updatedAt: tasks.updatedAt, completedAt: tasks.completedAt, dueDate: tasks.dueDate, isCompleted: tasks.isCompleted, parentTaskId: tasks.parentTaskId, statusName: formStatuses.name, statusColor: formStatuses.color, statusIsFinal: formStatuses.isFinal, }) .from(tasks) .innerJoin(forms, eq(tasks.formId, forms.id)) .innerJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id)) .where(and( eq(forms.organizationId, organizationId), inArray(tasks.formId, validFormIds) )) .orderBy(asc(tasks.updatedAt)); let allowedTaskRows = taskRows; if (accessibleIds !== null) { allowedTaskRows = taskRows.filter((t) => accessibleIds.has(t.id)); } const taskIds = allowedTaskRows.map((t) => t.id); // Load field values const fieldValuesRows = await db .select({ taskId: taskFieldValues.taskId, fieldId: taskFieldValues.fieldId, value: taskFieldValues.value }) .from(taskFieldValues) .where(inArray(taskFieldValues.taskId, taskIds)); const fieldValuesByTask = new Map>(); for (const fv of fieldValuesRows) { if (!fieldValuesByTask.has(fv.taskId)) fieldValuesByTask.set(fv.taskId, {}); fieldValuesByTask.get(fv.taskId)![fv.fieldId] = fv.value; } // Load assignees const assigneeRows = await db .select({ taskId: taskAssignees.taskId, userId: taskAssignees.userId, firstName: users.firstName, lastName: users.lastName, email: users.email, }) .from(taskAssignees) .innerJoin(users, eq(taskAssignees.userId, users.id)) .where(inArray(taskAssignees.taskId, taskIds)); const assigneesByTask = new Map>(); for (const a of assigneeRows) { if (!assigneesByTask.has(a.taskId)) assigneesByTask.set(a.taskId, []); assigneesByTask.get(a.taskId)!.push({ userId: a.userId, user: { firstName: a.firstName, lastName: a.lastName, email: a.email }, }); } // Load gantt fields meta const ganttFields = await getGanttFieldsByForm(validFormIds, organizationId); const ganttDates = await getGanttTaskDates(taskIds, ganttFields, organizationId); const datesMap = new Map(); for (const d of ganttDates) datesMap.set(d.taskId, d); // Build meta per form const metaByForm = new Map(); for (const formId of validFormIds) { metaByForm.set(formId, { formId, startFieldId: ganttFields.find((f) => f.formId === formId && f.role === "start")?.id, endFieldId: ganttFields.find((f) => f.formId === formId && f.role === "end")?.id, milestoneFieldIds: ganttFields.filter((f) => f.formId === formId && f.role === "milestone").map((f) => f.id), }); } // Build result tasks let resultTasks: GanttTask[] = allowedTaskRows.map((t) => { const dates = datesMap.get(t.id); const fvs = fieldValuesByTask.get(t.id) || {}; return { id: t.id, title: t.title, description: t.description, formId: t.formId, currentStatusId: t.currentStatusId, statusName: t.statusName, statusColor: t.statusColor, statusIsFinal: t.statusIsFinal ?? false, assignedTo: t.assignedTo, assignees: assigneesByTask.get(t.id), createdBy: t.createdBy, createdAt: t.createdAt ? t.createdAt.toISOString() : "", updatedAt: t.updatedAt ? t.updatedAt.toISOString() : "", completedAt: t.completedAt ? t.completedAt.toISOString() : null, dueDate: t.dueDate ? t.dueDate.toISOString() : null, isCompleted: t.isCompleted ?? false, parentTaskId: t.parentTaskId, fieldValues: fvs, start: dates?.start ? dates.start.toISOString() : null, end: dates?.end ? dates.end.toISOString() : null, milestones: dates?.milestones.map((m) => ({ fieldId: m.fieldId, date: m.date.toISOString() })) || [], }; }); // Apply filters (server-side subset) const filtersStr = req.query.filters as string; if (filtersStr) { try { const filters: Record = JSON.parse(filtersStr); // TODO: implement column filtering if needed; for now keep all tasks and let client filter. } catch { /* ignore */ } } // Build related task titles for task-type fields const relatedTaskTitles: Record = {}; const referencedIds = new Set(); for (const t of resultTasks) { if (!t.fieldValues) continue; for (const [fieldIdStr, val] of Object.entries(t.fieldValues)) { const fieldId = parseInt(fieldIdStr); const field = ganttFields.find((f) => f.id === fieldId); // We don't have field type here; collect all numeric task refs. 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) { const relatedRows = await db .select({ id: tasks.id, title: tasks.title }) .from(tasks) .innerJoin(forms, eq(tasks.formId, forms.id)) .where(and(inArray(tasks.id, Array.from(referencedIds)), eq(forms.organizationId, organizationId))); for (const r of relatedRows) { relatedTaskTitles[r.id] = r.title || `#${r.id}`; } } res.json({ success: true, tasks: resultTasks, meta: Array.from(metaByForm.values()), relatedTaskTitles, }); } catch (error) { console.error("Gantt tasks error:", error); res.status(500).json({ success: false, error: "Ошибка при получении задач Гантта" }); } }); // GET /api/gantt/relations?taskIds=1,2,3 ganttRouter.get("/api/gantt/relations", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskIds = parseFormIds(req.query.taskIds); if (!taskIds || taskIds.length === 0) { return res.json({ success: true, relations: [] }); } const organizationId = req.organizationId!; const relations = await db .select({ parentTaskId: taskRelations.parentTaskId, childTaskId: taskRelations.childTaskId }) .from(taskRelations) .where(and( eq(taskRelations.organizationId, organizationId), or( inArray(taskRelations.parentTaskId, taskIds), inArray(taskRelations.childTaskId, taskIds) ) )); res.json({ success: true, relations }); } catch (error) { console.error("Gantt relations error:", error); res.status(500).json({ success: false, error: "Ошибка при получении связей Гантта" }); } }); // PUT /api/gantt/tasks/:id/dates — update gantt dates with cascade shift ganttRouter.put("/api/gantt/tasks/:id/dates", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id, 10); if (isNaN(taskId)) { return res.status(400).json({ success: false, error: "Неверный ID задачи" }); } const { start, end, startFieldId, endFieldId } = req.body; const organizationId = req.organizationId!; const existingTask = await storage.getTask(taskId, organizationId); if (!existingTask) { return res.status(404).json({ success: false, error: "Задача не найдена" }); } const hasAccess = await storage.canUserAccessTask(taskId, req.user!.id, organizationId, req.user!.appRole); if (!hasAccess) { return res.status(403).json({ success: false, error: "Нет доступа к этой задаче" }); } const ganttFields = await getGanttFieldsByForm([existingTask.formId], organizationId); const dates = await getGanttTaskDates([taskId], ganttFields, organizationId); const currentDates = dates[0]; let deltaMs = 0; const updatedFields: Array<{ fieldId: number; value: string }> = []; if (startFieldId && start !== undefined) { const newStart = start ? new Date(start) : null; const oldStart = currentDates?.start || null; if (newStart && !isNaN(newStart.getTime())) { updatedFields.push({ fieldId: startFieldId, value: newStart.toISOString() }); if (deltaMs === 0 && oldStart) { deltaMs = calculateDateShift(oldStart, newStart); } } } if (endFieldId && end !== undefined) { const newEnd = end ? new Date(end) : null; const oldEnd = currentDates?.end || null; if (newEnd && !isNaN(newEnd.getTime())) { updatedFields.push({ fieldId: endFieldId, value: newEnd.toISOString() }); if (deltaMs === 0 && oldEnd) { deltaMs = calculateDateShift(oldEnd, newEnd); } } } if (updatedFields.length === 0) { return res.status(400).json({ success: false, error: "Не переданы даты для обновления" }); } // Apply updates to the source task for (const { fieldId, value } of updatedFields) { await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value }); } // Cascade shift related tasks const shiftResult = await shiftRelatedTasks(taskId, deltaMs, organizationId, { triggeredByUserId: req.user?.id }); // Invalidate caches const { tasksMinimalCache } = await import("../utils/cache"); tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`); res.json({ success: true, taskId, deltaMs, shifted: shiftResult.shifted, errors: shiftResult.errors }); } catch (error) { console.error("Gantt update dates error:", error); res.status(500).json({ success: false, error: "Ошибка при обновлении дат Гантта" }); } });