import { db } from "../db"; import { userDelegations, taskAssignees, tasks, users, forms, type UserDelegation, } from "@shared/schema"; import { eq, and, or, isNull, isNotNull, sql, desc } from "drizzle-orm"; import { storage } from "../storage"; import { notifyTaskAssigned } from "../utils/notifyAssignee"; import { logAudit } from "../utils/audit"; import { invalidateAccessibleTasksForUser } from "../utils/cache"; /** * Сервис замещения пользователей (delegation/substitution, референс — Pyrus). * * Модель: * - user_delegations — кто кого замещает, на какой период и (опционально) по какой форме; * form_id IS NULL — заместитель по умолчанию для всех форм. * - При назначении пользователя U исполнителем задачи активный заместитель D * добавляется в task_assignees рядом с U (строка с delegated_from_user_id = U). * - Контур ознакомления: после пересчёта исполнителей при переходе отсутствующий U * возвращается в task_assignees с pending_review = true, пока не подтвердит * ознакомление (acknowledgeDelegationReview). */ function formatUserName(u: { firstName: string | null; lastName: string | null; middleName?: string | null; email: string } | null | undefined): string { if (!u) return ""; return (`${u.firstName || ""} ${u.middleName || ""} ${u.lastName || ""}`.trim()) || u.email; } function todayIso(): string { return new Date().toISOString().slice(0, 10); } /** * Ищет активное на сегодня замещение пользователя. * Приоритет: точное совпадение form_id, затем запись по умолчанию (form_id IS NULL). */ async function findActiveDelegation( userId: number, formId: number | null, organizationId: number ): Promise { const rows = await db .select() .from(userDelegations) .where( and( eq(userDelegations.organizationId, organizationId), eq(userDelegations.userId, userId), eq(userDelegations.isActive, true), sql`${userDelegations.dateFrom} <= CURRENT_DATE`, sql`${userDelegations.dateTo} >= CURRENT_DATE`, formId ? or(eq(userDelegations.formId, formId), isNull(userDelegations.formId)) : isNull(userDelegations.formId) ) ) // Сначала точное совпадение формы (form_id IS NULL → false → первым) .orderBy(sql`(${userDelegations.formId} IS NULL) ASC`, sql`${userDelegations.dateTo} ASC`) .limit(1); const delegation = rows[0]; // Защита от самоназначения if (!delegation || delegation.delegateUserId === userId) return null; return delegation; } /** * Резолвит активного заместителя пользователя с учётом цепочки * (1 дополнительный уровень: если у заместителя тоже есть замещение — * берём его заместителя) и защитой от циклов через visited-set. */ export async function getActiveDelegate( userId: number, formId: number | null, organizationId: number, visited: Set = new Set() ): Promise { if (visited.has(userId)) return null; visited.add(userId); const delegation = await findActiveDelegation(userId, formId, organizationId); if (!delegation) return null; const delegateId = delegation.delegateUserId; if (delegateId === userId || visited.has(delegateId)) return null; // Цепочка: один дополнительный уровень вложенности const nested = await findActiveDelegation(delegateId, formId, organizationId); if (nested && nested.delegateUserId !== delegateId && !visited.has(nested.delegateUserId)) { return nested.delegateUserId; } return delegateId; } /** * Вызывается при назначении пользователя исполнителем задачи. * Резолвит заместителя и добавляет его в task_assignees рядом с назначенным * (tasks.assignedTo не меняется). Идемпотентно: дубликаты игнорируются. */ export async function applyDelegationsOnAssign( taskId: number, userId: number, organizationId: number ): Promise { try { const task = await storage.getTask(taskId, organizationId); if (!task) return; const delegateId = await getActiveDelegate(userId, task.formId, organizationId); if (!delegateId) return; const existing = await db .select({ id: taskAssignees.id }) .from(taskAssignees) .where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, delegateId))) .limit(1); if (existing.length > 0) return; // заместитель уже исполнитель — ничего не делаем await storage.addTaskAssignee(taskId, delegateId, organizationId, { delegatedFromUserId: userId }); const [absentUser, delegateUser] = await Promise.all([ storage.getUser(userId), storage.getUser(delegateId), ]); const absentName = formatUserName(absentUser) || String(userId); const delegateName = formatUserName(delegateUser) || String(delegateId); storage .addTaskAuditLog({ taskId, organizationId, action: "task.updated", fieldName: "Ответственный добавлен", oldValue: null, newValue: `${delegateName} (замещение: ${absentName})`, changedBy: null, changedByName: "Замещение", metadata: { delegation: true, delegatedFrom: userId, delegateUserId: delegateId }, }) .catch((e: unknown) => console.error("[Delegation] audit log error:", e)); notifyTaskAssigned(task, delegateId, null, organizationId, { delegatedFromName: absentName }).catch( (err) => console.error("[Delegation] notifyTaskAssigned error:", err) ); } catch (err) { console.error("[Delegation] applyDelegationsOnAssign error:", err); } } /** * Контур ознакомления. Вызывается ПОСЛЕ пересчёта исполнителей при переходе. * Находит отсутствующих исполнителей U с активным замещением, чей заместитель * есть среди исполнителей задачи (строка delegated_from_user_id = U), и возвращает * U в task_assignees с pending_review = true (если строки нет — создаёт). */ export async function ensureReviewAssignees(taskId: number, organizationId: number): Promise { try { const rows = await db .select() .from(taskAssignees) .where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.organizationId, organizationId))); const delegatedFromIds = [ ...new Set( rows.filter((r) => r.delegatedFromUserId != null).map((r) => r.delegatedFromUserId as number) ), ]; if (delegatedFromIds.length === 0) return; const task = await storage.getTask(taskId, organizationId); if (!task) return; const reviewNames: string[] = []; for (const absentId of delegatedFromIds) { // Возвращаем на ознакомление только пока замещение активно const active = await findActiveDelegation(absentId, task.formId, organizationId); if (!active) continue; const existingRow = rows.find((r) => r.userId === absentId); if (existingRow) { if (existingRow.pendingReview) continue; // уже на ознакомлении await db .update(taskAssignees) .set({ pendingReview: true }) .where(eq(taskAssignees.id, existingRow.id)); } else { await storage.addTaskAssignee(taskId, absentId, organizationId, { pendingReview: true }); } const u = await storage.getUser(absentId); reviewNames.push(formatUserName(u) || String(absentId)); } if (reviewNames.length > 0) { storage .addTaskAuditLog({ taskId, organizationId, action: "task.updated", fieldName: "Замещение: на ознакомление", oldValue: null, newValue: reviewNames.join(", "), changedBy: null, changedByName: "Замещение", metadata: { delegation: true, pendingReview: true }, }) .catch((e: unknown) => console.error("[Delegation] audit log (review) error:", e)); } } catch (err) { console.error("[Delegation] ensureReviewAssignees error:", err); } } /** * Подтверждение ознакомления: удаляет строку task_assignees пользователя, * если она помечена pending_review = true. Возвращает true, если строка была снята. */ export async function acknowledgeDelegationReview( taskId: number, userId: number, organizationId: number ): Promise { const rows = await db .select() .from(taskAssignees) .where( and( eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId), eq(taskAssignees.organizationId, organizationId), eq(taskAssignees.pendingReview, true) ) ) .limit(1); if (rows.length === 0) return false; await storage.removeTaskAssignee(taskId, userId, organizationId); const u = await storage.getUser(userId); const name = formatUserName(u) || String(userId); storage .addTaskAuditLog({ taskId, organizationId, action: "task.updated", fieldName: "Замещение: ознакомление подтверждено", oldValue: name, newValue: null, changedBy: userId, changedByName: name, metadata: { delegation: true }, }) .catch((e: unknown) => console.error("[Delegation] audit log (ack) error:", e)); return true; } /** * Снимает строки заместителей, у которых замещение истекло/удалено/деактивировано * (Pyrus: «при изменении задачи заместитель удаляется из маршрута»). * Строки с delegated_from_user_id IS NULL (прямые назначения) не трогаем. */ export async function cleanupStaleDelegates(taskId: number, organizationId: number): Promise { try { const rows = await db .select() .from(taskAssignees) .where( and( eq(taskAssignees.taskId, taskId), eq(taskAssignees.organizationId, organizationId), isNotNull(taskAssignees.delegatedFromUserId) ) ); if (rows.length === 0) return; const task = await storage.getTask(taskId, organizationId); if (!task) return; for (const row of rows) { // Проверяем через резолвер — так корректно обрабатываются цепочки замещений const activeDelegateId = await getActiveDelegate(row.delegatedFromUserId as number, task.formId, organizationId); if (activeDelegateId !== row.userId) { await db.delete(taskAssignees).where(eq(taskAssignees.id, row.id)); invalidateAccessibleTasksForUser(row.userId, organizationId); } } } catch (err) { console.error("[Delegation] cleanupStaleDelegates error:", err); } } // ===================== // CRUD замещений (REST API) // ===================== export async function listDelegations(userId: number, organizationId: number) { return db .select({ id: userDelegations.id, userId: userDelegations.userId, delegateUserId: userDelegations.delegateUserId, formId: userDelegations.formId, dateFrom: userDelegations.dateFrom, dateTo: userDelegations.dateTo, isActive: userDelegations.isActive, createdBy: userDelegations.createdBy, createdAt: userDelegations.createdAt, delegateName: sql`concat(${users.firstName}, ' ', ${users.lastName})`, formName: forms.name, }) .from(userDelegations) .leftJoin(users, eq(users.id, userDelegations.delegateUserId)) .leftJoin(forms, eq(forms.id, userDelegations.formId)) .where(and(eq(userDelegations.userId, userId), eq(userDelegations.organizationId, organizationId))) .orderBy(desc(userDelegations.createdAt)); } export async function getDelegation(delegationId: number): Promise { const rows = await db.select().from(userDelegations).where(eq(userDelegations.id, delegationId)).limit(1); return rows[0] ?? null; } /** * Создание замещения с проверкой пересечения периодов * для той же пары (user_id, delegate_user_id, form_id). */ export async function createDelegation(data: { organizationId: number; userId: number; delegateUserId: number; formId: number | null; dateFrom: string; dateTo: string; createdBy: number | null; }): Promise<{ conflict: true } | { conflict: false; delegation: UserDelegation }> { const overlaps = await db .select({ id: userDelegations.id }) .from(userDelegations) .where( and( eq(userDelegations.organizationId, data.organizationId), eq(userDelegations.userId, data.userId), eq(userDelegations.delegateUserId, data.delegateUserId), data.formId ? eq(userDelegations.formId, data.formId) : isNull(userDelegations.formId), eq(userDelegations.isActive, true), sql`${userDelegations.dateFrom} <= ${data.dateTo}::date`, sql`${userDelegations.dateTo} >= ${data.dateFrom}::date` ) ) .limit(1); if (overlaps.length > 0) return { conflict: true }; const [delegation] = await db .insert(userDelegations) .values({ organizationId: data.organizationId, userId: data.userId, delegateUserId: data.delegateUserId, formId: data.formId, dateFrom: data.dateFrom, dateTo: data.dateTo, isActive: true, createdBy: data.createdBy, }) .returning(); // Замещение меняет доступ заместителя (и потенциально отсутствующего) invalidateAccessibleTasksForUser(data.userId, data.organizationId); invalidateAccessibleTasksForUser(data.delegateUserId, data.organizationId); return { conflict: false, delegation }; } export async function updateDelegation( delegationId: number, userId: number, organizationId: number, patch: { isActive?: boolean; dateFrom?: string; dateTo?: string } ): Promise { const [updated] = await db .update(userDelegations) .set(patch) .where( and( eq(userDelegations.id, delegationId), eq(userDelegations.userId, userId), eq(userDelegations.organizationId, organizationId) ) ) .returning(); if (updated) { invalidateAccessibleTasksForUser(userId, organizationId); invalidateAccessibleTasksForUser(updated.delegateUserId, organizationId); } return updated ?? null; } export async function deleteDelegation( delegationId: number, userId: number, organizationId: number ): Promise { const deleted = await db .delete(userDelegations) .where( and( eq(userDelegations.id, delegationId), eq(userDelegations.userId, userId), eq(userDelegations.organizationId, organizationId) ) ) .returning({ id: userDelegations.id, delegateUserId: userDelegations.delegateUserId }); if (deleted.length > 0) { invalidateAccessibleTasksForUser(userId, organizationId); invalidateAccessibleTasksForUser(deleted[0].delegateUserId, organizationId); } return deleted.length > 0; } /** * Массовое до-назначение: заместитель D добавляется во все незавершённые задачи, * где отсутствующий U — исполнитель (assigned_to или task_assignees). * Если form_id записи NULL — все формы, иначе только эта форма. * Обработка чанками по 50 с паузой, чтобы не перегружать БД. */ export async function bulkAssignDelegate(delegationId: number): Promise { try { const delegation = await getDelegation(delegationId); if (!delegation) return; // До-назначаем только актуальное замещение (активно и сегодня внутри периода) const today = todayIso(); if (!delegation.isActive || delegation.dateFrom > today || delegation.dateTo < today) return; const tasksToUpdate = await db .select({ id: tasks.id, title: tasks.title, formId: tasks.formId }) .from(tasks) .where( and( eq(tasks.organizationId, delegation.organizationId), eq(tasks.isCompleted, false), delegation.formId ? eq(tasks.formId, delegation.formId) : undefined, sql`(${tasks.assignedTo} = ${delegation.userId} OR EXISTS ( SELECT 1 FROM task_assignees ta WHERE ta.task_id = ${tasks.id} AND ta.user_id = ${delegation.userId} ))` ) ); if (tasksToUpdate.length === 0) return; const absentUser = await storage.getUser(delegation.userId); const absentName = formatUserName(absentUser) || String(delegation.userId); const CHUNK_SIZE = 50; let affected = 0; for (let i = 0; i < tasksToUpdate.length; i += CHUNK_SIZE) { const chunk = tasksToUpdate.slice(i, i + CHUNK_SIZE); for (const t of chunk) { const existing = await db .select({ id: taskAssignees.id }) .from(taskAssignees) .where(and(eq(taskAssignees.taskId, t.id), eq(taskAssignees.userId, delegation.delegateUserId))) .limit(1); if (existing.length > 0) continue; await storage .addTaskAssignee(t.id, delegation.delegateUserId, delegation.organizationId, { delegatedFromUserId: delegation.userId, }) .catch(() => {}); affected++; notifyTaskAssigned( { id: t.id, title: t.title, formId: t.formId }, delegation.delegateUserId, null, delegation.organizationId, { delegatedFromName: absentName } ).catch((err) => console.error("[Delegation] bulk notify error:", err)); } // Пауза между чанками — не перегружаем БД и шину уведомлений if (i + CHUNK_SIZE < tasksToUpdate.length) { await new Promise((resolve) => setTimeout(resolve, 100)); } } logAudit({ action: "delegation.bulk_assign", organizationId: delegation.organizationId, userId: delegation.userId, details: { delegationId, delegateUserId: delegation.delegateUserId, formId: delegation.formId, tasksScanned: tasksToUpdate.length, tasksAffected: affected, }, }); } catch (err) { console.error("[Delegation] bulkAssignDelegate error:", err); } }