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
+399
View File
@@ -52,8 +52,10 @@ lister.on('notification', (msg) => {
const type = payload && payload.type ? payload.type : 'entry_created';
if (type === 'ai_status') {
broadcastAiStatus(payload);
notifyEntryAi(payload).catch(err => console.error('Notify AI:', err.message));
} else {
broadcastEntryChanged();
notifyEntryCreated(payload && payload.id).catch(err => console.error('Notify entry:', err.message));
}
});
@@ -175,6 +177,229 @@ function invalidateShare() { cacheDrop('share:payload:'); }
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
function invalidateAll() { cache.clear().catch(err => console.error('Cache clear failed:', err.message)); }
// --- Notifications ---
const NOTIFY_CHANNEL = 'whatido:notifications';
const NOTIFY_RETENTION_DEFAULT_DAYS = 30;
const NOTIFY_TYPES = {
'entry.new': { label: 'Новая запись в журнале', hint: 'Ответ ученика отправлен через форму или запись добавлена вручную', icon: 'book-open', level: 'info', enabled: true, admin: false },
'entry.ai.corrected': { label: 'ИИ исправил текст', hint: 'Автопроверка изменила текст записи', icon: 'sparkles', level: 'info', enabled: false, admin: false },
'entry.ai.error': { label: 'Ошибка автопроверки текста', hint: 'ИИ не смог обработать запись после всех попыток', icon: 'bot', level: 'warning', enabled: true, admin: false },
'photo.job.done': { label: 'Фото обработано', hint: 'Нейросеть или сервер улучшили фото в записи', icon: 'image', level: 'info', enabled: true, admin: false },
'photo.job.error': { label: 'Ошибка обработки фото', hint: 'Очередь улучшения фото исчерпала попытки', icon: 'image-off', level: 'warning', enabled: true, admin: false },
'ip.ban': { label: 'IP отправлен в бан', hint: 'Автоблокировка за спам или подбор пароля либо блокировка вручную', icon: 'shield-off', level: 'warning', enabled: true, admin: true },
'backup.restore': { label: 'Восстановление из бэкапа', hint: 'Данные системы заменены содержимым архива', icon: 'database', level: 'critical', enabled: true, admin: true },
'backup.create': { label: 'Создан архив бэкапа', hint: 'Архив данных скачан из админ-панели', icon: 'download', level: 'info', enabled: false, admin: true },
'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true },
};
function notifySettingKey(type) {
return 'notify_' + String(type).replace(/\./g, '_');
}
function notifyCatalog() {
return Object.entries(NOTIFY_TYPES)
.filter(([, spec]) => !spec.hidden)
.map(([type, spec]) => ({
type,
key: notifySettingKey(type),
label: spec.label,
hint: spec.hint,
icon: spec.icon,
level: spec.level || 'info',
admin_only: !!spec.admin,
default_enabled: spec.enabled !== false,
}));
}
async function notifyTypeEnabled(type) {
const spec = NOTIFY_TYPES[type];
if (!spec) return false;
if (String(await getSetting('notify_enabled', 'true')) === 'false') return false;
const def = spec.enabled !== false ? 'true' : 'false';
return String(await getSetting(notifySettingKey(type), def)) !== 'false';
}
const notifyClients = new Set();
function notificationVisible(user, n) {
if (!user) return false;
if (user.role === 'admin') return true;
if (n.admin_only) return false;
if (n.branch_id === null || n.branch_id === undefined) return true;
return (user.branch_ids || []).map(Number).includes(Number(n.branch_id));
}
function writeNotifyFrame(client, event, data) {
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
function publishNotification(row) {
cache.publish(NOTIFY_CHANNEL, row).catch(err => console.error('Notify publish failed:', err.message));
}
cache.on(NOTIFY_CHANNEL, message => {
let payload = null;
try { payload = JSON.parse(message); } catch (e) { return; }
if (!payload || !payload.id) return;
for (const client of [...notifyClients]) {
if (!notificationVisible(client.user, payload)) continue;
try { writeNotifyFrame(client, 'notification', payload); } catch (e) { notifyClients.delete(client); }
}
});
async function pushNotification({ type, title, body, link, target, branchId, level, adminOnly }) {
const spec = NOTIFY_TYPES[type];
if (!spec) return null;
if (!(await notifyTypeEnabled(type))) return null;
try {
const { rows } = await pool.query(
`INSERT INTO notifications (type, level, title, body, link, target, admin_only, branch_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, type, level, title, body, link, target, admin_only, branch_id, created_at`,
[
type,
level || spec.level || 'info',
String(title || spec.label).slice(0, 200),
body ? String(body).slice(0, 2000) : null,
link || null,
target && Object.keys(target).length ? JSON.stringify(target) : null,
adminOnly === undefined ? !!spec.admin : !!adminOnly,
branchId || null,
]
);
const row = rows[0];
publishNotification(row);
return row;
} catch (e) {
console.error('Notification failed:', type, e.message);
return null;
}
}
async function notifyEntry(entryId, { type, title, body, link, target, level }) {
const id = parseInt(entryId, 10);
if (!Number.isInteger(id) || id < 1) return null;
const { rows } = await pool.query(
`SELECT e.id, e.student_name, e.group_id, g.name AS group_name, g.branch_id
FROM entries e LEFT JOIN groups g ON g.id = e.group_id WHERE e.id = $1`,
[id]
);
if (!rows.length) return null;
const ctx = rows[0];
const fill = s => String(s === null || s === undefined ? '' : s)
.replace(/\{student\}/g, ctx.student_name || '—')
.replace(/\{group\}/g, ctx.group_name || '—');
return pushNotification({
type,
level,
title: fill(title),
body: fill(body),
link: link || 'journal.html',
target: Object.assign({ entry_id: ctx.id, student_name: ctx.student_name, group_name: ctx.group_name }, target || {}),
branchId: ctx.branch_id,
});
}
async function notifyEntryCreated(entryId) {
return notifyEntry(entryId, {
type: 'entry.new',
title: 'Новая запись: {student}',
body: 'Группа {group}',
});
}
async function notifyEntryAi(payload) {
const status = payload && payload.status;
if (status !== 'done' && status !== 'error') return null;
if (status === 'done' && String(payload.description ?? '') === String(payload.description_original ?? '')) return null;
if (status === 'error') {
const err = payload.error ? ' · ' + String(payload.error).slice(0, 300) : '';
return notifyEntry(payload.id, {
type: 'entry.ai.error',
title: 'ИИ не смог проверить текст: {student}',
body: `Группа {group}${err}`,
target: { error: payload.error || null },
});
}
return notifyEntry(payload.id, {
type: 'entry.ai.corrected',
title: 'ИИ исправил текст: {student}',
body: 'Группа {group}',
});
}
async function purgeOldNotifications() {
const raw = parseInt(await getSetting('notify_retention_days', String(NOTIFY_RETENTION_DEFAULT_DAYS)), 10);
const days = Number.isFinite(raw) && raw >= 1 ? Math.min(raw, 365) : NOTIFY_RETENTION_DEFAULT_DAYS;
const { rowCount } = await pool.query(
`DELETE FROM notifications WHERE created_at < now() - ($1 || ' days')::interval`,
[String(days)]
);
if (rowCount) console.log(`Notifications pruned: ${rowCount} (older than ${days} days)`);
}
async function ensureNotificationsTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL,
level VARCHAR(20) NOT NULL DEFAULT 'info',
title VARCHAR(200) NOT NULL,
body TEXT,
link VARCHAR(255),
target JSONB,
admin_only BOOLEAN NOT NULL DEFAULT false,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id)`);
await pool.query(`CREATE TABLE IF NOT EXISTS notification_reads (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
read_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (user_id, notification_id)
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id)`);
await pool.query(`INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
await pool.query(
`INSERT INTO settings (key, value) VALUES ('notify_retention_days', $1) ON CONFLICT (key) DO NOTHING`,
[String(NOTIFY_RETENTION_DEFAULT_DAYS)]
);
for (const [type, spec] of Object.entries(NOTIFY_TYPES)) {
await pool.query(
`INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[notifySettingKey(type), spec.enabled !== false ? 'true' : 'false']
);
}
}
function notificationsScope(user) {
const s = branchScope(user);
if (s.admin) return { cond: '', params: [] };
const params = [];
let cond = 'n.admin_only = false';
if (s.ids.length) {
cond += ` AND (n.branch_id IS NULL OR n.branch_id IN (${s.ids.map(id => '$' + params.push(id)).join(',')}))`;
} else {
cond += ' AND n.branch_id IS NULL';
}
return { cond, params };
}
async function notificationsCounts(user) {
const scope = notificationsScope(user);
const { rows } = await pool.query(
`SELECT count(*)::int AS total,
count(*) FILTER (WHERE r.user_id IS NULL)::int AS unread
FROM notifications n
LEFT JOIN notification_reads r ON r.notification_id = n.id AND r.user_id = $1
${scope.cond ? 'WHERE ' + scope.cond : ''}`,
[user.id, ...scope.params]
);
return rows[0];
}
const BAN_TTL_MS = 24 * 60 * 60 * 1000;
const FAIL_WINDOW_MS = 15 * 60 * 1000;
const banKey = ip => 'ban:' + ip;
@@ -188,6 +413,13 @@ async function banIP(req, reason, ms) {
await banIpAddr(ipOf(req), reason, ms, req);
}
const BAN_REASON_LABELS = {
honeypot: 'Антиспам-поле',
'login-bruteforce': 'Подбор пароля входа',
'share-password-bruteforce': 'Подбор пароля ссылки',
manual: 'Вручную',
};
async function banIpAddr(ip, reason, ms, actorReq) {
const until = new Date(Date.now() + ms);
await cache.set(banKey(ip), { reason, banned_until: until.toISOString() }, ms);
@@ -196,6 +428,15 @@ async function banIpAddr(ip, reason, ms, actorReq) {
[ip, reason, until.toISOString()]
);
await logAudit(actorReq, 'ip.ban', { ip, reason });
const hours = Math.max(1, Math.round(ms / 3600000));
await pushNotification({
type: 'ip.ban',
title: `IP отправлен в бан: ${ip}`,
body: `${BAN_REASON_LABELS[reason] || reason} · блокировка на ${hours} ч.`,
link: 'bans.html',
target: { ip, reason },
adminOnly: true,
});
console.log(`IP banned: ${ip} (${reason})`);
}
@@ -1144,6 +1385,130 @@ app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
res.json({ ok: true });
});
// --- Notifications ---
const notificationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 600,
standardHeaders: true,
legacyHeaders: false,
store: cache.rateLimitStore('notify', 15 * 60 * 1000),
message: { error: 'Слишком много запросов. Попробуйте позже.' },
});
app.get('/api/notifications', requireAuth, notificationLimiter, async (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 30, 1), 100);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const unreadOnly = req.query.unread === '1';
const scope = notificationsScope(req.user);
const params = [req.user.id, ...scope.params];
const where = [];
if (scope.cond) where.push(scope.cond);
if (unreadOnly) where.push('r.user_id IS NULL');
const { rows } = await pool.query(
`SELECT n.id, n.type, n.level, n.title, n.body, n.link, n.target, n.admin_only, n.branch_id, n.created_at,
(r.user_id IS NOT NULL) AS read
FROM notifications n
LEFT JOIN notification_reads r ON r.notification_id = n.id AND r.user_id = $1
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY n.id DESC LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`,
params
);
const counts = await notificationsCounts(req.user);
res.json({ items: rows, total: counts.total, unread: counts.unread });
});
app.get('/api/notifications/meta', requireAdmin, async (_, res) => {
res.json({
enabled: String(await getSetting('notify_enabled', 'true')) !== 'false',
retention_days: parseInt(await getSetting('notify_retention_days', String(NOTIFY_RETENTION_DEFAULT_DAYS)), 10) || NOTIFY_RETENTION_DEFAULT_DAYS,
types: notifyCatalog(),
});
});
app.get('/api/notifications/stream', async (req, res) => {
let user = null;
try {
const token = req.headers['x-auth-token'] || req.query.token;
user = await loadUserByToken(token);
} catch (e) {
return res.status(500).end();
}
if (!user || !user.is_active) return res.status(401).end();
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write(':ok\n\n');
const client = { res, user };
notifyClients.add(client);
notificationsCounts(user)
.then(counts => writeNotifyFrame(client, 'ready', counts))
.catch(err => console.error('Notify counts failed:', err.message));
const ping = setInterval(() => {
try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); notifyClients.delete(client); }
}, 25000);
req.on('close', () => { clearInterval(ping); notifyClients.delete(client); });
});
app.post('/api/notifications/read-all', requireAuth, notificationLimiter, async (req, res) => {
const scope = notificationsScope(req.user);
const { rowCount } = await pool.query(
`INSERT INTO notification_reads (user_id, notification_id)
SELECT $1, n.id FROM notifications n
WHERE NOT EXISTS (
SELECT 1 FROM notification_reads r WHERE r.notification_id = n.id AND r.user_id = $1
)${scope.cond ? ' AND (' + scope.cond + ')' : ''}
ON CONFLICT DO NOTHING`,
[req.user.id, ...scope.params]
);
const counts = await notificationsCounts(req.user);
res.json({ ok: true, marked: rowCount, unread: counts.unread });
});
app.post('/api/notifications/:id/read', requireAuth, notificationLimiter, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
const scope = notificationsScope(req.user);
const { rows } = await pool.query(
`SELECT n.id FROM notifications n WHERE n.id = $1${scope.cond ? ' AND (' + scope.cond + ')' : ''}`,
[id, ...scope.params]
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
await pool.query(
`INSERT INTO notification_reads (user_id, notification_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[req.user.id, id]
);
res.json({ ok: true });
});
app.post('/api/notifications/test', requireAdmin, notificationLimiter, async (req, res) => {
const row = await pushNotification({
type: 'system.test',
title: 'Тестовое уведомление',
body: `Отправлено из настроек пользователем ${req.user.username}`,
link: 'notifications.html',
adminOnly: true,
});
res.json({ ok: true, id: row ? row.id : null, delivered: !!row });
});
app.delete('/api/notifications/:id', requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
const { rowCount } = await pool.query('DELETE FROM notifications WHERE id = $1', [id]);
if (!rowCount) return res.status(404).json({ error: 'Not found' });
await logAudit(req, 'notifications.delete', { id });
res.json({ ok: true });
});
app.delete('/api/notifications', requireAdmin, async (req, res) => {
const { rowCount } = await pool.query('DELETE FROM notifications');
await logAudit(req, 'notifications.clear', { deleted: rowCount });
res.json({ ok: true, deleted: rowCount });
});
// --- Users (admin only) ---
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
const { rows } = await pool.query(
@@ -1351,6 +1716,15 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
if (key === 'system_logo' && String(value) !== '' && !isSafeUploadPath(String(value))) {
return res.status(400).json({ error: 'system_logo — некорректный путь' });
}
if (key === 'notify_retention_days') {
const n = parseInt(String(value), 10);
if (!Number.isFinite(n) || n < 1 || n > 365) {
return res.status(400).json({ error: 'notify_retention_days должен быть целым числом от 1 до 365' });
}
}
if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: `${key} должен быть true или false` });
}
}
const client = await pool.connect();
try {
@@ -1974,6 +2348,13 @@ app.post('/api/backup', requireAdmin, async (req, res) => {
const expiresAt = Date.now() + BACKUP_TTL_MS;
backupTickets.set(token, { file: archive.file, name: archive.name, size: archive.size, expiresAt });
await logAudit(req, 'backup.download', { size: archive.size });
await pushNotification({
type: 'backup.create',
title: 'Создан архив бэкапа',
body: `${archive.name} · ${(archive.size / 1024 / 1024).toFixed(1)} МБ`,
link: 'settings.html#sec-backup',
adminOnly: true,
});
res.json({
url: `/api/backup/${token}`,
filename: archive.name,
@@ -2007,6 +2388,13 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
try {
const archive = await buildBackupArchive();
await logAudit(req, 'backup.download', { size: archive.size });
await pushNotification({
type: 'backup.create',
title: 'Создан архив бэкапа',
body: `${archive.name} · ${(archive.size / 1024 / 1024).toFixed(1)} МБ`,
link: 'settings.html#sec-backup',
adminOnly: true,
});
sendBackupArchive(res, archive);
} catch (err) {
console.error(err);
@@ -2213,6 +2601,13 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
await ensureFirstAdmin().catch(err => console.error('First admin:', err));
await logAudit(req, 'backup.restore', {});
await pushNotification({
type: 'backup.restore',
title: 'Восстановление из бэкапа завершено',
body: `Данные заменены архивом · пользователь ${req.user.username}`,
link: 'settings.html',
adminOnly: true,
});
invalidateAll();
res.json({ ok: true });
});
@@ -5542,6 +5937,9 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();
try { await pool.query(`INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Camera setting:', err); }
try { await pool.query(`INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Trash purge days setting:', err); }
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
@@ -5570,6 +5968,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
photoAiUrl: PHOTO_AI_URL,
uploadsDir: UPLOADS_DIR,
storage,
notifyEvent: notifyEntry,
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
});
photoWorker.start();