Add feedback system functionality

- Implement feedback management system in admin interface
- Add feedback processing and AI-based features
- Update user interface with new feedback components
- Modify server-side API endpoints for feedback handling
- Enhance worker.js for background feedback processing
- Update deployment configuration for feedback components

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
dev
2026-10-06 13:10:14 +03:00
co-authored by openhands
parent a3169648f3
commit 98f24faaaf
13 changed files with 1717 additions and 31 deletions
+457 -2
View File
@@ -6,7 +6,7 @@ const { ipKeyGenerator } = require('express-rate-limit');
const helmet = require('helmet');
const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert');
const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker } = require('./worker');
const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, createFeedbackWriter } = require('./worker');
const { createZipWriter, renderStudentReport } = require('./student-report');
const { createStorage, mimeFor } = require('./storage');
@@ -122,6 +122,10 @@ function invalidateLessonReports() {
cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:');
broadcastLessonChanged();
}
function invalidateFeedbacks() {
cacheDrop('feedbacks:');
broadcastFeedbackChanged();
}
function invalidateSessions() { cacheDrop('session:'); }
function invalidateApiKeys() { cacheDrop('apikey:'); cacheDrop('rl:apikey'); }
@@ -129,6 +133,7 @@ const EVENTS_CHANNEL = 'whatido:events';
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo';
const LESSON_AI_WAKE_CHANNEL = 'whatido:wake:lesson-ai';
const FEEDBACK_WAKE_CHANNEL = 'whatido:wake:feedback';
function createWorkerBus(channel) {
return {
@@ -155,6 +160,7 @@ function dispatchEvent(payload) {
writeFrame('lesson_report_status', {
id: payload.id,
ai_status: payload.status,
status: payload.status,
ai_error: payload.error || null,
text: payload.text === undefined ? null : payload.text,
text_ai: payload.text_ai === undefined ? null : payload.text_ai,
@@ -162,6 +168,10 @@ function dispatchEvent(payload) {
});
return;
}
if (payload && payload.type === 'feedback_status') {
writeFrame('feedback_status', payload);
return;
}
if (payload && (payload.type === 'entries_changed' || payload.type === 'lessons_changed' || payload.type === 'shorts_changed')) {
writeFrame(payload.type, { ts: Date.now() });
return;
@@ -191,6 +201,11 @@ function broadcastLessonChanged() {
.catch(err => console.error('Publish failed:', err.message));
}
function broadcastFeedbackChanged() {
cache.publish(EVENTS_CHANNEL, { type: 'feedbacks_changed', ts: Date.now() })
.catch(err => console.error('Publish failed:', err.message));
}
function broadcastAiStatus(payload) {
cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload })
.catch(err => console.error('Publish failed:', err.message));
@@ -243,6 +258,7 @@ const NOTIFY_TYPES = {
'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 },
'lesson.ai.formatted': { label: 'Отчёт оформлен по шаблону', hint: 'ИИ привёл текст отчёта о занятии к деловому виду', icon: 'file-check', level: 'info', enabled: false, admin: false },
'feedback.ai.ready': { label: 'Отзыв сгенерирован', hint: 'ИИ сформировал отзыв по результатам занятия', icon: 'message-square-heart', level: 'info', enabled: true, admin: false },
};
function notifySettingKey(type) {
@@ -2644,6 +2660,53 @@ async function ensureShortMessagesTable() {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at)`);
}
async function ensureFeedbacksTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS feedbacks (
id SERIAL PRIMARY KEY,
feedback_date DATE NOT NULL,
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
resident VARCHAR(200) NOT NULL,
topic VARCHAR(500),
past_review TEXT,
short_message_ids INT[] DEFAULT '{}'::int[],
text TEXT NOT NULL DEFAULT '',
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
ai_profile VARCHAR(100),
author_id INT REFERENCES users(id) ON DELETE SET NULL,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
purge_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS feedback_date DATE`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS group_id INT`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS resident VARCHAR(200)`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS topic VARCHAR(500)`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS past_review TEXT`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS short_message_ids INT[] DEFAULT '{}'::int[]`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS text TEXT NOT NULL DEFAULT ''`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100)`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT now()`);
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id)`);
await pool.query(`INSERT INTO settings (key,value) VALUES ('feedback_ai_enabled','true') ON CONFLICT (key) DO NOTHING`);
await pool.query(`INSERT INTO settings (key,value) VALUES ('feedback_ai_prompt','${FEEDBACK_AI_DEFAULT_PROMPT.replace(/'/g,"''")}') ON CONFLICT (key) DO NOTHING`);
await pool.query(`INSERT INTO settings (key,value) VALUES ('notify_feedback_ai_ready','false') ON CONFLICT (key) DO NOTHING`);
}
function invalidateShorts() {
cache.dropPrefix('shorts:');
cache.publish(EVENTS_CHANNEL, { type: 'shorts_changed' }).catch(() => {});
@@ -2842,6 +2905,144 @@ app.delete('/api/shorts/:id/permanent', requireAuth, requireAdmin, async (req, r
}
});
// Feedbacks
async function feedbackById(user, id) {
const { rows } = await pool.query(
`SELECT f.*, g.name AS group_name, g.branch_id AS group_branch_id
FROM feedbacks f
LEFT JOIN groups g ON g.id = f.group_id
WHERE f.id = $1`,
[id]
);
if (!rows.length) return null;
const row = rows[0];
if (user.role !== 'admin') {
const bid = row.branch_id ?? row.group_branch_id ?? null;
if (bid != null) {
const ub = user.branch_ids || [];
if (!ub.includes(bid)) return { forbidden: true };
} else {
return { forbidden: true };
}
}
row.branch_id = row.branch_id ?? row.group_branch_id ?? null;
return row;
}
function parseFeedbackDate(s) {
const str = typeof s === 'string' ? s.trim() : '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
return str;
}
app.get('/api/feedbacks', requireAuth, async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const dateFrom = parseFeedbackDate(req.query.date_from);
const dateTo = parseFeedbackDate(req.query.date_to);
const groupId = parseInt(req.query.group_id, 10);
const status = typeof req.query.status === 'string' ? req.query.status.trim() : '';
const deleted = req.query.deleted === '1';
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
const resident = typeof req.query.resident === 'string' ? req.query.resident.trim() : '';
const key = ['feedbacks:list:', scopeKey(req.user), dateFrom || '', dateTo || '', groupId || '', status || '', deleted ? '1' : '', search || '', resident || '', limit || '', offset || ''].join(':');
const payload = await cacheWrap(key, FEEDBACK_LIST_TTL_MS, async () => {
const params = [];
const cond = [];
if (deleted) cond.push('f.deleted_at IS NOT NULL');
else {
cond.push('f.deleted_at IS NULL');
if (status === 'sent') cond.push('f.sent_at IS NOT NULL');
else cond.push('f.sent_at IS NULL');
}
if (dateFrom) { params.push(dateFrom); cond.push(`f.feedback_date >= $${params.length}`); }
if (dateTo) { params.push(dateTo); cond.push(`f.feedback_date <= $${params.length}`); }
if (Number.isInteger(groupId) && groupId > 0) { params.push(groupId); cond.push(`f.group_id = $${params.length}`); }
if (resident) { params.push(`%${resident}%`); cond.push(`f.resident ILIKE $${params.length}`); }
if (status && status !== 'sent') { params.push(status); cond.push(`f.ai_status = $${params.length}`); }
if (search) {
params.push(`%${search}%`);
cond.push(`(f.resident ILIKE $${params.length} OR f.topic ILIKE $${params.length} OR f.text ILIKE $${params.length})`);
}
const bs = branchScope(req.user);
if (bs.ids) { params.push(bs.ids); cond.push(`f.branch_id = ANY($${params.length}::int[])`); }
const where = cond.length ? `WHERE ${cond.join(' AND ')}` : '';
const countParams = params.slice();
params.push(limit, offset);
const count = await pool.query(`SELECT COUNT(*) FROM feedbacks f ${where}`, countParams);
const items = await pool.query(
`SELECT f.id, f.feedback_date, f.group_id, f.resident, f.topic, f.past_review, f.short_message_ids, f.text, f.ai_status, f.ai_checked_at, f.ai_error, f.ai_profile, f.sent_at, f.author_id, f.branch_id, f.deleted_at, f.purge_at, f.created_at, f.updated_at, g.name AS group_name
FROM feedbacks f
LEFT JOIN groups g ON g.id = f.group_id
${where}
ORDER BY f.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
);
return { items: items.rows, total: parseInt(count.rows[0].count, 10) };
});
res.json(payload);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal error' });
}
});
app.get('/api/feedbacks/:id', requireAuth, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
const fb = await feedbackById(req.user, id);
if (!fb) return res.status(404).json({ error: 'Not found' });
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
res.json(fb);
});
app.post('/api/feedbacks', requireAuth, async (req, res) => {
const feedbackDate = parseFeedbackDate(req.body?.feedback_date);
if (!feedbackDate) return res.status(400).json({ error: 'Некорректная дата' });
const resident = reqStr(req.body?.resident, FEEDBACK_RESIDENT_MAX);
const topic = optStr(req.body?.topic, FEEDBACK_TOPIC_MAX);
const text = optStr(req.body?.text, FEEDBACK_TEXT_MAX);
const groupId = parseInt(req.body?.group_id, 10);
if (!Number.isInteger(groupId) || groupId < 1) return res.status(400).json({ error: 'Некорректная группа' });
const pastReview = optStr(req.body?.past_review, 2000);
let shortIds = Array.isArray(req.body?.short_message_ids) ? req.body.short_message_ids.map(Number).filter(v => Number.isInteger(v) && v > 0) : [];
shortIds = [...new Set(shortIds)];
const aiCheck = !!req.body?.ai_check;
const aiProfile = optStr(req.body?.ai_profile, 100);
if (req.user.role !== 'admin') {
const ok = await groupBelongsToBranches(req.user, groupId);
if (!ok) return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
const g = await pool.query('SELECT branch_id FROM groups WHERE id = $1', [groupId]);
if (!g.rows.length) return res.status(400).json({ error: 'Группа не найдена' });
const branchId = g.rows[0].branch_id;
const client = await pool.connect();
try {
await client.query('BEGIN');
const insert = await client.query(
`INSERT INTO feedbacks (feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_profile, author_id, branch_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', aiCheck ? 'pending' : 'none', aiProfile || null, req.user.id, branchId]
);
const fb = insert.rows[0];
if (shortIds.length) {
await client.query(`UPDATE short_messages SET used = true, used_at = CURRENT_DATE WHERE id = ANY($1::int[]) AND deleted_at IS NULL`, [shortIds]);
}
await client.query('COMMIT');
await logAudit(req, 'feedback.create', { id: fb.id, group_id: groupId, resident, ai_check: aiCheck, ai_status: fb.ai_status, short_count: shortIds.length });
invalidateFeedbacks();
invalidateShorts();
if (aiCheck) wakeFeedbackWorker();
res.status(201).json(fb);
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
});
app.post('/api/shorts/ai-correct', requireAuth, aiLimiter, async (req, res) => {
try {
const text = reqStr(req.body?.text, 5000);
@@ -2992,6 +3193,122 @@ app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
}
});
app.put('/api/feedbacks/:id', requireAuth, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
const fb = await feedbackById(req.user, id);
if (!fb) return res.status(404).json({ error: 'Not found' });
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
const feedbackDate = parseFeedbackDate(req.body?.feedback_date);
if (!feedbackDate) return res.status(400).json({ error: 'Некорректная дата' });
const resident = reqStr(req.body?.resident, FEEDBACK_RESIDENT_MAX);
const topic = optStr(req.body?.topic, FEEDBACK_TOPIC_MAX);
const text = optStr(req.body?.text, FEEDBACK_TEXT_MAX);
const groupId = parseInt(req.body?.group_id, 10);
if (!Number.isInteger(groupId) || groupId < 1) return res.status(400).json({ error: 'Некорректная группа' });
const pastReview = optStr(req.body?.past_review, 2000);
let shortIds = Array.isArray(req.body?.short_message_ids) ? req.body.short_message_ids.map(Number).filter(v => Number.isInteger(v) && v > 0) : [];
shortIds = [...new Set(shortIds)];
if (req.user.role !== 'admin') {
const ok = await groupBelongsToBranches(req.user, groupId);
if (!ok) return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
await pool.query(
`UPDATE feedbacks SET feedback_date=$1, group_id=$2, resident=$3, topic=$4, past_review=$5, short_message_ids=$6, text=$7, updated_at=now() WHERE id=$8`,
[feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', id]
);
invalidateFeedbacks();
res.json({ ok: true });
});
app.delete('/api/feedbacks/:id', requireAuth, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
const fb = await feedbackById(req.user, id);
if (!fb) return res.status(404).json({ error: 'Not found' });
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
const d = await pool.query('SELECT COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) AS d');
const days = d.rows[0]?.d || 30;
await pool.query('UPDATE feedbacks SET deleted_at=now(), purge_at = now() + ($1 || \' days\')::interval WHERE id=$2 AND deleted_at IS NULL', [days, id]);
invalidateFeedbacks();
res.json({ ok: true });
});
app.put('/api/feedbacks/:id/restore', requireAuth, requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
await pool.query('UPDATE feedbacks SET deleted_at=NULL, purge_at=NULL WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
invalidateFeedbacks();
res.json({ ok: true });
});
app.delete('/api/feedbacks/:id/permanent', requireAuth, requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
await pool.query('UPDATE feedbacks SET purge_at = now() + (COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) || \' days\')::interval WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
invalidateFeedbacks();
res.json({ ok: true });
});
app.put('/api/feedbacks/:id/sent', requireAuth, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
const fb = await feedbackById(req.user, id);
if (!fb) return res.status(404).json({ error: 'Not found' });
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
const sent = typeof req.body?.sent === 'boolean' ? req.body.sent : null;
if (sent === null) return res.status(400).json({ error: 'Некорректный статус' });
const shortIds = Array.isArray(fb.short_message_ids) ? fb.short_message_ids : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE feedbacks SET sent_at = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL', [sent ? new Date() : null, id]);
if (shortIds.length) {
if (sent) {
await client.query('UPDATE short_messages SET used = true, used_at = CURRENT_DATE WHERE id = ANY($1::int[]) AND deleted_at IS NULL', [shortIds]);
} else {
await client.query(`UPDATE short_messages sm SET used = false, used_at = NULL
WHERE sm.id = ANY($1::int[]) AND sm.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM feedbacks f WHERE f.deleted_at IS NULL AND f.id <> $2 AND sm.id = ANY(f.short_message_ids))`, [shortIds, id]);
}
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
await logAudit(req, 'feedback.sent', { id, sent, short_count: shortIds.length });
invalidateFeedbacks();
invalidateShorts();
res.json({ ok: true, sent });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal error' });
}
});
app.post('/api/feedbacks/:id/ai/retry', requireAuth, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
const fb = await feedbackById(req.user, id);
if (!fb) return res.status(404).json({ error: 'Not found' });
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, [id]);
wakeFeedbackWorker();
invalidateFeedbacks();
res.json({ ok: true });
});
app.post('/api/feedbacks/requeue-failed', requireAuth, requireAdmin, async (req, res) => {
await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL, updated_at=now() WHERE ai_status='error' AND deleted_at IS NULL`);
wakeFeedbackWorker();
invalidateFeedbacks();
res.json({ ok: true });
});
app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
if (req.params.id === String(req.user.id)) {
return res.status(400).json({ error: 'Нельзя удалить самого себя' });
@@ -3363,11 +3680,16 @@ const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор т
let entryAutoChecker = null;
let photoWorker = null;
let lessonAiChecker = null;
let feedbackWorker = null;
function wakeLessonAiWorker() {
if (lessonAiChecker) lessonAiChecker.notify();
}
function wakeFeedbackWorker() {
if (feedbackWorker) feedbackWorker.notify();
}
async function onLessonAiDone({ row, status, text, aiText, error }) {
invalidateLessonReports();
cache.publish(EVENTS_CHANNEL, {
@@ -3473,6 +3795,28 @@ async function aiComplete(systemPrompt, userText, maxTokensOverride, profileOver
}
}
async function onFeedbackDone({ row, status, text, aiText, error }) {
invalidateFeedbacks();
cache.publish(EVENTS_CHANNEL, {
type: 'feedback_status',
id: row.id,
ai_status: status,
status,
error: error || null,
text,
ts: Date.now()
}).catch(() => {});
if (status === 'done') {
try {
const lenBefore = row.text ? row.text.length : 0;
const lenAfter = (aiText || '').length;
logAudit(null, 'feedback.ai.generate', { id: row.id, len_before: lenBefore, len_after: lenAfter }).catch(() => {});
const branchId = row.branch_id ?? null;
pushNotification('feedback.ai.ready', { id: row.id, resident: row.resident }, branchId).catch(() => {});
} catch (e) {}
}
}
async function aiCorrectText(text, profileOverride) {
try {
const systemPrompt = await getAiPrompt();
@@ -3759,6 +4103,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
await client.query('DELETE FROM project_files');
await client.query('DELETE FROM lesson_report_versions');
await client.query('DELETE FROM lesson_reports');
await client.query('DELETE FROM feedbacks');
await client.query('DELETE FROM entries');
await client.query('DELETE FROM modules');
await client.query('DELETE FROM students');
@@ -3919,6 +4264,15 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.resident, x.lesson_date, x.topic, x.message, x.used, x.used_at, okUser ? x.author_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at]
);
}
for (const x of (ndata.feedbacks || [])) {
const okGroup = x.group_id == null || (await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id])).rowCount;
const okUser = x.author_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.author_id])).rowCount;
const okBranch = x.branch_id == null || (await client.query('SELECT 1 FROM branches WHERE id = $1', [x.branch_id])).rowCount;
await client.query(
'INSERT INTO feedbacks (id, feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_checked_at, ai_error, ai_profile, author_id, branch_id, deleted_at, purge_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)',
[x.id, x.feedback_date, okGroup ? x.group_id : null, x.resident, x.topic, x.past_review, Array.isArray(x.short_message_ids) ? x.short_message_ids : [], x.text || '', x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.ai_profile, okUser ? x.author_id : null, okBranch ? x.branch_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at]
);
}
for (const x of ndata.audit_log) {
const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount;
await client.query(
@@ -3940,7 +4294,9 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
}
for (const tbl of BACKUP_SEQUENCE_TABLES) {
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]);
try {
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
} catch (e) {}
}
await client.query('COMMIT');
} catch (e) {
@@ -4889,6 +5245,41 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
// --- Lesson reports (отчёт о занятии) ---
const LESSON_REPORT_LIST_TTL_MS = 30 * 1000;
const LESSON_AI_VERSION_LIMIT = 50;
const LESSON_AI_TIMEOUT_MS = 120000;
const FEEDBACK_AI_VERSION_LIMIT = 50;
const FEEDBACK_AI_TIMEOUT_MS = 120000;
const FEEDBACK_LIST_TTL_MS = 30 * 1000;
const FEEDBACK_TEXT_MAX = 5000;
const FEEDBACK_TOPIC_MAX = 500;
const FEEDBACK_RESIDENT_MAX = 200;
const FEEDBACK_AI_DEFAULT_PROMPT = [
'Ты — помощник по написанию коротких деловых отзывов тьютора детской IT-школы.',
'',
'ЗАДАЧА: Сформируй короткий, ёмкий, деловой отзыв по итогам занятия. По стилю он должен быть близок к «короткому сообщению для обратной связи»: 1–3 предложения, без воды, конкретно по делу.',
'',
'КОНТЕКСТ, КОТОРЫЙ ТЫ ПОЛУЧИШЬ:',
'- Группа: ...',
'- Дата занятия: ...',
'- Резидент: ...',
'- Тема занятия: ...',
'- Позиция темы: ... (если есть)',
'- Прошлый отзыв: ... (если есть)',
'- Короткие сообщения (выбраны): список в формате "дата – тема – сообщение"',
'',
'ПРАВИЛА:',
'- Пиши только отзыв. Без заголовков, списков, markdown, подписей и пояснений.',
'- Не упоминай группу, дату, время занятия в тексте отзыва.',
'- Не начинай со слов «Сегодня», «Вчера», «На занятии», «В ходе занятия», если они избыточны.',
'- Опирайся на факты из контекста: тема занятия, выбранные короткие сообщения и прошлый отзыв (если есть).',
'- Сохраняй деловой, спокойный тон. Без оценок типа «отлично», «замечательно», избегай шаблонных «молодец».',
'- Максимально кратко: 1–2 предложения. Если хватает одного — лучше одно.',
'- Не оборачивай ответ в кавычки.',
'- Не придумывай факты, которых нет в контексте.',
'',
'ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.'
].join('\n');
const LESSON_AI_DEFAULT_PROMPT = [
'Ты — редактор сообщений тьютора детской IT-школы.',
'',
@@ -5038,6 +5429,45 @@ app.get('/api/lesson-reports', requireAuth, async (req, res) => {
res.json({ items: rows, total });
});
app.get('/api/lesson-reports/topics', requireAuth, async (req, res) => {
const bw = branchWhere(req.user, 'g');
const { rows } = await pool.query(
`SELECT DISTINCT lr.topic FROM lesson_reports lr
JOIN groups g ON g.id = lr.group_id
WHERE lr.topic IS NOT NULL AND lr.topic <> ''${bw.where}
ORDER BY lr.topic`,
bw.params
);
res.json(rows.map(r => r.topic));
});
app.get('/api/lesson-reports/by-topic', requireAuth, async (req, res) => {
try {
const topic = typeof req.query.topic === 'string' ? req.query.topic.trim() : '';
if (!topic) return res.status(400).json({ error: 'Укажите тему' });
const cond = ['lr.topic = $1'];
const params = [topic];
const s = branchScope(req.user);
if (!s.admin) {
if (!s.ids.length) cond.push('1 = 0');
else { params.push(s.ids); cond.push(`g.branch_id = ANY($${params.length}::int[])`); }
}
const { rows } = await pool.query(
`SELECT lr.id, lr.group_id, lr.lesson_date, lr.topic, lr.text, g.name AS group_name
FROM lesson_reports lr
JOIN groups g ON g.id = lr.group_id
WHERE ${cond.join(' AND ')}
ORDER BY lr.lesson_date DESC, lr.id DESC
LIMIT 1`,
params
);
res.json({ report: rows.length ? rows[0] : null });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal error' });
}
});
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 });
@@ -5254,6 +5684,19 @@ app.get('/api/students/names', requireAuth, async (req, res) => {
res.json(rows.map(r => r.name));
});
app.get('/api/students/names-with-groups', requireAuth, async (req, res) => {
const bw = branchWhere(req.user, 'g');
const { rows } = await pool.query(
`SELECT DISTINCT ON (e.student_name) e.student_name AS name, e.group_id, g.name AS group_name
FROM entries e
JOIN groups g ON g.id = e.group_id
WHERE e.student_name IS NOT NULL AND e.student_name <> ''${bw.where}
ORDER BY e.student_name, e.created_at DESC NULLS LAST, e.id DESC`,
bw.params
);
res.json(rows);
});
app.post('/api/students', requireAuth, async (req, res) => {
const { name, group_id } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
@@ -8755,6 +9198,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); }
try { await ensureShortMessagesTable(); } catch (err) { console.error('Short messages table:', err); }
try { await ensureFeedbacksTable(); } catch (err) { console.error('Feedbacks 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();
try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); }
@@ -8805,4 +9249,15 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
});
lessonAiChecker.start();
console.log('Lesson report AI worker started');
feedbackWorker = createFeedbackWriter({
pool,
getSetting,
logAudit,
aiUrl: AI_URL,
defaultPrompt: FEEDBACK_AI_DEFAULT_PROMPT,
bus: createWorkerBus(FEEDBACK_WAKE_CHANNEL),
onDone: onFeedbackDone,
});
feedbackWorker.start();
console.log('Feedback AI worker started');
})();