feat(lessons): отчёты о занятиях — таблица, CRUD, страница и блок на дашборде
Добавлена сущность «что прошли на занятии»: - lesson_reports (init.sql + migration.sql + ensureLessonReportsTable) - GET/POST /api/lesson-reports, PUT/DELETE /api/lesson-reports/:id с branchScope, уникальностью (group_id, lesson_date) и лимитом текста - уведомление lesson.report (NOTIFY_TYPES + настройка + иконка) - кэш-префикс lessons: + инвалидация stats:/dashboard: - восстановление lesson_reports в normalizeRestoreData - блок recent_lessons в /api/dashboard Фронтенд: - public/lessons.html + public/js/lessons.js — список с фильтрами и правкой - openLessonModal в admin.js — общая модалка из журнала и дашборда - кнопки в журнале и быстрые действия дашборда Также исправлен сдвиг индексов параметров в notificationsScope — $1 уходил повторно в список филиалов из-за params.push без смещения.
This commit is contained in:
@@ -95,6 +95,7 @@ function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload:
|
||||
function invalidateStudents() { cacheDrop('students:'); }
|
||||
function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||||
function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||||
function invalidateLessonReports() { cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:'); }
|
||||
function invalidateSessions() { cacheDrop('session:'); }
|
||||
|
||||
const EVENTS_CHANNEL = 'whatido:events';
|
||||
@@ -192,6 +193,7 @@ const NOTIFY_TYPES = {
|
||||
'backup.restore': { label: 'Восстановление из бэкапа', hint: 'Данные системы заменены содержимым архива', icon: 'database', level: 'critical', enabled: true, admin: true },
|
||||
'backup.create': { label: 'Создан архив бэкапа', hint: 'Архив данных скачан из админ-панели', icon: 'download', level: 'info', enabled: false, admin: true },
|
||||
'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true },
|
||||
'lesson.report': { label: 'Отчёт о занятии', hint: 'Тьютор сохранил отчёт о том, что прошли на занятии', icon: 'notebook-pen', level: 'info', enabled: true, admin: false },
|
||||
};
|
||||
|
||||
function notifySettingKey(type) {
|
||||
@@ -381,7 +383,7 @@ function notificationsScope(user) {
|
||||
const params = [];
|
||||
let cond = 'n.admin_only = false';
|
||||
if (s.ids.length) {
|
||||
cond += ` AND (n.branch_id IS NULL OR n.branch_id IN (${s.ids.map(id => '$' + params.push(id)).join(',')}))`;
|
||||
cond += ` AND (n.branch_id IS NULL OR n.branch_id IN (${s.ids.map(id => '$' + (params.push(id) + 1)).join(',')}))`;
|
||||
} else {
|
||||
cond += ' AND n.branch_id IS NULL';
|
||||
}
|
||||
@@ -1227,6 +1229,26 @@ async function ensureStudentPhotosTable() {
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id)');
|
||||
}
|
||||
|
||||
async function ensureLessonReportsTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
lesson_date DATE NOT NULL,
|
||||
lesson_time TIME,
|
||||
text TEXT NOT NULL,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL');
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL');
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS lesson_time TIME');
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()');
|
||||
await pool.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date)');
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC)');
|
||||
}
|
||||
|
||||
async function ensurePhotoOriginalColumn() {
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
|
||||
}
|
||||
@@ -2030,6 +2052,12 @@ function optDate(v) {
|
||||
return v;
|
||||
}
|
||||
|
||||
function reqDate(v) {
|
||||
const s = optDate(v);
|
||||
if (!s) throw new Error('Invalid date');
|
||||
return s;
|
||||
}
|
||||
|
||||
function optBool(v) {
|
||||
if (v === null || v === undefined) return null;
|
||||
return !!v;
|
||||
@@ -2305,6 +2333,17 @@ function normalizeRestoreData(data) {
|
||||
show_group_photos: optBool(x.show_group_photos),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const lesson_reports = (data.lesson_reports || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
group_id: reqInt(x.group_id),
|
||||
lesson_date: reqDate(x.lesson_date),
|
||||
lesson_time: optTime(x.lesson_time),
|
||||
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
|
||||
author_id: optInt(x.author_id, 0, 2147483647),
|
||||
branch_id: optInt(x.branch_id, 0, 2147483647),
|
||||
created_at: optTs(x.created_at),
|
||||
updated_at: optTs(x.updated_at),
|
||||
}));
|
||||
const settings = {};
|
||||
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
||||
@@ -2324,7 +2363,7 @@ function normalizeRestoreData(data) {
|
||||
created_at: optTs(x.created_at),
|
||||
finished_at: optTs(x.finished_at),
|
||||
}));
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs };
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports };
|
||||
}
|
||||
|
||||
const BACKUP_TTL_MS = 30 * 60 * 1000;
|
||||
@@ -2358,7 +2397,7 @@ function sweepBackupStorage() {
|
||||
async function buildBackupArchive() {
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||
try {
|
||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj] = await Promise.all([
|
||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr] = 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'),
|
||||
@@ -2373,10 +2412,11 @@ async function buildBackupArchive() {
|
||||
pool.query('SELECT * FROM student_photos ORDER BY id'),
|
||||
pool.query('SELECT * FROM share_links ORDER BY id'),
|
||||
pool.query('SELECT * FROM photo_jobs ORDER BY id'),
|
||||
pool.query('SELECT * FROM lesson_reports 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, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.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, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows };
|
||||
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||||
await storage.downloadAll(path.join(staging, 'uploads'));
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
||||
@@ -2533,6 +2573,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM project_files');
|
||||
await client.query('DELETE FROM lesson_reports');
|
||||
await client.query('DELETE FROM entries');
|
||||
await client.query('DELETE FROM modules');
|
||||
await client.query('DELETE FROM students');
|
||||
@@ -2590,6 +2631,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
[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.photo_original_path, x.deleted_at, x.purge_at || null, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.lesson_reports) {
|
||||
const ex = await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id]);
|
||||
if (!ex.rowCount) continue;
|
||||
await client.query(
|
||||
'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, text, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9)',
|
||||
[x.id, x.group_id, x.lesson_date, x.lesson_time, x.text, x.author_id, x.branch_id, x.created_at, x.updated_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.photo_jobs) {
|
||||
const ex = await client.query('SELECT 1 FROM entries WHERE id = $1', [x.entry_id]);
|
||||
if (!ex.rowCount) continue;
|
||||
@@ -2630,7 +2679,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', 'student_photos', 'share_links', 'photo_jobs']) {
|
||||
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports']) {
|
||||
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]);
|
||||
}
|
||||
@@ -3129,12 +3178,14 @@ async function hardDeleteGroup(groupId) {
|
||||
);
|
||||
const files = eres.rows.concat(gres.rows);
|
||||
await client.query('DELETE FROM group_photos WHERE group_id = $1', [groupId]);
|
||||
await client.query('DELETE FROM lesson_reports WHERE group_id = $1', [groupId]);
|
||||
await client.query('DELETE FROM entries WHERE group_id = $1', [groupId]);
|
||||
await client.query('UPDATE students SET group_id = NULL WHERE group_id = $1', [groupId]);
|
||||
await client.query('DELETE FROM share_links WHERE group_id = $1', [groupId]);
|
||||
const d = await client.query('DELETE FROM groups WHERE id = $1', [groupId]);
|
||||
await client.query('COMMIT');
|
||||
files.forEach(r => safeUnlink(r.p));
|
||||
invalidateLessonReports();
|
||||
return { entries: eres.rowCount, photos: gres.rowCount };
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -3574,6 +3625,182 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Lesson reports (отчёт о занятии) ---
|
||||
const LESSON_REPORT_TEXT_MAX = 5000;
|
||||
const LESSON_REPORT_LIST_TTL_MS = 30 * 1000;
|
||||
|
||||
async function lessonReportGroup(user, groupId) {
|
||||
const gid = Number(groupId);
|
||||
if (!Number.isInteger(gid) || gid < 1) return { error: 'Группа не выбрана', code: 400 };
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, name, branch_id FROM groups WHERE id = $1 AND deleted_at IS NULL',
|
||||
[gid]
|
||||
);
|
||||
if (!rows.length) return { error: 'Группа не найдена', code: 404 };
|
||||
if (user.role !== 'admin' && !(await groupBelongsToBranches(user, gid))) {
|
||||
return { error: 'Нет доступа к этой группе', code: 403 };
|
||||
}
|
||||
return { group: rows[0] };
|
||||
}
|
||||
|
||||
async function lessonReportById(user, id) {
|
||||
const rid = Number(id);
|
||||
if (!Number.isInteger(rid) || rid < 1) return { error: 'Не найдено', code: 404 };
|
||||
const { rows } = await pool.query(
|
||||
`SELECT lr.*, g.name AS group_name, u.name AS author_name, u.username AS author_username
|
||||
FROM lesson_reports lr
|
||||
JOIN groups g ON g.id = lr.group_id
|
||||
LEFT JOIN users u ON u.id = lr.author_id
|
||||
WHERE lr.id = $1`,
|
||||
[rid]
|
||||
);
|
||||
if (!rows.length) return { error: 'Не найдено', code: 404 };
|
||||
if (user.role !== 'admin' && !(await groupBelongsToBranches(user, rows[0].group_id))) {
|
||||
return { error: 'Нет доступа к этому отчёту', code: 403 };
|
||||
}
|
||||
return { report: rows[0] };
|
||||
}
|
||||
|
||||
function parseLessonReportDate(v) {
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
||||
const d = new Date(s + 'T00:00:00Z');
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseLessonReportTime(v) {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(s)) return undefined;
|
||||
return s.length === 5 ? s + ':00' : s;
|
||||
}
|
||||
|
||||
app.get('/api/lesson-reports', requireAuth, async (req, res) => {
|
||||
const { date_from, date_to, group_id, search, limit, offset } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (group_id) { params.push(group_id); conditions.push(`lr.group_id = $${params.length}`); }
|
||||
if (date_from) { params.push(date_from); conditions.push(`lr.lesson_date >= $${params.length}::date`); }
|
||||
if (date_to) { params.push(date_to); conditions.push(`lr.lesson_date <= $${params.length}::date`); }
|
||||
if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`lr.text ILIKE $${params.length}`); }
|
||||
const s = branchScope(req.user);
|
||||
if (!s.admin) {
|
||||
if (!s.ids.length) conditions.push('1 = 0');
|
||||
else conditions.push(`g.branch_id IN (${s.ids.map(id => `$${params.push(id)}`).join(',')})`);
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||
const from = `FROM lesson_reports lr JOIN groups g ON g.id = lr.group_id LEFT JOIN users u ON u.id = lr.author_id${where}`;
|
||||
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n ${from}`, params);
|
||||
const total = crows[0].n;
|
||||
const key = ['lessons:list:', scopeKey(req.user), date_from || '', date_to || '', group_id || '', search || '', limit || '', offset || ''].join(':');
|
||||
const rows = await cacheWrap(key, LESSON_REPORT_LIST_TTL_MS, async () => {
|
||||
const qparams = params.slice();
|
||||
let q = `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.text, lr.author_id,
|
||||
lr.created_at, lr.updated_at, g.name AS group_name,
|
||||
u.name AS author_name, u.username AS author_username
|
||||
${from}
|
||||
ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST, lr.id DESC`;
|
||||
const lim = parseInt(limit, 10);
|
||||
if (lim > 0) q += ` LIMIT $${qparams.push(lim)}`;
|
||||
const off = parseInt(offset, 10);
|
||||
if (off > 0) q += ` OFFSET $${qparams.push(off)}`;
|
||||
const r = await pool.query(q, qparams);
|
||||
return r.rows;
|
||||
});
|
||||
res.json({ items: rows, total });
|
||||
});
|
||||
|
||||
app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
res.json(report);
|
||||
});
|
||||
|
||||
app.post('/api/lesson-reports', requireAuth, async (req, res) => {
|
||||
const { group_id, lesson_date, lesson_time, text } = req.body || {};
|
||||
const grp = await lessonReportGroup(req.user, group_id);
|
||||
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
|
||||
const date = parseLessonReportDate(lesson_date);
|
||||
if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' });
|
||||
const time = parseLessonReportTime(lesson_time);
|
||||
if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' });
|
||||
const body = typeof text === 'string' ? text.trim() : '';
|
||||
if (!body) return res.status(400).json({ error: 'Введите текст отчёта' });
|
||||
if (body.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` });
|
||||
const { rows: existing } = await pool.query(
|
||||
'SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2',
|
||||
[grp.group.id, date]
|
||||
);
|
||||
if (existing.length) {
|
||||
return res.status(409).json({ error: 'За эту группу и дату отчёт уже есть — откройте его для редактирования', id: existing[0].id });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, author_id, branch_id)
|
||||
VALUES ($1, $2::date, $3, $4, $5, $6) RETURNING *`,
|
||||
[grp.group.id, date, time, body, req.user.id || null, grp.group.branch_id || null]
|
||||
);
|
||||
await logAudit(req, 'lesson_report.create', { id: rows[0].id, group_id: grp.group.id, lesson_date: date });
|
||||
invalidateLessonReports();
|
||||
await pushNotification({
|
||||
type: 'lesson.report',
|
||||
title: `Отчёт о занятии: ${grp.group.name}`,
|
||||
body: body.slice(0, 300),
|
||||
link: 'lessons.html',
|
||||
target: { lesson_report_id: rows[0].id, group_id: grp.group.id, lesson_date: date },
|
||||
branchId: grp.group.branch_id,
|
||||
});
|
||||
res.status(201).json(rows[0]);
|
||||
});
|
||||
|
||||
app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
const { lesson_date, lesson_time, text } = req.body || {};
|
||||
const curDate = String(report.lesson_date).slice(0, 10);
|
||||
let date = curDate;
|
||||
if (lesson_date !== undefined && lesson_date !== null && lesson_date !== '') {
|
||||
date = parseLessonReportDate(lesson_date);
|
||||
if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' });
|
||||
}
|
||||
let time;
|
||||
if (lesson_time !== undefined) {
|
||||
time = parseLessonReportTime(lesson_time);
|
||||
if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' });
|
||||
}
|
||||
const body = text === undefined ? null : (typeof text === 'string' ? text.trim() : '');
|
||||
if (text !== undefined && !body) return res.status(400).json({ error: 'Введите текст отчёта' });
|
||||
if (body && body.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` });
|
||||
if (date !== curDate) {
|
||||
const { rows: clash } = await pool.query(
|
||||
'SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2 AND id <> $3',
|
||||
[report.group_id, date, report.id]
|
||||
);
|
||||
if (clash.length) return res.status(409).json({ error: 'За эту группу и дату уже есть другой отчёт' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE lesson_reports SET
|
||||
lesson_date = $1::date,
|
||||
lesson_time = $2,
|
||||
text = COALESCE($3, text),
|
||||
updated_at = now()
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[date, time === undefined ? report.lesson_time : time, body, report.id]
|
||||
);
|
||||
await logAudit(req, 'lesson_report.update', { id: report.id, lesson_date: date });
|
||||
invalidateLessonReports();
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
app.delete('/api/lesson-reports/:id', requireAdmin, async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
await pool.query('DELETE FROM lesson_reports WHERE id = $1', [report.id]);
|
||||
await logAudit(req, 'lesson_report.delete', { id: report.id, group_id: report.group_id, lesson_date: report.lesson_date });
|
||||
invalidateLessonReports();
|
||||
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 () => {
|
||||
@@ -4993,7 +5220,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||
const whereGroupParams = isAdmin ? [] : branchIds;
|
||||
const whereEntry = isAdmin ? '' : (branchIds.length ? ` AND g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' AND 1 = 0');
|
||||
|
||||
const [stats, activity, active, recent, top, photos, latestPhotos] = await Promise.all([
|
||||
const [stats, activity, active, recent, top, photos, latestPhotos, lessons] = await Promise.all([
|
||||
(async () => {
|
||||
const ew = !!whereEntry;
|
||||
const entriesP = `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`;
|
||||
@@ -5044,6 +5271,12 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
|
||||
whereGroup ? whereGroupParams : []
|
||||
),
|
||||
pool.query(
|
||||
`SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.text, g.name AS group_name
|
||||
FROM lesson_reports lr JOIN groups g ON g.id = lr.group_id${whereGroup}
|
||||
ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST, lr.id DESC LIMIT 5`,
|
||||
whereGroup ? whereGroupParams : []
|
||||
),
|
||||
]);
|
||||
const activeGroups = active.rows.map(g => ({
|
||||
id: g.id,
|
||||
@@ -5059,6 +5292,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||
recent_entries: recent.rows,
|
||||
top_students: top.rows,
|
||||
photos: photos.rows,
|
||||
recent_lessons: lessons.rows,
|
||||
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
|
||||
disk: getDiskInfo(),
|
||||
};
|
||||
@@ -6454,6 +6688,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
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); }
|
||||
try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports table:', err); }
|
||||
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
|
||||
try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
|
||||
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();
|
||||
|
||||
Reference in New Issue
Block a user