/
DATKAI
/
label_system
Обзор
Документация
Войти
/
DATKAI
/
label_system
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
lib/notify.js
208 строк
10 KB
DATKAI
Оповещения: предпросмотр и тестовая отправка писем — v3.13.0
22 июл 2026, 14:28
22 июл 2026, 14:28
d525891
Код
Авторство
О чём код?
'use strict'; // ══════════════════════════════════════════════════════════════ // ШИНА ОПОВЕЩЕНИЙ — доменные события → правила → письма в очередь. // emit() НИКОГДА не бросает исключений и не ждёт отправки: сбой почты // не должен ломать основную операцию (взять/закрыть задание). // Правило = событие + фильтры + получатели + режим (мгновенно/дайджест). // Отправкой (и сбросом дайджестов) занимается воркер lib/outbox.js. // ══════════════════════════════════════════════════════════════ const { readDB, writeDB, genId } = require('./db'); const mailtpl = require('./mailtpl'); const EVENTS = [ { id:'task.created', label:'Выдано задание', hint:'Мастер создал задание' }, { id:'task.taken', label:'Оператор взял задание', hint:'Задание взято в работу' }, { id:'task.done', label:'Оператор закрыл задание', hint:'Выполнено полностью или частично' }, { id:'task.confirmed', label:'Закрытие подтверждено', hint:'Мастер подтвердил в админке' }, { id:'shift.opened', label:'Смена открыта', hint:'Оператор открыл смену' }, { id:'shift.closed', label:'Смена закрыта', hint:'Вручную, при выходе или по бездействию' }, ]; const EVENT_IDS = new Set(EVENTS.map(e => e.id)); const DEFAULT_QUIET = { enabled:false, from:'22:00', to:'07:00' }; const DEFAULT_RULE = { name:'', event:'task.done', enabled:true, filters:{}, recipients:'', mode:'instant', digestMin:15 }; function parseAddrs(s){ return String(s || '').split(/[,;\s]+/).map(x => x.trim()).filter(x => x.includes('@')); } // ── Чтение конфигурации (+ разовая миграция старого events{} → rules[]) ── function getRules(db){ if(!db.notify) db.notify = {}; // модуль ещё ни разу не настраивался if(Array.isArray(db.notify.rules)) return db.notify.rules; const old = db.notify.events; if(old && typeof old === 'object'){ const rules = Object.entries(old).filter(([k,v]) => v && v.enabled && EVENT_IDS.has(k)) .map(([k,v]) => Object.assign({}, DEFAULT_RULE, { id: genId('rule_'), event: k, recipients: v.recipients || '' })); if(!db.notify) db.notify = {}; db.notify.rules = rules; delete db.notify.events; writeDB(db); return rules; } return []; } function getQuiet(db){ return Object.assign({}, DEFAULT_QUIET, db.notify?.quiet || {}); } // ── Тихие часы: "22:00".."07:00" — окно может пересекать полночь ── function isQuietAt(quiet, date){ if(!quiet.enabled) return false; const mins = date.getHours() * 60 + date.getMinutes(); const [fh,fm] = String(quiet.from||'0:0').split(':').map(Number); const [th,tm] = String(quiet.to||'0:0').split(':').map(Number); const from = fh*60+fm, to = th*60+tm; return from <= to ? (mins >= from && mins < to) : (mins >= from || mins < to); } // Момент, когда тихие часы, начавшиеся в date (или сейчас), закончатся function quietEndAfter(quiet, date){ const [th,tm] = String(quiet.to||'0:0').split(':').map(Number); const end = new Date(date); end.setHours(th, tm, 0, 0); if(end <= date) end.setDate(end.getDate() + 1); return end; } // Не раньше сейчас, и не в тихие часы — сдвигаем до их конца function nextSendTime(db, from){ const quiet = getQuiet(db); let t = from || new Date(); if(isQuietAt(quiet, t)) t = quietEndAfter(quiet, t); return t; } // ── Реалистичные тестовые данные — для предпросмотра/тестовой отправки ── function sampleForEvent(event){ const now = new Date(); const iso = d => d.toISOString(); const taken = new Date(now.getTime() - 90*60000); const task = { decor: 'Дуб Сонома', width: 293, length: 500, rollsCount: 2, batch: 'Б-1042', priority: 'high', date: now.toISOString().slice(0,10), createdBy: 'Мастер Пётр', createdAt: iso(new Date(now.getTime() - 4*3600000)), assignTo: 'ivan', takenBy: 'Иванов Иван', takenAt: iso(taken), status: 'done', doneLength: 480, doneBy: 'Иванов Иван', finishedAt: iso(now), durationMs: now.getTime() - taken.getTime(), confirmedBy: 'Администратор', confirmedAt: iso(now), }; const shift = { operatorName: 'Иванов Иван', operatorLogin: 'ivan', openedAt: iso(new Date(now.getTime() - 5*3600000)), closedAt: iso(now), durationMs: 5*3600000, closeReason: 'manual', }; const actor = 'Иванов Иван'; if(event.startsWith('shift.')) return { shift, actor }; return { task, actor, actorLogin: 'ivan' }; } // ── Совпадение payload с фильтрами правила ───────────────────── function matchFilters(event, payload, filters){ filters = filters || {}; const t = payload.task || {}; if(filters.priority && t.priority && filters.priority !== t.priority) return false; if(filters.priority && !t.priority) return false; if(filters.operatorLogin){ const login = payload.actorLogin || t.takenByLogin || t.assignTo || ''; if(login !== filters.operatorLogin) return false; } if(filters.decor && t.decor){ if(!t.decor.toLowerCase().includes(String(filters.decor).toLowerCase())) return false; } const len = parseFloat(t.length || t.doneLength || 0); if(filters.minLength !== undefined && filters.minLength !== '' && len < parseFloat(filters.minLength)) return false; if(filters.maxLength !== undefined && filters.maxLength !== '' && len > parseFloat(filters.maxLength)) return false; return true; } // Поставить готовое письмо в outbox (учитывая тихие часы) function enqueue(db, { to, subject, html, text, event }){ if(!db.outbox) db.outbox = []; const now = new Date(); const sendAt = nextSendTime(db, now); db.outbox.push({ id: genId('mail_'), event, to, subject, html, text, status: 'pending', attempts: 0, lastError: '', createdAt: now.toISOString(), nextTryAt: sendAt.toISOString(), sentAt: '', }); if(db.outbox.length > 500) db.outbox = db.outbox.slice(-500); } // ── emit: событие → подходящие правила → мгновенно или в буфер дайджеста ── function emit(event, payload){ try{ const db = readDB(); const n = db.notify || {}; if(!n.enabled) return; const rules = getRules(db).filter(r => r.enabled && r.event === event); if(!rules.length) return; let changed = false; for(const rule of rules){ if(!matchFilters(event, payload, rule.filters)) continue; const to = parseAddrs(rule.recipients || n.recipients); if(!to.length) continue; if(rule.mode === 'digest' && rule.digestMin > 0){ // В буфер дайджеста: копим короткие строки, письмо соберёт flushDigests() if(!db.notify.digestBuf) db.notify.digestBuf = {}; const buf = db.notify.digestBuf[rule.id] || { firstAt: new Date().toISOString(), items: [] }; buf.items.push({ event, ts: new Date().toISOString(), line: digestLine(event, payload) }); db.notify.digestBuf[rule.id] = buf; } else { const msg = mailtpl.build(event, payload); if(!msg) continue; enqueue(db, { to: to.join(', '), subject: msg.subject, html: msg.html, text: msg.text, event }); } changed = true; } if(changed) writeDB(db); }catch(e){ try{ console.error('[notify] emit error:', e.message); }catch(_){} } } // Компактная строка для дайджест-письма function digestLine(event, payload){ const t = payload.task || {}, sh = payload.shift || {}; const who = payload.actor || ''; const EV_LABEL = Object.fromEntries(EVENTS.map(e => [e.id, e.label])); if(event.startsWith('task.')) return `${EV_LABEL[event]||event}: ${t.decor||''} — ${who} (${mailtpl.specText(t)})`; return `${EV_LABEL[event]||event}: ${sh.operatorName || sh.operatorLogin || ''}`; } // ── Сброс просроченных буферов дайджеста в письма (вызывает outbox.js) ── function flushDigests(){ try{ const db = readDB(); const buf = db.notify?.digestBuf; if(!buf || !Object.keys(buf).length) return; const rules = getRules(db); const now = new Date(); let changed = false; for(const [ruleId, b] of Object.entries(buf)){ const rule = rules.find(r => r.id === ruleId); if(!rule || !b.items?.length){ delete buf[ruleId]; changed = true; continue; } const ageMin = (now - new Date(b.firstAt)) / 60000; if(ageMin < (rule.digestMin || 15)) continue; const to = parseAddrs(rule.recipients || db.notify.recipients); if(to.length){ const title = `📨 Сводка: ${(EVENTS.find(e=>e.id===rule.event)||{}).label || rule.event} (${b.items.length})`; const rows = b.items.map((it,i) => [String(i+1), it.line]); enqueue(db, { to: to.join(', '), event: rule.event, subject: title, html: mailtpl.htmlLetter({ title, subtitle: `За период ${mailtpl.fmtDT(b.firstAt)} — ${mailtpl.fmtDT(now.toISOString())}`, rows }), text: mailtpl.textLetter({ title, rows }), }); } delete buf[ruleId]; changed = true; } if(changed) writeDB(db); }catch(e){ try{ console.error('[notify] flushDigests error:', e.message); }catch(_){} } } module.exports = { emit, flushDigests, EVENTS, DEFAULT_QUIET, DEFAULT_RULE, getRules, getQuiet, isQuietAt, matchFilters, parseAddrs, sampleForEvent, };