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

@@ -48,12 +48,41 @@ export class SystemStorage extends RolesStorage {
));
}
async addTaskAssignee(taskId: number, userId: number, organizationId: number): Promise<TaskAssignee> {
async addTaskAssignee(
taskId: number,
userId: number,
organizationId: number,
options?: { delegatedFromUserId?: number | null; pendingReview?: boolean }
): Promise<TaskAssignee> {
const existing = await db.select().from(taskAssignees)
.where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId)))
.limit(1);
if (existing.length > 0) return existing[0];
const [row] = await db.insert(taskAssignees).values({ taskId, userId, organizationId }).returning();
if (existing.length > 0) {
// Явный запрос «на ознакомление» — проставляем флаг на существующей строке
if (options?.pendingReview && !existing[0].pendingReview) {
const [updated] = await db.update(taskAssignees)
.set({ pendingReview: true })
.where(eq(taskAssignees.id, existing[0].id))
.returning();
return updated;
}
return existing[0];
}
const [row] = await db.insert(taskAssignees).values({
taskId,
userId,
organizationId,
delegatedFromUserId: options?.delegatedFromUserId ?? null,
pendingReview: options?.pendingReview ?? false,
}).returning();
// Хук замещения: прямое назначение резолвит активного заместителя.
// Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию.
// Динамический import разрывает цикл storage → delegation.service → storage.
if (!options?.delegatedFromUserId && !options?.pendingReview) {
import("../services/delegation.service")
.then((m) => m.applyDelegationsOnAssign(taskId, userId, organizationId))
.catch((err) => console.error("[Delegation] applyDelegationsOnAssign hook error:", err));
}
return row;
}