feat(modules): module topics for student form, admin CRUD with pagination
- db: modules table (name, lessons_count) + entries.module_id (ON DELETE SET NULL), migration + idempotent startup ensure - api: GET /api/modules (public, search + limit/offset, entries_count), POST/PUT/DELETE (admin, audit-logged) - entries: accept/validate module_id on create/update, return module_name, module_id filter - backup/restore: include modules and entries.module_id - student form: required module select, hidden while no modules exist - admin: modules.html + js/modules.js list with pagination, search, create/edit/delete modal - journal: module filter, module select in edit modal, module badge, CSV column
This commit is contained in:
@@ -665,6 +665,17 @@ async function ensureBannedIpsTable() {
|
||||
)`);
|
||||
}
|
||||
|
||||
async function ensureModulesTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS modules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
lessons_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query('ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL');
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id)');
|
||||
}
|
||||
|
||||
async function ensureEntryPhotosTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -1292,6 +1303,7 @@ function normalizeRestoreData(data) {
|
||||
id: reqInt(x.id),
|
||||
student_name: reqStr(x.student_name, 150),
|
||||
group_id: reqInt(x.group_id),
|
||||
module_id: optInt(x.module_id, 0, 2147483647),
|
||||
description: reqStr(x.description, 100000),
|
||||
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
|
||||
description_ai: optAiText(x.description_ai, 100000),
|
||||
@@ -1330,6 +1342,12 @@ function normalizeRestoreData(data) {
|
||||
user_id: reqInt(x.user_id),
|
||||
branch_id: reqInt(x.branch_id),
|
||||
}));
|
||||
const modules = (data.modules || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
name: reqStr(x.name, 200),
|
||||
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const entry_photos = (data.entry_photos || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
entry_id: reqInt(x.entry_id),
|
||||
@@ -1369,13 +1387,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 };
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_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] = await Promise.all([
|
||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md] = 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'),
|
||||
@@ -1386,10 +1404,11 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
|
||||
pool.query('SELECT * FROM user_branches ORDER BY user_id, branch_id'),
|
||||
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'),
|
||||
]);
|
||||
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 };
|
||||
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 };
|
||||
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||||
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
||||
const dir = path.join(__dirname, 'uploads');
|
||||
@@ -1483,6 +1502,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM project_files');
|
||||
await client.query('DELETE FROM entries');
|
||||
await client.query('DELETE FROM modules');
|
||||
await client.query('DELETE FROM students');
|
||||
await client.query('DELETE FROM groups');
|
||||
await client.query('DELETE FROM user_branches');
|
||||
@@ -1507,10 +1527,16 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
[x.id, x.name, x.created_at, x.group_id]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.modules) {
|
||||
await client.query(
|
||||
'INSERT INTO modules (id, name, lessons_count, created_at) VALUES ($1,$2,$3,$4)',
|
||||
[x.id, x.name, x.lessons_count, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.entries) {
|
||||
await client.query(
|
||||
'INSERT INTO entries (id, student_name, group_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
|
||||
[x.id, x.student_name, x.group_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
|
||||
'INSERT INTO entries (id, student_name, group_id, module_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)',
|
||||
[x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.project_files) {
|
||||
@@ -1549,7 +1575,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']) {
|
||||
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules']) {
|
||||
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]);
|
||||
}
|
||||
@@ -2263,6 +2289,81 @@ app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) =
|
||||
res.json(gRows[0]);
|
||||
});
|
||||
|
||||
// --- Modules (темы модулей) ---
|
||||
app.get('/api/modules', apiLimiter, async (req, res) => {
|
||||
const { limit, offset, search } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); }
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params);
|
||||
const total = crows[0].n;
|
||||
let q = `SELECT m.*, count(e.id)::int AS entries_count
|
||||
FROM modules m
|
||||
LEFT JOIN entries e ON e.module_id = m.id${where}
|
||||
GROUP BY m.id ORDER BY m.id`;
|
||||
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);
|
||||
res.json({ modules: rows, total });
|
||||
});
|
||||
|
||||
function parseLessonsCount(v) {
|
||||
if (v === undefined || v === null || v === '') return 0;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error('Количество занятий — целое число от 0 до 10000');
|
||||
return n;
|
||||
}
|
||||
|
||||
app.post('/api/modules', requireAdmin, async (req, res) => {
|
||||
const { name, lessons_count } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
let lessons;
|
||||
try { lessons = parseLessonsCount(lessons_count); }
|
||||
catch (e) { return res.status(400).json({ error: e.message }); }
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'INSERT INTO modules (name, lessons_count) VALUES ($1, $2) RETURNING *',
|
||||
[name.trim(), lessons]
|
||||
);
|
||||
await logAudit(req, 'module.create', { id: rows[0].id, name: name.trim(), lessons_count: lessons });
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/modules/:id', requireAdmin, async (req, res) => {
|
||||
const { name, lessons_count } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
let lessons;
|
||||
try { lessons = parseLessonsCount(lessons_count); }
|
||||
catch (e) { return res.status(400).json({ error: e.message }); }
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'UPDATE modules SET name = $1, lessons_count = $2 WHERE id = $3 RETURNING *',
|
||||
[name.trim(), lessons, req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||
await logAudit(req, 'module.update', { id: req.params.id, name: name.trim(), lessons_count: lessons });
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/modules/:id', requireAdmin, async (req, res) => {
|
||||
const { rows } = await pool.query('DELETE FROM modules WHERE id = $1 RETURNING id', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||
await logAudit(req, 'module.delete', { id: req.params.id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Students CRUD ---
|
||||
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
|
||||
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
|
||||
@@ -3009,12 +3110,13 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||
|
||||
// --- Entries ---
|
||||
app.get('/api/entries', requireAuth, async (req, res) => {
|
||||
const { group_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
|
||||
const { group_id, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (deleted === '1') conditions.push('e.deleted_at IS NOT NULL');
|
||||
else conditions.push('e.deleted_at IS NULL');
|
||||
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||
if (module_id) { params.push(module_id); conditions.push(`e.module_id = $${params.length}`); }
|
||||
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||||
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||||
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||
@@ -3037,8 +3139,9 @@ app.get('/api/entries', requireAuth, async (req, res) => {
|
||||
params
|
||||
);
|
||||
const total = crows[0].n;
|
||||
let q = `SELECT e.*, g.name AS group_name FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`;
|
||||
let q = `SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id
|
||||
LEFT JOIN modules m ON m.id = e.module_id${where} ORDER BY e.created_at DESC`;
|
||||
const qparams = params.slice();
|
||||
const lim = parseInt(limit, 10);
|
||||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||||
@@ -3082,7 +3185,10 @@ app.get('/api/entries/:id', requireAuth, async (req, res) => {
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
'SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1',
|
||||
`SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id
|
||||
LEFT JOIN modules m ON m.id = e.module_id
|
||||
WHERE e.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -3543,7 +3649,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||
});
|
||||
}, async (req, res) => {
|
||||
const { student_name, group_id, description, website } = req.body;
|
||||
const { student_name, group_id, description, module_id, website } = req.body;
|
||||
const photos = req.files?.photo || [];
|
||||
const projectFiles = req.files?.files || [];
|
||||
if (website) {
|
||||
@@ -3579,6 +3685,20 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'Группа не найдена' });
|
||||
}
|
||||
const mid = module_id === undefined || module_id === null || module_id === '' ? null : Number.parseInt(module_id, 10);
|
||||
if (mid !== null && !Number.isInteger(mid)) {
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'Модуль не найден' });
|
||||
}
|
||||
if (mid !== null) {
|
||||
const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
|
||||
if (!modCheck.rows.length) {
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'Модуль не найден' });
|
||||
}
|
||||
}
|
||||
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
|
||||
if (intervalMin > 0) {
|
||||
const dup = await pool.query(
|
||||
@@ -3603,9 +3723,9 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
);
|
||||
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO entries (student_name, group_id, description, description_original, photo_path)
|
||||
VALUES ($1, $2, $3, $3, $4) RETURNING *`,
|
||||
[student_name.trim(), gid, description.trim(), mainPhotoPath]
|
||||
`INSERT INTO entries (student_name, group_id, module_id, description, description_original, photo_path)
|
||||
VALUES ($1, $2, $3, $4, $4, $5) RETURNING *`,
|
||||
[student_name.trim(), gid, mid, description.trim(), mainPhotoPath]
|
||||
);
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const p = photos[i];
|
||||
@@ -3644,12 +3764,27 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req,
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { student_name, group_id, description } = req.body;
|
||||
const { student_name, group_id, description, module_id } = req.body;
|
||||
const newPhotos = req.files || [];
|
||||
for (const p of newPhotos) {
|
||||
await convertPhoto(p);
|
||||
}
|
||||
|
||||
const hasModule = Object.prototype.hasOwnProperty.call(req.body, 'module_id');
|
||||
let mid = null;
|
||||
if (hasModule && module_id !== undefined && module_id !== null && module_id !== '') {
|
||||
mid = Number.parseInt(module_id, 10);
|
||||
if (!Number.isInteger(mid)) {
|
||||
newPhotos.forEach(p => safeUnlink(p.path));
|
||||
return res.status(400).json({ error: 'Модуль не найден' });
|
||||
}
|
||||
const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
|
||||
if (!modCheck.rows.length) {
|
||||
newPhotos.forEach(p => safeUnlink(p.path));
|
||||
return res.status(400).json({ error: 'Модуль не найден' });
|
||||
}
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE entries SET
|
||||
student_name = COALESCE($1, student_name),
|
||||
@@ -3659,13 +3794,16 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req,
|
||||
description_ai = CASE WHEN $3 IS NULL THEN description_ai ELSE NULL END,
|
||||
ai_status = CASE WHEN $3 IS NULL THEN ai_status ELSE 'pending' END,
|
||||
ai_error = CASE WHEN $3 IS NULL THEN ai_error ELSE NULL END,
|
||||
ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END
|
||||
ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END,
|
||||
module_id = CASE WHEN $5 THEN $6 ELSE module_id END
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[
|
||||
student_name ? student_name.trim() : null,
|
||||
group_id || null,
|
||||
description ? description.trim() : null,
|
||||
req.params.id,
|
||||
hasModule,
|
||||
mid,
|
||||
]
|
||||
);
|
||||
if (!rows.length) {
|
||||
@@ -4674,6 +4812,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
try { await ensureBannedIpsTable(); } catch (err) { console.error('Banned IPs table:', err); }
|
||||
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
|
||||
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 ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||
|
||||
Reference in New Issue
Block a user