233 lines
8.0 KiB
TypeScript
233 lines
8.0 KiB
TypeScript
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: "Ошибка при обновлении срока задачи" });
|
||
}
|
||
});
|