feat(shorts): короткие сообщения резидентов с ИИ-коррекцией
Таблица short_messages (резидент, дата занятия, тема, текст, отметка об использовании), CRUD API /api/shorts с мягким удалением и плановой очисткой через purge_at, ИИ-коррекция текста по профилю, страница shorts.html с массовым добавлением разбором строк «ФИО - текст».
This commit is contained in:
@@ -2516,6 +2516,246 @@ app.get('/api/chat/files/:token', fileLimiter, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Short messages (admin only) ---
|
||||
const SHORTS_PAGE_SIZE = 50;
|
||||
|
||||
async function ensureShortMessagesTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS short_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resident VARCHAR(200) NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
topic VARCHAR(500),
|
||||
message TEXT NOT NULL,
|
||||
used BOOLEAN NOT NULL DEFAULT false,
|
||||
used_at DATE,
|
||||
author_id INT REFERENCES users(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 short_messages ADD COLUMN IF NOT EXISTS used_at DATE`);
|
||||
await pool.query(`ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at)`);
|
||||
}
|
||||
|
||||
function invalidateShorts() {
|
||||
cache.dropPrefix('shorts:');
|
||||
cache.publish(EVENTS_CHANNEL, { type: 'shorts_changed' }).catch(() => {});
|
||||
}
|
||||
|
||||
async function parseShortMessagesFromLines(text) {
|
||||
const lines = String(text || '').split('\n');
|
||||
const messages = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||||
if (match) {
|
||||
messages.push({ resident: match[1].trim(), message: match[2].trim() });
|
||||
} else {
|
||||
messages.push({ resident: '', message: trimmed });
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
app.get('/api/shorts/:id', 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 { rows } = await pool.query(
|
||||
`SELECT sm.*, u.name AS author_name, u.username AS author_username FROM short_messages sm
|
||||
LEFT JOIN users u ON u.id = sm.author_id WHERE sm.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/shorts', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || SHORTS_PAGE_SIZE, 1), 200);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
const showDeleted = req.query.deleted === '1' || req.query.deleted === 'true';
|
||||
const search = req.query.search ? String(req.query.search).trim() : '';
|
||||
const params = [];
|
||||
let where = showDeleted ? 'WHERE sm.deleted_at IS NOT NULL' : 'WHERE sm.deleted_at IS NULL';
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
where += ` AND (sm.resident ILIKE $${params.length} OR sm.topic ILIKE $${params.length} OR sm.message ILIKE $${params.length})`;
|
||||
}
|
||||
params.push(limit, offset);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT sm.id, sm.resident, sm.lesson_date, sm.topic, sm.message, sm.used, sm.used_at, sm.author_id,
|
||||
u.name AS author_name, u.username AS author_username, sm.deleted_at, sm.created_at
|
||||
FROM short_messages sm LEFT JOIN users u ON u.id = sm.author_id
|
||||
${where} ORDER BY sm.lesson_date DESC, sm.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
);
|
||||
let total = 0;
|
||||
if (offset === 0) {
|
||||
const { rows: cnt } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM short_messages sm ${where}`,
|
||||
params.slice(0, -2)
|
||||
);
|
||||
total = cnt[0]?.n || 0;
|
||||
}
|
||||
res.json({ items: rows, total });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shorts', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const lessonDate = req.body?.lesson_date ? String(req.body.lesson_date).trim() : null;
|
||||
const topic = req.body?.topic ? String(req.body.topic).trim() : null;
|
||||
const linesText = req.body?.lines ? String(req.body.lines) : '';
|
||||
if (!lessonDate || !/^\d{4}-\d{2}-\d{2}$/.test(lessonDate)) {
|
||||
return res.status(400).json({ error: 'lesson_date обязателен в формате YYYY-MM-DD' });
|
||||
}
|
||||
const messages = await parseShortMessagesFromLines(linesText);
|
||||
if (!messages.length) return res.status(400).json({ error: 'Нет сообщений для добавления' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const created = [];
|
||||
for (const m of messages) {
|
||||
const resident = m.resident || req.body?.resident ? String(m.resident || req.body.resident).trim() : 'Не указан';
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO short_messages (resident, lesson_date, topic, message, author_id)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[resident, lessonDate, topic, m.message, req.user.id || null]
|
||||
);
|
||||
created.push(rows[0]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
await logAudit(req, 'shorts.create', { count: created.length, lesson_date: lessonDate, topic });
|
||||
invalidateShorts();
|
||||
res.status(201).json({ items: created });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/shorts/:id', 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 current = await pool.query('SELECT * FROM short_messages WHERE id = $1', [id]);
|
||||
if (!current.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
const target = current.rows[0];
|
||||
const resident = req.body?.resident !== undefined ? String(req.body.resident).trim() : target.resident;
|
||||
const lessonDate = req.body?.lesson_date !== undefined ? String(req.body.lesson_date).trim() : target.lesson_date;
|
||||
const topic = req.body?.topic !== undefined ? String(req.body.topic).trim() : target.topic;
|
||||
const message = req.body?.message !== undefined ? String(req.body.message).trim() : target.message;
|
||||
let used = req.body?.used !== undefined ? Boolean(req.body.used) : Boolean(target.used);
|
||||
let usedAt = req.body?.used_at !== undefined
|
||||
? (req.body.used_at ? String(req.body.used_at).slice(0, 10) : null)
|
||||
: (target.used_at ? String(target.used_at).slice(0, 10) : null);
|
||||
if (usedAt && !/^\d{4}-\d{2}-\d{2}$/.test(usedAt)) {
|
||||
return res.status(400).json({ error: 'used_at должен быть в формате YYYY-MM-DD' });
|
||||
}
|
||||
if (used && !usedAt) usedAt = new Date().toISOString().slice(0, 10);
|
||||
if (usedAt) used = true;
|
||||
if (!used) usedAt = null;
|
||||
if (lessonDate && !/^\d{4}-\d{2}-\d{2}$/.test(lessonDate)) {
|
||||
return res.status(400).json({ error: 'lesson_date должен быть в формате YYYY-MM-DD' });
|
||||
}
|
||||
if (!resident) return res.status(400).json({ error: 'Резидент обязателен' });
|
||||
if (!message) return res.status(400).json({ error: 'Сообщение обязательно' });
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE short_messages SET resident = $1, lesson_date = $2, topic = $3, message = $4, used = $5, used_at = $6, updated_at = now() WHERE id = $7 RETURNING *`,
|
||||
[resident, lessonDate, topic, message, used, usedAt, id]
|
||||
);
|
||||
await logAudit(req, 'shorts.update', { id, resident, lesson_date: lessonDate, topic, used, used_at: usedAt });
|
||||
invalidateShorts();
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/shorts/:id', 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 { rowCount } = await pool.query(
|
||||
`UPDATE short_messages SET deleted_at = now(), purge_at = now() + (COALESCE((SELECT value FROM settings WHERE key='short_messages_retention_days'), '365') || ' days')::interval WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Not found or already deleted' });
|
||||
await logAudit(req, 'shorts.delete', { id });
|
||||
invalidateShorts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/shorts/:id/restore', requireAuth, requireAdmin, 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 { rowCount } = await pool.query(
|
||||
'UPDATE short_messages SET deleted_at = NULL, purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL',
|
||||
[id]
|
||||
);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Not found or not deleted' });
|
||||
await logAudit(req, 'shorts.restore', { id });
|
||||
invalidateShorts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/shorts/:id/permanent', requireAuth, requireAdmin, 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 { rowCount } = await pool.query(
|
||||
'DELETE FROM short_messages WHERE id = $1 AND deleted_at IS NOT NULL',
|
||||
[id]
|
||||
);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Not found or not deleted' });
|
||||
await logAudit(req, 'shorts.permanent-delete', { id });
|
||||
invalidateShorts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shorts/ai-correct', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const text = reqStr(req.body?.text, 5000);
|
||||
if (!text) return res.status(400).json({ error: 'Текст не указан' });
|
||||
const profileId = req.body?.profile ? String(req.body.profile) : (req.body?.model ? String(req.body.model) : null);
|
||||
let profile = null;
|
||||
if (profileId && profileId !== 'native') {
|
||||
const profiles = await getAiProfiles();
|
||||
profile = profiles.find(p => p.id === profileId) || null;
|
||||
if (!profile) return res.status(400).json({ error: 'Профиль ИИ не найден' });
|
||||
}
|
||||
const corrected = await aiCorrectText(text, profile);
|
||||
res.json({ suggestion: corrected });
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: 'Сервис ИИ недоступен: ' + e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Users (admin only) ---
|
||||
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
@@ -3089,8 +3329,8 @@ function normalizeOpenAiBase(base) {
|
||||
return b;
|
||||
}
|
||||
|
||||
async function aiComplete(systemPrompt, userText, maxTokensOverride) {
|
||||
const profile = await getActiveAiProfile();
|
||||
async function aiComplete(systemPrompt, userText, maxTokensOverride, profileOverride) {
|
||||
const profile = profileOverride || await getActiveAiProfile();
|
||||
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||||
let model = AI_MODEL;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
@@ -3129,10 +3369,10 @@ async function aiComplete(systemPrompt, userText, maxTokensOverride) {
|
||||
}
|
||||
}
|
||||
|
||||
async function aiCorrectText(text) {
|
||||
async function aiCorrectText(text, profileOverride) {
|
||||
try {
|
||||
const systemPrompt = await getAiPrompt();
|
||||
const out = await aiComplete(systemPrompt, text);
|
||||
const out = await aiComplete(systemPrompt, text, undefined, profileOverride);
|
||||
return out || text;
|
||||
} catch (e) {
|
||||
console.error('AI correct error:', e);
|
||||
@@ -8397,6 +8637,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports table:', err); }
|
||||
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 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); }
|
||||
|
||||
Reference in New Issue
Block a user