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
+31
View File
@@ -250,6 +250,37 @@ async function main() {
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
{ status: plain.status, ai_status: plain.data.ai_status });
const topicName = 'Циклы for и while';
const withTopic = await api('/api/lesson-reports', {
token, method: 'POST',
body: { group_id: gid, lesson_date: '2019-05-18', lesson_time: '10:00', topic: topicName, text: 'Тема занятия для смоук-теста.', ai_check: false },
});
ok('lesson-report: тема занятия сохраняется при создании',
withTopic.status === 201 && withTopic.data.topic === topicName, { status: withTopic.status, topic: withTopic.data.topic });
const listed = await api(`/api/lesson-reports?group_id=${gid}&date_from=2019-05-18&date_to=2019-05-18`, { token });
ok('lesson-report: тема занятия в списке',
listed.status === 200 && (listed.data.items || []).some(i => i.topic === topicName),
{ status: listed.status, item: (listed.data.items || [])[0] && (listed.data.items || [])[0].topic });
const edited = await api(`/api/lesson-reports/${withTopic.data.id}`, {
token, method: 'PUT', body: { topic: 'Обновлённая тема' },
});
ok('lesson-report: тема занятия обновляется при редактировании',
edited.status === 200 && edited.data.topic === 'Обновлённая тема', { status: edited.status, topic: edited.data.topic });
const cleared = await api(`/api/lesson-reports/${withTopic.data.id}`, {
token, method: 'PUT', body: { topic: '' },
});
ok('lesson-report: тему занятия можно очистить',
cleared.status === 200 && !cleared.data.topic, { status: cleared.status, topic: cleared.data.topic });
const tooLong = await api(`/api/lesson-reports/${withTopic.data.id}`, {
token, method: 'PUT', body: { topic: 'я'.repeat(301) },
});
ok('lesson-report: слишком длинная тема -> 400', tooLong.status === 400, { status: tooLong.status, error: tooLong.data && tooLong.data.error });
await api(`/api/lesson-reports/${withTopic.data.id}`, { token, method: 'DELETE' });
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
ok('lesson-report: история версий содержит исходный текст',
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
+3
View File
@@ -1,5 +1,6 @@
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
const LESSON_REPORT_TEXT_MAX = 5000;
const LESSON_REPORT_TOPIC_MAX = 300;
const BACKUP_FORMAT_VERSION = 2;
const BACKUP_MIN_FORMAT_VERSION = 1;
const BACKUP_TABLES = [
@@ -390,6 +391,7 @@ function normalizeRestoreData(data) {
group_id: reqInt(x.group_id),
lesson_date: reqDate(x.lesson_date),
lesson_time: optTime(x.lesson_time),
topic: optStr(x.topic, LESSON_REPORT_TOPIC_MAX),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX),
text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX),
@@ -466,6 +468,7 @@ function normalizeRestoreData(data) {
module.exports = {
PHOTO_JOB_ACTIONS,
LESSON_REPORT_TEXT_MAX,
LESSON_REPORT_TOPIC_MAX,
SAFE_NAME,
isSafeUploadPath,
photoRefKey,
+1
View File
@@ -230,6 +230,7 @@ CREATE TABLE IF NOT EXISTS lesson_reports (
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,
+1
View File
@@ -326,6 +326,7 @@ ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS topic TEXT;
CREATE TABLE IF NOT EXISTS lesson_report_versions (
id SERIAL PRIMARY KEY,
+1
View File
@@ -689,6 +689,7 @@ body[data-page=settings] .page-head{align-items:flex-end}
.lesson-when i{width:13px;height:13px}
.lesson-badge{font-size:.7rem;font-weight:700;padding:2px 9px;border-radius:999px;background:rgba(37,99,235,.1);color:var(--accent)}
.lesson-badge.today{background:rgba(16,185,129,.14);color:#059669}
.lesson-topic{font-size:.85rem;font-weight:600;color:var(--accent)}
.lesson-text{font-size:.88rem;color:var(--text);white-space:pre-wrap;word-break:break-word;line-height:1.55}
.lesson-author{font-size:.75rem;color:var(--muted)}
.lesson-actions{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
+11 -1
View File
@@ -534,6 +534,11 @@ function ensureLessonModal() {
<input type="time" id="lessonTime" class="settings-input">
</div>
</div>
<div class="settings-field">
<label>Тема занятия</label>
<input type="text" id="lessonTopic" class="settings-input" maxlength="300"
placeholder="Например: циклы for и while, задачи на списки">
</div>
<div class="settings-field">
<label>Что прошли на занятии <span style="color:#ef4444">*</span></label>
<textarea id="lessonText" class="settings-input" rows="6" maxlength="5000" required
@@ -591,6 +596,7 @@ async function submitLessonModal(e) {
const dateEl = modal.querySelector('#lessonDate');
const timeEl = modal.querySelector('#lessonTime');
const textEl = modal.querySelector('#lessonText');
const topicEl = modal.querySelector('#lessonTopic');
const submitBtn = modal.querySelector('#lessonSubmit');
const aiCheckEl = modal.querySelector('#lessonAiCheck');
const groupId = groupSel.value;
@@ -606,7 +612,7 @@ async function submitLessonModal(e) {
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
method: id ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text, ai_check: aiCheck }),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, topic: topicEl.value.trim(), text, ai_check: aiCheck }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -675,6 +681,9 @@ function fillLessonModalFromReport(modal, r) {
textEl.value = r.text || '';
textEl.dispatchEvent(new Event('input'));
}
if (!modal.querySelector('#lessonTopic').value) {
modal.querySelector('#lessonTopic').value = r.topic || '';
}
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = false;
updateLessonAiRow(modal, r);
@@ -713,6 +722,7 @@ function resetLessonModalFields(modal) {
showLessonDuplicate(modal, null);
modal.querySelector('#lessonDate').value = lessonTodayIso();
modal.querySelector('#lessonTime').value = lessonNowTime();
modal.querySelector('#lessonTopic').value = '';
const textEl = modal.querySelector('#lessonText');
textEl.value = '';
textEl.dispatchEvent(new Event('input'));
+1
View File
@@ -52,6 +52,7 @@ function lessonItemHtml(r) {
${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''}
</div>
</div>
${r.topic ? `<div class="lesson-topic">${esc(r.topic)}</div>` : ''}
<div class="lesson-text">${esc(r.text || '')}</div>
${author ? `<div class="lesson-author">${esc(author)}</div>` : ''}
</div>`;
+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);
+3 -1
View File
@@ -809,7 +809,7 @@ function createLessonReportChecker({ pool, getSetting, logAudit, aiUrl, model, d
try {
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT lr.id, lr.text, lr.text_original, lr.lesson_date, lr.lesson_time, lr.author_id,
`SELECT lr.id, lr.text, lr.text_original, lr.lesson_date, lr.lesson_time, lr.topic, lr.author_id,
g.name AS group_name, g.branch_id
FROM lesson_reports lr
JOIN groups g ON g.id = lr.group_id
@@ -928,10 +928,12 @@ async function finish(row, status, text, error, aiText) {
const date = String(row.lesson_date).slice(0, 10);
const time = row.lesson_time ? String(row.lesson_time).slice(0, 5) : '';
const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original;
const topic = String(row.topic || '').trim();
const context = [
`Группа: ${row.group_name || '—'}`,
`Дата занятия: ${date}`,
time ? `Время занятия: ${time}` : '',
topic ? `Тема занятия: ${topic}` : '',
'',
'Текст отчёта:',
input,