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