feat(students): multi-photo gallery, group photos in export, KIBERone rebrand

This commit is contained in:
dev
2026-09-23 13:32:09 +03:00
parent 5e2533b876
commit 4623358f21
8 changed files with 475 additions and 105 deletions
+192 -9
View File
@@ -616,16 +616,17 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() {
const dir = path.join(__dirname, 'uploads');
if (!fs.existsSync(dir)) return;
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }] = await Promise.all([
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'),
pool.query('SELECT photo_path AS p FROM entry_photos'),
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'),
pool.query('SELECT photo_path AS p FROM student_photos'),
]);
const refs = new Set();
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (!fs.statSync(fp).isFile()) continue;
@@ -693,6 +694,16 @@ async function ensureEntryPhotosTable() {
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
}
async function ensureStudentPhotosTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query('CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id)');
}
async function ensurePhotoOriginalColumn() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
}
@@ -1475,6 +1486,12 @@ function normalizeRestoreData(data) {
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
created_at: optTs(x.created_at),
}));
const student_photos = (data.student_photos || []).map(x => ({
id: reqInt(x.id),
student_id: reqInt(x.student_id),
photo_path: reqUploadPath(x.photo_path, 255),
created_at: optTs(x.created_at),
}));
const group_photos = (data.group_photos || []).map(x => ({
id: reqInt(x.id),
group_id: reqInt(x.group_id),
@@ -1506,13 +1523,13 @@ function normalizeRestoreData(data) {
for (const [k, v] of Object.entries(data.settings || {})) {
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
}
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links, modules };
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules };
}
app.get('/api/backup', requireAdmin, async (req, res) => {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try {
const [g, s, e, st, pf, br, us, ub, gp, ep, md] = await Promise.all([
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'),
@@ -1524,10 +1541,11 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
pool.query('SELECT * FROM group_photos ORDER BY id'),
pool.query('SELECT * FROM entry_photos ORDER BY id'),
pool.query('SELECT * FROM modules ORDER BY id'),
pool.query('SELECT * FROM student_photos ORDER BY id'),
]);
const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; });
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows };
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows };
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
const dir = path.join(__dirname, 'uploads');
@@ -1676,6 +1694,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.entry_id, x.photo_path, x.caption, x.sort_order, x.created_at]
);
}
for (const x of ndata.student_photos) {
const ex = await client.query('SELECT 1 FROM students WHERE id = $1', [x.student_id]);
if (!ex.rowCount) continue;
await client.query(
'INSERT INTO student_photos (id, student_id, photo_path, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO NOTHING',
[x.id, x.student_id, x.photo_path, x.created_at]
);
}
for (const x of ndata.users) {
await client.query(
'INSERT INTO users (id, username, password_hash, name, role, is_active, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
@@ -1694,7 +1720,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[k, String(v ?? '')]
);
}
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules']) {
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos']) {
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
}
@@ -2667,6 +2693,8 @@ app.delete('/api/students/:id', requireAuth, async (req, res) => {
}
}
await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
const { rows: spRows } = await pool.query('SELECT photo_path AS p FROM student_photos WHERE student_id = $1', [req.params.id]);
spRows.forEach(r => safeUnlink(r.p));
await logAudit(req, 'student.delete', { id: req.params.id });
invalidateStudents();
invalidateStats();
@@ -2736,6 +2764,133 @@ app.put('/api/students/:id/profile', requireAuth, async (req, res) => {
res.json(rows[0]);
});
const studentPhotoUpload = upload.single('photo');
app.get('/api/students/:id/photos', requireAuth, async (req, res) => {
let id;
try {
id = reqInt(req.params.id);
} catch {
return res.status(400).json({ error: 'Неверный id ученика' });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
const { rows } = await pool.query(
'SELECT id, photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC',
[id]
);
res.json({ photos: rows, photo_path: acc.student.photo_path || null });
});
app.post('/api/students/:id/photos', requireAuth, (req, res, next) => {
studentPhotoUpload(req, res, (err) => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
return res.status(400).json({ error: 'Недопустимый файл' });
}
next();
});
}, async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
let id;
try {
id = reqInt(req.params.id);
} catch {
removeUpload(req.file);
return res.status(400).json({ error: 'Неверный id ученика' });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
removeUpload(req.file);
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
try {
await convertPhoto(req.file);
const photoPath = `/uploads/${req.file.filename}`;
const { rows } = await pool.query(
'INSERT INTO student_photos (student_id, photo_path) VALUES ($1, $2) RETURNING id, photo_path, created_at',
[id, photoPath]
);
if (!acc.student.photo_path) {
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [photoPath, id]);
}
await logAudit(req, 'student.photo.create', { id, photo_path: photoPath });
invalidateStudents();
invalidateShare();
res.status(201).json(rows[0]);
} catch (e) {
safeUnlink(`uploads/${req.file.filename}`);
console.error('POST /api/students/:id/photos:', e);
res.status(500).json({ error: e.message });
}
});
app.delete('/api/students/:id/photos/:pid', requireAuth, async (req, res) => {
let id, pid;
try {
id = reqInt(req.params.id);
pid = reqInt(req.params.pid);
} catch {
return res.status(400).json({ error: 'Неверный id' });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
const { rows } = await pool.query(
'SELECT id, photo_path FROM student_photos WHERE id = $1 AND student_id = $2',
[pid, id]
);
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
const { rows: rest } = await pool.query(
'SELECT photo_path FROM student_photos WHERE student_id = $1 AND id <> $2 ORDER BY id DESC LIMIT 1',
[id, pid]
);
try {
await pool.query('DELETE FROM student_photos WHERE id = $1', [pid]);
if (acc.student.photo_path === rows[0].photo_path) {
const next = rest.length ? rest[0].photo_path : null;
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [next, id]);
}
safeUnlink(rows[0].photo_path);
await logAudit(req, 'student.photo.delete', { id, photo_path: rows[0].photo_path });
invalidateStudents();
invalidateShare();
res.json({ ok: true });
} catch (e) {
console.error('DELETE /api/students/:id/photos/:pid:', e);
res.status(500).json({ error: e.message });
}
});
app.put('/api/students/:id/photos/:pid/main', requireAuth, async (req, res) => {
let id, pid;
try {
id = reqInt(req.params.id);
pid = reqInt(req.params.pid);
} catch {
return res.status(400).json({ error: 'Неверный id' });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
const { rows } = await pool.query(
'SELECT photo_path FROM student_photos WHERE id = $1 AND student_id = $2',
[pid, id]
);
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, id]);
await logAudit(req, 'student.photo.main', { id, photo_path: rows[0].photo_path });
invalidateStudents();
invalidateShare();
res.json({ ok: true, photo_path: rows[0].photo_path });
});
function fmtLongDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
@@ -2789,7 +2944,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
}
const condStr = conds.join(' AND ');
const whereStr = ' WHERE ' + condStr;
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes] = await Promise.all([
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([
pool.query(
`SELECT s.id, s.name, s.created_at, s.group_id, s.photo_path, s.profile,
g.name AS group_name, b.name AS branch_name
@@ -2827,6 +2982,11 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
JOIN modules m ON m.id = e.module_id
WHERE ${condStr}
GROUP BY m.id ORDER BY min(e.created_at)`, params),
pool.query(`SELECT gp.photo_path, gp.caption, gp.taken_at, gp.created_at, g.name AS group_name
FROM group_photos gp
JOIN groups g ON g.id = gp.group_id
WHERE gp.group_id = (SELECT group_id FROM students WHERE name = $1)
ORDER BY gp.sort_order ASC, gp.created_at DESC`, [name]),
]);
const entryRows = entriesRes.rows;
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
@@ -2869,12 +3029,33 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
for (const m of mainsRes.rows) addPhoto(m);
photosBuilt.sort((a, b) => new Date(b.createdAt || Date.now()) - new Date(a.createdAt || Date.now()));
if (avatarStored) {
if (!seenPhotos.has(avatarStored)) addPhoto({ photo_path: '/uploads/' + avatarStored, caption: 'Фото ученика', created_at: student.created_at });
if (!seenPhotos.has(avatarStored)) addPhoto({ photo_path: '/uploads/' + avatarStored, caption: 'Фото резидента', created_at: student.created_at });
const idx = photosBuilt.findIndex(p => p.stored === avatarStored);
if (idx > 0) photosBuilt.unshift(photosBuilt.splice(idx, 1)[0]);
}
}
const groupPhotosBuilt = [];
if (opts.includePhotos) {
for (const gp of groupPhotosRes.rows) {
if (!isSafeUploadPath(gp.photo_path)) continue;
const stored = gp.photo_path.slice('/uploads/'.length);
if (seenPhotos.has(stored)) continue;
const src = path.join(UPLOADS_DIR, stored);
if (!fs.existsSync(src)) continue;
const ts = gp.taken_at || gp.created_at || new Date();
zip.addFile('photos/' + stored, fs.readFileSync(src), new Date(ts));
seenPhotos.add(stored);
groupPhotosBuilt.push({
stored,
caption: gp.caption || null,
takenAt: gp.taken_at || null,
createdAt: gp.created_at || null,
groupName: gp.group_name || null,
});
}
}
const seenFiles = new Map();
const filesBuilt = [];
if (opts.includeFiles) {
@@ -2927,6 +3108,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
entries: entryRows,
modules: modulesRes.rows,
photos: photosBuilt,
groupPhotos: groupPhotosBuilt,
files: filesBuilt,
generatedAt: new Date(),
period,
@@ -2938,7 +3120,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
student: reportData.student,
profile: student.profile || null,
period: period || null,
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length },
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length },
options: opts,
entries: entryRows.map(e => ({ id: e.id, group: e.group_name, module: e.module_name || null, created_at: e.created_at, ai_status: e.ai_status, description: e.description })),
photos: photosBuilt.map(p => ({ file: 'photos/' + p.stored, caption: p.caption, created_at: p.createdAt, entry_id: p.entryId })),
@@ -4669,6 +4851,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
try { await ensureModulesTable(); } catch (err) { console.error('Modules table:', err); }
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
try { await ensureStudentPhotosTable(); } catch (err) { console.error('Student photos table:', err); }
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }