feat(lessons): тема занятия в отчёте о занятии

В модалку #lessonModal добавлено текстовое поле «Тема занятия» (#lessonTopic)
между датой/временем и текстом отчёта. Поле необязательное, лимит 300 символов.

Протянуто по всему срезу:
- колонка lesson_reports.topic в db/init.sql, db/migration.sql и
  ensureLessonReportsTable() — миграция идемпотентная, ALTER IF NOT EXISTS
- POST /api/lesson-reports принимает topic, PUT обновляет; в GET-списке
  колонка добавлена в явный SELECT (там не SELECT *, без правки не пришла бы)
- тема в normalizeRestoreData, чтобы бэкап/restore её не теряли
- тема уходит в ИИ-контекст воркера строкой «Тема занятия: ...»
- вывод в списке отчётов (lessons.js + .lesson-topic в admin.css)

В PUT пустая строка очищает тему, а отсутствие поля в теле запроса её не
трогает: CASE WHEN $6::boolean THEN $3::text ELSE topic END, а не COALESCE —
при COALESCE пустая тема затиралась бы старым значением и поле нельзя было бы
очистить.

Версии не трогаем: lesson_report_versions хранит только text.

Тесты: +5 проверок (создание, список, редактирование, очистка, >300 символов),
api.smoketest.js — 76 PASS / 0 FAIL.
This commit is contained in:
dev
2026-10-04 11:56:55 +03:00
parent 001ff8a7b7
commit c994edbed7
9 changed files with 79 additions and 18 deletions
+27 -16
View File
@@ -14,6 +14,7 @@ const { buildEntryDiff, textDiff, normalizeEditSource, stripDiffs } = require('.
const {
PHOTO_JOB_ACTIONS,
LESSON_REPORT_TEXT_MAX,
LESSON_REPORT_TOPIC_MAX,
SAFE_NAME,
isSafeUploadPath,
photoRefKey,
@@ -1307,6 +1308,7 @@ async function ensureLessonReportsTable() {
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
lesson_date DATE NOT NULL,
lesson_time TIME,
topic TEXT,
text TEXT NOT NULL,
text_original TEXT,
text_ai TEXT,
@@ -1327,6 +1329,7 @@ async function ensureLessonReportsTable() {
await pool.query("ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'");
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ');
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT');
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS topic TEXT');
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)');
await pool.query(`CREATE TABLE IF NOT EXISTS lesson_report_versions (
@@ -2474,8 +2477,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
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, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)',
[x.id, x.group_id, x.lesson_date, x.lesson_time, x.text, x.text_original, x.text_ai, x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.author_id, x.branch_id, x.created_at, x.updated_at]
'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)',
[x.id, x.group_id, x.lesson_date, x.lesson_time, x.topic, x.text, x.text_original, x.text_ai, x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.author_id, x.branch_id, x.created_at, x.updated_at]
);
}
for (const x of ndata.lesson_report_versions) {
@@ -3624,7 +3627,7 @@ app.get('/api/lesson-reports', requireAuth, async (req, res) => {
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,
let q = `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.topic, lr.text, lr.author_id,
lr.text_original, lr.text_ai, lr.ai_status, lr.ai_checked_at, lr.ai_error,
lr.created_at, lr.updated_at, g.name AS group_name,
u.name AS author_name, u.username AS author_username
@@ -3647,13 +3650,15 @@ app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => {
});
app.post('/api/lesson-reports', requireAuth, async (req, res) => {
const { group_id, lesson_date, lesson_time, text, ai_check } = req.body || {};
const { group_id, lesson_date, lesson_time, topic, text, ai_check } = 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 topicText = typeof topic === 'string' ? topic.trim() : '';
if (topicText.length > LESSON_REPORT_TOPIC_MAX) return res.status(400).json({ error: `Тема занятия длиннее ${LESSON_REPORT_TOPIC_MAX} символов` });
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} символов` });
@@ -3666,10 +3671,10 @@ app.post('/api/lesson-reports', requireAuth, async (req, res) => {
}
const aiWanted = ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false';
const { rows } = await pool.query(
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id)
VALUES ($1, $2::date, $3, $4, $5, NULL, $6::text, CASE WHEN $6::text = 'none' THEN NULL ELSE now() END, NULL, $7, $8) RETURNING *`,
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id)
VALUES ($1, $2::date, $3, $4, $5, $6, NULL, $7::text, CASE WHEN $7::text = 'none' THEN NULL ELSE now() END, NULL, $8, $9) RETURNING *`,
[
grp.group.id, date, time, body,
grp.group.id, date, time, topicText || null, body,
aiWanted ? body : null,
aiWanted ? 'pending' : 'none',
req.user.id || null,
@@ -3695,7 +3700,7 @@ app.post('/api/lesson-reports', requireAuth, async (req, res) => {
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, ai_check } = req.body || {};
const { lesson_date, lesson_time, topic, text, ai_check } = req.body || {};
const curDate = String(report.lesson_date).slice(0, 10);
let date = curDate;
if (lesson_date !== undefined && lesson_date !== null && lesson_date !== '') {
@@ -3710,6 +3715,11 @@ app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
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} символов` });
let topicText;
if (topic !== undefined) {
topicText = typeof topic === 'string' ? topic.trim() : '';
if (topicText.length > LESSON_REPORT_TOPIC_MAX) return res.status(400).json({ error: `Тема занятия длиннее ${LESSON_REPORT_TOPIC_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',
@@ -3723,15 +3733,16 @@ app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
`UPDATE lesson_reports SET
lesson_date = $1::date,
lesson_time = $2,
text = COALESCE($3, text),
text_original = CASE WHEN $4::boolean THEN $3::text ELSE text_original END,
text_ai = CASE WHEN $4::boolean THEN NULL ELSE text_ai END,
ai_status = CASE WHEN $4::boolean THEN 'pending'::varchar ELSE ai_status END,
ai_checked_at = CASE WHEN $4::boolean THEN now() ELSE ai_checked_at END,
ai_error = CASE WHEN $4::boolean THEN NULL ELSE ai_error END,
topic = CASE WHEN $6::boolean THEN $3::text ELSE topic END,
text = COALESCE($4, text),
text_original = CASE WHEN $5::boolean THEN $4::text ELSE text_original END,
text_ai = CASE WHEN $5::boolean THEN NULL ELSE text_ai END,
ai_status = CASE WHEN $5::boolean THEN 'pending'::varchar ELSE ai_status END,
ai_checked_at = CASE WHEN $5::boolean THEN now() ELSE ai_checked_at END,
ai_error = CASE WHEN $5::boolean THEN NULL ELSE ai_error END,
updated_at = now()
WHERE id = $5 RETURNING *`,
[date, nextTime, body, aiWanted, report.id]
WHERE id = $7 RETURNING *`,
[date, nextTime, topicText === undefined ? null : (topicText || null), body, aiWanted, topic !== undefined, report.id]
);
const updated = rows[0];
if (body) await saveLessonReportVersion(report.id, body, 'manual', req.user.id);