From ee099586446419de3eabbb5e132f6894b8149a9b Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 5 Oct 2026 23:51:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(shorts):=20=D0=BA=D0=BE=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B7=D0=B8=D0=B4=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=81=20=D0=98=D0=98-=D0=BA=D0=BE=D1=80=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=86=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Таблица short_messages (резидент, дата занятия, тема, текст, отметка об использовании), CRUD API /api/shorts с мягким удалением и плановой очисткой через purge_at, ИИ-коррекция текста по профилю, страница shorts.html с массовым добавлением разбором строк «ФИО - текст». --- db/init.sql | 18 +++ db/migration.sql | 27 ++++ public/admin.js | 1 + public/js/shorts.js | 324 ++++++++++++++++++++++++++++++++++++++++++++ public/shorts.html | 135 ++++++++++++++++++ server.js | 249 +++++++++++++++++++++++++++++++++- 6 files changed, 750 insertions(+), 4 deletions(-) create mode 100644 public/js/shorts.js create mode 100644 public/shorts.html 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); }