feat(photos): раздел «Фото» — единая галерея всех загруженных фотографий

Новый read-only раздел для просмотра всех фотографий системы:
GET /api/photos собирает через UNION ALL пять источников — главное фото
записи, фото записи (entry_photos), фотохронику групп, фото учеников и фото
тем модулей. Фильтры search/student_name/group_id/date_from/date_to и
пагинация limit/offset, ответ { photos, total }. Не-admin ограничен
своими филиалами (branch_id), чужой group_id отдаёт 403.

Фронтенд: public/photos.html + public/js/photos.js — сетка превью
(/uploads/thumb/...), бейдж источника, описание, ученик/группа, дата,
ссылка на источник, lightbox по клику, поиск с debounce и пагинация.
Навигация: пункт «Фото» в сайдбаре и плитка в быстрых действиях дашборда.
This commit is contained in:
dev
2026-09-28 00:15:32 +03:00
parent 2afe676969
commit 31542de33b
6 changed files with 383 additions and 31 deletions
+83
View File
@@ -4401,6 +4401,89 @@ app.get('/api/files/:token', fileLimiter, async (req, res) => {
}
});
app.get('/api/photos', requireAuth, async (req, res) => {
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
const conditions = [];
const params = [];
if (search) { params.push(`%${search}%`); conditions.push(`t.title ILIKE $${params.length}`); }
if (student_name) { params.push(student_name); conditions.push(`t.student_name = $${params.length}`); }
if (group_id) { params.push(group_id); conditions.push(`t.group_id = $${params.length}`); }
if (date_from) { params.push(date_from); conditions.push(`t.created_at >= $${params.length}::date`); }
if (date_to) { params.push(date_to); conditions.push(`t.created_at < ($${params.length}::date + interval '1 day')`); }
if (req.user.role !== 'admin') {
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
const s = branchScope(req.user);
if (!s.ids.length) {
conditions.push('1 = 0');
} else {
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
conditions.push(`t.branch_id IN (${ph})`);
}
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const from = `FROM (
SELECT 'entry'::text AS source_type, 'Главное фото записи'::text AS source_label,
e.photo_path AS path, e.id AS source_id, e.student_name, e.group_id,
g.branch_id, g.name AS group_name, e.created_at,
e.description AS title
FROM entries e
JOIN groups g ON g.id = e.group_id
WHERE e.deleted_at IS NULL AND e.photo_path IS NOT NULL
UNION ALL
SELECT 'entry_photo', 'Фото записи', ep.photo_path, ep.entry_id, e.student_name, e.group_id,
g.branch_id, g.name, ep.created_at,
COALESCE(NULLIF(ep.caption, ''), e.description)
FROM entry_photos ep
JOIN entries e ON e.id = ep.entry_id
JOIN groups g ON g.id = e.group_id
WHERE e.deleted_at IS NULL
UNION ALL
SELECT 'group_photo', 'Фотохроника группы', gp.photo_path, gp.group_id, NULL::varchar, gp.group_id,
g.branch_id, g.name, gp.created_at,
COALESCE(NULLIF(gp.caption, ''), g.name)
FROM group_photos gp
JOIN groups g ON g.id = gp.group_id
UNION ALL
SELECT 'student_photo', 'Фото ученика', sp.photo_path, sp.student_id, s.name, s.group_id,
g.branch_id, g.name, sp.created_at,
s.name
FROM student_photos sp
JOIN students s ON s.id = sp.student_id
LEFT JOIN groups g ON g.id = s.group_id
UNION ALL
SELECT 'module_photo', 'Тема модуля', m.photo_path, m.id, NULL::varchar, NULL::int,
NULL::int, NULL::varchar, m.created_at,
m.name
FROM modules m
WHERE m.photo_path IS NOT NULL
) t`;
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n ${from}${where}`, params);
const total = crows[0].n;
let q = `SELECT t.source_type, t.source_label, t.path, t.source_id, t.student_name,
t.group_id, t.group_name, t.created_at, t.title ${from}${where}
ORDER BY t.created_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 { rows } = await pool.query(q, qparams);
const photos = rows.map(r => ({
source_type: r.source_type,
source_label: r.source_label,
source_id: r.source_id,
path: r.path,
title: r.title || '',
student_name: r.student_name || null,
group_id: r.group_id || null,
group_name: r.group_name || null,
created_at: r.created_at
}));
res.json({ photos, total });
});
app.get('/api/stats', requireAuth, async (req, res) => {
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
const isAdmin = req.user.role === 'admin';