Files
iistwin/server/services/delegation.service.ts
Ильяс Султанов 23c49adcd1 perf(access): TTL-кэш getAccessibleTaskIds с точечной инвалидацией
Шаг 0.12 плана production-готовности:
- accessibleTasksCache (45с) per userId:orgId внутри storage-метода
- инвалидация в storage-слое: роли, делегирования, form access,
  assignees, createTask/updateTask, appRole пользователя
- 9 новых юнит-тестов (65/65 зелёные)
2026-09-07 22:27:33 +03:00

499 lines
19 KiB
TypeScript
Raw Permalink 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 { 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<UserDelegation | null> {
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<number> = new Set()
): Promise<number | null> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<string>`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<UserDelegation | null> {
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<UserDelegation | null> {
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<boolean> {
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<void> {
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);
}
}