Files
minicrm/notifications.js
2026-04-07 00:50:13 +05:00

430 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// notifications.js
const { getDb } = require('./database');
const emailNotifications = require('./email-notifications');
/**
* Отправляет уведомления о событиях в задаче согласно новой логике:
* - Если инициатор = автор → уведомления всем исполнителям
* - Если инициатор = исполнитель → уведомление только автору
* - Иначе (например, администратор вне задачи) → уведомления всем, кроме инициатора (старое поведение)
*/
async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, authorId, comment = '', status = '', userName = '') {
try {
const db = getDb();
if (!db) {
console.log('❌ База данных не доступна для отправки уведомлений');
return;
}
console.log(`📢 Начинаем отправку уведомлений для задачи ${taskId}:`);
console.log(` Тип: ${type}, Автор действия: ${authorId}, Название: ${taskTitle}`);
// 1. Получаем автора задачи (создателя)
const creator = await new Promise((resolve, reject) => {
db.get(`
SELECT t.created_by as user_id, u.name as user_name, u.login as user_login, u.email
FROM tasks t
LEFT JOIN users u ON t.created_by = u.id
WHERE t.id = ?
`, [taskId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
if (!creator) {
console.error(`❌ Задача ${taskId} не найдена или у неё нет автора`);
return;
}
// 2. Получаем всех исполнителей задачи
const assignees = await new Promise((resolve, reject) => {
db.all(`
SELECT ta.user_id, u.name as user_name, u.login as user_login, u.email
FROM task_assignments ta
LEFT JOIN users u ON ta.user_id = u.id
WHERE ta.task_id = ?
`, [taskId], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
// 3. Получаем информацию об авторе действия (инициаторе)
const author = await new Promise((resolve, reject) => {
db.get("SELECT name, login, email FROM users WHERE id = ?", [authorId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
const authorName = author ? author.name : 'Система';
const authorLogin = author ? author.login : 'system';
// 4. Определяем получателей уведомления согласно новой логике
let recipients = [];
// Проверяем, является ли инициатор автором задачи
if (parseInt(authorId) === parseInt(creator.user_id)) {
// Инициатор = автор → уведомления всем исполнителям
recipients = assignees;
console.log(` Инициатор является автором. Получатели: ${assignees.length} исполнителей`);
}
// Проверяем, является ли инициатор одним из исполнителей
else {
const isInitiatorAssignee = assignees.some(a => parseInt(a.user_id) === parseInt(authorId));
if (isInitiatorAssignee) {
// Инициатор = исполнитель → уведомление только автору
recipients = [creator];
console.log(` Инициатор является исполнителем. Получатель: автор`);
} else {
// Инициатор не является ни автором, ни исполнителем (например, администратор)
// Отправляем уведомления всем участникам, кроме инициатора (старое поведение)
recipients = [creator, ...assignees].filter(p => parseInt(p.user_id) !== parseInt(authorId));
console.log(` Инициатор вне задачи. Получатели: ${recipients.length} участников (старое поведение)`);
}
}
// 5. Отправляем email уведомления выбранным получателям
for (const recipient of recipients) {
const taskData = {
taskId,
title: taskTitle,
description: taskDescription,
due_date: null, // при необходимости можно добавить получение срока из БД
author_name: authorName,
comment: comment,
status: status,
user_name: userName || recipient.user_name,
hours_left: type === 'deadline' ? 24 : null
};
await emailNotifications.sendTaskNotification(
recipient.user_id,
taskData,
type
);
}
console.log(`✅ Уведомления отправлены для задачи ${taskId}`);
} catch (error) {
console.error('❌ Общая ошибка при обработке уведомлений:', error);
}
}
/**
* Отправляет уведомление о приближающемся дедлайне
*/
async function sendDeadlineNotification(assignment, hoursLeft) {
try {
const taskData = {
taskId: assignment.task_id,
title: assignment.title,
description: assignment.description || '',
due_date: assignment.due_date,
author_name: assignment.creator_name,
hours_left: hoursLeft
};
// Отправляем уведомление исполнителю
await emailNotifications.sendTaskNotification(
assignment.user_id,
taskData,
'deadline'
);
// Отправляем уведомление заказчику (автору)
await emailNotifications.sendTaskNotification(
assignment.created_by,
taskData,
'deadline'
);
console.log(`✅ Email уведомление о сроке (${hoursLeft}ч) отправлено для задачи ${assignment.task_id}`);
} catch (error) {
console.error('❌ Ошибка отправки email уведомления о сроке:', error);
}
}
/**
* Проверяет приближающиеся дедлайны и отправляет уведомления
*/
async function checkUpcomingDeadlines() {
const now = new Date();
const in48Hours = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString();
const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
const nowISO = now.toISOString();
const query = `
SELECT DISTINCT ta.*, t.title, t.description, t.created_by, u.name as user_name, u.email as user_email,
creator.name as creator_name, creator.email as creator_email
FROM task_assignments ta
JOIN tasks t ON ta.task_id = t.id
JOIN users u ON ta.user_id = u.id
JOIN users creator ON t.created_by = creator.id
WHERE ta.due_date IS NOT NULL
AND ta.due_date > ?
AND ta.due_date <= ?
AND ta.status NOT IN ('completed', 'overdue')
AND t.status = 'active'
AND t.closed_at IS NULL
`;
const db = getDb();
if (!db) {
console.error('❌ База данных не доступна для проверки сроков');
return;
}
db.all(query, [nowISO, in48Hours], async (err, assignments) => {
if (err) {
console.error('❌ Ошибка при проверке сроков задач:', err);
return;
}
for (const assignment of assignments) {
const dueDate = new Date(assignment.due_date);
const timeLeft = dueDate.getTime() - now.getTime();
const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000));
if (hoursLeft <= 48 && hoursLeft > 24) {
await sendDeadlineNotification(assignment, 48);
} else if (hoursLeft <= 24) {
await sendDeadlineNotification(assignment, 24);
}
}
});
}
/**
* Возвращает тему уведомления в зависимости от типа
*/
function getNotificationSubject(type, taskTitle) {
switch (type) {
case 'created':
return `Новая задача: ${taskTitle}`;
case 'updated':
return `Обновлена задача: ${taskTitle}`;
case 'rework':
return `Задача возвращена на доработку: ${taskTitle}`;
case 'closed':
return `Задача закрыта: ${taskTitle}`;
case 'status_changed':
return `Изменен статус задачи: ${taskTitle}`;
default:
return `Уведомление по задаче: ${taskTitle}`;
}
}
/**
* Преобразует внутренний статус в читаемый текст
*/
function getStatusText(status) {
const statusMap = {
'assigned': 'Назначена',
'in_progress': 'В работе',
'completed': 'Выполнена',
'overdue': 'Просрочена',
'rework': 'На доработке'
};
return statusMap[status] || status;
}
/**
* Отправляет сводку о непрочитанных сообщениях в чатах всем пользователям,
* у которых есть такие сообщения (раз в час, только с 8 до 21 по часовому поясу сервера).
*/
async function sendChatSummaryNotifications() {
const db = getDb();
if (!db) {
console.error('❌ База данных не доступна для отправки сводки чата');
return;
}
// Определяем часовой пояс
const timezone = process.env.TIMEZONE || 'Asia/Yekaterinburg';
const now = new Date();
// Форматтер для полного времени (логирование)
const fullFormatter = new Intl.DateTimeFormat('ru-RU', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// Форматтер только для часа
const hourFormatter = new Intl.DateTimeFormat('ru-RU', { hour: 'numeric', timeZone: timezone });
const currentHour = parseInt(hourFormatter.format(now));
// Логируем текущее время и используемый час
console.log(`📅 Текущее время (${timezone}): ${fullFormatter.format(now)} (час: ${currentHour})`);
// Проверка рабочего времени (8:00 21:00)
if (currentHour < 8 || currentHour >= 21) {
console.log(`🕒 [Чат-сводка] Пропущена: сейчас ${currentHour}:00 (рабочие часы 8-21), время отправки не наступило`);
return;
}
console.log(`✅ [Чат-сводка] Рабочие часы, отправляем уведомления...`);
// Получаем всех пользователей, у которых включены email уведомления
const users = await new Promise((resolve, reject) => {
db.all(`
SELECT u.id, u.name, u.email, u.login,
COALESCE(us.email_notifications, 1) as email_notifications,
us.notification_email,
us.last_chat_notification_sent_at
FROM users u
LEFT JOIN user_settings us ON u.id = us.user_id
WHERE u.email IS NOT NULL AND u.email != ''
`, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
for (const user of users) {
// Проверяем настройки уведомлений
if (!user.email_notifications) continue;
const emailTo = user.notification_email || user.email;
if (!emailTo) continue;
// Время последней отправки уведомления (или NULL тогда берём текущее, но при логине уже должно быть установлено)
let lastSent = user.last_chat_notification_sent_at;
if (!lastSent) {
// Если нет метки устанавливаем сейчас и пропускаем (при первом входе всё равно сбросится)
await new Promise((resolve) => {
db.run(
`UPDATE user_settings SET last_chat_notification_sent_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
[user.id],
resolve
);
});
continue;
}
// Получаем задачи с количеством новых сообщений после lastSent
const tasks = await new Promise((resolve, reject) => {
db.all(`
SELECT
t.id,
t.title,
COUNT(m.id) as new_messages_count
FROM tasks t
LEFT JOIN task_chat_messages m ON t.id = m.task_id
AND m.created_at > ?
AND m.is_deleted = 0
WHERE (t.created_by = ? OR EXISTS (
SELECT 1 FROM task_assignments ta
WHERE ta.task_id = t.id AND ta.user_id = ?
))
GROUP BY t.id
HAVING new_messages_count > 0
`, [lastSent, user.id, user.id], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
if (tasks.length === 0) continue;
// Формируем HTMLписьмо со списком задач
const appUrl = process.env.APP_URL || 'http://localhost:3000';
let tasksHtml = '';
for (const task of tasks) {
const taskUrl = `${appUrl}/task?id=${task.id}`;
tasksHtml += `
<li style="margin-bottom: 15px;">
<strong><a href="${taskUrl}" style="color: #3498db;">${escapeHtml(task.title)}</a></strong><br>
Новых сообщений: ${task.new_messages_count}
</li>
`;
}
const subject = `📬 Новые сообщения в чатах задач (${tasks.length} задач)`;
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #3498db; color: white; padding: 15px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.footer { margin-top: 20px; font-size: 12px; color: #666; text-align: center; }
ul { padding-left: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>💬 Новые сообщения в чатах</h2>
</div>
<div class="content">
<p>Здравствуйте, ${escapeHtml(user.name)}!</p>
<p>У вас есть непрочитанные сообщения в следующих задачах:</p>
<ul>${tasksHtml}</ul>
<p>Вы получили это письмо, потому что подписаны на уведомления о чатах.<br>
Уведомления приходят раз в час. Чтобы отключить их, измените настройки в личном кабинете.</p>
</div>
<div class="footer">
<p>© School CRM, ${new Date().getFullYear()}</p>
</div>
</div>
</body>
</html>
`;
// Отправляем email
const result = await emailNotifications.sendEmailNotification(
emailTo,
subject,
htmlContent,
user.id,
null,
'chat_summary'
);
if (result.sent) {
// Обновляем время последней отправки
await new Promise((resolve) => {
db.run(
`UPDATE user_settings SET last_chat_notification_sent_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
[user.id],
resolve
);
});
console.log(`✅ Отправлена сводка чата для ${user.login} (${tasks.length} задач)`);
} else {
console.error(`Не удалось отправить сводку чата для ${user.login}: ${result.error}`);
// Не обновляем last_sent в следующий раз попробуем снова
}
}
}
/**
* Вспомогательная функция для экранирования HTML
*/
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
// Экспортируем функции
module.exports = {
sendTaskNotifications,
checkUpcomingDeadlines,
sendDeadlineNotification,
getStatusText,
emailNotifications,
sendChatSummaryNotifications
};