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:
@@ -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:<token>`, иначе деактивированный пользователь сохранит доступ
|
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
|
||||||
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
|
- **Секреты**: пароль только в `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
|
### 4. API Patterns
|
||||||
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
- **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` и фильтр не добавляется
|
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
||||||
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
||||||
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
|
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
|
||||||
|
- **Уведомления** — системные события (новые записи журнала, обработка фото нейросетью, ошибки авто-проверки текста, блокировки IP, бэкапы) собираются в «колокольчике» и на странице «Уведомления»; набор событий включается/выключается в «Настройках» → «Уведомления»
|
||||||
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
@@ -399,6 +400,27 @@ node redis.selftest.js # юнит-тесты redis.js
|
|||||||
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
|
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/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
||||||
| `GET` | `/api/backup` | Скачать бэкап |
|
| `GET` | `/api/backup` | Скачать бэкап |
|
||||||
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
| `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` | Статистика |
|
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
|
||||||
|
|
||||||
Авторизация — по сессиям, не по статическому токену:
|
Авторизация — по сессиям, не по статическому токену:
|
||||||
|
|||||||
@@ -137,6 +137,44 @@ async function main() {
|
|||||||
reader.cancel().catch(() => {});
|
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' });
|
const logout = await api('/api/auth/logout', { token, method: 'POST' });
|
||||||
ok('logout', logout.status === 200, logout.status);
|
ok('logout', logout.status === 200, logout.status);
|
||||||
const afterLogout = await api('/api/auth/me', { token });
|
const afterLogout = await api('/api/auth/me', { token });
|
||||||
|
|||||||
+48
@@ -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 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;
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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}}
|
@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}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+220
@@ -49,6 +49,7 @@ function buildSidebar(active) {
|
|||||||
const base = [
|
const base = [
|
||||||
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||||
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||||
|
{ page: 'notifications', label: 'Уведомления', icon: 'bell' },
|
||||||
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||||
{ page: 'groups', label: 'Группы', icon: 'users' },
|
{ page: 'groups', label: 'Группы', icon: 'users' },
|
||||||
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
||||||
@@ -81,12 +82,14 @@ function buildSidebar(active) {
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<div class="version" id="sidebarVersion"></div>
|
<div class="version" id="sidebarVersion"></div>
|
||||||
|
<a href="#" class="notify-bell" id="notifyBell" title="Уведомления"><i data-lucide="bell"></i>Уведомления<span class="notify-badge" id="notifyBadge" hidden>0</span></a>
|
||||||
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
|
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
|
||||||
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
|
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
renderIcons();
|
renderIcons();
|
||||||
loadVersion();
|
loadVersion();
|
||||||
loadBrand();
|
loadBrand();
|
||||||
|
initNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBrand() {
|
async function loadBrand() {
|
||||||
@@ -122,6 +125,223 @@ async function loadVersion() {
|
|||||||
} catch { el.textContent = ''; }
|
} 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 `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
|
||||||
|
<span class="notify-ic"><i data-lucide="${notifyIcon(n.type)}"></i></span>
|
||||||
|
<span class="notify-body">
|
||||||
|
<span class="notify-title">${esc(n.title)}</span>
|
||||||
|
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
|
||||||
|
<span class="notify-time">${esc(notifyTime(n.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="notify-head">
|
||||||
|
<b>Уведомления</b>
|
||||||
|
<button type="button" class="notify-link" id="notifyReadAll">Прочитать все</button>
|
||||||
|
</div>
|
||||||
|
<div class="notify-list">
|
||||||
|
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '<div class="notify-empty">Пока уведомлений нет</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="notify-foot">
|
||||||
|
<a href="notifications.html">Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}</a>
|
||||||
|
<button type="button" class="notify-link" id="notifyDesktopToggle">${desktopNotify ? 'В браузере: вкл' : 'В браузере: выкл'}</button>
|
||||||
|
</div>`;
|
||||||
|
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 ---
|
// --- Common UI ---
|
||||||
function renderIcons() {
|
function renderIcons() {
|
||||||
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
||||||
|
|||||||
@@ -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 `<div class="notify-card${n.read ? '' : ' unread'}${nfLevelClass(n.level)}" data-nf-card="${n.id}">
|
||||||
|
<span class="notify-ic"><i data-lucide="${nfIcon(n.type)}"></i></span>
|
||||||
|
<span class="notify-body">
|
||||||
|
<span class="notify-title">${esc(n.title)}</span>
|
||||||
|
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
|
||||||
|
<span class="notify-time">${esc(when)}${n.read ? '' : ' · не прочитано'}</span>
|
||||||
|
</span>
|
||||||
|
<span class="notify-card-actions">
|
||||||
|
${n.link ? `<a class="btn-primary ghost" style="padding:6px 12px;font-size:.78rem" href="${linkAttr}">Открыть</a>` : ''}
|
||||||
|
${n.read ? '' : `<button type="button" class="notify-link" data-nf-read="${n.id}">Прочитано</button>`}
|
||||||
|
${isAdmin() ? `<button type="button" class="notify-link" style="color:#ef4444" data-nf-del="${n.id}">Удалить</button>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('')
|
||||||
|
: `<div class="notify-empty">${nfUnreadOnly ? 'Непрочитанных уведомлений нет' : 'Уведомлений пока нет'}</div>`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-3
@@ -1,5 +1,6 @@
|
|||||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays'];
|
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans'];
|
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 SPAM_MAX = 10080;
|
||||||
const BAN_REASONS = {
|
const BAN_REASONS = {
|
||||||
'honeypot': 'Антиспам-поле',
|
'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() {
|
async function loadSettings() {
|
||||||
const [sRes, stRes] = await Promise.all([
|
const [sRes, stRes] = await Promise.all([
|
||||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
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';
|
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||||
if (!pe.value) pe.value = 'auto';
|
if (!pe.value) pe.value = 'auto';
|
||||||
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
|
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
|
||||||
|
await renderNotifyTypes(s);
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
loadAiQueue();
|
loadAiQueue();
|
||||||
@@ -647,6 +681,18 @@ document.addEventListener('keydown', e => {
|
|||||||
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
|
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() {
|
async function saveSettings() {
|
||||||
const v = spamInput.value;
|
const v = spamInput.value;
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
@@ -663,6 +709,12 @@ async function saveSettings() {
|
|||||||
showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)');
|
showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)');
|
||||||
return;
|
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');
|
const btn = document.getElementById('saveBtn');
|
||||||
btn.dataset.loadingText = 'Сохранение...';
|
btn.dataset.loadingText = 'Сохранение...';
|
||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
@@ -687,6 +739,7 @@ async function saveSettings() {
|
|||||||
photo_enhance_engine: document.getElementById('photoEngine').value,
|
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||||
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
||||||
trash_purge_days: String(trashDays),
|
trash_purge_days: String(trashDays),
|
||||||
|
...notifySettingsPayload(notifyRetentionDays),
|
||||||
} })
|
} })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -835,6 +888,22 @@ if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSyste
|
|||||||
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
|
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
|
||||||
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(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');
|
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
|
||||||
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
|
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
|
||||||
statsRefreshBtn.classList.add('spinning');
|
statsRefreshBtn.classList.add('spinning');
|
||||||
@@ -919,12 +988,12 @@ window.addEventListener('beforeunload', e => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
if (await requireAdminPage()) {
|
if (await requireAdminPage()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
bindDirty();
|
|
||||||
initSectionNav();
|
initSectionNav();
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
const authStatus = document.getElementById('authStatus');
|
const authStatus = document.getElementById('authStatus');
|
||||||
try {
|
try {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
bindDirty();
|
||||||
if (authStatus) authStatus.classList.add('loaded');
|
if (authStatus) authStatus.classList.add('loaded');
|
||||||
} catch {
|
} catch {
|
||||||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Уведомления — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="notifications">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Уведомления</h2>
|
||||||
|
<p class="page-sub">События системы: новые записи в журнале, обработка фото нейросетью, блокировки IP и другое</p>
|
||||||
|
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
|
||||||
|
<button class="head-btn" id="nfReloadBtn" type="button" title="Обновить список"><i data-lucide="rotate-ccw"></i> Обновить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notify-page-head">
|
||||||
|
<div class="view-toggle" id="notifyFilter">
|
||||||
|
<button type="button" data-filter="all" class="active">Все</button>
|
||||||
|
<button type="button" data-filter="unread">Непрочитанные</button>
|
||||||
|
</div>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<button class="btn-primary ghost" id="nfReadAllBtn" type="button"><i data-lucide="check"></i> Прочитать все</button>
|
||||||
|
<button class="btn-primary danger-ghost" id="nfClearBtn" type="button" hidden><i data-lucide="trash-2"></i> Очистить всё</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="nfState"></div>
|
||||||
|
|
||||||
|
<div class="notify-page-list" id="nfList"></div>
|
||||||
|
<div style="display:flex;justify-content:center;margin-top:16px">
|
||||||
|
<button class="btn-primary ghost" id="nfMoreBtn" type="button" hidden>Показать ещё</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/notifications.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||||
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||||
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||||
|
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
|
||||||
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||||
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||||
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
|
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
|
||||||
@@ -274,6 +275,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card wide" id="sec-notify">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="bell"></i></div>
|
||||||
|
<h3>Уведомления</h3>
|
||||||
|
<button class="icon-btn" id="notifyTestBtn" type="button" title="Отправить тестовое уведомление" aria-label="Тестовое уведомление"><i data-lucide="send"></i></button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Уведомления собираются в «колокольчике» админ-панели и на странице «Уведомления». Здесь выбирается, о каких событиях сообщать: новые записи журнала, обработка фото нейросетью, блокировки IP, бэкапы и другое.</p>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="notifyEnabled">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Уведомления включены</b><small>Общий выключатель: при снятии галочки не создаётся ни одно уведомление</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="notifyRetentionDays">Хранение уведомлений</label>
|
||||||
|
<div class="unit-row">
|
||||||
|
<input type="number" id="notifyRetentionDays" class="settings-input" min="1" max="365" step="1">
|
||||||
|
<span class="unit">дней</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint">От 1 до 365 дней, старые уведомления удаляются автоматически</span>
|
||||||
|
</div>
|
||||||
|
<div class="notify-types" id="notifyTypes"><span class="hint">Загрузка…</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-bans">
|
<div class="settings-card" id="sec-bans">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ lister.on('notification', (msg) => {
|
|||||||
const type = payload && payload.type ? payload.type : 'entry_created';
|
const type = payload && payload.type ? payload.type : 'entry_created';
|
||||||
if (type === 'ai_status') {
|
if (type === 'ai_status') {
|
||||||
broadcastAiStatus(payload);
|
broadcastAiStatus(payload);
|
||||||
|
notifyEntryAi(payload).catch(err => console.error('Notify AI:', err.message));
|
||||||
} else {
|
} else {
|
||||||
broadcastEntryChanged();
|
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 invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
||||||
function invalidateAll() { cache.clear().catch(err => console.error('Cache clear failed:', err.message)); }
|
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 BAN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const banKey = ip => 'ban:' + ip;
|
const banKey = ip => 'ban:' + ip;
|
||||||
@@ -188,6 +413,13 @@ async function banIP(req, reason, ms) {
|
|||||||
await banIpAddr(ipOf(req), reason, ms, req);
|
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) {
|
async function banIpAddr(ip, reason, ms, actorReq) {
|
||||||
const until = new Date(Date.now() + ms);
|
const until = new Date(Date.now() + ms);
|
||||||
await cache.set(banKey(ip), { reason, banned_until: until.toISOString() }, 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()]
|
[ip, reason, until.toISOString()]
|
||||||
);
|
);
|
||||||
await logAudit(actorReq, 'ip.ban', { ip, reason });
|
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})`);
|
console.log(`IP banned: ${ip} (${reason})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,6 +1385,130 @@ app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
|||||||
res.json({ ok: true });
|
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) ---
|
// --- Users (admin only) ---
|
||||||
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
||||||
const { rows } = await pool.query(
|
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))) {
|
if (key === 'system_logo' && String(value) !== '' && !isSafeUploadPath(String(value))) {
|
||||||
return res.status(400).json({ error: 'system_logo — некорректный путь' });
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -1974,6 +2348,13 @@ app.post('/api/backup', requireAdmin, async (req, res) => {
|
|||||||
const expiresAt = Date.now() + BACKUP_TTL_MS;
|
const expiresAt = Date.now() + BACKUP_TTL_MS;
|
||||||
backupTickets.set(token, { file: archive.file, name: archive.name, size: archive.size, expiresAt });
|
backupTickets.set(token, { file: archive.file, name: archive.name, size: archive.size, expiresAt });
|
||||||
await logAudit(req, 'backup.download', { size: archive.size });
|
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({
|
res.json({
|
||||||
url: `/api/backup/${token}`,
|
url: `/api/backup/${token}`,
|
||||||
filename: archive.name,
|
filename: archive.name,
|
||||||
@@ -2007,6 +2388,13 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const archive = await buildBackupArchive();
|
const archive = await buildBackupArchive();
|
||||||
await logAudit(req, 'backup.download', { size: archive.size });
|
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);
|
sendBackupArchive(res, archive);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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 sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
||||||
await ensureFirstAdmin().catch(err => console.error('First admin:', err));
|
await ensureFirstAdmin().catch(err => console.error('First admin:', err));
|
||||||
await logAudit(req, 'backup.restore', {});
|
await logAudit(req, 'backup.restore', {});
|
||||||
|
await pushNotification({
|
||||||
|
type: 'backup.restore',
|
||||||
|
title: 'Восстановление из бэкапа завершено',
|
||||||
|
body: `Данные заменены архивом · пользователь ${req.user.username}`,
|
||||||
|
link: 'settings.html',
|
||||||
|
adminOnly: true,
|
||||||
|
});
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
res.json({ ok: true });
|
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 ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
||||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', 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 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 ('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 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); }
|
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,
|
photoAiUrl: PHOTO_AI_URL,
|
||||||
uploadsDir: UPLOADS_DIR,
|
uploadsDir: UPLOADS_DIR,
|
||||||
storage,
|
storage,
|
||||||
|
notifyEvent: notifyEntry,
|
||||||
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
|
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
|
||||||
});
|
});
|
||||||
photoWorker.start();
|
photoWorker.start();
|
||||||
|
|||||||
@@ -10,11 +10,20 @@ const { textDiff, FIELD_LABELS } = require('./diff');
|
|||||||
const PHOTO_MAX_ATTEMPTS = 3;
|
const PHOTO_MAX_ATTEMPTS = 3;
|
||||||
const PHOTO_AI_TIMEOUT_MS = 300000;
|
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 AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
||||||
const IDLE_MIN = 2000;
|
const IDLE_MIN = 2000;
|
||||||
const IDLE_MAX = 30000;
|
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 started = false;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let idleMs = IDLE_MIN;
|
let idleMs = IDLE_MIN;
|
||||||
@@ -136,6 +145,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
[newPath, job.id]
|
[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 });
|
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) {
|
async function processOne(job) {
|
||||||
stats.jobs++;
|
stats.jobs++;
|
||||||
@@ -148,6 +163,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
stats.errors++;
|
stats.errors++;
|
||||||
stats.last_at = new Date().toISOString();
|
stats.last_at = new Date().toISOString();
|
||||||
stats.last_error = 'У записи нет фото';
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
const photoPath = rows[0].photo_path;
|
const photoPath = rows[0].photo_path;
|
||||||
@@ -175,6 +196,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
);
|
);
|
||||||
stats.errors++;
|
stats.errors++;
|
||||||
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user