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:
@@ -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: 'Ошибка при удалении замещения' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user