feat(trash): deferred purge after configurable days, groups in trash
This commit is contained in:
+10
-1
@@ -21,9 +21,14 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
day_of_week INT,
|
day_of_week INT,
|
||||||
time_start TIME,
|
time_start TIME,
|
||||||
time_end 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 (
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(200) NOT NULL UNIQUE,
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
@@ -71,6 +76,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
photo_path VARCHAR(255),
|
photo_path VARCHAR(255),
|
||||||
photo_original_path VARCHAR(255),
|
photo_original_path VARCHAR(255),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
|
purge_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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_checked_at TIMESTAMPTZ;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
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 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_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);
|
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;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
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 (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -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);
|
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 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_original TEXT;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai 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';
|
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')
|
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
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_start TIME;
|
||||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end 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 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 (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -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{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
|
||||||
.del-btn:hover{opacity:.8}
|
.del-btn:hover{opacity:.8}
|
||||||
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
.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) --- */
|
/* --- Настройки (UX/UI) --- */
|
||||||
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
|
|||||||
'group.create': 'Создана группа',
|
'group.create': 'Создана группа',
|
||||||
'group.update': 'Изменена группа',
|
'group.update': 'Изменена группа',
|
||||||
'group.delete': 'Удалена группа',
|
'group.delete': 'Удалена группа',
|
||||||
|
'group.soft-delete': 'Группа в корзину',
|
||||||
|
'group.restore': 'Группа восстановлена',
|
||||||
|
'group.permanent-delete': 'Группа удалена безвозвратно',
|
||||||
|
'group.schedule-delete': 'Группа помечена на удаление',
|
||||||
|
'group.unschedule': 'Удаление группы отменено',
|
||||||
'group.photo.create': 'Загружена фото группы',
|
'group.photo.create': 'Загружена фото группы',
|
||||||
'group.photo.update': 'Изменено фото группы',
|
'group.photo.update': 'Изменено фото группы',
|
||||||
'group.photo.reorder': 'Изменён порядок фото группы',
|
'group.photo.reorder': 'Изменён порядок фото группы',
|
||||||
@@ -44,6 +49,9 @@ const ACTION_LABELS = {
|
|||||||
'entry.soft-delete': 'Запись в корзину',
|
'entry.soft-delete': 'Запись в корзину',
|
||||||
'entry.restore': 'Запись восстановлена',
|
'entry.restore': 'Запись восстановлена',
|
||||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||||
|
'entry.schedule-delete': 'Запись помечена на удаление',
|
||||||
|
'entry.unschedule': 'Удаление записи отменено',
|
||||||
|
'trash.clear': 'Корзина помечена на удаление',
|
||||||
'link.create': 'Создана ссылка',
|
'link.create': 'Создана ссылка',
|
||||||
'link.update': 'Изменена ссылка',
|
'link.update': 'Изменена ссылка',
|
||||||
'link.delete': 'Удалена ссылка',
|
'link.delete': 'Удалена ссылка',
|
||||||
|
|||||||
+3
-2
@@ -113,8 +113,9 @@ async function addGroup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function delGroup(id) {
|
async function delGroup(id) {
|
||||||
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
|
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
|
||||||
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
|
||||||
loadGroups();
|
loadGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -1,5 +1,5 @@
|
|||||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled'];
|
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-form', 'sec-bans'];
|
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 SPAM_MAX = 10080;
|
||||||
const BAN_REASONS = {
|
const BAN_REASONS = {
|
||||||
'honeypot': 'Антиспам-поле',
|
'honeypot': 'Антиспам-поле',
|
||||||
@@ -165,6 +165,7 @@ async function loadSettings() {
|
|||||||
logoOriginal = currentLogo;
|
logoOriginal = currentLogo;
|
||||||
renderLogoPreview();
|
renderLogoPreview();
|
||||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
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('footerLeft').value = s.footer_left || '';
|
||||||
document.getElementById('footerRight').value = s.footer_right || '';
|
document.getElementById('footerRight').value = s.footer_right || '';
|
||||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||||
@@ -501,6 +502,12 @@ async function saveSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearFieldError('spamInterval');
|
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');
|
const btn = document.getElementById('saveBtn');
|
||||||
btn.dataset.loadingText = 'Сохранение...';
|
btn.dataset.loadingText = 'Сохранение...';
|
||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
@@ -524,6 +531,7 @@ async function saveSettings() {
|
|||||||
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||||
photo_enhance_engine: document.getElementById('photoEngine').value,
|
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||||
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
||||||
|
trash_purge_days: String(trashDays),
|
||||||
} })
|
} })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
+177
-13
@@ -1,7 +1,14 @@
|
|||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
let currentEntries = [];
|
let currentEntries = [];
|
||||||
|
let currentGroups = [];
|
||||||
|
let pendingEntries = [];
|
||||||
|
let pendingGroups = [];
|
||||||
let page = 1;
|
let page = 1;
|
||||||
let totalEntries = 0;
|
let totalEntries = 0;
|
||||||
|
let totalGroups = 0;
|
||||||
|
let pendingTotalEntries = 0;
|
||||||
|
let pendingTotalGroups = 0;
|
||||||
|
let purgeDays = 30;
|
||||||
|
|
||||||
function isImageFile(name) {
|
function isImageFile(name) {
|
||||||
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
@@ -28,32 +35,129 @@ function entryHTML(e) {
|
|||||||
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||||||
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">×</button>
|
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">×</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupHTML(g) {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">${esc(g.name)}</div>
|
||||||
|
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||||
|
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
|
||||||
|
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingEntryHTML(e) {
|
||||||
|
return `
|
||||||
|
<div class="card pending">
|
||||||
|
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">${esc(e.student_name)}</div>
|
||||||
|
<div class="group">${esc(e.group_name)}</div>
|
||||||
|
<div class="desc">${esc(e.description)}</div>
|
||||||
|
${filesHTML(e.files)}
|
||||||
|
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingGroupHTML(g) {
|
||||||
|
return `
|
||||||
|
<div class="card pending">
|
||||||
|
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">${esc(g.name)}</div>
|
||||||
|
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||||
|
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroups() {
|
||||||
|
const grid = document.getElementById('groupsGrid');
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.innerHTML = currentGroups.length
|
||||||
|
? currentGroups.map(groupHTML).join('')
|
||||||
|
: '<div class="empty">Групп в корзине нет</div>';
|
||||||
|
document.getElementById('groupsInfo').textContent =
|
||||||
|
totalGroups > 0 ? `— групп в корзине: ${totalGroups}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
function renderGrid() {
|
function renderGrid() {
|
||||||
const grid = document.getElementById('grid');
|
const grid = document.getElementById('grid');
|
||||||
grid.className = 'grid';
|
grid.className = 'grid';
|
||||||
grid.innerHTML = currentEntries.length
|
grid.innerHTML = currentEntries.length
|
||||||
? currentEntries.map(entryHTML).join('')
|
? currentEntries.map(entryHTML).join('')
|
||||||
: '<div class="empty">Корзина пуста</div>';
|
: '<div class="empty">Записей в корзине нет</div>';
|
||||||
|
document.getElementById('entriesInfo').textContent =
|
||||||
|
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
|
||||||
document.getElementById('trashInfo').textContent =
|
document.getElementById('trashInfo').textContent =
|
||||||
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
totalEntries + totalGroups > 0
|
||||||
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
|
||||||
|
: 'Корзина пуста';
|
||||||
|
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
|
||||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
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('')
|
||||||
|
: '<div class="empty">Нет групп, помеченных на удаление</div>';
|
||||||
|
document.getElementById('pendingGrid').className = 'grid';
|
||||||
|
document.getElementById('pendingGrid').innerHTML = pendingEntries.length
|
||||||
|
? pendingEntries.map(pendingEntryHTML).join('')
|
||||||
|
: '<div class="empty">Нет записей, помеченных на удаление</div>';
|
||||||
|
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() {
|
async function loadTrash() {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
p.set('limit', PAGE_SIZE);
|
p.set('limit', PAGE_SIZE);
|
||||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
const [trashRes, pendRes] = await Promise.all([
|
||||||
const data = await res.json();
|
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;
|
currentEntries = data.entries;
|
||||||
totalEntries = data.total;
|
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();
|
renderGrid();
|
||||||
|
renderPending();
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(p) { page = p; loadTrash(); }
|
function goPage(p) { page = p; loadTrash(); }
|
||||||
@@ -65,22 +169,55 @@ async function restoreEntry(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function hardDelete(id) {
|
async function hardDelete(id) {
|
||||||
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
|
||||||
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||||
showToast('Запись удалена навсегда');
|
if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
|
||||||
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() {
|
async function emptyTrash() {
|
||||||
if (totalEntries === 0) return;
|
const parts = [];
|
||||||
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
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() });
|
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 || 'Ошибка'); }
|
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
||||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
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 => {
|
document.getElementById('grid').addEventListener('click', e => {
|
||||||
const card = e.target.closest('.card');
|
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)); }
|
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 () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||||
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||||
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||||
|
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
|
||||||
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -243,6 +244,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-trash">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="trash-2"></i></div>
|
||||||
|
<h3>Корзина</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).</p>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="trashPurgeDays">Время до безвозвратного удаления</label>
|
||||||
|
<div class="unit-row">
|
||||||
|
<input type="number" id="trashPurgeDays" class="settings-input" min="1" max="365" step="1">
|
||||||
|
<span class="unit">дней</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint">От 1 до 365 дней</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-bans">
|
<div class="settings-card" id="sec-bans">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||||
|
|||||||
@@ -15,10 +15,30 @@
|
|||||||
<span class="trash-muted" id="trashInfo"></span>
|
<span class="trash-muted" id="trashInfo"></span>
|
||||||
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="toggle pending-toggle" id="pendingToggleRow">
|
||||||
|
<input type="checkbox" id="pendingToggle">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Показать помеченные на удаление</b><small id="pendingToggleHint">Данные не удаляются сразу: они помечаются и исчезают из корзины. Здесь их можно вернуть в корзину и восстановить</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="trash-section" id="pendingSection" hidden>
|
||||||
|
<h3>Помечены на удаление <span class="trash-muted" id="pendingInfo"></span></h3>
|
||||||
|
<p class="trash-muted">Эти данные будут удалены автоматически и безвозвратно вместе с файлами. Чтобы отменить удаление, нажмите «В корзину» — данные вернутся в корзину, откуда их можно восстановить.</p>
|
||||||
|
<h4>Группы <span class="trash-muted" id="pendingGroupsInfo"></span></h4>
|
||||||
|
<div class="grid" id="pendingGroupsGrid"><div class="empty">Загрузка...</div></div>
|
||||||
|
<h4>Записи журнала <span class="trash-muted" id="pendingEntriesInfo"></span></h4>
|
||||||
|
<div class="grid" id="pendingGrid"><div class="empty">Загрузка...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="trash-section" id="groupsSection">
|
||||||
|
<h3>Группы <span class="trash-muted" id="groupsInfo"></span></h3>
|
||||||
|
<div class="grid" id="groupsGrid"><div class="empty">Загрузка...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="trash-section">
|
||||||
|
<h3>Записи журнала <span class="trash-muted" id="entriesInfo"></span></h3>
|
||||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||||
<div class="pager" id="pager"></div>
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
|
|||||||
@@ -556,6 +556,30 @@ async function getSetting(key, def) {
|
|||||||
return value;
|
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');
|
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||||||
|
|
||||||
function safeUnlink(relPath) {
|
function safeUnlink(relPath) {
|
||||||
@@ -658,6 +682,8 @@ async function ensureBranchesTable() {
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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 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() {
|
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_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_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 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 INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
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 дней)' });
|
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 (key === 'photo_capture_resolution') {
|
||||||
if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) {
|
if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) {
|
||||||
return res.status(400).json({ error: 'photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480' });
|
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) {
|
for (const x of ndata.groups) {
|
||||||
await client.query(
|
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)',
|
'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.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) {
|
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) {
|
for (const x of ndata.entries) {
|
||||||
await client.query(
|
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)',
|
'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.created_at]
|
[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) {
|
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
|
b.name AS branch_name
|
||||||
FROM groups g
|
FROM groups g
|
||||||
LEFT JOIN branches b ON b.id = g.branch_id
|
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`,
|
ORDER BY g.id`,
|
||||||
bw.params
|
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 cacheWrap('groups:active', PUBLIC_TTL_MS, async () => {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT * FROM groups
|
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_start IS NOT NULL
|
||||||
AND time_end IS NOT NULL
|
AND time_end IS NOT NULL
|
||||||
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
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) => {
|
app.delete('/api/groups/:id', requireAuth, async (req, res) => {
|
||||||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||||||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT photo_path FROM group_photos WHERE group_id = $1',
|
'UPDATE groups SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL',
|
||||||
[req.params.id]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
rows.forEach(r => safeUnlink(r.photo_path));
|
if (result.rowCount === 0) {
|
||||||
await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
|
return res.status(404).json({ error: 'Группа не найдена или уже в корзине' });
|
||||||
await logAudit(req, 'group.delete', { id: req.params.id });
|
}
|
||||||
|
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();
|
invalidateGroups();
|
||||||
invalidateStats();
|
invalidateStats();
|
||||||
res.json({ ok: true });
|
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 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 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 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(*)::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(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.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 {
|
return {
|
||||||
entries: entries.rows[0].n,
|
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,
|
groups: groups.rows[0].n,
|
||||||
students: students.rows[0].n,
|
students: students.rows[0].n,
|
||||||
today: today.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: 'Нет доступа к этой записи' });
|
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}`);
|
console.log(`[RESTORE] User ${req.user.id} (${req.user.username}) restored entry ${req.params.id}, rowCount=${result.rowCount}`);
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return res.status(404).json({ error: 'Запись не найдена или уже восстановлена' });
|
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: 'Нет доступа к этой записи' });
|
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await removeEntryFiles(req.params.id);
|
const days = await trashPurgeDays();
|
||||||
await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]);
|
const result = await pool.query(
|
||||||
await logAudit(req, 'entry.permanent-delete', { id: req.params.id });
|
`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();
|
invalidateEntries();
|
||||||
invalidateStats();
|
invalidateStats();
|
||||||
res.json({ ok: true });
|
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 });
|
res.json({ ok: true, count: rowCount });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/trash', requireAuth, async (req, res) => {
|
async function trashData(req, mode, limit, offset) {
|
||||||
const { limit, offset } = req.query;
|
|
||||||
const bw = branchScope(req.user);
|
const bw = branchScope(req.user);
|
||||||
const scoped = req.user.role !== 'admin';
|
const scoped = req.user.role !== 'admin';
|
||||||
let where = ' WHERE e.deleted_at IS NOT NULL';
|
const isPending = mode === 'pending';
|
||||||
const params = [];
|
const eWhere = isPending
|
||||||
if (scoped) {
|
? ' WHERE e.purge_at IS NOT NULL'
|
||||||
if (bw.ids.length) {
|
: ' WHERE e.deleted_at IS NOT NULL AND e.purge_at IS NULL';
|
||||||
where += ` AND g.branch_id IN (${bw.ids.map(id => `$${params.push(id)}`).join(',')})`;
|
const gWhere = isPending
|
||||||
} else {
|
? ' WHERE g.purge_at IS NOT NULL'
|
||||||
where += ' AND 1 = 0';
|
: ' WHERE g.deleted_at IS NOT NULL AND g.purge_at IS NULL';
|
||||||
}
|
const eparams = [];
|
||||||
}
|
const gparams = [];
|
||||||
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 esco = scoped
|
||||||
const { rows: crows } = await pool.query(
|
? (bw.ids.length ? ` AND g.branch_id IN (${bw.ids.map(id => `$${eparams.push(id)}`).join(',')})` : ' AND 1 = 0')
|
||||||
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id ${where}`,
|
: '';
|
||||||
params
|
const gsco = scoped
|
||||||
);
|
? (bw.ids.length ? ` AND g.branch_id IN (${bw.ids.map(id => `$${gparams.push(id)}`).join(',')})` : ' AND 1 = 0')
|
||||||
const total = crows[0].n;
|
: '';
|
||||||
let q = `SELECT e.*, g.name AS group_name FROM entries e
|
const eOrder = isPending ? 'e.purge_at' : 'e.deleted_at';
|
||||||
JOIN groups g ON g.id = e.group_id
|
const gOrder = isPending ? 'g.purge_at' : 'g.deleted_at';
|
||||||
${where} ORDER BY e.deleted_at DESC`;
|
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);
|
||||||
const qparams = params.slice();
|
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 lim = parseInt(limit, 10);
|
const qparams = eparams.slice();
|
||||||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
if (limit > 0) { qparams.push(limit); q += ` LIMIT $${qparams.length}`; }
|
||||||
const off = parseInt(offset, 10);
|
if (offset > 0) { qparams.push(offset); q += ` OFFSET $${qparams.length}`; }
|
||||||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
|
||||||
const { rows } = await pool.query(q, qparams);
|
const { rows } = await pool.query(q, qparams);
|
||||||
console.log(`[TRASH] Found ${rows.length} entries for user ${req.user.id}`);
|
|
||||||
let files = {};
|
let files = {};
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
const fRes = await pool.query(
|
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); });
|
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
|
||||||
}
|
}
|
||||||
rows.forEach(r => { r.files = files[r.id] || []; });
|
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) => {
|
app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => {
|
||||||
const { rows } = await pool.query(
|
const days = await trashPurgeDays();
|
||||||
`SELECT photo_path AS p FROM entries WHERE deleted_at IS NOT NULL
|
const e = await pool.query(
|
||||||
UNION ALL
|
`UPDATE entries SET purge_at = now() + ($1 || ' days')::interval WHERE deleted_at IS NOT NULL AND purge_at IS NULL`,
|
||||||
SELECT pf.path AS p FROM project_files pf
|
[days]
|
||||||
JOIN entries e ON e.id = pf.entry_id WHERE e.deleted_at IS NOT NULL`
|
);
|
||||||
|
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();
|
invalidateEntries();
|
||||||
|
invalidateGroups();
|
||||||
invalidateStats();
|
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 ---
|
// --- 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 ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||||
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', 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 ('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 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 = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
||||||
entryAutoChecker.start();
|
entryAutoChecker.start();
|
||||||
console.log('AI auto-check worker started');
|
console.log('AI auto-check worker started');
|
||||||
|
|||||||
Reference in New Issue
Block a user