From d00de2cb1a62855178c7cecd5093dec353ac6895 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 6 Oct 2026 00:06:29 +0300 Subject: [PATCH] =?UTF-8?q?fix(shorts):=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0?= =?UTF-8?q?,=20=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF,=20=D1=87=D0=B0=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=BE=D1=8F=D1=81=20=D0=B8=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - purgeScheduledDeletions() теперь чистит short_messages по purge_at - short_messages добавлена в BACKUP_TABLES/BACKUP_SEQUENCE_TABLES, normalizeRestoreData и restore (поле автора проверяется на живость) - сид short_messages_retention_days перенесён и в db/init.sql - used_at по умолчанию берётся в зоне приложения (appTodayIso), а не по UTC - поле «Использовано» скрыто при создании, открыто при правке - удалён пустой escInline и мёртвые data-message/canEdit: сырой текст сообщения попадал в атрибут неэкранированным --- backup-restore.js | 19 +++++++++++++++++-- backup.selftest.js | 20 ++++++++++++++++++++ db/init.sql | 2 ++ public/js/shorts.js | 18 ++++++++---------- public/shorts.html | 4 ++-- server.js | 29 ++++++++++++++++++++++++----- 6 files changed, 73 insertions(+), 19 deletions(-) diff --git a/backup-restore.js b/backup-restore.js index 125d9c5..65da24a 100644 --- a/backup-restore.js +++ b/backup-restore.js @@ -8,12 +8,13 @@ const BACKUP_TABLES = [ 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', 'notification_reads', 'banned_ips', 'chat_threads', 'chat_messages', 'chat_attachments', + 'short_messages', ]; const BACKUP_SEQUENCE_TABLES = [ 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', 'chat_threads', 'chat_messages', - 'chat_attachments', + 'chat_attachments', 'short_messages', ]; function isSupportedBackupVersion(data) { @@ -500,7 +501,21 @@ function normalizeRestoreData(data) { size: optInt(x.size, 0, 2147483647) ?? 0, created_at: optTs(x.created_at), })); - return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips, chat_threads, chat_messages, chat_attachments }; + const short_messages = (data.short_messages || []).map(x => ({ + id: reqInt(x.id), + resident: reqStr(x.resident, 200), + lesson_date: reqDate(x.lesson_date), + topic: optStr(x.topic, 500), + message: reqStr(x.message, 10000), + used: !!x.used, + used_at: optDate(x.used_at), + author_id: optInt(x.author_id, 0, 2147483647), + deleted_at: optTs(x.deleted_at), + purge_at: optTs(x.purge_at), + created_at: optTs(x.created_at), + updated_at: optTs(x.updated_at), + })); + return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips, chat_threads, chat_messages, chat_attachments, short_messages }; } module.exports = { diff --git a/backup.selftest.js b/backup.selftest.js index 04c4d52..4488cad 100644 --- a/backup.selftest.js +++ b/backup.selftest.js @@ -79,6 +79,26 @@ const ch = normalizeRestoreData({ ok('chat_threads нормализуются', ch.chat_threads[0].tutor_id === 1 && ch.chat_threads[0].tutor_unread === 2, ch.chat_threads[0]); ok('chat_messages нормализуются', ch.chat_messages[0].thread_id === 3 && ch.chat_messages[0].body === 'привет', ch.chat_messages[0]); ok('chat_attachments нормализуются', ch.chat_attachments[0].name === 'photo.png' && ch.chat_attachments[0].size === 1234, ch.chat_attachments[0]); +const sm = normalizeRestoreData({ + ...base, + short_messages: [{ id: 1, resident: 'Иванов И.И.', lesson_date: '2026-05-05', topic: 'T', message: 'текст', used: true, used_at: '2026-05-06', author_id: 1, deleted_at: '2026-05-07T00:00:00.000Z', purge_at: '2027-05-07T00:00:00.000Z' }], +}); +ok('short_messages нормализуются', sm.short_messages[0].resident === 'Иванов И.И.' && sm.short_messages[0].used === true && sm.short_messages[0].used_at === '2026-05-06', sm.short_messages[0]); +ok('short_messages сохраняет purge_at', sm.short_messages[0].deleted_at === '2026-05-07T00:00:00.000Z' && sm.short_messages[0].purge_at === '2027-05-07T00:00:00.000Z', sm.short_messages[0]); +let smBadDate = false; +try { + normalizeRestoreData({ ...base, short_messages: [{ id: 1, resident: 'X', lesson_date: '05.05.2026', message: 't' }] }); +} catch (e) { + smBadDate = true; +} +ok('кривая дата занятия в short_messages отклоняется', smBadDate); +let smNoResident = false; +try { + normalizeRestoreData({ ...base, short_messages: [{ id: 1, resident: '', lesson_date: '2026-05-05', message: 't' }] }); +} catch (e) { + smNoResident = true; +} +ok('пустой резидент в short_messages отклоняется', smNoResident); const chEdits = normalizeRestoreData({ ...base, chat_messages: [ diff --git a/db/init.sql b/db/init.sql index b3ab257..19839d8 100644 --- a/db/init.sql +++ b/db/init.sql @@ -476,3 +476,5 @@ CREATE TABLE IF NOT EXISTS short_messages ( 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); + +INSERT INTO settings (key, value) VALUES ('short_messages_retention_days', '365') ON CONFLICT (key) DO NOTHING; diff --git a/public/js/shorts.js b/public/js/shorts.js index 8e03844..cbc974a 100644 --- a/public/js/shorts.js +++ b/public/js/shorts.js @@ -58,21 +58,20 @@ async function loadShorts() { 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(''); @@ -129,11 +128,14 @@ 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('shortsDate').value = row ? row.lesson_date : todayIso(); 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) : ''; + const usedDisplay = row ? '' : 'none'; + document.getElementById('shortsUsedField').style.display = usedDisplay; + document.getElementById('shortsUsedAtField').style.display = usedDisplay; if (row) { document.getElementById('shortsLines').value = row.message || ''; } else { @@ -317,8 +319,4 @@ function acceptShortsAi() { closeShortsAi(); } -function escInline(s) { - return String(s || '').replace(/'/g, "'").replace(/"/g, '"').replace(//g, '>'); -} - init(); diff --git a/public/shorts.html b/public/shorts.html index f9ae3a1..cdf2699 100644 --- a/public/shorts.html +++ b/public/shorts.html @@ -72,11 +72,11 @@ -
+ -
+ diff --git a/server.js b/server.js index 5650b78..51a1a39 100644 --- a/server.js +++ b/server.js @@ -1329,6 +1329,12 @@ async function appTimezone() { return validTimezone(v) ? v : DEFAULT_TIMEZONE; } +async function appTodayIso() { + const tz = await appTimezone(); + const { rows } = await pool.query('SELECT to_char(now() AT TIME ZONE $1, \'YYYY-MM-DD\') AS d', [tz]); + return rows[0].d; +} + async function appHour12() { return (await getSetting('time_format', '24h')) === '12h'; } @@ -1362,12 +1368,15 @@ async function purgeScheduledDeletions() { publishChat({ type: 'thread_update', thread_id: c.id, tutor_id: null, deleted: true }); } } - if (erefs.rowCount || gcount || ccount) { + const sres = await pool.query('DELETE FROM short_messages WHERE purge_at IS NOT NULL AND purge_at <= now()'); + const scount = sres.rowCount; + if (erefs.rowCount || gcount || ccount || scount) { invalidateEntries(); invalidateGroups(); invalidateStats(); } - return { entries: erefs.rowCount, groups: gcount, chat_threads: ccount }; + if (scount) invalidateShorts(); + return { entries: erefs.rowCount, groups: gcount, chat_threads: ccount, short_messages: scount }; } function safeUnlink(relPath) { @@ -2667,7 +2676,7 @@ app.put('/api/shorts/:id', requireAuth, async (req, res) => { 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 (used && !usedAt) usedAt = await appTodayIso(); if (usedAt) used = true; if (!used) usedAt = null; if (lessonDate && !/^\d{4}-\d{2}-\d{2}$/.test(lessonDate)) { @@ -3443,7 +3452,7 @@ function sweepBackupStorage() { async function buildBackupArchive() { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { - const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca] = await Promise.all([ + const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca, sm] = await Promise.all([ pool.query('SELECT * FROM groups ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM entries ORDER BY id'), @@ -3467,6 +3476,7 @@ async function buildBackupArchive() { pool.query('SELECT * FROM chat_threads ORDER BY id'), pool.query('SELECT * FROM chat_messages ORDER BY id'), pool.query('SELECT * FROM chat_attachments ORDER BY id'), + pool.query('SELECT * FROM short_messages ORDER BY id'), ]); const settings = {}; st.rows.forEach(r => { settings[r.key] = r.value; }); @@ -3482,8 +3492,9 @@ async function buildBackupArchive() { lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount, notification_reads: nr.rowCount, banned_ips: bi.rowCount, chat_threads: ct.rowCount, chat_messages: cm.rowCount, chat_attachments: ca.rowCount, + short_messages: sm.rowCount, }, - groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows, + groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows, short_messages: sm.rows, }; fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); const files = await storage.downloadAll(path.join(staging, 'uploads')); @@ -3649,6 +3660,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req await client.query('DELETE FROM chat_attachments'); await client.query('DELETE FROM chat_messages'); await client.query('DELETE FROM chat_threads'); + await client.query('DELETE FROM short_messages'); await client.query('DELETE FROM project_files'); await client.query('DELETE FROM lesson_report_versions'); await client.query('DELETE FROM lesson_reports'); @@ -3802,6 +3814,13 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [x.id, x.message_id, x.token, x.path, x.name, x.size || 0, x.created_at] ); } + for (const x of ndata.short_messages) { + const okUser = x.author_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.author_id])).rowCount; + await client.query( + 'INSERT INTO short_messages (id, resident, lesson_date, topic, message, used, used_at, author_id, deleted_at, purge_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)', + [x.id, x.resident, x.lesson_date, x.topic, x.message, x.used, x.used_at, okUser ? x.author_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at] + ); + } for (const x of ndata.audit_log) { const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount; await client.query(