From 8bf54fb95d8d338f35bd6e628e4e21100a5bf371 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 24 Sep 2026 13:11:31 +0300 Subject: [PATCH] feat(trash): deferred purge after configurable days, groups in trash --- db/init.sql | 11 +- db/migration.sql | 6 + public/admin.css | 5 + public/js/audit.js | 8 ++ public/js/groups.js | 5 +- public/js/settings.js | 12 +- public/js/trash.js | 190 +++++++++++++++++++++++++++-- public/settings.html | 17 +++ public/trash.html | 24 +++- server.js | 277 ++++++++++++++++++++++++++++++++++-------- 10 files changed, 482 insertions(+), 73 deletions(-) diff --git a/db/init.sql b/db/init.sql index bbcc54a..5aafb17 100644 --- a/db/init.sql +++ b/db/init.sql @@ -21,9 +21,14 @@ CREATE TABLE IF NOT EXISTS groups ( day_of_week INT, time_start TIME, time_end TIME, - cover_path VARCHAR(255) + cover_path VARCHAR(255), + deleted_at TIMESTAMPTZ, + purge_at TIMESTAMPTZ ); +ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ; + CREATE TABLE IF NOT EXISTS modules ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL UNIQUE, @@ -71,6 +76,7 @@ CREATE TABLE IF NOT EXISTS entries ( photo_path VARCHAR(255), photo_original_path VARCHAR(255), deleted_at TIMESTAMPTZ, + purge_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); @@ -80,6 +86,7 @@ ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFA ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ; ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT; ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL; +ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id); @@ -122,6 +129,8 @@ INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сай ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') +ON CONFLICT (key) DO NOTHING; CREATE TABLE IF NOT EXISTS share_links ( id SERIAL PRIMARY KEY, diff --git a/db/migration.sql b/db/migration.sql index ea01a7e..b359fac 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -92,6 +92,7 @@ CREATE TABLE IF NOT EXISTS entry_photos ( CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id); ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ; ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT; ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT; ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending'; @@ -118,6 +119,9 @@ ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') +ON CONFLICT (key) DO NOTHING; + DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN @@ -140,6 +144,8 @@ ALTER TABLE groups ADD COLUMN IF NOT EXISTS day_of_week INT; ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME; ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME; ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255); +ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ; CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, diff --git a/public/admin.css b/public/admin.css index e285e42..05b6daa 100644 --- a/public/admin.css +++ b/public/admin.css @@ -257,6 +257,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0} .del-btn:hover{opacity:.8} .trash-time{color:var(--muted);font-size:.75rem;margin-top:8px} +.pending-toggle{margin-bottom:16px;max-width:680px} +.trash-section{margin-top:24px} +.trash-section h4{margin:16px 0 8px;font-size:.95rem;font-weight:600} +.card.pending{border-color:#d97706;background:linear-gradient(180deg,rgba(217,119,6,.06),transparent 55%)} +.trash-time.warn{color:#d97706;font-weight:600} /* --- Настройки (UX/UI) --- */ .page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px} diff --git a/public/js/audit.js b/public/js/audit.js index 7e2530f..27291f7 100644 --- a/public/js/audit.js +++ b/public/js/audit.js @@ -9,6 +9,11 @@ const ACTION_LABELS = { 'group.create': 'Создана группа', 'group.update': 'Изменена группа', 'group.delete': 'Удалена группа', + 'group.soft-delete': 'Группа в корзину', + 'group.restore': 'Группа восстановлена', + 'group.permanent-delete': 'Группа удалена безвозвратно', + 'group.schedule-delete': 'Группа помечена на удаление', + 'group.unschedule': 'Удаление группы отменено', 'group.photo.create': 'Загружена фото группы', 'group.photo.update': 'Изменено фото группы', 'group.photo.reorder': 'Изменён порядок фото группы', @@ -44,6 +49,9 @@ const ACTION_LABELS = { 'entry.soft-delete': 'Запись в корзину', 'entry.restore': 'Запись восстановлена', 'entry.permanent-delete': 'Запись удалена безвозвратно', + 'entry.schedule-delete': 'Запись помечена на удаление', + 'entry.unschedule': 'Удаление записи отменено', + 'trash.clear': 'Корзина помечена на удаление', 'link.create': 'Создана ссылка', 'link.update': 'Изменена ссылка', 'link.delete': 'Удалена ссылка', diff --git a/public/js/groups.js b/public/js/groups.js index 11e4c24..a74d622 100644 --- a/public/js/groups.js +++ b/public/js/groups.js @@ -113,8 +113,9 @@ async function addGroup() { } async function delGroup(id) { - if (!confirm('Удалить группу? Фото группы также будут удалены.')) return; - await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() }); + if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return; + const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() }); + if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); } loadGroups(); } diff --git a/public/js/settings.js b/public/js/settings.js index 8b63433..deada3f 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1,5 +1,5 @@ -const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled']; -const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans']; +const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays']; +const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans']; const SPAM_MAX = 10080; const BAN_REASONS = { 'honeypot': 'Антиспам-поле', @@ -165,6 +165,7 @@ async function loadSettings() { logoOriginal = currentLogo; renderLogoPreview(); if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min; + document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30'; document.getElementById('footerLeft').value = s.footer_left || ''; document.getElementById('footerRight').value = s.footer_right || ''; document.getElementById('aiPrompt').value = s.ai_prompt || ''; @@ -501,6 +502,12 @@ async function saveSettings() { return; } clearFieldError('spamInterval'); + const trashValue = document.getElementById('trashPurgeDays').value; + const trashDays = Math.round(Number(trashValue)); + if (trashValue === '' || !Number.isFinite(trashDays) || trashDays < 1 || trashDays > 365) { + showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)'); + return; + } const btn = document.getElementById('saveBtn'); btn.dataset.loadingText = 'Сохранение...'; setBtnLoading(btn, true); @@ -524,6 +531,7 @@ async function saveSettings() { photo_capture_quality: String(document.getElementById('photoQuality').value), photo_enhance_engine: document.getElementById('photoEngine').value, camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false', + trash_purge_days: String(trashDays), } }) }); if (res.ok) { diff --git a/public/js/trash.js b/public/js/trash.js index ce5c4db..4ea3e3d 100644 --- a/public/js/trash.js +++ b/public/js/trash.js @@ -1,7 +1,14 @@ const PAGE_SIZE = 20; let currentEntries = []; +let currentGroups = []; +let pendingEntries = []; +let pendingGroups = []; let page = 1; let totalEntries = 0; +let totalGroups = 0; +let pendingTotalEntries = 0; +let pendingTotalGroups = 0; +let purgeDays = 30; function isImageFile(name) { return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); @@ -28,32 +35,129 @@ function entryHTML(e) {
Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}
- + `; } +function groupHTML(g) { + return ` +
+ ${g.cover_path ? `` : ''} +
+
${esc(g.name)}
+ ${g.branch_name ? `
${esc(g.branch_name)}
` : ''} +
Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}
+
+ + +
+ `; +} + +function pendingEntryHTML(e) { + return ` +
+ ${e.photo_path ? `` : ''} +
+
${esc(e.student_name)}
+
${esc(e.group_name)}
+
${esc(e.description)}
+ ${filesHTML(e.files)} +
Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}
+
+ +
+ `; +} + +function pendingGroupHTML(g) { + return ` +
+ ${g.cover_path ? `` : ''} +
+
${esc(g.name)}
+ ${g.branch_name ? `
${esc(g.branch_name)}
` : ''} +
Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}
+
+ +
+ `; +} + +function renderGroups() { + const grid = document.getElementById('groupsGrid'); + grid.className = 'grid'; + grid.innerHTML = currentGroups.length + ? currentGroups.map(groupHTML).join('') + : '
Групп в корзине нет
'; + document.getElementById('groupsInfo').textContent = + totalGroups > 0 ? `— групп в корзине: ${totalGroups}` : ''; +} + function renderGrid() { const grid = document.getElementById('grid'); grid.className = 'grid'; grid.innerHTML = currentEntries.length ? currentEntries.map(entryHTML).join('') - : '
Корзина пуста
'; + : '
Записей в корзине нет
'; + document.getElementById('entriesInfo').textContent = + totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : ''; document.getElementById('trashInfo').textContent = - totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : ''; - document.getElementById('emptyBtn').disabled = totalEntries === 0; + totalEntries + totalGroups > 0 + ? `В корзине: записей ${totalEntries}, групп ${totalGroups}` + : 'Корзина пуста'; + document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0; renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage); } +function renderPending() { + document.getElementById('pendingGroupsGrid').className = 'grid'; + document.getElementById('pendingGroupsGrid').innerHTML = pendingGroups.length + ? pendingGroups.map(pendingGroupHTML).join('') + : '
Нет групп, помеченных на удаление
'; + document.getElementById('pendingGrid').className = 'grid'; + document.getElementById('pendingGrid').innerHTML = pendingEntries.length + ? pendingEntries.map(pendingEntryHTML).join('') + : '
Нет записей, помеченных на удаление
'; + document.getElementById('pendingGroupsInfo').textContent = + pendingTotalGroups > 0 ? `— ${pendingTotalGroups}` : ''; + document.getElementById('pendingEntriesInfo').textContent = + pendingTotalEntries > 0 ? `— ${pendingTotalEntries}` : ''; + const n = pendingTotalEntries + pendingTotalGroups; + document.getElementById('pendingInfo').textContent = + n > 0 ? `— будет удалено: ${n}` : ''; + const hint = document.getElementById('pendingToggleHint'); + if (hint) { + hint.textContent = n > 0 + ? `Помечено: записей ${pendingTotalEntries}, групп ${pendingTotalGroups} — безвозвратно удалятся через ${purgeDays} дней` + : 'Данных, помеченных на удаление, нет'; + } + document.getElementById('pendingSection').hidden = !document.getElementById('pendingToggle').checked; +} + async function loadTrash() { const p = new URLSearchParams(); p.set('limit', PAGE_SIZE); p.set('offset', (page - 1) * PAGE_SIZE); - const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() }); - const data = await res.json(); + const [trashRes, pendRes] = await Promise.all([ + fetch(`${API}/api/trash?${p}`, { headers: hdr() }), + fetch(`${API}/api/trash/pending`, { headers: hdr() }), + ]); + const data = await trashRes.json(); + const pend = await pendRes.json(); currentEntries = data.entries; totalEntries = data.total; + currentGroups = data.groups; + totalGroups = data.total_groups; + pendingEntries = pend.entries; + pendingTotalEntries = pend.total; + pendingGroups = pend.groups; + pendingTotalGroups = pend.total_groups; + if (pend.purge_days > 0) purgeDays = pend.purge_days; + renderGroups(); renderGrid(); + renderPending(); } function goPage(p) { page = p; loadTrash(); } @@ -65,22 +169,55 @@ async function restoreEntry(id) { } async function hardDelete(id) { - if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return; - await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() }); - showToast('Запись удалена навсегда'); - loadTrash(); + if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return; + const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() }); + if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); } + else { const err = await res.json(); showToast(err.error || 'Ошибка'); } +} + +async function restoreGroup(id) { + const res = await fetch(`${API}/api/groups/${id}/restore`, { method: 'PUT', headers: hdr() }); + if (res.ok) { showToast('Группа восстановлена'); loadTrash(); } + else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); } +} + +async function hardDeleteGroup(id, name) { + if (!confirm(`Пометить группу «${name}» на удаление? Записи журнала, фото и файлы группы будут безвозвратно удалены через ${purgeDays} дней, ученики открепятся.`)) return; + const res = await fetch(`${API}/api/groups/${id}/permanent`, { method: 'DELETE', headers: hdr() }); + if (res.ok) { showToast(`Группа помечена на удаление через ${purgeDays} дн.`); loadTrash(); } + else { const err = await res.json(); showToast(err.error || 'Ошибка'); } +} + +async function unscheduleEntry(id) { + const res = await fetch(`${API}/api/entries/${id}/unschedule`, { method: 'PUT', headers: hdr() }); + if (res.ok) { showToast('Запись возвращена в корзину'); loadTrash(); } + else { const err = await res.json(); showToast(err.error || 'Ошибка'); } +} + +async function unscheduleGroup(id) { + const res = await fetch(`${API}/api/groups/${id}/unschedule`, { method: 'PUT', headers: hdr() }); + if (res.ok) { showToast('Группа возвращена в корзину'); loadTrash(); } + else { const err = await res.json(); showToast(err.error || 'Ошибка'); } } async function emptyTrash() { - if (totalEntries === 0) return; - if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return; + const parts = []; + if (totalEntries) parts.push(`записей: ${totalEntries}`); + if (totalGroups) parts.push(`групп: ${totalGroups}`); + if (!parts.length) return; + if (!confirm(`Пометить всё из корзины (${parts.join(', ')}) на удаление? Данные будут безвозвратно удалены через ${purgeDays} дней.`)) return; const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() }); - if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); } + if (res.ok) { showToast(`Всё помечено на удаление через ${purgeDays} дн.`); page = 1; loadTrash(); } else { const err = await res.json(); alert(err.error || 'Ошибка'); } } document.getElementById('emptyBtn').addEventListener('click', emptyTrash); document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open')); +document.getElementById('pendingToggle').addEventListener('change', () => { + const open = document.getElementById('pendingToggle').checked; + document.getElementById('pendingSection').hidden = !open; + if (open) loadTrash(); +}); document.getElementById('grid').addEventListener('click', e => { const card = e.target.closest('.card'); @@ -91,6 +228,33 @@ document.getElementById('grid').addEventListener('click', e => { if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); } }); +document.getElementById('groupsGrid').addEventListener('click', e => { + const card = e.target.closest('.card'); + if (!card) return; + const rs = card.querySelector('[data-restore-group]'); + if (rs && (e.target === rs || rs.contains(e.target))) { restoreGroup(parseInt(rs.dataset.restoreGroup, 10)); return; } + const hd = card.querySelector('[data-hard-group]'); + if (hd && (e.target === hd || hd.contains(e.target))) { + const id = parseInt(hd.dataset.hardGroup, 10); + const g = currentGroups.find(x => String(x.id) === String(id)); + hardDeleteGroup(id, g ? g.name : id); + } +}); + +document.getElementById('pendingGrid').addEventListener('click', e => { + const card = e.target.closest('.card'); + if (!card) return; + const us = card.querySelector('[data-unschedule]'); + if (us && (e.target === us || us.contains(e.target))) { unscheduleEntry(parseInt(us.dataset.unschedule, 10)); } +}); + +document.getElementById('pendingGroupsGrid').addEventListener('click', e => { + const card = e.target.closest('.card'); + if (!card) return; + const us = card.querySelector('[data-unschedule-group]'); + if (us && (e.target === us || us.contains(e.target))) { unscheduleGroup(parseInt(us.dataset.unscheduleGroup, 10)); } +}); + (async () => { if (await checkAuth()) { buildSidebar(document.body.dataset.page); diff --git a/public/settings.html b/public/settings.html index 51b03e7..3923769 100644 --- a/public/settings.html +++ b/public/settings.html @@ -27,6 +27,7 @@ Фото ИИ Бэкапы + Корзина Блокировки @@ -243,6 +244,22 @@ +
+
+
+

Корзина

+
+

При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).

+
+ +
+ + дней +
+ От 1 до 365 дней +
+
+
diff --git a/public/trash.html b/public/trash.html index cee1f9f..2a84de1 100644 --- a/public/trash.html +++ b/public/trash.html @@ -15,8 +15,28 @@
-
Загрузка...
-
+ + +
+

Группы

+
Загрузка...
+
+
+

Записи журнала

+
Загрузка...
+
+
diff --git a/server.js b/server.js index fc5d596..cf7aaeb 100644 --- a/server.js +++ b/server.js @@ -556,6 +556,30 @@ async function getSetting(key, def) { return value; } +async function trashPurgeDays() { + const v = parseInt(await getSetting('trash_purge_days', '30'), 10); + return Number.isFinite(v) && v >= 1 && v <= 3650 ? v : 30; +} + +async function purgeScheduledDeletions() { + const erefs = await pool.query('SELECT id FROM entries WHERE purge_at IS NOT NULL AND purge_at <= now()'); + for (const r of erefs.rows) { + await removeEntryFiles(r.id); + await pool.query('DELETE FROM entries WHERE id = $1', [r.id]); + } + const grefs = await pool.query('SELECT id FROM groups WHERE purge_at IS NOT NULL AND purge_at <= now()'); + let gcount = 0; + for (const g of grefs.rows) { + if (await hardDeleteGroup(g.id)) gcount++; + } + if (erefs.rowCount || gcount) { + invalidateEntries(); + invalidateGroups(); + invalidateStats(); + } + return { entries: erefs.rowCount, groups: gcount }; +} + const UPLOADS_DIR = path.join(__dirname, 'uploads'); function safeUnlink(relPath) { @@ -658,6 +682,8 @@ async function ensureBranchesTable() { created_at TIMESTAMPTZ DEFAULT now() )`); await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`); + await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ`); + await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ`); } async function ensureBannedIpsTable() { @@ -715,6 +741,7 @@ async function ensureEntryAiColumns() { await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending'`); await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`); await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`); + await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`); await pool.query(` CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$ @@ -1068,6 +1095,12 @@ app.put('/api/settings', requireAdmin, async (req, res) => { return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' }); } } + if (key === 'trash_purge_days') { + const n = parseInt(String(value), 10); + if (!Number.isFinite(n) || n < 1 || n > 3650) { + return res.status(400).json({ error: 'trash_purge_days должен быть целым числом от 1 до 3650' }); + } + } if (key === 'photo_capture_resolution') { if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) { return res.status(400).json({ error: 'photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480' }); @@ -1856,8 +1889,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } for (const x of ndata.groups) { await client.query( - 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end, branch_id, tutor_id, cover_path) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', - [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end, x.branch_id, x.tutor_id, x.cover_path] + 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end, branch_id, tutor_id, cover_path, deleted_at, purge_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)', + [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end, x.branch_id, x.tutor_id, x.cover_path, x.deleted_at || null, x.purge_at || null] ); } for (const x of ndata.share_links) { @@ -1880,8 +1913,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } for (const x of ndata.entries) { await client.query( - 'INSERT INTO entries (id, student_name, group_id, module_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, photo_original_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)', - [x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.photo_original_path, x.deleted_at, x.created_at] + 'INSERT INTO entries (id, student_name, group_id, module_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, photo_original_path, deleted_at, purge_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)', + [x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.photo_original_path, x.deleted_at, x.purge_at || null, x.created_at] ); } for (const x of ndata.photo_jobs) { @@ -2314,7 +2347,7 @@ app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => { b.name AS branch_name FROM groups g LEFT JOIN branches b ON b.id = g.branch_id - WHERE 1=1${bw.where} + WHERE 1=1 AND g.deleted_at IS NULL${bw.where} ORDER BY g.id`, bw.params ); @@ -2327,7 +2360,8 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => { const rows = await cacheWrap('groups:active', PUBLIC_TTL_MS, async () => { const { rows } = await pool.query(` SELECT * FROM groups - WHERE day_of_week IS NOT NULL + WHERE deleted_at IS NULL + AND day_of_week IS NOT NULL AND time_start IS NOT NULL AND time_end IS NOT NULL AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int @@ -2412,17 +2446,102 @@ app.post('/api/groups', requireAuth, async (req, res) => { } }); +async function hardDeleteGroup(groupId) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const g = await client.query('SELECT id FROM groups WHERE id = $1', [groupId]); + if (!g.rowCount) { await client.query('ROLLBACK'); return null; } + const gres = await client.query('SELECT photo_path AS p FROM group_photos WHERE group_id = $1', [groupId]); + const eres = await client.query( + `SELECT photo_path AS p FROM entries WHERE group_id = $1 + UNION ALL + SELECT pf.path AS p FROM project_files pf JOIN entries e ON e.id = pf.entry_id WHERE e.group_id = $1 + UNION ALL + SELECT ep.photo_path AS p FROM entry_photos ep JOIN entries e ON e.id = ep.entry_id WHERE e.group_id = $1`, + [groupId] + ); + const files = eres.rows.concat(gres.rows); + await client.query('DELETE FROM group_photos WHERE group_id = $1', [groupId]); + await client.query('DELETE FROM entries WHERE group_id = $1', [groupId]); + await client.query('UPDATE students SET group_id = NULL WHERE group_id = $1', [groupId]); + await client.query('DELETE FROM share_links WHERE group_id = $1', [groupId]); + const d = await client.query('DELETE FROM groups WHERE id = $1', [groupId]); + await client.query('COMMIT'); + files.forEach(r => safeUnlink(r.p)); + return { entries: eres.rowCount, photos: gres.rowCount }; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + app.delete('/api/groups/:id', requireAuth, async (req, res) => { if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) { return res.status(403).json({ error: 'Нет доступа к этой группе' }); } - const { rows } = await pool.query( - 'SELECT photo_path FROM group_photos WHERE group_id = $1', + const result = await pool.query( + 'UPDATE groups SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL', [req.params.id] ); - rows.forEach(r => safeUnlink(r.photo_path)); - await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]); - await logAudit(req, 'group.delete', { id: req.params.id }); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Группа не найдена или уже в корзине' }); + } + await logAudit(req, 'group.soft-delete', { id: req.params.id }); + invalidateGroups(); + invalidateStats(); + res.json({ ok: true }); +}); + +app.put('/api/groups/:id/restore', requireAuth, async (req, res) => { + if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) { + return res.status(403).json({ error: 'Нет доступа к этой группе' }); + } + const result = await pool.query( + 'UPDATE groups SET deleted_at = NULL, purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL', + [req.params.id] + ); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Группа не найдена или не в корзине' }); + } + await logAudit(req, 'group.restore', { id: req.params.id }); + invalidateGroups(); + invalidateStats(); + res.json({ ok: true }); +}); + +app.delete('/api/groups/:id/permanent', requireAuth, async (req, res) => { + if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) { + return res.status(403).json({ error: 'Нет доступа к этой группе' }); + } + const days = await trashPurgeDays(); + const result = await pool.query( + `UPDATE groups SET purge_at = now() + ($2 || ' days')::interval WHERE id = $1 AND deleted_at IS NOT NULL AND purge_at IS NULL`, + [req.params.id, days] + ); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Группа не найдена, не в корзине или уже помечена на удаление' }); + } + await logAudit(req, 'group.schedule-delete', { id: req.params.id, days }); + invalidateGroups(); + invalidateStats(); + res.json({ ok: true, purge_at: (await pool.query('SELECT purge_at FROM groups WHERE id = $1', [req.params.id])).rows[0].purge_at }); +}); + +app.put('/api/groups/:id/unschedule', requireAuth, async (req, res) => { + if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) { + return res.status(403).json({ error: 'Нет доступа к этой группе' }); + } + const result = await pool.query( + 'UPDATE groups SET purge_at = NULL WHERE id = $1 AND purge_at IS NOT NULL', + [req.params.id] + ); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Группа не найдена или не помечена на удаление' }); + } + await logAudit(req, 'group.unschedule', { id: req.params.id }); invalidateGroups(); invalidateStats(); res.json({ ok: true }); @@ -3689,16 +3808,24 @@ app.get('/api/stats', requireAuth, async (req, res) => { } const groupsParams = isAdmin ? [] : (s.ids.length ? s.ids : [0]); const groupsWhere = isAdmin ? '' : (s.ids.length ? ` WHERE g.branch_id IN (${groupsParams.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0'); - const [entries, trash, groups, students, today] = await Promise.all([ + const gTrash = (bin) => { + const base = bin === 'pending' ? ' AND g.purge_at IS NOT NULL' : ' AND g.deleted_at IS NOT NULL AND g.purge_at IS NULL'; + return isAdmin ? ` WHERE g.deleted_at IS NOT NULL${base}` : (s.ids.length ? ` WHERE g.deleted_at IS NOT NULL${base} AND g.branch_id IN (${groupsParams.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0'); + }; + const [entries, eTrash, gTrashR, groups, students, today, ePend, gPendR] = await Promise.all([ pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params), - pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${groupFilter.where}`, groupFilter.params), + pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL AND e.purge_at IS NULL${groupFilter.where}`, groupFilter.params), + pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('visible')}`, groupsParams), pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams), pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params), pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${groupFilter.where}`, groupFilter.params), + pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.purge_at IS NOT NULL${groupFilter.where}`, groupFilter.params), + pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('pending')}`, groupsParams), ]); return { entries: entries.rows[0].n, - trash: trash.rows[0].n, + trash: eTrash.rows[0].n + gTrashR.rows[0].n, + trash_pending: ePend.rows[0].n + gPendR.rows[0].n, groups: groups.rows[0].n, students: students.rows[0].n, today: today.rows[0].n, @@ -4534,7 +4661,7 @@ app.put('/api/entries/:id/restore', requireAuth, async (req, res) => { return res.status(403).json({ error: 'Нет доступа к этой записи' }); } } - const result = await pool.query('UPDATE entries SET deleted_at = NULL WHERE id = $1', [req.params.id]); + const result = await pool.query('UPDATE entries SET deleted_at = NULL, purge_at = NULL WHERE id = $1', [req.params.id]); console.log(`[RESTORE] User ${req.user.id} (${req.user.username}) restored entry ${req.params.id}, rowCount=${result.rowCount}`); if (result.rowCount === 0) { return res.status(404).json({ error: 'Запись не найдена или уже восстановлена' }); @@ -4554,9 +4681,31 @@ app.delete('/api/entries/:id/permanent', requireAuth, async (req, res) => { return res.status(403).json({ error: 'Нет доступа к этой записи' }); } } - await removeEntryFiles(req.params.id); - await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]); - await logAudit(req, 'entry.permanent-delete', { id: req.params.id }); + const days = await trashPurgeDays(); + const result = await pool.query( + `UPDATE entries SET purge_at = now() + ($2 || ' days')::interval WHERE id = $1 AND deleted_at IS NOT NULL AND purge_at IS NULL`, + [req.params.id, days] + ); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Запись не найдена, не в корзине или уже помечена на удаление' }); + } + await logAudit(req, 'entry.schedule-delete', { id: req.params.id, days }); + invalidateEntries(); + invalidateStats(); + res.json({ ok: true }); +}); + +app.put('/api/entries/:id/unschedule', requireAuth, async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + const result = await pool.query('UPDATE entries SET purge_at = NULL WHERE id = $1 AND purge_at IS NOT NULL', [req.params.id]); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Запись не найдена или не помечена на удаление' }); + } + await logAudit(req, 'entry.unschedule', { id: req.params.id }); invalidateEntries(); invalidateStats(); res.json({ ok: true }); @@ -4957,35 +5106,32 @@ app.post('/api/photo-jobs/requeue-failed', requireAdmin, async (req, res) => { res.json({ ok: true, count: rowCount }); }); -app.get('/api/trash', requireAuth, async (req, res) => { - const { limit, offset } = req.query; +async function trashData(req, mode, limit, offset) { const bw = branchScope(req.user); const scoped = req.user.role !== 'admin'; - let where = ' WHERE e.deleted_at IS NOT NULL'; - const params = []; - if (scoped) { - if (bw.ids.length) { - where += ` AND g.branch_id IN (${bw.ids.map(id => `$${params.push(id)}`).join(',')})`; - } else { - where += ' AND 1 = 0'; - } - } - console.log(`[TRASH] User ${req.user.id} (${req.user.username}) role=${req.user.role} branch_ids=${JSON.stringify(bw.ids)} scoped=${scoped} where=${where} params=${JSON.stringify(params)}`); - const { rows: crows } = await pool.query( - `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id ${where}`, - params - ); - const total = crows[0].n; - let q = `SELECT e.*, g.name AS group_name FROM entries e - JOIN groups g ON g.id = e.group_id - ${where} ORDER BY e.deleted_at DESC`; - const qparams = params.slice(); - const lim = parseInt(limit, 10); - if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; } - const off = parseInt(offset, 10); - if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; } + const isPending = mode === 'pending'; + const eWhere = isPending + ? ' WHERE e.purge_at IS NOT NULL' + : ' WHERE e.deleted_at IS NOT NULL AND e.purge_at IS NULL'; + const gWhere = isPending + ? ' WHERE g.purge_at IS NOT NULL' + : ' WHERE g.deleted_at IS NOT NULL AND g.purge_at IS NULL'; + const eparams = []; + const gparams = []; + const esco = scoped + ? (bw.ids.length ? ` AND g.branch_id IN (${bw.ids.map(id => `$${eparams.push(id)}`).join(',')})` : ' AND 1 = 0') + : ''; + const gsco = scoped + ? (bw.ids.length ? ` AND g.branch_id IN (${bw.ids.map(id => `$${gparams.push(id)}`).join(',')})` : ' AND 1 = 0') + : ''; + const eOrder = isPending ? 'e.purge_at' : 'e.deleted_at'; + const gOrder = isPending ? 'g.purge_at' : 'g.deleted_at'; + const crows = await pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${eWhere}${esco}`, eparams); + let q = `SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id${eWhere}${esco} ORDER BY ${eOrder} DESC`; + const qparams = eparams.slice(); + if (limit > 0) { qparams.push(limit); q += ` LIMIT $${qparams.length}`; } + if (offset > 0) { qparams.push(offset); q += ` OFFSET $${qparams.length}`; } const { rows } = await pool.query(q, qparams); - console.log(`[TRASH] Found ${rows.length} entries for user ${req.user.id}`); let files = {}; if (rows.length) { const fRes = await pool.query( @@ -4995,21 +5141,43 @@ app.get('/api/trash', requireAuth, async (req, res) => { fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); }); } rows.forEach(r => { r.files = files[r.id] || []; }); - res.json({ entries: rows, total }); + const { rows: gcount } = await pool.query(`SELECT count(*)::int AS n FROM groups g${gWhere}${gsco}`, gparams); + const { rows: ggroups } = await pool.query( + `SELECT g.*, b.name AS branch_name FROM groups g + LEFT JOIN branches b ON b.id = g.branch_id + ${gWhere}${gsco} ORDER BY ${gOrder} DESC`, + gparams + ); + return { entries: rows, total: crows.rows[0].n, groups: ggroups, total_groups: gcount[0].n }; +} + +app.get('/api/trash', requireAuth, async (req, res) => { + const limit = parseInt(req.query.limit, 10); + const offset = parseInt(req.query.offset, 10); + res.json(await trashData(req, 'visible', limit, offset)); +}); + +app.get('/api/trash/pending', requireAuth, async (req, res) => { + const data = await trashData(req, 'pending'); + data.purge_days = await trashPurgeDays(); + res.json(data); }); app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => { - const { rows } = await pool.query( - `SELECT photo_path AS p FROM entries WHERE deleted_at IS NOT NULL - UNION ALL - SELECT pf.path AS p FROM project_files pf - JOIN entries e ON e.id = pf.entry_id WHERE e.deleted_at IS NOT NULL` + const days = await trashPurgeDays(); + const e = await pool.query( + `UPDATE entries SET purge_at = now() + ($1 || ' days')::interval WHERE deleted_at IS NOT NULL AND purge_at IS NULL`, + [days] + ); + const g = await pool.query( + `UPDATE groups SET purge_at = now() + ($1 || ' days')::interval WHERE deleted_at IS NOT NULL AND purge_at IS NULL`, + [days] ); - rows.forEach(r => safeUnlink(r.p)); - const d = await pool.query('DELETE FROM entries WHERE deleted_at IS NOT NULL'); invalidateEntries(); + invalidateGroups(); invalidateStats(); - res.json({ ok: true, deleted: d.rowCount }); + await logAudit(req, 'trash.clear', { entries: e.rowCount, groups: g.rowCount, days }); + res.json({ ok: true, entries: e.rowCount, groups: g.rowCount, days }); }); // --- Error handlers --- @@ -5080,7 +5248,10 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); } try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', 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 sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); } + try { await purgeScheduledDeletions(); } catch (err) { console.error('Trash purge:', err); } + setInterval(() => { purgeScheduledDeletions().catch(err => console.error('Trash purge:', err)); }, 60 * 60 * 1000).unref(); entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT }); entryAutoChecker.start(); console.log('AI auto-check worker started');