From 2afe6769694c1c0f35df828384059c46de627fc6 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 27 Sep 2026 23:34:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(notifications):=20=D1=86=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=80=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BE=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена система уведомлений о системных и фоновых событиях (новые записи журнала, обработка фото, авто-проверка текста, блокировки 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 --- AGENTS.md | 12 ++ README.md | 28 +++ api.smoketest.js | 38 ++++ db/init.sql | 48 +++++ db/migration.sql | 37 ++++ public/admin.css | 45 +++++ public/admin.js | 220 ++++++++++++++++++++ public/js/notifications.js | 164 +++++++++++++++ public/js/settings.js | 75 ++++++- public/notifications.html | 43 ++++ public/settings.html | 24 +++ server.js | 399 +++++++++++++++++++++++++++++++++++++ worker.js | 29 ++- 13 files changed, 1158 insertions(+), 4 deletions(-) create mode 100644 public/js/notifications.js create mode 100644 public/notifications.html diff --git a/AGENTS.md b/AGENTS.md index 0a5a7bb..1d4f2cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,18 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:`, иначе деактивированный пользователь сохранит доступ - **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1` +### 3c. Уведомления (`server.js`, `worker.js`, `public/`) +- **Каталог событий** — только `NOTIFY_TYPES` в `server.js` (тип → `label`, `hint`, `icon`, `level`, `enabled` по умолчанию, `admin`); фронтенд берёт список из `GET /api/notifications/meta`, дублировать каталог в HTML нельзя +- **Таблицы**: `notifications` (событие, `admin_only`, `branch_id`) + `notification_reads` (прочтение на пользователя). Изменения схемы — в `db/init.sql` и `db/migration.sql` и в `ensureNotificationsTable()` +- **Настройки**: `notify_enabled` (общий), `notify_retention_days` (1–365), `notify_<тип>` (точки типа заменяются на `_`, см. `notifySettingKey`). Значения только `'true'` / `'false'` — `PUT /api/settings` это валидирует +- **Создание события** — только через `pushNotification()` / `notifyEntry()`; они сами проверяют переключатели и при выключенном типе возвращают `null`. `notifyEntry` подставляет `{student}` и `{group}` и определяет филиал по группе записи +- **Хук в фото-воркере называется `notifyEvent`** — имя `notify` внутри `createPhotoEnhanceWorker` уже занято будильником воркера (`photoWorker.notify()` из `POST /api/photo-jobs/wake`), объявление функции перекрыло бы параметр +- **Видимость**: админ видит всё; остальные — `admin_only = false` и `branch_id IS NULL` или филиал из `user_branches` +- **Доставка**: запись в БД → `cache.publish('whatido:notifications', row)` → SSE `GET /api/notifications/stream` (клиенты фильтруются по `notificationVisible`). Redis недоступен — работает in-memory pub/sub +- **Очистка**: `purgeOldNotifications()` при старте и раз в час по `notify_retention_days`; чтения удаляются каскадом +- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide +- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`) + ### 4. API Patterns - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется diff --git a/README.md b/README.md index c864994..11f20b1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции - **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`) +- **Уведомления** — системные события (новые записи журнала, обработка фото нейросетью, ошибки авто-проверки текста, блокировки IP, бэкапы) собираются в «колокольчике» и на странице «Уведомления»; набор событий включается/выключается в «Настройках» → «Уведомления» - **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido..ts.net` без проброса портов, внешнего IP и reverse-proxy ## Технологии @@ -399,6 +400,27 @@ node redis.selftest.js # юнит-тесты redis.js node api.smoketest.js # сквозная проверка API (нужен запущенный стек) ``` +## Уведомления + +Система уведомлений — журнал событий (`notifications`) с отметками прочтения на пользователя (`notification_reads`) плюс каталог типов событий `NOTIFY_TYPES` в `server.js`. + +| Тип | Событие | Кому видно | +|-----|---------|------------| +| `entry.new` | новая запись в журнале (форма ученика или ручное добавление) | филиал группы | +| `entry.ai.corrected` | ИИ исправил текст (по умолчанию выключено) | филиал группы | +| `entry.ai.error` | авто-проверка текста не удалась | филиал группы | +| `photo.job.done` | фото обработано нейросетью или сервером | филиал группы | +| `photo.job.error` | очередь обработки фото исчерпала попытки | филиал группы | +| `ip.ban` | IP отправлен в бан (авто или вручную) | только админ | +| `backup.restore` | восстановление из бэкапа | только админ | +| `backup.create` | создан архив бэкапа (по умолчанию выключено) | только админ | + +Где видно: «колокольчик» в боковом меню (панель последних событий, бейдж непрочитанных, опциональные уведомления браузера) и страница `notifications.html` (фильтр «непрочитанные», отметка «прочитано», удаление и полная очистка для админа). Новые события приходят в реальном времени по SSE (`GET /api/notifications/stream`), транспорт — Redis pub/sub с in-memory fallback. + +Что настраивается в «Настройках» → «Уведомления» (ключи таблицы `settings`): общий выключатель `notify_enabled`, срок хранения `notify_retention_days` (1–365 дней, старые уведомления удаляются ежечасно) и отдельный переключатель `notify_<тип>` для каждого события. Там же кнопка тестового уведомления. + +Видимость: администратор видит все уведомления, остальные — только события своего филиала (или без филиала) и никогда — события с пометкой `admin_only`. + ## Бэкапы @@ -448,6 +470,12 @@ node api.smoketest.js # сквозная проверка API (нужен | `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки | | `GET` | `/api/backup` | Скачать бэкап | | `POST` | `/api/restore` | Восстановить из бэкапа | +| `GET` | `/api/notifications` | Уведомления пользователя (`limit`, `offset`, `unread=1`) | +| `GET` | `/api/notifications/stream` | SSE-поток уведомлений (заголовок `X-Auth-Token` или `?token=`) | +| `GET` | `/api/notifications/meta` | Каталог типов событий и текущие переключатели (admin) | +| `POST` | `/api/notifications/:id/read`, `/api/notifications/read-all` | Отметить прочитанным | +| `POST` | `/api/notifications/test` | Тестовое уведомление (admin) | +| `DELETE` | `/api/notifications/:id`, `/api/notifications` | Удалить уведомление / очистить все (admin) | | `GET` | `/api/dashboard`, `/api/stats` | Статистика | Авторизация — по сессиям, не по статическому токену: diff --git a/api.smoketest.js b/api.smoketest.js index 6b1d433..602e495 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -137,6 +137,44 @@ async function main() { reader.cancel().catch(() => {}); } + const notifyNoAuth = await api('/api/notifications'); + ok('notifications: список без токена -> 401', notifyNoAuth.status === 401, notifyNoAuth.status); + const notifyList = await api('/api/notifications?limit=5', { token }); + ok('notifications: список -> 200', notifyList.status === 200 && Array.isArray(notifyList.data.items) && typeof notifyList.data.unread === 'number', notifyList.status); + const notifyMeta = await api('/api/notifications/meta', { token }); + ok('notifications: meta перечисляет типы событий', notifyMeta.status === 200 && Array.isArray(notifyMeta.data.types) && notifyMeta.data.types.length > 0, notifyMeta.status); + ok('notifications: meta содержит тип ip.ban', Boolean((notifyMeta.data.types || []).find(t => t.type === 'ip.ban')), (notifyMeta.data.types || []).map(t => t.type)); + const notifyCreate = await api('/api/notifications/test', { token, method: 'POST' }); + ok('notifications: тестовое уведомление создано', notifyCreate.status === 200 && notifyCreate.data.id > 0 && notifyCreate.data.delivered === true, notifyCreate.data); + const notifyUnread = await api('/api/notifications?unread=1', { token }); + ok('notifications: непрочитанные растут', notifyUnread.data.unread >= 1, notifyUnread.data.unread); + const notifyRead = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' }); + ok('notifications: отметить уведомление прочитанным', notifyRead.status === 200, notifyRead.status); + const notifyReadAll = await api('/api/notifications/read-all', { token, method: 'POST' }); + ok('notifications: отметить всё прочитанным', notifyReadAll.status === 200 && notifyReadAll.data.unread === 0, notifyReadAll.data); + const notifyStream = await fetch(BASE + '/api/notifications/stream?token=' + encodeURIComponent(token)); + ok('notifications: SSE открывается', notifyStream.status === 200, notifyStream.status); + if (notifyStream.status === 200) { + const reader = notifyStream.body.getReader(); + const first = await reader.read(); + const text = new TextDecoder().decode(first.value || new Uint8Array()); + ok('notifications: SSE отдаёт ready-кадр', text.includes('event: ready') || text.includes(':ok'), JSON.stringify(text.slice(0, 60))); + reader.cancel().catch(() => {}); + } + const notifyOff = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'false' } } }); + const notifySuppressed = await api('/api/notifications/test', { token, method: 'POST' }); + ok('notifications: выключенный тип не создаётся', notifyOff.status === 200 && notifySuppressed.status === 200 && notifySuppressed.data.id === null, notifySuppressed.data); + const notifyOn = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'true' } } }); + ok('notifications: тип включается обратно', notifyOn.status === 200, notifyOn.status); + const notifyBadSetting = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_entry_new: 'maybe' } } }); + ok('notifications: неверное значение настройки -> 400', notifyBadSetting.status === 400, notifyBadSetting.status); + const notifyClearNoAuth = await api('/api/notifications', { method: 'DELETE' }); + ok('notifications: очистка без токена -> 401', notifyClearNoAuth.status === 401, notifyClearNoAuth.status); + const notifyDel = await api('/api/notifications/' + notifyCreate.data.id, { token, method: 'DELETE' }); + ok('notifications: удаление уведомления', notifyDel.status === 200, notifyDel.status); + const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' }); + ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status); + const logout = await api('/api/auth/logout', { token, method: 'POST' }); ok('logout', logout.status === 200, logout.status); const afterLogout = await api('/api/auth/me', { token }); diff --git a/db/init.sql b/db/init.sql index 5aafb17..5c85153 100644 --- a/db/init.sql +++ b/db/init.sql @@ -252,3 +252,51 @@ CREATE TABLE IF NOT EXISTS audit_log ( ); CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC); + +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() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id); + +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) +); + +CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id); + +INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') +ON CONFLICT (key) DO NOTHING; diff --git a/db/migration.sql b/db/migration.sql index b359fac..f650e23 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -258,3 +258,40 @@ CREATE TABLE IF NOT EXISTS student_photos ( ); CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id); + +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() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id); + +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) +); + +CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id); + +INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING; diff --git a/public/admin.css b/public/admin.css index a35500a..c7bb04d 100644 --- a/public/admin.css +++ b/public/admin.css @@ -568,4 +568,49 @@ body[data-page=settings] .page-head{align-items:flex-end} } @media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}} +/* --- Notifications --- */ +.sidebar nav a[data-nav=notifications] svg{color:#f59e0b} +.notify-bell{position:relative} +.notify-badge{position:absolute;left:20px;top:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:#ef4444;color:#fff;font-size:.62rem;font-weight:700;line-height:16px;text-align:center} +.notify-badge[hidden]{display:none} +.notify-panel{position:fixed;left:12px;bottom:96px;width:min(380px,calc(100vw - 24px));max-height:min(70vh,520px);display:flex;flex-direction:column;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 40px rgba(0,0,0,.18);z-index:90;overflow:hidden} +.notify-panel[hidden]{display:none} +.notify-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border);font-size:.9rem} +.notify-list{overflow-y:auto;flex:1} +.notify-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center} +.notify-item{display:flex;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--text);transition:background .15s} +.notify-item:hover{background:var(--bg)} +.notify-item:last-child{border-bottom:none} +.notify-item.unread{background:rgba(37,99,235,.05)} +.notify-item.unread .notify-title{font-weight:600} +.notify-item.level-warning .notify-ic{background:rgba(245,158,11,.14);color:#f59e0b} +.notify-item.level-critical .notify-ic{background:rgba(239,68,68,.14);color:#ef4444} +.notify-ic{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--bg);color:var(--muted);flex-shrink:0} +.notify-ic svg{width:15px;height:15px} +.notify-body{display:flex;flex-direction:column;gap:2px;min-width:0} +.notify-title{font-size:.84rem;line-height:1.3} +.notify-text{font-size:.76rem;color:var(--muted);line-height:1.35;overflow-wrap:anywhere} +.notify-time{font-size:.7rem;color:var(--muted)} +.notify-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--border);font-size:.78rem} +.notify-foot a{color:var(--accent);text-decoration:none} +.notify-foot a:hover{text-decoration:underline} +.notify-link{background:none;border:none;color:var(--accent);font-size:.78rem;cursor:pointer;padding:0} +.notify-link:hover{text-decoration:underline} +.notify-types{display:flex;flex-direction:column;gap:6px;margin-top:6px} +.notify-type-badge{font-size:.65rem;font-weight:700;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:6px;white-space:nowrap} +.notify-page-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px} +.notify-page-head .grow{flex:1} +.notify-page-list{display:flex;flex-direction:column;gap:8px} +.notify-card{display:flex;gap:12px;padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:12px} +.notify-card.unread{border-color:rgba(37,99,235,.45);background:rgba(37,99,235,.04)} +.notify-card.level-warning{border-left:3px solid #f59e0b} +.notify-card.level-critical{border-left:3px solid #ef4444} +.notify-card .notify-body{flex:1} +.notify-card .notify-title{font-size:.92rem} +.notify-card .notify-text{font-size:.82rem} +.notify-card-actions{display:flex;flex-direction:column;gap:6px;align-items:flex-end} +@media(max-width:1024px){ + .notify-panel{left:12px;right:12px;width:auto;bottom:12px} +} + diff --git a/public/admin.js b/public/admin.js index 7d156e3..c9a0620 100644 --- a/public/admin.js +++ b/public/admin.js @@ -49,6 +49,7 @@ function buildSidebar(active) { const base = [ { page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' }, { page: 'journal', label: 'Журнал', icon: 'book-open' }, + { page: 'notifications', label: 'Уведомления', icon: 'bell' }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' }, { page: 'groups', label: 'Группы', icon: 'users' }, { page: 'files', label: 'Файлы', icon: 'folder' }, @@ -81,12 +82,14 @@ function buildSidebar(active) { `; renderIcons(); loadVersion(); loadBrand(); + initNotifications(); } async function loadBrand() { @@ -122,6 +125,223 @@ async function loadVersion() { } catch { el.textContent = ''; } } +// --- Notifications --- +const NOTIFY_ICONS = { + 'entry.new': 'book-open', + 'entry.ai.corrected': 'sparkles', + 'entry.ai.error': 'bot', + 'photo.job.done': 'image', + 'photo.job.error': 'image-off', + 'ip.ban': 'shield-off', + 'backup.restore': 'database', + 'backup.create': 'download', + 'system.test': 'send', +}; +const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' }; + +let notifyList = []; +let notifyUnread = 0; +let notifyStream = null; +let notifyReconnectTimer = null; +let desktopNotify = localStorage.getItem('notifyDesktop') === '1'; + +function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; } + +function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '"')); } + +function notifyTime(ts) { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + const diff = Date.now() - d.getTime(); + if (diff < 60000) return 'только что'; + if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`; + return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +function setNotifyUnread(n) { + notifyUnread = Number(n) || 0; + const badge = document.getElementById('notifyBadge'); + if (!badge) return; + badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread); + badge.hidden = notifyUnread <= 0; +} + +function notifyItemHtml(n, expanded) { + const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : ''; + return ` + + + ${esc(n.title)} + ${n.body ? `${esc(n.body)}` : ''} + ${esc(notifyTime(n.created_at))} + + `; +} + +function notifyPanelEl() { + let panel = document.getElementById('notifyPanel'); + if (panel) return panel; + panel = document.createElement('div'); + panel.className = 'notify-panel'; + panel.id = 'notifyPanel'; + panel.hidden = true; + document.body.appendChild(panel); + return panel; +} + +function renderNotifyPanel() { + const panel = notifyPanelEl(); + if (!panel) return; + const items = notifyList.slice(0, 12); + panel.innerHTML = ` +
+ Уведомления + +
+
+ ${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '
Пока уведомлений нет
'} +
+
+ Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''} + +
`; + renderIcons(); +} + +async function loadNotifications() { + try { + const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() }); + if (!res.ok) return; + const data = await res.json(); + notifyList = data.items || []; + setNotifyUnread(data.unread); + renderNotifyPanel(); + } catch {} +} + +function toggleNotifyPanel(force) { + const panel = notifyPanelEl(); + if (!panel) return; + const open = force === undefined ? panel.hidden : !!force; + panel.hidden = !open; + if (open) { renderNotifyPanel(); loadNotifications(); } +} +async function markNotifyRead(id) { + const item = notifyList.find(n => n.id === id); + if (item && !item.read) { + item.read = true; + setNotifyUnread(Math.max(0, notifyUnread - 1)); + renderNotifyPanel(); + } + try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {} +} + +async function markAllNotifyRead() { + try { + const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() }); + if (!res.ok) return; + notifyList.forEach(n => { n.read = true; }); + setNotifyUnread(0); + renderNotifyPanel(); + if (typeof onNotifyListChanged === 'function') onNotifyListChanged(); + } catch {} +} + +function showDesktopNotification(n) { + if (!desktopNotify) return; + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; + try { + const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id }); + note.onclick = () => { + window.focus(); + if (n.link) location.href = n.link; + note.close(); + }; + } catch {} +} + +async function toggleDesktopNotifications() { + if (desktopNotify) { + desktopNotify = false; + localStorage.setItem('notifyDesktop', '0'); + renderNotifyPanel(); + return; + } + if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; } + let perm = Notification.permission; + if (perm === 'default') { + try { perm = await Notification.requestPermission(); } catch {} + } + if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; } + desktopNotify = true; + localStorage.setItem('notifyDesktop', '1'); + renderNotifyPanel(); + showToast('Уведомления в браузере включены'); +} + +function handleIncomingNotification(n) { + notifyList.unshift(n); + if (notifyList.length > 40) notifyList.length = 40; + setNotifyUnread(notifyUnread + 1); + renderNotifyPanel(); + if (n.level === 'warning' || n.level === 'critical') showToast(n.title); + showDesktopNotification(n); + if (typeof onNotificationArrived === 'function') onNotificationArrived(n); +} + +function connectNotifyStream() { + if (notifyStream) { notifyStream.close(); notifyStream = null; } + const connect = () => { + const t = sessionStorage.getItem('authToken'); + if (!t) return; + notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`); + notifyStream.addEventListener('ready', ev => { + try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {} + }); + notifyStream.addEventListener('notification', ev => { + let n = null; + try { n = JSON.parse(ev.data); } catch { return; } + if (!n || !n.id) return; + handleIncomingNotification(n); + }); + notifyStream.onerror = () => { + if (notifyStream) { notifyStream.close(); notifyStream = null; } + clearTimeout(notifyReconnectTimer); + notifyReconnectTimer = setTimeout(connect, 5000); + }; + }; + connect(); + window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); }); +} + +function initNotifications() { + const bell = document.getElementById('notifyBell'); + const panel = notifyPanelEl(); + if (!bell || !panel) return; + bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); }); + panel.addEventListener('click', e => { + if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; } + if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; } + const item = e.target.closest('[data-notify-id]'); + if (!item) return; + e.preventDefault(); + const id = parseInt(item.dataset.notifyId, 10); + const link = item.dataset.notifyLink; + toggleNotifyPanel(false); + markNotifyRead(id); + if (link) location.href = link; + }); + document.addEventListener('click', e => { + if (panel.hidden) return; + if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return; + toggleNotifyPanel(false); + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); }); + loadNotifications(); + connectNotifyStream(); +} + // --- Common UI --- function renderIcons() { if (window.lucide && typeof window.lucide.createIcons === 'function') { diff --git a/public/js/notifications.js b/public/js/notifications.js new file mode 100644 index 0000000..2279418 --- /dev/null +++ b/public/js/notifications.js @@ -0,0 +1,164 @@ +const PAGE_SIZE = 25; + +let nfItems = []; +let nfTotal = 0; +let nfUnread = 0; +let nfUnreadOnly = false; +let nfLoading = false; + + +async function nfMarkRead(id) { + const res = await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); + if (!res.ok) { showToast('Не удалось отметить уведомление'); return; } + const item = nfItems.find(n => n.id === id); + if (item) item.read = true; + nfUnread = Math.max(0, nfUnread - 1); + if (typeof notifyList !== 'undefined') { + const shared = notifyList.find(n => n.id === id); + if (shared) shared.read = true; + } + if (nfUnreadOnly) { + nfItems = nfItems.filter(n => n.id !== id); + nfTotal = Math.max(0, nfTotal - 1); + loadNotifyPage(true); + return; + } + renderNotifyPage(); + if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread); +} + +async function nfDelete(id) { + if (!confirm('Удалить уведомление у всех пользователей?')) return; + const res = await fetch(`${API}/api/notifications/${id}`, { method: 'DELETE', headers: hdr() }); + if (!res.ok) { showToast('Не удалось удалить уведомление'); return; } + if (typeof notifyList !== 'undefined') notifyList = notifyList.filter(n => n.id !== id); + loadNotifyPage(true); +} + +async function nfMarkAllRead() { + const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() }); + if (!res.ok) { showToast('Не удалось отметить уведомления'); return; } + showToast('Все уведомления прочитаны'); + if (typeof notifyList !== 'undefined') notifyList.forEach(n => { n.read = true; }); + if (typeof setNotifyUnread === 'function') setNotifyUnread(0); + loadNotifyPage(true); +} + +async function nfClearAll() { + if (!confirm('Удалить все уведомления у всех пользователей? Действие необратимо.')) return; + const res = await fetch(`${API}/api/notifications`, { method: 'DELETE', headers: hdr() }); + if (!res.ok) { showToast('Не удалось очистить уведомления'); return; } + const data = await res.json().catch(() => ({})); + showToast(`Удалено уведомлений: ${data.deleted || 0}`); + if (typeof notifyList !== 'undefined') notifyList = []; + if (typeof setNotifyUnread === 'function') setNotifyUnread(0); + loadNotifyPage(true); +} + +function onNotificationArrived() { + loadNotifyPage(true); +} + +function onNotifyListChanged() { + loadNotifyPage(true); +} + +document.getElementById('nfList').addEventListener('click', e => { + const readBtn = e.target.closest('[data-nf-read]'); + if (readBtn) { e.preventDefault(); nfMarkRead(parseInt(readBtn.dataset.nfRead, 10)); return; } + const delBtn = e.target.closest('[data-nf-del]'); + if (delBtn) { e.preventDefault(); nfDelete(parseInt(delBtn.dataset.nfDel, 10)); return; } + if (e.target.closest('a[href]')) { + const card = e.target.closest('[data-nf-card]'); + if (card) nfMarkRead(parseInt(card.dataset.nfCard, 10)); + } +}); + +document.getElementById('notifyFilter').addEventListener('click', e => { + const btn = e.target.closest('button[data-filter]'); + if (!btn) return; + nfUnreadOnly = btn.dataset.filter === 'unread'; + document.querySelectorAll('#notifyFilter button').forEach(b => b.classList.toggle('active', b === btn)); + loadNotifyPage(true); +}); + +document.getElementById('nfReadAllBtn').addEventListener('click', nfMarkAllRead); +document.getElementById('nfClearBtn').addEventListener('click', nfClearAll); +document.getElementById('nfMoreBtn').addEventListener('click', () => loadNotifyPage(false)); +document.getElementById('nfReloadBtn').addEventListener('click', () => loadNotifyPage(true)); + +(async () => { + if (!(await requireAdminPage())) return; + buildSidebar(document.body.dataset.page); + const clearBtn = document.getElementById('nfClearBtn'); + if (clearBtn) clearBtn.hidden = !isAdmin(); + const authStatus = document.getElementById('authStatus'); + if (authStatus) authStatus.classList.add('loaded'); + await loadNotifyPage(true); +})(); + +function nfIcon(type) { + return typeof NOTIFY_ICONS !== 'undefined' && NOTIFY_ICONS[type] ? NOTIFY_ICONS[type] : 'bell'; +} + +function nfLevelClass(level) { + return level === 'warning' || level === 'critical' ? ` level-${level}` : ''; +} + +function nfCardHtml(n) { + const when = typeof notifyTime === 'function' ? notifyTime(n.created_at) : ''; + const linkAttr = typeof attrEsc === 'function' ? attrEsc(n.link) : esc(n.link); + return `
+ + + ${esc(n.title)} + ${n.body ? `${esc(n.body)}` : ''} + ${esc(when)}${n.read ? '' : ' · не прочитано'} + + + ${n.link ? `Открыть` : ''} + ${n.read ? '' : ``} + ${isAdmin() ? `` : ''} + +
`; +} + +function renderNotifyPage() { + const list = document.getElementById('nfList'); + const state = document.getElementById('nfState'); + const more = document.getElementById('nfMoreBtn'); + if (!list) return; + list.innerHTML = nfItems.length + ? nfItems.map(nfCardHtml).join('') + : `
${nfUnreadOnly ? 'Непрочитанных уведомлений нет' : 'Уведомлений пока нет'}
`; + if (state) { + state.textContent = nfItems.length + ? `Показано ${nfItems.length} из ${nfTotal} · непрочитанных: ${nfUnread}` + : `Непрочитанных: ${nfUnread}`; + } + if (more) more.hidden = nfItems.length >= nfTotal; + renderIcons(); +} + +async function loadNotifyPage(reset) { + if (nfLoading) return; + nfLoading = true; + const offset = reset ? 0 : nfItems.length; + try { + const q = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) }); + if (nfUnreadOnly) q.set('unread', '1'); + const res = await fetch(`${API}/api/notifications?${q.toString()}`, { headers: hdr() }); + if (!res.ok) { showToast('Ошибка загрузки уведомлений'); return; } + const data = await res.json(); + const items = data.items || []; + nfItems = reset ? items : nfItems.concat(items); + nfTotal = Number(data.total) || 0; + nfUnread = Number(data.unread) || 0; + renderNotifyPage(); + if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread); + } catch { + showToast('Ошибка сети'); + } finally { + nfLoading = false; + } +} \ No newline at end of file diff --git a/public/js/settings.js b/public/js/settings.js index ee85e1f..6f964eb 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -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 = 'Не удалось загрузить список событий'; + 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 ? 'только админ' : ''; + return ``; + }).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 = 'Ошибка загрузки настроек'; } diff --git a/public/notifications.html b/public/notifications.html new file mode 100644 index 0000000..a7b991b --- /dev/null +++ b/public/notifications.html @@ -0,0 +1,43 @@ + + + + + +Уведомления — Админ-панель + + + +
+ +
+
+

Уведомления

+

События системы: новые записи в журнале, обработка фото нейросетью, блокировки IP и другое

+ Загрузка… + +
+ +
+
+ + +
+ + + +
+
+ +
+
+ +
+
+
+ +
+ + + + + diff --git a/public/settings.html b/public/settings.html index ef68770..04b2d6b 100644 --- a/public/settings.html +++ b/public/settings.html @@ -26,6 +26,7 @@ Публичные ссылки Форма ответа Фото + Уведомления ИИ Бэкапы Корзина @@ -274,6 +275,29 @@ +
+
+
+

Уведомления

+ +
+

Уведомления собираются в «колокольчике» админ-панели и на странице «Уведомления». Здесь выбирается, о каких событиях сообщать: новые записи журнала, обработка фото нейросетью, блокировки IP, бэкапы и другое.

+ +
+ +
+ + дней +
+ От 1 до 365 дней, старые уведомления удаляются автоматически +
+
Загрузка…
+
+
diff --git a/server.js b/server.js index d801f50..b23f83c 100644 --- a/server.js +++ b/server.js @@ -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(); diff --git a/worker.js b/worker.js index 4e83de2..4e0ab5b 100644 --- a/worker.js +++ b/worker.js @@ -10,11 +10,20 @@ const { textDiff, FIELD_LABELS } = require('./diff'); const PHOTO_MAX_ATTEMPTS = 3; const PHOTO_AI_TIMEOUT_MS = 300000; -function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus }) { +function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus, notifyEvent }) { const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || ''; const IDLE_MIN = 2000; const IDLE_MAX = 30000; + async function sendNotification(entryId, payload) { + if (!notifyEvent) return; + try { + await notifyEvent(entryId, payload); + } catch (e) { + console.error('Photo worker notification failed:', e.message); + } + } + let started = false; let stopped = false; let idleMs = IDLE_MIN; @@ -136,6 +145,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie [newPath, job.id] ); if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath }); + await sendNotification(job.entry_id, { + type: 'photo.job.done', + title: job.action === 'ai' ? 'Фото обработано нейросетью: {student}' : 'Фото улучшено на сервере: {student}', + body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения`, + target: { job_id: job.id, action: job.action, after_path: newPath }, + }); } async function processOne(job) { stats.jobs++; @@ -148,6 +163,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie stats.errors++; stats.last_at = new Date().toISOString(); stats.last_error = 'У записи нет фото'; + await sendNotification(job.entry_id, { + type: 'photo.job.error', + title: 'Ошибка обработки фото: {student}', + body: `Группа {group} · запись #${job.entry_id} · у записи нет фото для обработки`, + target: { job_id: job.id, action: job.action }, + }); return true; } const photoPath = rows[0].photo_path; @@ -175,6 +196,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie ); stats.errors++; if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message }); + await sendNotification(job.entry_id, { + type: 'photo.job.error', + title: 'Ошибка обработки фото: {student}', + body: `Группа {group} · запись #${job.entry_id} · ${message}`, + target: { job_id: job.id, action: job.action, error: message }, + }); return true; } }