Files
iistwin/server/routes/calendar.routes.ts

233 lines
8.0 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 { db } from "../db";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { tasks, forms, formStatuses, taskAssignees, taskReminders, type ReminderRecipient } from "@shared/schema";
import { storage } from "../storage";
import { eq, and, or, gte, lte, sql, inArray, isNotNull, asc, exists } from "drizzle-orm";
export const calendarRouter = Router();
interface CalendarTask {
id: number;
title: string;
dueDate: string;
formId: number;
currentStatusId: number;
statusColor: string | null;
statusName: string | null;
isCompleted: boolean;
isAllDay: boolean;
}
// Напоминание на задаче (событие по дате remindAt, drag&drop не применим)
interface CalendarReminder {
taskId: number;
title: string;
remindAt: string;
formId: number;
statusName: string | null;
statusColor: string | null;
}
function parseDateParam(value: unknown): Date | null {
if (typeof value !== "string" || !value) return null;
const d = new Date(value);
if (isNaN(d.getTime())) return null;
return d;
}
function isMidnight(date: Date): boolean {
// All-day tasks are stored in the DB with time 00:00:00 (no timezone).
// Checking UTC midnight makes the detection independent of the client's timezone.
return date.getUTCHours() === 0 && date.getUTCMinutes() === 0 && date.getUTCSeconds() === 0 && date.getUTCMilliseconds() === 0;
}
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/calendar/tasks?from=ISO&to=ISO&formIds=1,2,3
// Returns tasks assigned to current user within date range, excluding completed/final tasks.
calendarRouter.get("/api/calendar/tasks", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const from = parseDateParam(req.query.from);
const to = parseDateParam(req.query.to);
const formIds = parseFormIds(req.query.formIds);
if (!from || !to) {
return res.status(400).json({ success: false, error: "Требуются параметры from и to" });
}
const userId = req.user!.id;
const organizationId = req.organizationId!;
const conditions = [
eq(forms.organizationId, organizationId),
eq(tasks.isCompleted, false),
eq(formStatuses.isFinal, false),
isNotNull(tasks.dueDate),
gte(tasks.dueDate, from),
lte(tasks.dueDate, to),
or(eq(tasks.assignedTo, userId), exists(
db
.select()
.from(taskAssignees)
.where(and(
eq(taskAssignees.taskId, tasks.id),
eq(taskAssignees.userId, userId)
))
))
];
if (formIds) {
conditions.push(inArray(tasks.formId, formIds));
}
const results = await db
.select({
id: tasks.id,
title: tasks.title,
dueDate: tasks.dueDate,
formId: tasks.formId,
currentStatusId: tasks.currentStatusId,
statusColor: formStatuses.color,
statusName: formStatuses.name,
isCompleted: tasks.isCompleted,
})
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.innerJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
.where(and(...conditions))
.orderBy(asc(tasks.dueDate));
const mapped: CalendarTask[] = results.map((row) => {
const due = row.dueDate ? new Date(row.dueDate) : null;
return {
id: row.id,
title: row.title,
dueDate: row.dueDate ? row.dueDate.toISOString() : "",
formId: row.formId,
currentStatusId: row.currentStatusId,
statusColor: row.statusColor,
statusName: row.statusName,
isCompleted: row.isCompleted ?? false,
isAllDay: due ? isMidnight(due) : false,
};
});
// Задачи с напоминаниями, где текущий пользователь — автор или получатель.
// Возвращаются отдельным массивом: событие по дате remindAt (без drag&drop).
const reminderRows = await db
.select({
taskId: tasks.id,
title: tasks.title,
remindAt: taskReminders.remindAt,
formId: tasks.formId,
statusName: formStatuses.name,
statusColor: formStatuses.color,
recipients: taskReminders.recipients,
createdByUserId: taskReminders.createdByUserId,
})
.from(taskReminders)
.innerJoin(tasks, eq(taskReminders.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.innerJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
.where(and(
eq(taskReminders.organizationId, organizationId),
eq(taskReminders.isSent, false),
gte(taskReminders.remindAt, from),
lte(taskReminders.remindAt, to),
eq(tasks.isCompleted, false),
formIds ? inArray(tasks.formId, formIds) : sql`true`,
));
const reminders: CalendarReminder[] = [];
for (const row of reminderRows) {
let isRecipient = false;
if (row.createdByUserId === userId) {
isRecipient = true;
} else {
const recips = (row.recipients || []) as ReminderRecipient[];
isRecipient = recips.some(r => r.type === 'user' && r.userId === userId);
if (!isRecipient) {
for (const r of recips) {
if (r.type === 'role') {
const us = await storage.getUsersByRole(r.role, organizationId);
if (us.some(u => u.id === userId)) { isRecipient = true; break; }
} else if (r.type === 'orgRole') {
const us = await storage.getUsersByOrgRoleId(r.roleId, organizationId);
if (us.some(u => u.id === userId)) { isRecipient = true; break; }
}
}
}
}
if (!isRecipient || !row.remindAt) continue;
reminders.push({
taskId: row.taskId,
title: row.title,
remindAt: row.remindAt.toISOString(),
formId: row.formId,
statusName: row.statusName,
statusColor: row.statusColor,
});
}
res.json({ success: true, tasks: mapped, reminders });
} catch (error) {
console.error("Calendar tasks error:", error);
res.status(500).json({ success: false, error: "Ошибка при получении задач календаря" });
}
});
// PUT /api/calendar/tasks/:id/due-date
// Updates only dueDate for drag-and-drop rescheduling.
calendarRouter.put("/api/calendar/tasks/:id/due-date", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id, 10);
const { dueDate } = req.body;
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: "Неверный ID задачи" });
}
const newDueDate = dueDate ? new Date(dueDate) : null;
if (newDueDate && isNaN(newDueDate.getTime())) {
return res.status(400).json({ success: false, error: "Неверная дата" });
}
const organizationId = req.organizationId!;
const [updated] = await db
.update(tasks)
.set({
dueDate: newDueDate,
updatedAt: new Date(),
})
.where(and(
eq(tasks.id, taskId),
exists(
db
.select()
.from(forms)
.where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId)))
)
))
.returning();
if (!updated) {
return res.status(404).json({ success: false, error: "Задача не найдена" });
}
res.json({ success: true, task: updated });
} catch (error) {
console.error("Calendar update due date error:", error);
res.status(500).json({ success: false, error: "Ошибка при обновлении срока задачи" });
}
});