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