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

@@ -8,6 +8,14 @@ import { validateRequest } from "../middleware/validation.middleware";
import { hashPassword } from "../utils/password";
import { logAudit, getClientIp } from "../utils/audit";
import { canManageUser, filterUsersByAccess, getManagedUserIds } from "../utils/user-access";
import {
listDelegations,
getDelegation,
createDelegation,
updateDelegation,
deleteDelegation,
bulkAssignDelegate,
} from "../services/delegation.service";
import { db } from "../db";
import { roles, roleMembers, userStatuses, insertUserStatusSchema, updateUserStatusSchema } from "@shared/schema";
import { eq, and, inArray } from "drizzle-orm";
@@ -653,4 +661,220 @@ export function registerUserRoutes(router: ReturnType<typeof import("express").R
}
}
);
// === Замещение пользователей (delegation/substitution) ===
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
router.get('/api/users/:id(\\d+)/delegations',
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
const target = await storage.getUser(userId);
if (!target || target.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
// Чтение доступно всем сотрудникам организации: коллеги видят бейдж
// «Замещает: …» в профиле (как в Pyrus). Мутации — только владельцу/админу.
const delegations = await listDelegations(userId, req.organizationId!);
res.json({ success: true, delegations });
} catch (error) {
console.error('List delegations error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении замещений' });
}
}
);
router.post('/api/users/:id(\\d+)/delegations',
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
const { delegateUserId, formId, dateFrom, dateTo } = req.body ?? {};
const target = await storage.getUser(userId);
if (!target || target.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
if (!canEdit) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const delegateId = Number(delegateUserId);
if (!Number.isFinite(delegateId)) {
return res.status(400).json({ success: false, error: 'Укажите заместителя' });
}
if (delegateId === userId) {
return res.status(400).json({ success: false, error: 'Нельзя назначить заместителем самого себя' });
}
const delegate = await storage.getUser(delegateId);
if (!delegate || delegate.organizationId !== req.organizationId || !delegate.isActive) {
return res.status(400).json({ success: false, error: 'Заместитель не найден или неактивен' });
}
if (typeof dateFrom !== 'string' || !DATE_RE.test(dateFrom) ||
typeof dateTo !== 'string' || !DATE_RE.test(dateTo)) {
return res.status(400).json({ success: false, error: 'Укажите даты в формате YYYY-MM-DD' });
}
if (dateFrom > dateTo) {
return res.status(400).json({ success: false, error: 'Дата начала не может быть позже даты окончания' });
}
let formIdVal: number | null = null;
if (formId !== undefined && formId !== null) {
formIdVal = Number(formId);
if (!Number.isFinite(formIdVal)) {
return res.status(400).json({ success: false, error: 'Некорректная форма' });
}
const form = await storage.getForm(formIdVal, req.organizationId!);
if (!form) {
return res.status(400).json({ success: false, error: 'Форма не найдена' });
}
}
const result = await createDelegation({
organizationId: req.organizationId!,
userId,
delegateUserId: delegateId,
formId: formIdVal,
dateFrom,
dateTo,
createdBy: req.user?.id ?? null,
});
if (result.conflict) {
return res.status(409).json({
success: false,
error: 'Замещение для этого заместителя с пересекающимся периодом уже существует',
});
}
logAudit({
action: 'delegation.created',
userId: req.user?.id ?? null,
organizationId: req.organizationId!,
details: {
delegationId: result.delegation.id,
absentUserId: userId,
delegateUserId: delegateId,
formId: formIdVal,
dateFrom,
dateTo,
},
ip: getClientIp(req),
});
// Массовое до-назначение заместителя в текущие задачи — асинхронно
bulkAssignDelegate(result.delegation.id).catch((err) =>
console.error('[Delegation] bulkAssignDelegate after create error:', err)
);
res.json({ success: true, delegation: result.delegation });
} catch (error) {
console.error('Create delegation error:', error);
res.status(500).json({ success: false, error: 'Ошибка при создании замещения' });
}
}
);
router.patch('/api/users/:id(\\d+)/delegations/:delegationId(\\d+)',
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
const delegationId = Number(req.params.delegationId);
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
if (!canEdit) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const existing = await getDelegation(delegationId);
if (!existing || existing.userId !== userId || existing.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Замещение не найдено' });
}
const { isActive, dateFrom, dateTo } = req.body ?? {};
const patch: { isActive?: boolean; dateFrom?: string; dateTo?: string } = {};
if (isActive !== undefined) {
if (typeof isActive !== 'boolean') {
return res.status(400).json({ success: false, error: 'isActive должно быть boolean' });
}
patch.isActive = isActive;
}
if (dateFrom !== undefined) {
if (typeof dateFrom !== 'string' || !DATE_RE.test(dateFrom)) {
return res.status(400).json({ success: false, error: 'dateFrom в формате YYYY-MM-DD' });
}
patch.dateFrom = dateFrom;
}
if (dateTo !== undefined) {
if (typeof dateTo !== 'string' || !DATE_RE.test(dateTo)) {
return res.status(400).json({ success: false, error: 'dateTo в формате YYYY-MM-DD' });
}
patch.dateTo = dateTo;
}
if (Object.keys(patch).length === 0) {
return res.status(400).json({ success: false, error: 'Нет полей для обновления' });
}
const newFrom = patch.dateFrom ?? existing.dateFrom;
const newTo = patch.dateTo ?? existing.dateTo;
if (newFrom > newTo) {
return res.status(400).json({ success: false, error: 'Дата начала не может быть позже даты окончания' });
}
const updated = await updateDelegation(delegationId, userId, req.organizationId!, patch);
if (!updated) {
return res.status(404).json({ success: false, error: 'Замещение не найдено' });
}
logAudit({
action: 'delegation.updated',
userId: req.user?.id ?? null,
organizationId: req.organizationId!,
details: { delegationId, absentUserId: userId, patch },
ip: getClientIp(req),
});
// При активации/продлении — до-назначить заместителя в текущие задачи
if (updated.isActive) {
bulkAssignDelegate(updated.id).catch((err) =>
console.error('[Delegation] bulkAssignDelegate after update error:', err)
);
}
res.json({ success: true, delegation: updated });
} catch (error) {
console.error('Update delegation error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении замещения' });
}
}
);
router.delete('/api/users/:id(\\d+)/delegations/:delegationId(\\d+)',
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
const delegationId = Number(req.params.delegationId);
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
if (!canEdit) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const deleted = await deleteDelegation(delegationId, userId, req.organizationId!);
if (!deleted) {
return res.status(404).json({ success: false, error: 'Замещение не найдено' });
}
logAudit({
action: 'delegation.deleted',
userId: req.user?.id ?? null,
organizationId: req.organizationId!,
details: { delegationId, absentUserId: userId },
ip: getClientIp(req),
});
res.json({ success: true });
} catch (error) {
console.error('Delete delegation error:', error);
res.status(500).json({ success: false, error: 'Ошибка при удалении замещения' });
}
}
);
}