diff --git a/database.js b/database.js index 82bb00d..c778680 100644 --- a/database.js +++ b/database.js @@ -93,8 +93,8 @@ function initializeSQLite() { } function createSQLiteTables() { - // notification_history - db.run(`CREATE TABLE IF NOT EXISTS notification_history ( + // Таблица для истории уведомлений + db.run(`CREATE TABLE IF NOT EXISTS notification_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, task_id INTEGER NOT NULL, @@ -105,7 +105,34 @@ function createSQLiteTables() { FOREIGN KEY (task_id) REFERENCES tasks (id), UNIQUE(user_id, task_id, notification_type) )`); - // SQLite таблицы + + // Таблица очереди email + db.run(`CREATE TABLE IF NOT EXISTS email_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + to_email TEXT NOT NULL, + subject TEXT NOT NULL, + html_content TEXT NOT NULL, + user_id INTEGER, + task_id INTEGER, + notification_type TEXT, + retry_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'pending', + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (task_id) REFERENCES tasks(id) + )`); + + // Таблица настроек email (для хранения состояния блокировки) + db.run(`CREATE TABLE IF NOT EXISTS email_settings ( + setting_key TEXT PRIMARY KEY, + setting_value TEXT, + spam_blocked_until DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + + // Основные таблицы системы db.run(`CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, login TEXT UNIQUE NOT NULL, @@ -137,6 +164,9 @@ function createSQLiteTables() { rework_comment TEXT, closed_at DATETIME, closed_by INTEGER, + task_type TEXT DEFAULT 'regular', + approver_group_id INTEGER, + document_id INTEGER, FOREIGN KEY (created_by) REFERENCES users (id), FOREIGN KEY (deleted_by) REFERENCES users (id), FOREIGN KEY (original_task_id) REFERENCES tasks (id), @@ -189,7 +219,7 @@ function createSQLiteTables() { console.log('✅ База данных SQLite инициализирована'); - // Добавляем таблицу для пользовательских настроек + // Таблица для пользовательских настроек db.run(`CREATE TABLE IF NOT EXISTS user_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL, @@ -310,9 +340,7 @@ function createSQLiteTables() { console.log('✅ Таблицы для согласования документов созданы'); - // ===== НОВЫЕ ТАБЛИЦЫ ДЛЯ ГРУПП ПОЛЬЗОВАТЕЛЕЙ ===== - - // Таблица для групп пользователей + // Таблицы для групп пользователей db.run(`CREATE TABLE IF NOT EXISTS user_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -323,7 +351,6 @@ function createSQLiteTables() { updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); - // Таблица для связи пользователей с группами (многие-ко-многим) db.run(`CREATE TABLE IF NOT EXISTS user_group_memberships ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -361,12 +388,77 @@ function createSQLiteTables() { console.log('✅ Таблицы для типов документов и документов (расширенные задачи) созданы'); + // Создаем индексы для улучшения производительности + createSQLiteIndexes(); + // Запускаем проверку и обновление структуры таблиц setTimeout(() => { checkAndUpdateTableStructure(); }, 2000); } +function createSQLiteIndexes() { + console.log('🔧 Создаем индексы для SQLite...'); + + const indexes = [ + // Индексы для очереди email + "CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_queue(status, created_at)", + "CREATE INDEX IF NOT EXISTS idx_email_queue_user_id ON email_queue(user_id)", + "CREATE INDEX IF NOT EXISTS idx_email_queue_task_id ON email_queue(task_id)", + + // Индексы для уведомлений + "CREATE INDEX IF NOT EXISTS idx_notification_history_user_id ON notification_history(user_id)", + "CREATE INDEX IF NOT EXISTS idx_notification_history_task_id ON notification_history(task_id)", + "CREATE INDEX IF NOT EXISTS idx_notification_history_type ON notification_history(notification_type)", + "CREATE INDEX IF NOT EXISTS idx_notification_history_sent_at ON notification_history(last_sent_at)", + + // Индексы для пользователей + "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)", + "CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)", + + // Индексы для задач + "CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks(created_by)", + "CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)", + "CREATE INDEX IF NOT EXISTS idx_tasks_task_type ON tasks(task_type)", + "CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)", + "CREATE INDEX IF NOT EXISTS idx_tasks_closed_at ON tasks(closed_at)", + + // Индексы для назначений задач + "CREATE INDEX IF NOT EXISTS idx_task_assignments_task_id ON task_assignments(task_id)", + "CREATE INDEX IF NOT EXISTS idx_task_assignments_user_id ON task_assignments(user_id)", + "CREATE INDEX IF NOT EXISTS idx_task_assignments_status ON task_assignments(status)", + + // Индексы для файлов + "CREATE INDEX IF NOT EXISTS idx_task_files_task_id ON task_files(task_id)", + + // Индексы для логов активности + "CREATE INDEX IF NOT EXISTS idx_activity_logs_task_id ON activity_logs(task_id)", + "CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at)", + + // Индексы для настроек пользователей + "CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)", + + // Индексы для групп + "CREATE INDEX IF NOT EXISTS idx_user_groups_can_approve ON user_groups(can_approve_documents)", + "CREATE INDEX IF NOT EXISTS idx_user_group_memberships_user_id ON user_group_memberships(user_id)", + "CREATE INDEX IF NOT EXISTS idx_user_group_memberships_group_id ON user_group_memberships(group_id)", + + // Индексы для простых документов + "CREATE INDEX IF NOT EXISTS idx_simple_documents_task_id ON simple_documents(task_id)", + "CREATE INDEX IF NOT EXISTS idx_simple_documents_document_number ON simple_documents(document_number)" + ]; + + indexes.forEach(indexQuery => { + db.run(indexQuery, (err) => { + if (err) { + console.error(`❌ Ошибка создания индекса: ${err.message}`); + } else { + console.log(`✅ Индекс создан: ${indexQuery.split('ON')[1]}`); + } + }); + }); +} + // Функция для проверки и обновления структуры таблиц function checkAndUpdateTableStructure() { console.log('🔍 Проверка структуры таблиц...'); @@ -403,7 +495,6 @@ function checkAndUpdateTableStructure() { { name: 'rework_comment', type: 'TEXT' }, { name: 'closed_at', type: 'DATETIME' }, { name: 'closed_by', type: 'INTEGER' }, - // Новые колонки для типа задач { name: 'task_type', type: 'TEXT DEFAULT "regular"' }, { name: 'approver_group_id', type: 'INTEGER' }, { name: 'document_id', type: 'INTEGER' } @@ -454,6 +545,34 @@ function checkAndUpdateTableStructure() { { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } ], + notification_history: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'notification_type', type: 'TEXT NOT NULL' }, + { name: 'last_sent_at', type: 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP' }, + { name: 'created_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' } + ], + email_queue: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'to_email', type: 'TEXT NOT NULL' }, + { name: 'subject', type: 'TEXT NOT NULL' }, + { name: 'html_content', type: 'TEXT NOT NULL' }, + { name: 'user_id', type: 'INTEGER' }, + { name: 'task_id', type: 'INTEGER' }, + { name: 'notification_type', type: 'TEXT' }, + { name: 'retry_count', type: 'INTEGER DEFAULT 0' }, + { name: 'status', type: 'TEXT DEFAULT "pending"' }, + { name: 'error_message', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + email_settings: [ + { name: 'setting_key', type: 'TEXT PRIMARY KEY' }, + { name: 'setting_value', type: 'TEXT' }, + { name: 'spam_blocked_until', type: 'DATETIME' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], user_groups: [ { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, { name: 'name', type: 'TEXT NOT NULL UNIQUE' }, @@ -527,67 +646,24 @@ function checkAndUpdateTableStructure() { }); }); - // Проверяем индекс для таблицы user_settings + // Создаем группу "Секретарь" по умолчанию, если её нет setTimeout(() => { - db.get("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_user_settings_user_id'", (err, index) => { - if (err) { - console.error('❌ Ошибка проверки индекса:', err.message); - return; - } - - if (!index) { - console.log('🔧 Создаем индекс для таблицы user_settings...'); - db.run("CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)", (createErr) => { - if (createErr) { - console.error('❌ Ошибка создания индекса:', createErr.message); - } else { - console.log('✅ Индекс для user_settings создан'); + db.get("SELECT id FROM user_groups WHERE name = 'Секретарь'", (err, group) => { + if (err || !group) { + console.log('🔧 Создаем группу "Секретарь" по умолчанию...'); + db.run( + `INSERT INTO user_groups (name, description, color, can_approve_documents) + VALUES ('Секретарь', 'Группа для согласования документов', '#e74c3c', 1)`, + (insertErr) => { + if (insertErr) { + console.error('❌ Ошибка создания группы "Секретарь":', insertErr.message); + } else { + console.log('✅ Группа "Секретарь" создана по умолчанию'); + } } - }); + ); } }); - - // Создаем индексы для новых таблиц - setTimeout(() => { - const newIndexes = [ - "CREATE INDEX IF NOT EXISTS idx_tasks_task_type ON tasks(task_type)", - "CREATE INDEX IF NOT EXISTS idx_tasks_approver_group_id ON tasks(approver_group_id)", - "CREATE INDEX IF NOT EXISTS idx_user_groups_can_approve ON user_groups(can_approve_documents)", - "CREATE INDEX IF NOT EXISTS idx_user_group_memberships_user_id ON user_group_memberships(user_id)", - "CREATE INDEX IF NOT EXISTS idx_user_group_memberships_group_id ON user_group_memberships(group_id)", - "CREATE INDEX IF NOT EXISTS idx_simple_documents_task_id ON simple_documents(task_id)", - "CREATE INDEX IF NOT EXISTS idx_simple_documents_document_number ON simple_documents(document_number)" - ]; - - newIndexes.forEach(indexQuery => { - db.run(indexQuery, (err) => { - if (err) { - console.error(`❌ Ошибка создания индекса: ${err.message}`); - } else { - console.log(`✅ Индекс создан: ${indexQuery}`); - } - }); - }); - - // Создаем группу "Секретарь" по умолчанию, если её нет - db.get("SELECT id FROM user_groups WHERE name = 'Секретарь'", (err, group) => { - if (err || !group) { - console.log('🔧 Создаем группу "Секретарь" по умолчанию...'); - db.run( - `INSERT INTO user_groups (name, description, color, can_approve_documents) - VALUES ('Секретарь', 'Группа для согласования документов', '#e74c3c', 1)`, - (insertErr) => { - if (insertErr) { - console.error('❌ Ошибка создания группы "Секретарь":', insertErr.message); - } else { - console.log('✅ Группа "Секретарь" создана по умолчанию'); - } - } - ); - } - }); - - }, 1000); }, 1000); } @@ -728,7 +804,48 @@ async function createPostgresTables() { console.log('🔧 Проверяем/создаем таблицы в PostgreSQL...'); - // Создаем таблицы PostgreSQL + // Таблица для истории уведомлений + await client.query(` + CREATE TABLE IF NOT EXISTS notification_history ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + task_id INTEGER NOT NULL REFERENCES tasks(id), + notification_type VARCHAR(50) NOT NULL, + last_sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, task_id, notification_type) + ) + `); + + // Таблица очереди email + await client.query(` + CREATE TABLE IF NOT EXISTS email_queue ( + id SERIAL PRIMARY KEY, + to_email VARCHAR(500) NOT NULL, + subject VARCHAR(500) NOT NULL, + html_content TEXT NOT NULL, + user_id INTEGER REFERENCES users(id), + task_id INTEGER REFERENCES tasks(id), + notification_type VARCHAR(50), + retry_count INTEGER DEFAULT 0, + status VARCHAR(50) DEFAULT 'pending', + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Таблица настроек email + await client.query(` + CREATE TABLE IF NOT EXISTS email_settings ( + setting_key VARCHAR(100) PRIMARY KEY, + setting_value TEXT, + spam_blocked_until TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Основные таблицы await client.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, @@ -815,7 +932,7 @@ async function createPostgresTables() { ) `); - // Добавляем таблицу для пользовательских настроек + // Таблица для пользовательских настроек await client.query(` CREATE TABLE IF NOT EXISTS user_settings ( id SERIAL PRIMARY KEY, @@ -831,9 +948,7 @@ async function createPostgresTables() { ) `); - // ===== НОВЫЕ ТАБЛИЦЫ ДЛЯ ГРУПП ПОЛЬЗОВАТЕЛЕЙ ===== - - // Таблица для групп пользователей + // Таблицы для групп пользователей await client.query(` CREATE TABLE IF NOT EXISTS user_groups ( id SERIAL PRIMARY KEY, @@ -846,7 +961,6 @@ async function createPostgresTables() { ) `); - // Таблица для связи пользователей с группами await client.query(` CREATE TABLE IF NOT EXISTS user_group_memberships ( id SERIAL PRIMARY KEY, @@ -857,7 +971,7 @@ async function createPostgresTables() { ) `); - // Таблицы для документов (существующие) + // Таблицы для документов await client.query(` CREATE TABLE IF NOT EXISTS document_types ( id SERIAL PRIMARY KEY, @@ -980,40 +1094,7 @@ async function createPostgresTables() { console.log('✅ Все таблицы PostgreSQL созданы/проверены'); // Создаем индексы - const indexes = [ - 'CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks(created_by)', - 'CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)', - 'CREATE INDEX IF NOT EXISTS idx_tasks_closed_at ON tasks(closed_at)', - 'CREATE INDEX IF NOT EXISTS idx_task_assignments_task_id ON task_assignments(task_id)', - 'CREATE INDEX IF NOT EXISTS idx_task_assignments_user_id ON task_assignments(user_id)', - 'CREATE INDEX IF NOT EXISTS idx_task_assignments_status ON task_assignments(status)', - 'CREATE INDEX IF NOT EXISTS idx_task_files_task_id ON task_files(task_id)', - 'CREATE INDEX IF NOT EXISTS idx_activity_logs_task_id ON activity_logs(task_id)', - 'CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at)', - 'CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)', - // Новые индексы - 'CREATE INDEX IF NOT EXISTS idx_tasks_task_type ON tasks(task_type)', - 'CREATE INDEX IF NOT EXISTS idx_tasks_approver_group_id ON tasks(approver_group_id)', - 'CREATE INDEX IF NOT EXISTS idx_user_groups_can_approve ON user_groups(can_approve_documents)', - 'CREATE INDEX IF NOT EXISTS idx_user_group_memberships_user_id ON user_group_memberships(user_id)', - 'CREATE INDEX IF NOT EXISTS idx_user_group_memberships_group_id ON user_group_memberships(group_id)', - // Индексы для документов - 'CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status)', - 'CREATE INDEX IF NOT EXISTS idx_documents_created_by ON documents(created_by)', - 'CREATE INDEX IF NOT EXISTS idx_document_approvals_document_id ON document_approvals(document_id)', - 'CREATE INDEX IF NOT EXISTS idx_document_approvals_status ON document_approvals(status)', - // Индексы для простых документов - 'CREATE INDEX IF NOT EXISTS idx_simple_documents_task_id ON simple_documents(task_id)', - 'CREATE INDEX IF NOT EXISTS idx_simple_documents_document_number ON simple_documents(document_number)' - ]; - - for (const indexQuery of indexes) { - try { - await client.query(indexQuery); - } catch (err) { - console.warn(`⚠️ Не удалось создать индекс: ${err.message}`); - } - } + await createPostgresIndexes(client); client.release(); console.log('✅ Таблицы PostgreSQL проверены/созданы'); @@ -1040,6 +1121,66 @@ async function createPostgresTables() { } } +async function createPostgresIndexes(client) { + console.log('🔧 Создаем индексы для PostgreSQL...'); + + const indexes = [ + // Индексы для очереди email + 'CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_queue(status, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_email_queue_user_id ON email_queue(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_email_queue_task_id ON email_queue(task_id)', + + // Индексы для уведомлений + 'CREATE INDEX IF NOT EXISTS idx_notification_history_user_id ON notification_history(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_notification_history_task_id ON notification_history(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_notification_history_type ON notification_history(notification_type)', + 'CREATE INDEX IF NOT EXISTS idx_notification_history_sent_at ON notification_history(last_sent_at)', + + // Индексы для пользователей + 'CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)', + 'CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)', + + // Индексы для задач + 'CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks(created_by)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_task_type ON tasks(task_type)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_closed_at ON tasks(closed_at)', + + // Индексы для назначений задач + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_task_id ON task_assignments(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_user_id ON task_assignments(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_status ON task_assignments(status)', + + // Индексы для файлов + 'CREATE INDEX IF NOT EXISTS idx_task_files_task_id ON task_files(task_id)', + + // Индексы для логов активности + 'CREATE INDEX IF NOT EXISTS idx_activity_logs_task_id ON activity_logs(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at)', + + // Индексы для настроек пользователей + 'CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)', + + // Индексы для групп + 'CREATE INDEX IF NOT EXISTS idx_user_groups_can_approve ON user_groups(can_approve_documents)', + 'CREATE INDEX IF NOT EXISTS idx_user_group_memberships_user_id ON user_group_memberships(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_user_group_memberships_group_id ON user_group_memberships(group_id)', + + // Индексы для простых документов + 'CREATE INDEX IF NOT EXISTS idx_simple_documents_task_id ON simple_documents(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_simple_documents_document_number ON simple_documents(document_number)' + ]; + + for (const indexQuery of indexes) { + try { + await client.query(indexQuery); + } catch (err) { + console.warn(`⚠️ Не удалось создать индекс: ${err.message}`); + } + } +} + // Функция для проверки структуры таблиц PostgreSQL async function checkPostgresTableStructure() { if (!USE_POSTGRES) return; @@ -1051,32 +1192,31 @@ async function checkPostgresTableStructure() { // Определяем ожидаемую структуру таблиц PostgreSQL const tableSchemas = { - user_settings: [ - { name: 'id', type: 'SERIAL PRIMARY KEY' }, - { name: 'user_id', type: 'INTEGER UNIQUE NOT NULL REFERENCES users(id)' }, - { name: 'email_notifications', type: 'BOOLEAN DEFAULT true' }, - { name: 'notification_email', type: 'TEXT' }, - { name: 'telegram_notifications', type: 'BOOLEAN DEFAULT false' }, - { name: 'telegram_chat_id', type: 'TEXT' }, - { name: 'vk_notifications', type: 'BOOLEAN DEFAULT false' }, - { name: 'vk_user_id', type: 'TEXT' }, + email_queue: [ + { name: 'to_email', type: 'VARCHAR(500) NOT NULL' }, + { name: 'subject', type: 'VARCHAR(500) NOT NULL' }, + { name: 'html_content', type: 'TEXT NOT NULL' }, + { name: 'user_id', type: 'INTEGER REFERENCES users(id)' }, + { name: 'task_id', type: 'INTEGER REFERENCES tasks(id)' }, + { name: 'notification_type', type: 'VARCHAR(50)' }, + { name: 'retry_count', type: 'INTEGER DEFAULT 0' }, + { name: 'status', type: 'VARCHAR(50) DEFAULT \'pending\'' }, + { name: 'error_message', type: 'TEXT' }, { name: 'created_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, { name: 'updated_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' } ], - tasks: [ - { name: 'task_type', type: 'VARCHAR(50) DEFAULT "regular"' }, - { name: 'approver_group_id', type: 'INTEGER' }, - { name: 'document_id', type: 'INTEGER' } + email_settings: [ + { name: 'setting_key', type: 'VARCHAR(100) PRIMARY KEY' }, + { name: 'setting_value', type: 'TEXT' }, + { name: 'spam_blocked_until', type: 'TIMESTAMP' }, + { name: 'updated_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' } ], - simple_documents: [ - { name: 'task_id', type: 'INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE' }, - { name: 'document_type_id', type: 'INTEGER REFERENCES simple_document_types(id)' }, - { name: 'document_number', type: 'VARCHAR(100)' }, - { name: 'document_date', type: 'DATE' }, - { name: 'pages_count', type: 'INTEGER' }, - { name: 'urgency_level', type: 'VARCHAR(20) CHECK (urgency_level IN (\'normal\', \'urgent\', \'very_urgent\'))' }, - { name: 'comment', type: 'TEXT' }, - { name: 'refusal_reason', type: 'TEXT' } + notification_history: [ + { name: 'user_id', type: 'INTEGER NOT NULL REFERENCES users(id)' }, + { name: 'task_id', type: 'INTEGER NOT NULL REFERENCES tasks(id)' }, + { name: 'notification_type', type: 'VARCHAR(50) NOT NULL' }, + { name: 'last_sent_at', type: 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP' }, + { name: 'created_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' } ] }; diff --git a/email-notifications.js b/email-notifications.js index 2bb9260..a78ea36 100644 --- a/email-notifications.js +++ b/email-notifications.js @@ -1,4 +1,3 @@ -// email-notifications.js const nodemailer = require('nodemailer'); const { getDb } = require('./database'); @@ -7,7 +6,14 @@ class EmailNotifications { this.transporter = null; this.initialized = false; this.notificationCooldown = 12 * 60 * 60 * 1000; // 12 часов в миллисекундах + this.spamBlockCooldown = 60 * 60 * 1000; // 60 минут при блокировке спама + this.spamBlockedUntil = null; + this.isSpamBlocked = false; + this.maxRetries = 3; this.init(); + + // Запускаем обработку очереди каждые 5 минут + setInterval(() => this.processRetryQueue(), 5 * 60 * 1000); } async init() { @@ -37,8 +43,22 @@ class EmailNotifications { // Тестируем подключение await this.transporter.verify(); this.initialized = true; + + // Восстанавливаем состояние блокировки из БД + await this.restoreSpamBlockState(); + console.log('✅ Email уведомления инициализированы'); console.log(`📧 Отправитель: ${process.env.YANDEX_EMAIL}`); + + if (this.isSpamBlocked && this.spamBlockedUntil) { + const now = new Date(); + if (now < this.spamBlockedUntil) { + const minutesLeft = Math.ceil((this.spamBlockedUntil - now) / (60 * 1000)); + console.log(`⏸️ Email отправка заблокирована из-за спама. До разблокировки: ${minutesLeft} минут`); + } else { + this.clearSpamBlock(); + } + } } catch (error) { console.error('❌ Ошибка инициализации Email уведомлений:', error.message); @@ -46,6 +66,358 @@ class EmailNotifications { } } + async restoreSpamBlockState() { + if (!getDb) return; + + try { + const db = getDb(); + return new Promise((resolve, reject) => { + db.get( + `SELECT spam_blocked_until FROM email_settings WHERE setting_key = 'spam_block'`, + (err, row) => { + if (err) { + console.error('❌ Ошибка получения состояния блокировки:', err); + resolve(); + return; + } + + if (row && row.spam_blocked_until) { + const blockedUntil = new Date(row.spam_blocked_until); + const now = new Date(); + + if (now < blockedUntil) { + this.isSpamBlocked = true; + this.spamBlockedUntil = blockedUntil; + console.log(`🔄 Восстановлена блокировка из-за спама до: ${blockedUntil.toLocaleString('ru-RU')}`); + } else { + this.clearSpamBlockFromDB(); + } + } + resolve(); + } + ); + }); + } catch (error) { + console.error('❌ Ошибка восстановления состояния блокировки:', error); + } + } + + async setSpamBlock() { + this.isSpamBlocked = true; + this.spamBlockedUntil = new Date(Date.now() + this.spamBlockCooldown); + + console.log(`🚫 Email отправка заблокирована из-за спама до: ${this.spamBlockedUntil.toLocaleString('ru-RU')}`); + + // Сохраняем в БД + await this.saveSpamBlockToDB(); + } + + async saveSpamBlockToDB() { + if (!getDb) return; + + try { + const db = getDb(); + return new Promise((resolve, reject) => { + db.run( + `INSERT OR REPLACE INTO email_settings (setting_key, setting_value, spam_blocked_until, updated_at) + VALUES ('spam_block', 'blocked', ?, CURRENT_TIMESTAMP)`, + [this.spamBlockedUntil.toISOString()], + (err) => { + if (err) { + console.error('❌ Ошибка сохранения блокировки в БД:', err); + reject(err); + } else { + console.log('✅ Состояние блокировки сохранено в БД'); + resolve(); + } + } + ); + }); + } catch (error) { + console.error('❌ Ошибка сохранения блокировки:', error); + } + } + + async clearSpamBlock() { + this.isSpamBlocked = false; + this.spamBlockedUntil = null; + + console.log('✅ Блокировка из-за спама снята'); + + // Очищаем из БД + await this.clearSpamBlockFromDB(); + + // Запускаем обработку очереди + this.processRetryQueue(); + } + + async clearSpamBlockFromDB() { + if (!getDb) return; + + try { + const db = getDb(); + return new Promise((resolve, reject) => { + db.run( + `DELETE FROM email_settings WHERE setting_key = 'spam_block'`, + (err) => { + if (err) { + console.error('❌ Ошибка очистки блокировки из БД:', err); + reject(err); + } else { + console.log('✅ Состояние блокировки очищено из БД'); + resolve(); + } + } + ); + }); + } catch (error) { + console.error('❌ Ошибка очистки блокировки:', error); + } + } + + isSpamBlockActive() { + if (!this.isSpamBlocked || !this.spamBlockedUntil) { + return false; + } + + const now = new Date(); + if (now >= this.spamBlockedUntil) { + this.clearSpamBlock(); + return false; + } + + return true; + } + + async saveToQueue(to, subject, htmlContent, userId, taskId, notificationType, retryCount = 0) { + if (!getDb) { + console.warn('⚠️ БД не доступна для сохранения в очередь'); + return false; + } + + try { + const db = getDb(); + return new Promise((resolve, reject) => { + db.run( + `INSERT INTO email_queue + (to_email, subject, html_content, user_id, task_id, notification_type, retry_count, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)`, + [to, subject, htmlContent, userId, taskId, notificationType, retryCount], + function(err) { + if (err) { + console.error('❌ Ошибка сохранения в очередь:', err); + reject(err); + } else { + console.log(`📝 Email сохранен в очередь (ID: ${this.lastID}): ${to}, ${subject}`); + resolve(this.lastID); + } + } + ); + }); + } catch (error) { + console.error('❌ Ошибка сохранения в очередь:', error); + return false; + } + } + + async updateQueueStatus(queueId, status, errorMessage = null, retryCount = null) { + if (!getDb) return false; + + try { + const db = getDb(); + return new Promise((resolve, reject) => { + let query = `UPDATE email_queue SET status = ?, updated_at = CURRENT_TIMESTAMP`; + const params = [status]; + + if (errorMessage) { + query += `, error_message = ?`; + params.push(errorMessage); + } + + if (retryCount !== null) { + query += `, retry_count = ?`; + params.push(retryCount); + } + + query += ` WHERE id = ?`; + params.push(queueId); + + db.run(query, params, function(err) { + if (err) { + console.error('❌ Ошибка обновления статуса в очереди:', err); + reject(err); + } else { + console.log(`📝 Статус email в очереди обновлен (ID: ${queueId}): ${status}`); + resolve(this.changes > 0); + } + }); + }); + } catch (error) { + console.error('❌ Ошибка обновления статуса очереди:', error); + return false; + } + } + + async removeFromQueue(queueId) { + if (!getDb) return false; + + try { + const db = getDb(); + return new Promise((resolve, reject) => { + db.run( + `DELETE FROM email_queue WHERE id = ?`, + [queueId], + function(err) { + if (err) { + console.error('❌ Ошибка удаления из очереди:', err); + reject(err); + } else { + console.log(`🗑️ Email удален из очереди (ID: ${queueId})`); + resolve(this.changes > 0); + } + } + ); + }); + } catch (error) { + console.error('❌ Ошибка удаления из очереди:', error); + return false; + } + } + + async getPendingEmails(limit = 10) { + if (!getDb) return []; + + try { + const db = getDb(); + return new Promise((resolve, reject) => { + db.all( + `SELECT * FROM email_queue + WHERE status = 'pending' + ORDER BY created_at ASC + LIMIT ?`, + [limit], + (err, rows) => { + if (err) { + console.error('❌ Ошибка получения очереди:', err); + reject(err); + } else { + resolve(rows || []); + } + } + ); + }); + } catch (error) { + console.error('❌ Ошибка получения очереди:', error); + return []; + } + } + + async processRetryQueue() { + if (this.isSpamBlockActive()) { + const minutesLeft = Math.ceil((this.spamBlockedUntil - new Date()) / (60 * 1000)); + console.log(`⏸️ Пропуск обработки очереди: блокировка из-за спама (осталось ${minutesLeft} минут)`); + return; + } + + if (!this.initialized || !this.transporter) { + console.warn('⚠️ Пропуск обработки очереди: Email не инициализирован'); + return; + } + + console.log('🔍 Проверка очереди email для повторной отправки...'); + + try { + const pendingEmails = await this.getPendingEmails(20); + + if (pendingEmails.length === 0) { + console.log('📭 Очередь email пуста'); + return; + } + + console.log(`📧 Найдено ${pendingEmails.length} email в очереди`); + + for (const email of pendingEmails) { + try { + // Проверяем, не превышено ли максимальное количество попыток + if (email.retry_count >= this.maxRetries) { + console.log(`⏭️ Пропуск email ${email.id}: превышено максимальное количество попыток (${email.retry_count})`); + await this.updateQueueStatus(email.id, 'failed', 'Превышено максимальное количество попыток отправки'); + continue; + } + + // Обновляем статус на "в процессе отправки" + await this.updateQueueStatus(email.id, 'sending'); + + // Отправляем email + const info = await this.transporter.sendMail({ + from: `"School CRM" <${process.env.YANDEX_EMAIL}>`, + to: email.to_email, + subject: email.subject, + html: email.html_content, + text: email.html_content.replace(/<[^>]*>/g, '') + }); + + console.log(`✅ Email отправлен из очереди (ID: ${email.id}): ${email.to_email}, Message ID: ${info.messageId}`); + + // Удаляем из очереди при успешной отправке + await this.removeFromQueue(email.id); + + // Если есть связанная задача, обновляем историю уведомлений + if (email.user_id && email.task_id && email.notification_type) { + try { + await this.recordNotificationSent(email.user_id, email.task_id, email.notification_type); + console.log(`📝 История уведомлений обновлена для email из очереди (ID: ${email.id})`); + } catch (historyError) { + console.error('❌ Ошибка обновления истории для email из очереди:', historyError); + } + } + + // Делаем небольшую паузу между отправками + await new Promise(resolve => setTimeout(resolve, 1000)); + + } catch (emailError) { + console.error(`❌ Ошибка отправки email из очереди (ID: ${email.id}):`, emailError.message); + + // Проверяем, является ли ошибка блокировкой спама + if (emailError.message.includes('554 5.7.1 Message rejected under suspicion of SPAM') || + emailError.message.includes('suspicion of SPAM') || + emailError.message.includes('SPAM')) { + + console.log('🚫 Обнаружена блокировка из-за спама при обработке очереди'); + await this.setSpamBlock(); + + // Увеличиваем счетчик попыток и возвращаем в очередь + const newRetryCount = (email.retry_count || 0) + 1; + await this.updateQueueStatus( + email.id, + 'pending', + `SPAM блокировка: ${emailError.message}`, + newRetryCount + ); + break; // Прерываем цикл при блокировке спама + } + + // Для других ошибок просто увеличиваем счетчик попыток + const newRetryCount = (email.retry_count || 0) + 1; + await this.updateQueueStatus( + email.id, + 'pending', + `Ошибка отправки: ${emailError.message}`, + newRetryCount + ); + + // Делаем паузу перед следующей попыткой + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + + console.log(`✅ Обработка очереди email завершена`); + + } catch (error) { + console.error('❌ Ошибка обработки очереди email:', error); + } + } + async canSendNotification(userId, taskId, notificationType) { if (!getDb) return true; // Если БД не готова, разрешаем отправку @@ -221,10 +593,23 @@ class EmailNotifications { }); } - async sendEmailNotification(to, subject, htmlContent) { + async sendEmailNotification(to, subject, htmlContent, userId = null, taskId = null, notificationType = null) { + // Проверяем блокировку из-за спама + if (this.isSpamBlockActive()) { + const minutesLeft = Math.ceil((this.spamBlockedUntil - new Date()) / (60 * 1000)); + console.log(`⏸️ Email отправка заблокирована из-за спама. Сохраняем в очередь. Осталось: ${minutesLeft} минут`); + + // Сохраняем в очередь для последующей отправки + const queueId = await this.saveToQueue(to, subject, htmlContent, userId, taskId, notificationType); + return queueId ? { queued: true, queueId } : false; + } + if (!this.initialized || !this.transporter) { console.warn('⚠️ Email уведомления отключены'); - return false; + + // Также сохраняем в очередь на случай, если сервис восстановится + const queueId = await this.saveToQueue(to, subject, htmlContent, userId, taskId, notificationType); + return queueId ? { queued: true, queueId } : false; } try { @@ -233,15 +618,31 @@ class EmailNotifications { to: to, subject: subject, html: htmlContent, - text: htmlContent.replace(/<[^>]*>/g, '') // Конвертируем HTML в текст + text: htmlContent.replace(/<[^>]*>/g, '') }); console.log(`📧 Email отправлен: ${to}, Message ID: ${info.messageId}`); - return true; + return { sent: true, messageId: info.messageId }; } catch (error) { console.error('❌ Ошибка отправки email:', error.message); - return false; + + // Проверяем, является ли ошибка блокировкой спама + if (error.message.includes('554 5.7.1 Message rejected under suspicion of SPAM') || + error.message.includes('suspicion of SPAM') || + error.message.includes('SPAM')) { + + console.log('🚫 Обнаружена блокировка из-за спама. Активируем блокировку на 60 минут.'); + await this.setSpamBlock(); + + // Сохраняем email в очередь для повторной отправки после блокировки + const queueId = await this.saveToQueue(to, subject, htmlContent, userId, taskId, notificationType, 1); + return queueId ? { queued: true, queueId, spamBlocked: true } : false; + } + + // Для других ошибок также сохраняем в очередь + const queueId = await this.saveToQueue(to, subject, htmlContent, userId, taskId, notificationType, 1); + return queueId ? { queued: true, queueId } : false; } } @@ -386,14 +787,20 @@ class EmailNotifications { htmlContent = this.getDefaultHtml(taskData); } - const result = await this.sendEmailNotification(emailTo, subject, htmlContent); + const result = await this.sendEmailNotification(emailTo, subject, htmlContent, userId, taskId, notificationType); - // Если уведомление успешно отправлено, записываем в историю - if (result) { + // Если уведомление успешно отправлено (не в очередь), записываем в историю + if (result && result.sent) { await this.recordNotificationSent(userId, taskId, notificationType); console.log(`✅ Уведомление отправлено: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`); + } else if (result && result.queued) { + console.log(`📝 Уведомление сохранено в очередь (ID: ${result.queueId}): пользователь ${userId}, задача ${taskId}, тип ${notificationType}`); + + if (result.spamBlocked) { + console.log(`🚫 Отправка заблокирована из-за спама. Email будет отправлен после разблокировки.`); + } } else { - console.log(`❌ Не удалось отправить уведомление: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`); + console.log(`❌ Не удалось отправить/сохранить уведомление: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`); } return result; @@ -845,11 +1252,175 @@ class EmailNotifications { }); } + async getEmailQueueStats() { + if (!getDb) return { pending: 0, failed: 0, total: 0 }; + + return new Promise((resolve, reject) => { + const db = getDb(); + const query = ` + SELECT + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, + COUNT(*) as total + FROM email_queue + `; + + db.get(query, [], (err, stats) => { + if (err) { + console.error('❌ Ошибка получения статистики очереди:', err); + resolve({ pending: 0, failed: 0, total: 0 }); + } else { + resolve(stats || { pending: 0, failed: 0, total: 0 }); + } + }); + }); + } + + async getEmailQueueItems(status = null, limit = 50) { + if (!getDb) return []; + + return new Promise((resolve, reject) => { + const db = getDb(); + let query = `SELECT * FROM email_queue`; + const params = []; + + if (status) { + query += ` WHERE status = ?`; + params.push(status); + } + + query += ` ORDER BY created_at DESC LIMIT ?`; + params.push(limit); + + db.all(query, params, (err, items) => { + if (err) { + console.error('❌ Ошибка получения элементов очереди:', err); + resolve([]); + } else { + resolve(items || []); + } + }); + }); + } + + async retryFailedEmails() { + if (!getDb) return { retried: 0, total: 0 }; + + try { + const db = getDb(); + + // Получаем все проваленные email + const failedEmails = await new Promise((resolve, reject) => { + db.all( + `SELECT * FROM email_queue WHERE status = 'failed' AND retry_count < ?`, + [this.maxRetries], + (err, emails) => { + if (err) reject(err); + else resolve(emails || []); + } + ); + }); + + if (failedEmails.length === 0) { + console.log('📭 Нет проваленных email для повторной отправки'); + return { retried: 0, total: 0 }; + } + + console.log(`🔍 Найдено ${failedEmails.length} проваленных email для повторной отправки`); + + let retriedCount = 0; + + for (const email of failedEmails) { + try { + // Сбрасываем статус на pending для повторной попытки + await new Promise((resolve, reject) => { + db.run( + `UPDATE email_queue SET status = 'pending', retry_count = retry_count + 1 WHERE id = ?`, + [email.id], + function(err) { + if (err) reject(err); + else { + console.log(`🔄 Email ${email.id} подготовлен для повторной отправки`); + retriedCount++; + resolve(); + } + } + ); + }); + + } catch (error) { + console.error(`❌ Ошибка подготовки email ${email.id} для повторной отправки:`, error); + } + } + + console.log(`✅ Подготовлено ${retriedCount} email для повторной отправки`); + + // Запускаем обработку очереди + this.processRetryQueue(); + + return { retried: retriedCount, total: failedEmails.length }; + + } catch (error) { + console.error('❌ Ошибка при попытке повторной отправки email:', error); + return { retried: 0, total: 0 }; + } + } + + async clearOldQueueItems(days = 30) { + if (!getDb) return { deleted: 0 }; + + try { + const db = getDb(); + + const result = await new Promise((resolve, reject) => { + db.run( + `DELETE FROM email_queue + WHERE created_at < datetime('now', '-${days} days') + AND (status = 'sent' OR status = 'failed')`, + function(err) { + if (err) reject(err); + else resolve(this.changes || 0); + } + ); + }); + + console.log(`🗑️ Удалено ${result} старых записей из очереди email (старше ${days} дней)`); + return { deleted: result }; + + } catch (error) { + console.error('❌ Ошибка очистки старых записей очереди:', error); + return { deleted: 0 }; + } + } + isReady() { return this.initialized; } + + getSpamBlockStatus() { + if (!this.isSpamBlocked || !this.spamBlockedUntil) { + return { blocked: false, blockedUntil: null, minutesLeft: 0 }; + } + + const now = new Date(); + if (now >= this.spamBlockedUntil) { + this.clearSpamBlock(); + return { blocked: false, blockedUntil: null, minutesLeft: 0 }; + } + + const minutesLeft = Math.ceil((this.spamBlockedUntil - now) / (60 * 1000)); + return { + blocked: true, + blockedUntil: this.spamBlockedUntil, + minutesLeft: minutesLeft + }; + } } // Singleton const emailNotifications = new EmailNotifications(); + +// Экспортируем функцию для очистки старых записей (можно запускать по cron) +emailNotifications.clearOldQueueItems = emailNotifications.clearOldQueueItems.bind(emailNotifications); + module.exports = emailNotifications; \ No newline at end of file