add project file upload to student form, project_files table with backup/restore, download in journal, and crash-proof entries endpoint

This commit is contained in:
dev
2026-09-06 11:29:34 +03:00
parent c2c5f2ed42
commit c5fdf006f5
5 changed files with 175 additions and 14 deletions
+76 -14
View File
@@ -37,9 +37,9 @@ const upload = multer({
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images'));
fileFilter: (req, file, cb) => {
if (file.fieldname === 'photo' && !file.mimetype.startsWith('image/')) cb(new Error('Only images'));
else cb(null, true);
},
});
@@ -109,15 +109,16 @@ const SAFE_NAME = /^[\w,.()-]+$/;
app.get('/api/backup', requireAdmin, async (_, res) => {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try {
const [g, s, e, st] = await Promise.all([
const [g, s, e, st, pf] = 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'),
pool.query('SELECT key, value FROM settings'),
pool.query('SELECT * FROM project_files 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 };
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.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');
@@ -191,13 +192,19 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.student_name, x.group_id, x.description, x.photo_path ?? null, x.created_at]
);
}
for (const x of data.project_files || []) {
await client.query(
'INSERT INTO project_files (id, entry_id, path, name, created_at) VALUES ($1,$2,$3,$4,$5)',
[x.id, x.entry_id, x.path, x.name, x.created_at]
);
}
for (const [k, v] of Object.entries(data.settings || {})) {
await client.query(
'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
[k, String(v ?? '')]
);
}
for (const tbl of ['groups', 'students', 'entries']) {
for (const tbl of ['groups', 'students', 'entries', 'project_files']) {
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]);
}
@@ -470,9 +477,38 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
const off = parseInt(offset, 10);
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
const { rows } = await pool.query(q, qparams);
let files;
if (rows.length) {
const ids = rows.map(r => r.id);
const fRes = await pool.query(
'SELECT id, entry_id, path, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
[ids]
);
files = {};
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
} else {
files = {};
}
rows.forEach(r => { r.files = files[r.id] || []; });
res.json({ entries: rows, total });
});
app.get('/api/entries/:id/files', requireAdmin, async (req, res) => {
const { rows } = await pool.query(
'SELECT id, path, name FROM project_files WHERE entry_id = $1 ORDER BY id',
[req.params.id]
);
res.json(rows);
});
app.get('/api/files/:id', requireAdmin, async (req, res) => {
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
const fp = path.join(__dirname, rows[0].path);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' });
res.download(fp, rows[0].name);
});
app.get('/api/stats', requireAdmin, async (_, res) => {
const [entries, groups, students, today] = await Promise.all([
pool.query('SELECT count(*)::int AS n FROM entries'),
@@ -488,10 +524,13 @@ app.get('/api/stats', requireAdmin, async (_, res) => {
});
});
app.post('/api/entries', upload.single('photo'), async (req, res) => {
app.post('/api/entries', upload.fields([{ name: 'photo', maxCount: 1 }, { name: 'files', maxCount: 10 }]), async (req, res) => {
const { student_name, group_id, description } = req.body;
const photo = req.files?.photo?.[0] || null;
const projectFiles = req.files?.files || [];
if (!student_name?.trim() || !group_id || !description?.trim()) {
removeUpload(req.file);
removeUpload(photo);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'All fields required' });
}
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
@@ -501,11 +540,12 @@ app.post('/api/entries', upload.single('photo'), async (req, res) => {
[student_name.trim(), intervalMin]
);
if (dup.rows[0].n > 0) {
removeUpload(req.file);
removeUpload(photo);
projectFiles.forEach(removeUpload);
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
}
}
const photo_path = req.file ? `/uploads/${req.file.filename}` : null;
const photo_path = photo ? `/uploads/${photo.filename}` : null;
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -518,11 +558,20 @@ app.post('/api/entries', upload.single('photo'), async (req, res) => {
VALUES ($1, $2, $3, $4) RETURNING *`,
[student_name.trim(), group_id, description.trim(), photo_path]
);
for (const f of projectFiles) {
await client.query(
'INSERT INTO project_files (entry_id, path, name) VALUES ($1, $2, $3)',
[rows[0].id, `/uploads/${f.filename}`, f.originalname]
);
}
await client.query('COMMIT');
res.status(201).json(rows[0]);
res.status(201).json({ ...rows[0], files: projectFiles.length });
} catch (e) {
await client.query('ROLLBACK');
throw e;
await client.query('ROLLBACK').catch(() => {});
removeUpload(photo);
projectFiles.forEach(removeUpload);
console.error('POST /api/entries:', e);
res.status(500).json({ error: e.message });
} finally {
client.release();
}
@@ -549,19 +598,32 @@ app.put('/api/entries/:id', requireAdmin, async (req, res) => {
app.delete('/api/entries/:id', requireAdmin, async (req, res) => {
const { rows } = await pool.query(
'DELETE FROM entries WHERE id = $1 RETURNING photo_path',
'SELECT photo_path FROM entries WHERE id = $1',
[req.params.id]
);
if (rows[0]?.photo_path) {
const fp = path.join(__dirname, rows[0].photo_path);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
const fRes = await pool.query(
'SELECT path FROM project_files WHERE entry_id = $1',
[req.params.id]
);
for (const r of fRes.rows) {
const fp = path.join(__dirname, r.path);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
await pool.query('DELETE FROM project_files WHERE entry_id = $1', [req.params.id]);
await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]);
res.json({ ok: true });
});
const PORT = process.env.PORT || 3000;
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); });
process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); });
const certPath = path.join(__dirname, 'certs', 'cert.pem');
const keyPath = path.join(__dirname, 'certs', 'key.pem');