';
+ 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 @@
+
+
+
+
Корзина
+
+
При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).
Эти данные будут удалены автоматически и безвозвратно вместе с файлами. Чтобы отменить удаление, нажмите «В корзину» — данные вернутся в корзину, откуда их можно восстановить.
+
Группы
+
Загрузка...
+
Записи журнала
+
Загрузка...
+
+
+
Группы
+
Загрузка...
+
+
+
Записи журнала
+
Загрузка...
+
+
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');