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:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user