feat(notifications): центр уведомлений о системных событиях

Добавлена система уведомлений о системных и фоновых событиях (новые записи
журнала, обработка фото, авто-проверка текста, блокировки IP, бэкапы).

- backend (server.js, worker.js):
  - каталог NOTIFY_TYPES с метаданными и уровнями
  - таблицы notifications и notification_reads в db/init.sql и db/migration.sql
  - SSE-стрим GET /api/notifications/stream через Redis pub/sub с in-memory fallback
  - REST API: список, счётчик непрочитанных, отметка о прочтении, удаление, очистка
  - настройки уведомлений в settings (notify_enabled, notify_retention_days, notify_<тип>)
  - автоматическая очистка старых уведомлений по расписанию
- frontend:
  - колокольчик со счётчиком непрочитанных в шапке (admin.js)
  - страница списка уведомлений public/notifications.html и public/js/notifications.js
  - секция настроек уведомлений в public/settings.html и public/js/settings.js
  - стили для уведомлений в public/admin.css
- тесты и документация:
  - добавлены проверки в api.smoketest.js
  - обновлены README.md и AGENTS.md
This commit is contained in:
dev
2026-09-27 23:34:47 +03:00
parent f31b8deea2
commit 2afe676969
13 changed files with 1158 additions and 4 deletions
+72 -3
View File
@@ -1,5 +1,6 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays'];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans'];
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const NOTIFY_IDS = [];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
@@ -154,6 +155,38 @@ function initSectionNav() {
});
}
async function renderNotifyTypes(s) {
const el = document.getElementById('notifyTypes');
if (!el) return;
const enabledEl = document.getElementById('notifyEnabled');
if (enabledEl) enabledEl.checked = s.notify_enabled !== 'false';
const retentionEl = document.getElementById('notifyRetentionDays');
if (retentionEl) retentionEl.value = s.notify_retention_days !== undefined && s.notify_retention_days !== '' ? s.notify_retention_days : '30';
NOTIFY_IDS.length = 0;
let types = [];
try {
const res = await fetch(`${API}/api/notifications/meta`, { headers: hdr() });
if (res.ok) types = (await res.json()).types || [];
} catch {}
if (!types.length) {
el.innerHTML = '<span class="hint">Не удалось загрузить список событий</span>';
return;
}
el.innerHTML = types.map(t => {
NOTIFY_IDS.push(t.key);
if (!DIRTY_FIELDS.includes(t.key)) DIRTY_FIELDS.push(t.key);
const raw = s[t.key];
const value = raw !== undefined && raw !== '' ? raw : (t.default_enabled ? 'true' : 'false');
const badge = t.admin_only ? '<span class="notify-type-badge">только админ</span>' : '';
return `<label class="toggle">
<input type="checkbox" id="${esc(t.key)}" ${value !== 'false' ? 'checked' : ''}>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>${esc(t.label)}${badge}</b><small>${esc(t.hint || '')}</small></span>
</label>`;
}).join('');
renderIcons();
}
async function loadSettings() {
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
@@ -185,6 +218,7 @@ async function loadSettings() {
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = 'auto';
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
await renderNotifyTypes(s);
snapshotDirty();
updateSpamUi();
loadAiQueue();
@@ -647,6 +681,18 @@ document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
});
function notifySettingsPayload(retentionDays) {
const out = {
notify_enabled: document.getElementById('notifyEnabled').checked ? 'true' : 'false',
notify_retention_days: String(retentionDays),
};
NOTIFY_IDS.forEach(id => {
const el = document.getElementById(id);
if (el) out[id] = el.checked ? 'true' : 'false';
});
return out;
}
async function saveSettings() {
const v = spamInput.value;
const n = Number(v);
@@ -663,6 +709,12 @@ async function saveSettings() {
showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)');
return;
}
const notifyRetentionValue = document.getElementById('notifyRetentionDays').value;
const notifyRetentionDays = Math.round(Number(notifyRetentionValue));
if (notifyRetentionValue === '' || !Number.isFinite(notifyRetentionDays) || notifyRetentionDays < 1 || notifyRetentionDays > 365) {
showError('Проверьте поле «Хранение уведомлений» (от 1 до 365 дней)');
return;
}
const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true);
@@ -687,6 +739,7 @@ async function saveSettings() {
photo_enhance_engine: document.getElementById('photoEngine').value,
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
trash_purge_days: String(trashDays),
...notifySettingsPayload(notifyRetentionDays),
} })
});
if (res.ok) {
@@ -835,6 +888,22 @@ if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSyste
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn));
const notifyTestBtn = document.getElementById('notifyTestBtn');
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
notifyTestBtn.classList.add('spinning');
try {
const res = await fetch(`${API}/api/notifications/test`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showError(data.error || 'Ошибка отправки уведомления'); return; }
if (data.delivered) showToast('Тестовое уведомление отправлено');
else showError('Уведомление не создано: выключен общий переключатель или событие «Тестовое уведомление»');
} catch {
showError('Ошибка сети');
} finally {
notifyTestBtn.classList.remove('spinning');
}
});
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
statsRefreshBtn.classList.add('spinning');
@@ -919,12 +988,12 @@ window.addEventListener('beforeunload', e => {
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
bindDirty();
initSectionNav();
updateSpamUi();
const authStatus = document.getElementById('authStatus');
try {
await loadSettings();
bindDirty();
if (authStatus) authStatus.classList.add('loaded');
} catch {
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }