feat: замещение пользователей (substitution) — заместители по умолчанию и по формам, контур ознакомления, inbox reasons

- таблица user_delegations (миграция 0067) + колонки delegated_from_user_id/pending_review в task_assignees
- delegation.service: резолвер с цепочкой и защитой от циклов, bulk-до-назначение чанками, чистка истёкших замещений
- центральный хук в addTaskAssignee: заместитель добавляется при любом назначении (ручном, автопереходе, user-поле, MCP)
- контур ознакомления: после перехода отсутствующий остаётся в исполнителях с pending_review, снимается кнопкой «Ознакомлен»
- inbox reasons delegation/delegation_review + фильтр «Замещение» на главной
- блок «Замещение» в профиле пользователя, бейдж «за <имя>» и баннер ознакомления в карточке задачи
This commit is contained in:
2026-08-05 10:44:55 +03:00
parent 17a8dc42f2
commit 667bddeac9
15 changed files with 1216 additions and 12 deletions

View File

@@ -0,0 +1,485 @@
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";
/**
* Сервис замещения пользователей (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));
}
}
} 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();
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();
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 });
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);
}
}