feat(groups): ZIP-архив группы с выбором категорий и режима фото

Форма архива файлов группы на /groups.html получила галочки выгрузки:
- «Работы (файлы проектов)» — включена по умолчанию
- «Фото записи» — выключена по умолчанию
- режим фото «Последние» / «Все», по умолчанию «Последние»;
  радиокнопки блокируются, когда «Фото записи» выключена

Дефолты периода: дата «с» пустая, дата «по» — сегодня.

GET /api/groups/:id/export/files принимает include_files,
include_photos и photos_mode:
- latest — только entries.photo_path, то главное обработанное фото записи
- all — плюс вся галерея записи из entry_photos
- 400, если обе категории выключены

Запросы к БД для отключённых категорий не выполняются; в data.json
и в аудит добавлены options и раздельные счётчики works/photos.

В аудите добавлена метка export.group_files.

Проверено на стенде: группа с многофото-записями даёт 4 фото в режиме
«последние» и 6 в режиме «все» (совпадает с БД: 4 главных + 2 галереи),
категории комбинируются независимо. api.smoketest.js 57/57,
diff.selftest.js 16/16.
This commit is contained in:
dev
2026-10-02 12:48:36 +03:00
parent 45033bffa8
commit 55c4b281c3
4 changed files with 317 additions and 0 deletions
+172
View File
@@ -4132,6 +4132,178 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
}
});
// __GROUP_FILES_EXPORT__
app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
const gid = parseInt(req.params.id, 10);
if (!Number.isInteger(gid) || gid < 1) return res.status(400).json({ error: 'Некорректная группа' });
if (!(await groupBelongsToBranches(req.user, gid))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
const includeFiles = req.query.include_files !== '0';
const includePhotos = req.query.include_photos !== '0';
const photosMode = req.query.photos_mode === 'all' ? 'all' : 'latest';
if (!includeFiles && !includePhotos) {
return res.status(400).json({ error: 'Выберите хотя бы одну категорию: работы или фото записи' });
}
let dateFrom = null;
let dateTo = null;
try {
if (req.query.date_from) dateFrom = optDate(req.query.date_from);
if (req.query.date_to) dateTo = optDate(req.query.date_to);
} catch {
return res.status(400).json({ error: 'Неверный период' });
}
if (dateFrom && dateTo && dateFrom > dateTo) {
return res.status(400).json({ error: 'Дата «С» позже даты «По»' });
}
try {
const { rows: groupRows } = await pool.query('SELECT id, name FROM groups WHERE id = $1 AND deleted_at IS NULL', [gid]);
if (!groupRows.length) return res.status(404).json({ error: 'Группа не найдена' });
const groupName = groupRows[0].name;
const conds = ['e.group_id = $1', 'e.deleted_at IS NULL'];
const params = [gid];
if (dateFrom) {
params.push(dateFrom);
conds.push(`e.created_at >= $${params.length}::date`);
}
if (dateTo) {
params.push(dateTo);
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
}
const condStr = conds.join(' AND ');
const [studentsRes, filesRes, photosRes, entryPhotosRes] = await Promise.all([
pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]),
includeFiles
? pool.query(
`SELECT pf.name, pf.path, e.student_name, e.created_at
FROM project_files pf
JOIN entries e ON e.id = pf.entry_id
WHERE ${condStr} AND pf.detached_at IS NULL
ORDER BY e.student_name, e.created_at DESC, pf.id DESC`,
params
)
: null,
includePhotos
? pool.query(
`SELECT e.photo_path, e.student_name, e.created_at
FROM entries e
WHERE ${condStr} AND e.photo_path IS NOT NULL
ORDER BY e.student_name, e.created_at DESC`,
params
)
: null,
includePhotos && photosMode === 'all'
? pool.query(
`SELECT ep.photo_path, ep.caption, e.student_name, e.created_at
FROM entry_photos ep
JOIN entries e ON e.id = ep.entry_id
WHERE ${condStr}
ORDER BY e.student_name, e.created_at DESC, ep.sort_order ASC, ep.id ASC`,
params
)
: null,
]);
const fileRows = filesRes ? filesRes.rows : [];
const photoRows = photosRes ? photosRes.rows : [];
const entryPhotoRows = entryPhotosRes ? entryPhotosRes.rows : [];
const studentNames = new Set(studentsRes.rows.map(s => s.name));
const zip = createZipWriter();
const folderOf = name => String(name || '').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 80) || 'Без имени';
const seenFolders = new Set();
const uniqueFolder = base => {
let folder = base;
let n = 1;
while (seenFolders.has(folder)) {
n++;
folder = `${base} (${n})`;
}
seenFolders.add(folder);
return folder;
};
const folderByStudent = new Map();
const ensureFolder = studentName => {
const key = String(studentName || '');
if (folderByStudent.has(key)) return folderByStudent.get(key);
const folder = uniqueFolder(folderOf(key));
folderByStudent.set(key, folder);
zip.addDir(folder);
return folder;
};
const filesBuilt = [];
const seenFiles = new Map();
const seenPaths = new Set();
async function addEntryFile(row, subDir) {
if (!isSafeUploadPath(row.path)) return false;
const stored = row.path.slice('/uploads/'.length);
if (seenPaths.has(stored)) return false;
const data = await storage.getBuffer(stored);
if (!data) return false;
seenPaths.add(stored);
const folder = ensureFolder(row.student_name);
const base = String(row.name || 'file').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 120) || 'file';
const ext = path.extname(base);
const stem = ext ? base.slice(0, -ext.length) : base;
const keyPath = folder + '/' + (subDir ? subDir + '/' : '');
let saved = base;
let n = 1;
while (seenFiles.has(keyPath + saved)) {
n++;
saved = `${stem}(${n})${ext}`;
}
seenFiles.set(keyPath + saved, true);
const zipPath = subDir ? `${folder}/${subDir}/${saved}` : `${folder}/${saved}`;
if (subDir && !seenFiles.has(folder + '/' + subDir + '/')) {
seenFiles.set(folder + '/' + subDir + '/', true);
zip.addDir(`${folder}/${subDir}`);
}
zip.addFile(zipPath, data, new Date(row.created_at || Date.now()));
filesBuilt.push({ folder: subDir ? `${folder}/${subDir}` : folder, file: saved, original: row.name, size: data.length, created_at: row.created_at || null });
return true;
}
let worksAdded = 0;
let photosAdded = 0;
for (const f of fileRows) if (await addEntryFile(f, null)) worksAdded++;
for (const p of photoRows) {
if (await addEntryFile({ name: `photo_${new Date(p.created_at || Date.now()).toISOString().slice(0, 10)}.jpg`, path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++;
}
for (const p of entryPhotoRows) {
const storedName = String(p.photo_path).split('/').pop();
if (await addEntryFile({ name: storedName || 'photo.jpg', path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++;
}
const meta = {
exported_at: new Date().toISOString(),
group: { id: gid, name: groupName },
period: { date_from: dateFrom, date_to: dateTo },
options: { files: includeFiles, photos: includePhotos, photos_mode: photosMode },
totals: { students: studentNames.size, files: filesBuilt.length, works: worksAdded, photos: photosAdded },
students: [...studentNames].sort(),
files: filesBuilt,
};
zip.addFile('data.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8'));
const buf = zip.toBuffer();
await logAudit(req, 'export.group_files', {
group_id: gid,
group_name: groupName,
students: studentNames.size,
files: filesBuilt.length,
works: worksAdded,
photos: photosAdded,
photos_mode: photosMode,
date_from: dateFrom,
date_to: dateTo,
});
const safeGroup = groupName.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 60) || 'group';
const zipDate = new Date().toISOString().slice(0, 10);
const zipFname = `group_${safeGroup}_${zipDate}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFname}"; filename*=UTF-8''${encodeURIComponent(`group_${groupName}_${zipDate}.zip`)}`);
res.send(buf);
} catch (err) {
console.error('export group files:', err);
res.status(500).json({ error: err.message });
}
});
// --- Entries ---
app.get('/api/entries', requireAuth, async (req, res) => {
const { group_id, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;