- таблица user_delegations (миграция 0067) + колонки delegated_from_user_id/pending_review в task_assignees - delegation.service: резолвер с цепочкой и защитой от циклов, bulk-до-назначение чанками, чистка истёкших замещений - центральный хук в addTaskAssignee: заместитель добавляется при любом назначении (ручном, автопереходе, user-поле, MCP) - контур ознакомления: после перехода отсутствующий остаётся в исполнителях с pending_review, снимается кнопкой «Ознакомлен» - inbox reasons delegation/delegation_review + фильтр «Замещение» на главной - блок «Замещение» в профиле пользователя, бейдж «за <имя>» и баннер ознакомления в карточке задачи
77 lines
3.3 KiB
TypeScript
77 lines
3.3 KiB
TypeScript
import { notificationService, EVENT_TYPES } from "../services/notification.service";
|
||
import { eventBus } from "../routes/shared";
|
||
|
||
/**
|
||
* Отправляет уведомление о назначении ответственного по задаче.
|
||
* Централизованная функция — используется из всех endpoint'ов,
|
||
* где меняется assignedTo (assignees, transitions, crud-write, auto-transitions).
|
||
*
|
||
* Возвращает массив userId пользователей, которым было отправлено уведомление.
|
||
* Отправляет SSE notification events для real-time обновления UI.
|
||
*
|
||
* Если у назначенного пользователя нет подписки на task.assigned,
|
||
* создаёт уведомление принудительно (через targetUserIds), чтобы
|
||
* назначение задачи никогда не пропадало мимо пользователя.
|
||
*/
|
||
export async function notifyTaskAssigned(
|
||
task: { id: number; title: string; formId: number },
|
||
assigneeUserId: number,
|
||
triggeredBy: number | null,
|
||
organizationId: number,
|
||
options?: { delegatedFromName?: string }
|
||
): Promise<number[]> {
|
||
const delegationSuffix = options?.delegatedFromName ? ` (вы замещаете ${options.delegatedFromName})` : "";
|
||
const eventBase = {
|
||
type: EVENT_TYPES.TASK_ASSIGNED,
|
||
organizationId,
|
||
triggeredBy: triggeredBy ?? null,
|
||
taskId: task.id,
|
||
formId: task.formId,
|
||
payload: {
|
||
taskTitle: task.title,
|
||
assignedTo: assigneeUserId,
|
||
authorName: triggeredBy ? undefined : "Система",
|
||
message: `Вас назначили ответственным по задаче "${task.title}"${delegationSuffix}`,
|
||
...(options?.delegatedFromName
|
||
? { delegation: true, delegatedFromName: options.delegatedFromName }
|
||
: {}),
|
||
},
|
||
timestamp: new Date(),
|
||
};
|
||
|
||
// 1. Primary: normal subscriber-based flow
|
||
let notifiedUserIds = await notificationService.processEvent(eventBase);
|
||
if (!Array.isArray(notifiedUserIds)) {
|
||
console.warn(`[notifyTaskAssigned] processEvent returned non-array (${typeof notifiedUserIds}), treating as empty`);
|
||
notifiedUserIds = [];
|
||
}
|
||
|
||
// 2. Fallback: if subscription-based flow didn't notify the assignee,
|
||
// force-create the notification via targetUserIds (bypasses subscription check).
|
||
// This ensures task assignment is NEVER silently lost.
|
||
if (!notifiedUserIds.includes(assigneeUserId)) {
|
||
console.warn(
|
||
`[notifyTaskAssigned] User ${assigneeUserId} has no subscription for TASK_ASSIGNED. ` +
|
||
`Forcing notification via targetUserIds (taskId=${task.id}, formId=${task.formId})`
|
||
);
|
||
const forced = await notificationService.processEvent({
|
||
...eventBase,
|
||
targetUserIds: [assigneeUserId],
|
||
});
|
||
notifiedUserIds = Array.isArray(forced) ? forced : [];
|
||
}
|
||
|
||
// 3. Send SSE notification events for real-time UI update (badge + inbox count)
|
||
for (const userId of notifiedUserIds) {
|
||
console.log(`[SSE] Publishing notification event for user ${userId}, taskId=${task.id}`);
|
||
eventBus.publishEvent({
|
||
type: "notification",
|
||
data: { type: EVENT_TYPES.TASK_ASSIGNED, taskId: task.id },
|
||
organizationId,
|
||
userId,
|
||
});
|
||
}
|
||
|
||
return notifiedUserIds;
|
||
}
|