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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user