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:
@@ -44,3 +44,11 @@ INSERT INTO groups (name) VALUES
|
|||||||
('Intermediate'),
|
('Intermediate'),
|
||||||
('Advanced')
|
('Advanced')
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_files (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
path VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.card .group{color:var(--accent);font-size:.8rem;margin-top:2px}
|
.card .group{color:var(--accent);font-size:.8rem;margin-top:2px}
|
||||||
.card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap}
|
.card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap}
|
||||||
.card .time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
.card .time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
||||||
|
.entry-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
||||||
|
.entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:4px 8px;font-size:.75rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.entry-files a:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.entry-files a::before{content:'📎'}
|
||||||
.card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0}
|
.card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0}
|
||||||
.card .del:hover{color:#ef4444}
|
.card .del:hover{color:#ef4444}
|
||||||
.card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0}
|
.card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0}
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
|
|||||||
.photo-btns button:hover{border-color:var(--accent)}
|
.photo-btns button:hover{border-color:var(--accent)}
|
||||||
.photo-btns .cam{background:var(--accent);color:#fff;border-color:var(--accent)}
|
.photo-btns .cam{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||||
.photo-btns .cam:hover{opacity:.85}
|
.photo-btns .cam:hover{opacity:.85}
|
||||||
|
.photo-btns .files-btn p{font-size:.8rem;font-weight:500;margin:0}
|
||||||
|
.files-btn{width:100%;border-radius:var(--radius);padding:12px 16px;font-size:.9rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .15s}
|
||||||
|
.files-btn:hover{border-color:var(--accent)}
|
||||||
|
.files-card{width:100%;border:1px dashed var(--border);border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--bg)}
|
||||||
|
.files-card .files-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||||
|
.files-card label{font-size:.8rem;font-weight:600;color:var(--text);text-transform:none;letter-spacing:0}
|
||||||
|
.files-card .files-clear{background:none;border:none;color:#ef4444;font-size:.8rem;cursor:pointer;padding:0;display:none}
|
||||||
|
.files-card .files-clear.show{display:block}
|
||||||
|
.files-list{display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
|
||||||
|
.files-list:empty{display:none}
|
||||||
|
.files-list .file-row{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:.8rem}
|
||||||
|
.files-list .file-row .f-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
||||||
|
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
|
||||||
|
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
|
||||||
|
.files-list .file-row button:hover{color:#ef4444}
|
||||||
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||||
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
|
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
|
||||||
.preview.has-photo{border-style:solid}
|
.preview.has-photo{border-style:solid}
|
||||||
@@ -82,6 +97,15 @@ nav a:hover{color:var(--text)}
|
|||||||
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="files-card">
|
||||||
|
<div class="files-head">
|
||||||
|
<label>Файлы проекта</label>
|
||||||
|
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="files-btn" onclick="document.getElementById('filesInput').click()">Выбрать файлы</button>
|
||||||
|
<input type="file" id="filesInput" multiple hidden>
|
||||||
|
<div class="files-list" id="filesList"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fields-col">
|
<div class="fields-col">
|
||||||
@@ -139,6 +163,58 @@ const camModal = document.getElementById('camModal');
|
|||||||
let capturedBlob = null;
|
let capturedBlob = null;
|
||||||
let stream = null;
|
let stream = null;
|
||||||
const descInput = document.getElementById('descInput');
|
const descInput = document.getElementById('descInput');
|
||||||
|
const filesInput = document.getElementById('filesInput');
|
||||||
|
const filesList = document.getElementById('filesList');
|
||||||
|
const filesClearBtn = document.getElementById('filesClearBtn');
|
||||||
|
let selectedFiles = [];
|
||||||
|
|
||||||
|
function humanSize(b) {
|
||||||
|
if (b < 1024) return b + ' Б';
|
||||||
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||||
|
return (b / 1048576).toFixed(1) + ' МБ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles() {
|
||||||
|
filesList.innerHTML = '';
|
||||||
|
selectedFiles.forEach((f, i) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'file-row';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'f-name';
|
||||||
|
name.textContent = f.name;
|
||||||
|
name.title = f.name;
|
||||||
|
const size = document.createElement('span');
|
||||||
|
size.className = 'f-size';
|
||||||
|
size.textContent = humanSize(f.size);
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.type = 'button';
|
||||||
|
del.textContent = '✕';
|
||||||
|
del.onclick = () => removeFile(i);
|
||||||
|
row.append(name, size, del);
|
||||||
|
filesList.appendChild(row);
|
||||||
|
});
|
||||||
|
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(i) {
|
||||||
|
selectedFiles.splice(i, 1);
|
||||||
|
renderFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
selectedFiles = [];
|
||||||
|
filesInput.value = '';
|
||||||
|
renderFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
filesInput.addEventListener('change', () => {
|
||||||
|
const keep = 10 - selectedFiles.length;
|
||||||
|
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
||||||
|
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
||||||
|
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
||||||
|
filesInput.value = '';
|
||||||
|
renderFiles();
|
||||||
|
});
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
let studentName = '';
|
let studentName = '';
|
||||||
@@ -234,6 +310,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
|
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
|
||||||
|
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||||
fd.append('group_id', groupInput.value);
|
fd.append('group_id', groupInput.value);
|
||||||
fd.append('description', document.getElementById('descInput').value);
|
fd.append('description', document.getElementById('descInput').value);
|
||||||
@@ -250,6 +327,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
form.reset();
|
form.reset();
|
||||||
removePhoto();
|
removePhoto();
|
||||||
|
clearFiles();
|
||||||
toast.textContent = 'Готово!';
|
toast.textContent = 'Готово!';
|
||||||
toast.classList.remove('error');
|
toast.classList.remove('error');
|
||||||
toast.classList.add('show');
|
toast.classList.add('show');
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ let totalEntries = 0;
|
|||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||||
|
|
||||||
|
function filesHTML(files) {
|
||||||
|
if (!files || !files.length) return '';
|
||||||
|
return `<div class="entry-files">${files.map(f =>
|
||||||
|
`<a href="${API}/api/files/${f.id}" download>${esc(f.name)}</a>`
|
||||||
|
).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function gridEntryHTML(e) {
|
function gridEntryHTML(e) {
|
||||||
return `
|
return `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -115,6 +122,7 @@ function gridEntryHTML(e) {
|
|||||||
<div class="name">${esc(e.student_name)}</div>
|
<div class="name">${esc(e.student_name)}</div>
|
||||||
<div class="group">${esc(e.group_name)}</div>
|
<div class="group">${esc(e.group_name)}</div>
|
||||||
<div class="desc">${esc(e.description)}</div>
|
<div class="desc">${esc(e.description)}</div>
|
||||||
|
${filesHTML(e.files)}
|
||||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
|
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
|
||||||
@@ -133,6 +141,7 @@ function cardEntryHTML(e) {
|
|||||||
<div class="name">${esc(e.student_name)}</div>
|
<div class="name">${esc(e.student_name)}</div>
|
||||||
<div class="group">${esc(e.group_name)}</div>
|
<div class="group">${esc(e.group_name)}</div>
|
||||||
<div class="desc">${esc(e.description)}</div>
|
<div class="desc">${esc(e.description)}</div>
|
||||||
|
${filesHTML(e.files)}
|
||||||
</div>
|
</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ const upload = multer({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
fileFilter: (_, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
if (file.fieldname === 'photo' && !file.mimetype.startsWith('image/')) cb(new Error('Only images'));
|
||||||
else cb(new Error('Only images'));
|
else cb(null, true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,15 +109,16 @@ const SAFE_NAME = /^[\w,.()-]+$/;
|
|||||||
app.get('/api/backup', requireAdmin, async (_, res) => {
|
app.get('/api/backup', requireAdmin, async (_, res) => {
|
||||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||||
try {
|
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 groups ORDER BY id'),
|
||||||
pool.query('SELECT * FROM students ORDER BY id'),
|
pool.query('SELECT * FROM students ORDER BY id'),
|
||||||
pool.query('SELECT * FROM entries ORDER BY id'),
|
pool.query('SELECT * FROM entries ORDER BY id'),
|
||||||
pool.query('SELECT key, value FROM settings'),
|
pool.query('SELECT key, value FROM settings'),
|
||||||
|
pool.query('SELECT * FROM project_files ORDER BY id'),
|
||||||
]);
|
]);
|
||||||
const settings = {};
|
const settings = {};
|
||||||
st.rows.forEach(r => { settings[r.key] = r.value; });
|
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.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||||||
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
||||||
const dir = path.join(__dirname, 'uploads');
|
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]
|
[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 || {})) {
|
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
|
'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
|
||||||
[k, String(v ?? '')]
|
[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);
|
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]);
|
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);
|
const off = parseInt(offset, 10);
|
||||||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||||||
const { rows } = await pool.query(q, qparams);
|
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 });
|
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) => {
|
app.get('/api/stats', requireAdmin, async (_, res) => {
|
||||||
const [entries, groups, students, today] = await Promise.all([
|
const [entries, groups, students, today] = await Promise.all([
|
||||||
pool.query('SELECT count(*)::int AS n FROM entries'),
|
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 { 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()) {
|
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' });
|
return res.status(400).json({ error: 'All fields required' });
|
||||||
}
|
}
|
||||||
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
|
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]
|
[student_name.trim(), intervalMin]
|
||||||
);
|
);
|
||||||
if (dup.rows[0].n > 0) {
|
if (dup.rows[0].n > 0) {
|
||||||
removeUpload(req.file);
|
removeUpload(photo);
|
||||||
|
projectFiles.forEach(removeUpload);
|
||||||
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
@@ -518,11 +558,20 @@ app.post('/api/entries', upload.single('photo'), async (req, res) => {
|
|||||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
[student_name.trim(), group_id, description.trim(), photo_path]
|
[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');
|
await client.query('COMMIT');
|
||||||
res.status(201).json(rows[0]);
|
res.status(201).json({ ...rows[0], files: projectFiles.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
throw e;
|
removeUpload(photo);
|
||||||
|
projectFiles.forEach(removeUpload);
|
||||||
|
console.error('POST /api/entries:', e);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
@@ -549,19 +598,32 @@ app.put('/api/entries/:id', requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
app.delete('/api/entries/:id', requireAdmin, async (req, res) => {
|
app.delete('/api/entries/:id', requireAdmin, async (req, res) => {
|
||||||
const { rows } = await pool.query(
|
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]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
if (rows[0]?.photo_path) {
|
if (rows[0]?.photo_path) {
|
||||||
const fp = path.join(__dirname, rows[0].photo_path);
|
const fp = path.join(__dirname, rows[0].photo_path);
|
||||||
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
|
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 certPath = path.join(__dirname, 'certs', 'cert.pem');
|
||||||
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user