diff --git a/db/init.sql b/db/init.sql
index e31d462..b3ab257 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -458,3 +458,21 @@ INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — реда
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
ON CONFLICT (key) DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS short_messages (
+ id SERIAL PRIMARY KEY,
+ resident VARCHAR(200) NOT NULL,
+ lesson_date DATE NOT NULL,
+ topic VARCHAR(500),
+ message TEXT NOT NULL,
+ used BOOLEAN NOT NULL DEFAULT false,
+ used_at DATE,
+ author_id INT REFERENCES users(id) ON DELETE SET NULL,
+ deleted_at TIMESTAMPTZ,
+ purge_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC);
+CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at);
diff --git a/db/migration.sql b/db/migration.sql
index 894753b..fd83b61 100644
--- a/db/migration.sql
+++ b/db/migration.sql
@@ -559,3 +559,30 @@ WHERE key = 'lesson_ai_prompt' AND value IN (
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('time_format', '24h') ON CONFLICT (key) DO NOTHING;
+
+-- Short messages table migration
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'short_messages') THEN
+ CREATE TABLE short_messages (
+ id SERIAL PRIMARY KEY,
+ resident VARCHAR(200) NOT NULL,
+ lesson_date DATE NOT NULL,
+ topic VARCHAR(500),
+ message TEXT NOT NULL,
+ used BOOLEAN NOT NULL DEFAULT false,
+ used_at DATE,
+ deleted_at TIMESTAMPTZ,
+ purge_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+ );
+ CREATE INDEX idx_short_messages_date ON short_messages(lesson_date DESC);
+ CREATE INDEX idx_short_messages_deleted ON short_messages(deleted_at);
+ END IF;
+END$$;
+
+ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS used_at DATE;
+ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL;
+
+INSERT INTO settings (key, value) VALUES ('short_messages_retention_days', '365') ON CONFLICT (key) DO NOTHING;
diff --git a/public/admin.js b/public/admin.js
index 725cc07..edd7cf9 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -58,6 +58,7 @@ function buildSidebar(active) {
{ page: 'photos', label: 'Фото', icon: 'Image' },
{ page: 'files', label: 'Файлы', icon: 'folder' },
{ page: 'links', label: 'Ссылки', icon: 'link' },
+ { page: 'shorts', label: 'Короткие сообщения', icon: 'zap' },
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
];
const adminNav = [
diff --git a/public/js/shorts.js b/public/js/shorts.js
new file mode 100644
index 0000000..8e03844
--- /dev/null
+++ b/public/js/shorts.js
@@ -0,0 +1,324 @@
+async function init() {
+ if (!(await checkAuth())) return;
+ buildSidebar('shorts');
+ document.getElementById('addShortsBtn').addEventListener('click', () => openShortsModal(null));
+ document.getElementById('shortsCancel').addEventListener('click', closeModal);
+ document.getElementById('shortsUsed').addEventListener('change', e => {
+ const at = document.getElementById('shortsUsedAt');
+ if (e.target.checked && !at.value) at.value = todayIso();
+ if (!e.target.checked) at.value = '';
+ });
+ document.getElementById('shortsUsedAt').addEventListener('change', e => {
+ document.getElementById('shortsUsed').checked = !!e.target.value;
+ });
+ document.getElementById('shortsAiBtn').addEventListener('click', openShortsAi);
+ document.getElementById('aiImproveBtn').addEventListener('click', improveShortsAi);
+ document.getElementById('aiAcceptBtn').addEventListener('click', acceptShortsAi);
+ document.getElementById('aiRejectBtn').addEventListener('click', closeShortsAi);
+ document.getElementById('shortsSave').addEventListener('click', saveShorts);
+ document.getElementById('shortsSearchBtn').addEventListener('click', () => { shortsPage = 1; loadShorts(); });
+ document.getElementById('shortsClearBtn').addEventListener('click', () => {
+ document.getElementById('shortsSearch').value = '';
+ shortsPage = 1;
+ loadShorts();
+ });
+ document.getElementById('shortsSearch').addEventListener('keydown', e => {
+ if (e.key === 'Enter') { shortsPage = 1; loadShorts(); }
+ });
+ await loadShorts();
+}
+
+let shortsPage = 1;
+const SHORTS_PAGE_SIZE = 50;
+let shortsTotal = 0;
+let editingId = null;
+
+async function loadShorts() {
+ const tbody = document.querySelector('#shortsTable tbody');
+ tbody.innerHTML = '
| … |
';
+ const p = new URLSearchParams();
+ p.set('limit', SHORTS_PAGE_SIZE);
+ p.set('offset', (shortsPage - 1) * SHORTS_PAGE_SIZE);
+ const search = document.getElementById('shortsSearch').value.trim();
+ if (search) p.set('search', search);
+ try {
+ const res = await fetch(`${API}/api/shorts?${p}`, { headers: hdr() });
+ if (!res.ok) { tbody.innerHTML = '| Ошибка загрузки |
'; return; }
+ const data = await res.json();
+ shortsTotal = data.total || 0;
+ const rows = data.items || [];
+ const totalPages = Math.max(1, Math.ceil(shortsTotal / SHORTS_PAGE_SIZE));
+ const empty = document.getElementById('shortsEmpty');
+ if (!rows.length) {
+ empty.style.display = 'block';
+ tbody.innerHTML = '';
+ } else {
+ empty.style.display = 'none';
+ tbody.innerHTML = rows.map(r => {
+ const dateStr = r.lesson_date ? fmtDateOnlyIso(r.lesson_date) : '';
+ const usedDateStr = r.used_at ? fmtDateOnlyIso(r.used_at) : '—';
+ const author = r.author_name || r.author_username || '—';
+ const canEdit = true;
+ return `
+
+ | ${esc(r.resident)} |
+ ${dateStr} |
+ ${esc(r.topic || '')} |
+ ${esc(r.message || '')} |
+ ${esc(author)} |
+
+
+ ${r.used ? esc(usedDateStr) : '—'}
+ |
+
+ ${canEdit ? `
+ ` : '—'}
+ |
+
`;
+ }).join('');
+ }
+ renderShortsPager(document.getElementById('shortsPager'), shortsPage, totalPages, goShortsPage);
+ attachRowHandlers();
+ renderIcons();
+ } catch (e) {
+ tbody.innerHTML = '| Ошибка: ' + esc(e.message) + ' |
';
+ }
+}
+
+function attachRowHandlers() {
+ const tbody = document.querySelector('#shortsTable tbody');
+ tbody.querySelectorAll('.used-check').forEach(cb => {
+ cb.addEventListener('change', () => toggleUsed(Number(cb.dataset.id), cb.checked));
+ });
+ tbody.querySelectorAll('[data-edit]').forEach(b => {
+ b.addEventListener('click', () => loadShortsRow(Number(b.dataset.edit)));
+ });
+ tbody.querySelectorAll('[data-del]').forEach(b => {
+ b.addEventListener('click', () => delShorts(Number(b.dataset.del)));
+ });
+}
+function renderShortsPager(container, page, totalPages, onPageChange) {
+ if (totalPages <= 1) { container.innerHTML = ''; return; }
+ const pages = [];
+ const maxVisible = 5;
+ let start = Math.max(1, page - Math.floor(maxVisible / 2));
+ let end = Math.min(totalPages, start + maxVisible - 1);
+ if (end - start + 1 < maxVisible) start = Math.max(1, end - maxVisible + 1);
+ for (let i = start; i <= end; i++) pages.push(i);
+ let html = '';
+ if (page > 1) html += ``;
+ if (start > 1) {
+ html += ``;
+ if (start > 2) html += ``;
+ }
+ for (const p of pages) html += ``;
+ if (end < totalPages) {
+ if (end < totalPages - 1) html += ``;
+ html += ``;
+ }
+ if (page < totalPages) html += ``;
+ container.innerHTML = html;
+ container.querySelectorAll('button').forEach(b => {
+ b.addEventListener('click', () => onPageChange(parseInt(b.dataset.page, 10)));
+ });
+}
+
+function goShortsPage(p) { shortsPage = p; loadShorts(); }
+
+function openShortsModal(row) {
+ editingId = row ? row.id : null;
+ document.getElementById('shortsModalTitle').textContent = row ? `Сообщение #${row.id}` : 'Новое сообщение';
+ document.getElementById('shortsLinesHint').textContent = row ? ' (только текст сообщения)' : ' (формат: ФИО - текст или ФИО — текст)';
+ document.getElementById('shortsDate').value = row ? row.lesson_date : '';
+ document.getElementById('shortsTopic').value = row ? (row.topic || '') : '';
+ document.getElementById('shortsResident').value = row ? (row.resident || '') : '';
+ document.getElementById('shortsUsed').checked = row ? !!row.used : false;
+ document.getElementById('shortsUsedAt').value = row && row.used_at ? String(row.used_at).slice(0, 10) : '';
+ if (row) {
+ document.getElementById('shortsLines').value = row.message || '';
+ } else {
+ document.getElementById('shortsLines').value = '';
+ }
+ document.getElementById('shortsModal').classList.add('open');
+}
+
+function closeModal() {
+ document.getElementById('shortsModal').classList.remove('open');
+}
+
+async function saveShorts() {
+ const date = document.getElementById('shortsDate').value.trim();
+ const topic = document.getElementById('shortsTopic').value.trim() || null;
+ const linesText = document.getElementById('shortsLines').value.trim();
+ const residentOverride = document.getElementById('shortsResident').value.trim() || null;
+ const used = document.getElementById('shortsUsed').checked;
+ let usedAt = document.getElementById('shortsUsedAt').value.trim() || null;
+ if (used && !usedAt) usedAt = todayIso();
+ if (!used) usedAt = null;
+
+ if (!date) { showToast('Укажите дату занятия'); return; }
+ if (!linesText) { showToast('Укажите текст сообщения(я)'); return; }
+
+ let residentFromLine = residentOverride || '';
+ const lines = linesText.split('\n');
+ const parsed = [];
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ const match = trimmed.match(/^(.+?)\s*[-—]\s*(.+)$/);
+ if (match) {
+ parsed.push({ resident: match[1].trim(), message: match[2].trim() });
+ if (!residentFromLine) residentFromLine = match[1].trim();
+ } else {
+ parsed.push({ resident: residentOverride || 'Не указан', message: trimmed });
+ if (!residentFromLine) residentFromLine = residentOverride || 'Не указан';
+ }
+ }
+ if (!parsed.length) { showToast('Нет сообщений для добавления'); return; }
+
+ try {
+ let res;
+ if (editingId) {
+ const resident = residentOverride || parsed[0].resident || 'Не указан';
+ const message = parsed.length === 1 && !parsed[0].resident
+ ? parsed[0].message
+ : parsed.map(p => p.message).join('\n');
+ res = await fetch(`${API}/api/shorts/${editingId}`, {
+ method: 'PUT',
+ headers: hdrJson(),
+ body: JSON.stringify({ resident, lesson_date: date, topic, message, used, used_at: usedAt })
+ });
+ } else {
+ res = await fetch(`${API}/api/shorts`, {
+ method: 'POST',
+ headers: hdrJson(),
+ body: JSON.stringify({ lesson_date: date, topic, lines: linesText, resident: residentOverride, used })
+ });
+ }
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ showToast(err.error || 'Ошибка');
+ return;
+ }
+ closeModal();
+ shortsPage = 1;
+ await loadShorts();
+ showToast(editingId ? 'Сообщение обновлено' : 'Сообщения добавлены');
+ } catch (e) {
+ showToast('Ошибка: ' + e.message);
+ }
+}
+
+async function delShorts(id) {
+ if (!confirm('Переместить сообщение в корзину?')) return;
+ try {
+ const res = await fetch(`${API}/api/shorts/${id}`, { method: 'DELETE', headers: hdr() });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ showToast(err.error || 'Ошибка');
+ return;
+ }
+ await loadShorts();
+ showToast('Сообщение перемещено в корзину');
+ } catch (e) {
+ showToast('Ошибка: ' + e.message);
+ }
+}
+
+async function toggleUsed(id, checked) {
+ try {
+ const usedAt = checked ? todayIso() : null;
+ const res = await fetch(`${API}/api/shorts/${id}`, {
+ method: 'PUT',
+ headers: hdrJson(),
+ body: JSON.stringify({ used: checked, used_at: usedAt })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ showToast(err.error || 'Ошибка');
+ return;
+ }
+ await loadShorts();
+ } catch (e) {
+ showToast('Ошибка: ' + e.message);
+ }
+}
+
+async function loadShortsRow(id) {
+ try {
+ const res = await fetch(`${API}/api/shorts/${id}`, { headers: hdr() });
+ if (!res.ok) { showToast('Не найдено'); return; }
+ openShortsModal(await res.json());
+ } catch (e) {
+ showToast('Ошибка: ' + e.message);
+ }
+}
+
+let shortsAiBusy = false;
+
+async function openShortsAi() {
+ const src = document.getElementById('aiSourceText');
+ src.value = document.getElementById('shortsLines').value;
+ document.getElementById('aiSuggestion').textContent = '';
+ document.getElementById('aiCorrectModal').classList.add('open');
+ const sel = document.getElementById('aiModelSelect');
+ try {
+ const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
+ if (!res.ok) throw new Error('Профили недоступны');
+ const data = await res.json();
+ const profiles = data.profiles || [];
+ sel.innerHTML = `` +
+ profiles.map(p => ``).join('');
+ sel.value = data.active || 'native';
+ } catch (e) {
+ sel.innerHTML = '';
+ }
+}
+
+function closeShortsAi() {
+ document.getElementById('aiCorrectModal').classList.remove('open');
+}
+
+async function improveShortsAi() {
+ if (shortsAiBusy) return;
+ const text = document.getElementById('aiSourceText').value.trim();
+ if (!text) { showToast('Нет текста для улучшения'); return; }
+ const suggestionEl = document.getElementById('aiSuggestion');
+ const btn = document.getElementById('aiImproveBtn');
+ shortsAiBusy = true;
+ btn.disabled = true;
+ suggestionEl.textContent = 'Обработка...';
+ try {
+ const res = await fetch(`${API}/api/shorts/ai-correct`, {
+ method: 'POST',
+ headers: hdrJson(),
+ body: JSON.stringify({ text, profile: document.getElementById('aiModelSelect').value })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || 'Ошибка ИИ');
+ }
+ const data = await res.json();
+ suggestionEl.textContent = data.suggestion || text;
+ } catch (e) {
+ suggestionEl.textContent = 'Ошибка: ' + e.message;
+ showToast('ИИ недоступен: ' + e.message);
+ } finally {
+ shortsAiBusy = false;
+ btn.disabled = false;
+ }
+}
+
+function acceptShortsAi() {
+ const suggestion = document.getElementById('aiSuggestion').textContent;
+ if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
+ document.getElementById('shortsLines').value = suggestion;
+ }
+ closeShortsAi();
+}
+
+function escInline(s) {
+ return String(s || '').replace(/'/g, "'").replace(/"/g, '"').replace(//g, '>');
+}
+
+init();
diff --git a/public/shorts.html b/public/shorts.html
new file mode 100644
index 0000000..f9ae3a1
--- /dev/null
+++ b/public/shorts.html
@@ -0,0 +1,135 @@
+
+
+
+
+
+Короткие сообщения — Админ-панель
+
+
+
+
+
+
+
+
Короткие сообщения
+
Создание и редактирование сообщений
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Резидент |
+ Дата занятия |
+ Тема |
+ Сообщение |
+ Автор |
+ Использовано |
+ Действия |
+
+
+
+
+
Сообщения не найдены
+
+
+
+
+
+
+
+
+
+
+
Улучшение текста ИИ
+
Выберите профиль и нажмите «Улучшить»
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server.js b/server.js
index 1db72b6..5650b78 100644
--- a/server.js
+++ b/server.js
@@ -2516,6 +2516,246 @@ app.get('/api/chat/files/:token', fileLimiter, async (req, res) => {
}
});
+// --- Short messages (admin only) ---
+const SHORTS_PAGE_SIZE = 50;
+
+async function ensureShortMessagesTable() {
+ await pool.query(`CREATE TABLE IF NOT EXISTS short_messages (
+ id SERIAL PRIMARY KEY,
+ resident VARCHAR(200) NOT NULL,
+ lesson_date DATE NOT NULL,
+ topic VARCHAR(500),
+ message TEXT NOT NULL,
+ used BOOLEAN NOT NULL DEFAULT false,
+ used_at DATE,
+ author_id INT REFERENCES users(id) ON DELETE SET NULL,
+ deleted_at TIMESTAMPTZ,
+ purge_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+ )`);
+ await pool.query(`ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS used_at DATE`);
+ await pool.query(`ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL`);
+ await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC)`);
+ await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at)`);
+}
+
+function invalidateShorts() {
+ cache.dropPrefix('shorts:');
+ cache.publish(EVENTS_CHANNEL, { type: 'shorts_changed' }).catch(() => {});
+}
+
+async function parseShortMessagesFromLines(text) {
+ const lines = String(text || '').split('\n');
+ const messages = [];
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ const match = trimmed.match(/^(.+?)\s*[-—]\s*(.+)$/);
+ if (match) {
+ messages.push({ resident: match[1].trim(), message: match[2].trim() });
+ } else {
+ messages.push({ resident: '', message: trimmed });
+ }
+ }
+ return messages;
+}
+
+app.get('/api/shorts/:id', requireAuth, async (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
+ const { rows } = await pool.query(
+ `SELECT sm.*, u.name AS author_name, u.username AS author_username FROM short_messages sm
+ LEFT JOIN users u ON u.id = sm.author_id WHERE sm.id = $1`,
+ [id]
+ );
+ if (!rows.length) return res.status(404).json({ error: 'Not found' });
+ res.json(rows[0]);
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.get('/api/shorts', requireAuth, async (req, res) => {
+ try {
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || SHORTS_PAGE_SIZE, 1), 200);
+ const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
+ const showDeleted = req.query.deleted === '1' || req.query.deleted === 'true';
+ const search = req.query.search ? String(req.query.search).trim() : '';
+ const params = [];
+ let where = showDeleted ? 'WHERE sm.deleted_at IS NOT NULL' : 'WHERE sm.deleted_at IS NULL';
+ if (search) {
+ params.push(`%${search}%`);
+ where += ` AND (sm.resident ILIKE $${params.length} OR sm.topic ILIKE $${params.length} OR sm.message ILIKE $${params.length})`;
+ }
+ params.push(limit, offset);
+ const { rows } = await pool.query(
+ `SELECT sm.id, sm.resident, sm.lesson_date, sm.topic, sm.message, sm.used, sm.used_at, sm.author_id,
+ u.name AS author_name, u.username AS author_username, sm.deleted_at, sm.created_at
+ FROM short_messages sm LEFT JOIN users u ON u.id = sm.author_id
+ ${where} ORDER BY sm.lesson_date DESC, sm.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
+ params
+ );
+ let total = 0;
+ if (offset === 0) {
+ const { rows: cnt } = await pool.query(
+ `SELECT COUNT(*)::int AS n FROM short_messages sm ${where}`,
+ params.slice(0, -2)
+ );
+ total = cnt[0]?.n || 0;
+ }
+ res.json({ items: rows, total });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.post('/api/shorts', requireAuth, async (req, res) => {
+ try {
+ const lessonDate = req.body?.lesson_date ? String(req.body.lesson_date).trim() : null;
+ const topic = req.body?.topic ? String(req.body.topic).trim() : null;
+ const linesText = req.body?.lines ? String(req.body.lines) : '';
+ if (!lessonDate || !/^\d{4}-\d{2}-\d{2}$/.test(lessonDate)) {
+ return res.status(400).json({ error: 'lesson_date обязателен в формате YYYY-MM-DD' });
+ }
+ const messages = await parseShortMessagesFromLines(linesText);
+ if (!messages.length) return res.status(400).json({ error: 'Нет сообщений для добавления' });
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ const created = [];
+ for (const m of messages) {
+ const resident = m.resident || req.body?.resident ? String(m.resident || req.body.resident).trim() : 'Не указан';
+ const { rows } = await client.query(
+ `INSERT INTO short_messages (resident, lesson_date, topic, message, author_id)
+ VALUES ($1, $2, $3, $4, $5) RETURNING *`,
+ [resident, lessonDate, topic, m.message, req.user.id || null]
+ );
+ created.push(rows[0]);
+ }
+ await client.query('COMMIT');
+ await logAudit(req, 'shorts.create', { count: created.length, lesson_date: lessonDate, topic });
+ invalidateShorts();
+ res.status(201).json({ items: created });
+ } catch (e) {
+ await client.query('ROLLBACK').catch(() => {});
+ throw e;
+ } finally {
+ client.release();
+ }
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.put('/api/shorts/:id', requireAuth, async (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
+ const current = await pool.query('SELECT * FROM short_messages WHERE id = $1', [id]);
+ if (!current.rows.length) return res.status(404).json({ error: 'Not found' });
+ const target = current.rows[0];
+ const resident = req.body?.resident !== undefined ? String(req.body.resident).trim() : target.resident;
+ const lessonDate = req.body?.lesson_date !== undefined ? String(req.body.lesson_date).trim() : target.lesson_date;
+ const topic = req.body?.topic !== undefined ? String(req.body.topic).trim() : target.topic;
+ const message = req.body?.message !== undefined ? String(req.body.message).trim() : target.message;
+ let used = req.body?.used !== undefined ? Boolean(req.body.used) : Boolean(target.used);
+ let usedAt = req.body?.used_at !== undefined
+ ? (req.body.used_at ? String(req.body.used_at).slice(0, 10) : null)
+ : (target.used_at ? String(target.used_at).slice(0, 10) : null);
+ if (usedAt && !/^\d{4}-\d{2}-\d{2}$/.test(usedAt)) {
+ return res.status(400).json({ error: 'used_at должен быть в формате YYYY-MM-DD' });
+ }
+ if (used && !usedAt) usedAt = new Date().toISOString().slice(0, 10);
+ if (usedAt) used = true;
+ if (!used) usedAt = null;
+ if (lessonDate && !/^\d{4}-\d{2}-\d{2}$/.test(lessonDate)) {
+ return res.status(400).json({ error: 'lesson_date должен быть в формате YYYY-MM-DD' });
+ }
+ if (!resident) return res.status(400).json({ error: 'Резидент обязателен' });
+ if (!message) return res.status(400).json({ error: 'Сообщение обязательно' });
+ const { rows } = await pool.query(
+ `UPDATE short_messages SET resident = $1, lesson_date = $2, topic = $3, message = $4, used = $5, used_at = $6, updated_at = now() WHERE id = $7 RETURNING *`,
+ [resident, lessonDate, topic, message, used, usedAt, id]
+ );
+ await logAudit(req, 'shorts.update', { id, resident, lesson_date: lessonDate, topic, used, used_at: usedAt });
+ invalidateShorts();
+ res.json(rows[0]);
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.delete('/api/shorts/:id', requireAuth, async (req, res) => {
+ try {
+ 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(
+ `UPDATE short_messages SET deleted_at = now(), purge_at = now() + (COALESCE((SELECT value FROM settings WHERE key='short_messages_retention_days'), '365') || ' days')::interval WHERE id = $1 AND deleted_at IS NULL`,
+ [id]
+ );
+ if (!rowCount) return res.status(404).json({ error: 'Not found or already deleted' });
+ await logAudit(req, 'shorts.delete', { id });
+ invalidateShorts();
+ res.json({ ok: true });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.put('/api/shorts/:id/restore', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ 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(
+ 'UPDATE short_messages SET deleted_at = NULL, purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL',
+ [id]
+ );
+ if (!rowCount) return res.status(404).json({ error: 'Not found or not deleted' });
+ await logAudit(req, 'shorts.restore', { id });
+ invalidateShorts();
+ res.json({ ok: true });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.delete('/api/shorts/:id/permanent', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ 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 short_messages WHERE id = $1 AND deleted_at IS NOT NULL',
+ [id]
+ );
+ if (!rowCount) return res.status(404).json({ error: 'Not found or not deleted' });
+ await logAudit(req, 'shorts.permanent-delete', { id });
+ invalidateShorts();
+ res.json({ ok: true });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.post('/api/shorts/ai-correct', requireAuth, async (req, res) => {
+ try {
+ const text = reqStr(req.body?.text, 5000);
+ if (!text) return res.status(400).json({ error: 'Текст не указан' });
+ const profileId = req.body?.profile ? String(req.body.profile) : (req.body?.model ? String(req.body.model) : null);
+ let profile = null;
+ if (profileId && profileId !== 'native') {
+ const profiles = await getAiProfiles();
+ profile = profiles.find(p => p.id === profileId) || null;
+ if (!profile) return res.status(400).json({ error: 'Профиль ИИ не найден' });
+ }
+ const corrected = await aiCorrectText(text, profile);
+ res.json({ suggestion: corrected });
+ } catch (e) {
+ res.status(502).json({ error: 'Сервис ИИ недоступен: ' + e.message });
+ }
+});
+
// --- Users (admin only) ---
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
const { rows } = await pool.query(
@@ -3089,8 +3329,8 @@ function normalizeOpenAiBase(base) {
return b;
}
-async function aiComplete(systemPrompt, userText, maxTokensOverride) {
- const profile = await getActiveAiProfile();
+async function aiComplete(systemPrompt, userText, maxTokensOverride, profileOverride) {
+ const profile = profileOverride || await getActiveAiProfile();
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
let model = AI_MODEL;
const headers = { 'Content-Type': 'application/json' };
@@ -3129,10 +3369,10 @@ async function aiComplete(systemPrompt, userText, maxTokensOverride) {
}
}
-async function aiCorrectText(text) {
+async function aiCorrectText(text, profileOverride) {
try {
const systemPrompt = await getAiPrompt();
- const out = await aiComplete(systemPrompt, text);
+ const out = await aiComplete(systemPrompt, text, undefined, profileOverride);
return out || text;
} catch (e) {
console.error('AI correct error:', e);
@@ -8397,6 +8637,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports table:', err); }
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); }
+ try { await ensureShortMessagesTable(); } catch (err) { console.error('Short messages 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 purgeOldChat(); } catch (err) { console.error('Chat purge:', err); }