Add AI auto-check worker, share link message/link fields, and update journal/settings UI

This commit is contained in:
dev
2026-09-11 10:29:49 +03:00
parent 3850fe35e4
commit b7e798b867
20 changed files with 1403 additions and 127 deletions
+382 -32
View File
@@ -5,6 +5,7 @@ const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert');
const { createEntryAutoChecker } = require('./worker');
const https = require('https');
const path = require('path');
@@ -346,7 +347,9 @@ async function removeEntryFiles(entryId) {
const { rows } = await pool.query(
`SELECT photo_path AS p FROM entries WHERE id = $1
UNION ALL
SELECT path AS p FROM project_files WHERE entry_id = $1`,
SELECT path AS p FROM project_files WHERE entry_id = $1
UNION ALL
SELECT photo_path AS p FROM entry_photos WHERE entry_id = $1`,
[entryId]
);
rows.forEach(r => safeUnlink(r.p));
@@ -355,13 +358,14 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() {
const dir = path.join(__dirname, 'uploads');
if (!fs.existsSync(dir)) return;
const [{ rows: photos }, { rows: files }, { rows: gphotos }] = await Promise.all([
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'),
pool.query('SELECT photo_path AS p FROM entry_photos'),
]);
const refs = new Set();
[...photos, ...files, ...gphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
[...photos, ...files, ...gphotos, ...ephotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (!fs.statSync(fp).isFile()) continue;
@@ -394,6 +398,34 @@ async function ensureBranchesTable() {
await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`);
}
async function ensureEntryPhotosTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
caption TEXT,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
}
async function ensureEntryAiColumns() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending'`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
const marker = await getSetting('ai_autocheck_migrated', '');
if (marker !== '1') {
await pool.query(`UPDATE entries SET description_original = description WHERE description_original IS NULL`);
await pool.query(`UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending'`);
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1') ON CONFLICT (key) DO UPDATE SET value = '1'`);
}
}
async function ensureUserTables() {
await pool.query(`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
@@ -683,16 +715,24 @@ const zlib = require('zlib');
const tar = require('tar');
const os = require('os');
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
const AI_DEFAULT_PROMPT = 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.';
let entryAutoChecker = null;
async function getAiPrompt() {
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT);
return prompt;
}
async function aiCorrectText(text) {
try {
const systemPrompt = await getAiPrompt();
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'qwen2.5-1.5b-instruct',
messages: [
{ role: 'system', content: 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.' },
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
],
temperature: 0.1,
@@ -783,6 +823,20 @@ function optUploadPath(v, max) {
return reqUploadPath(v, max);
}
const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
function optAiText(v, max) {
if (v === null || v === undefined) return null;
return reqStr(v, max);
}
function reqAiStatus(v, fallback) {
if (v === null || v === undefined) return fallback;
const s = String(v);
if (s === 'processing') return 'pending';
return AI_STATUSES.has(s) ? s : fallback;
}
function normalizeRestoreData(data) {
const groups = (data.groups || []).map(x => ({
id: reqInt(x.id),
@@ -804,6 +858,11 @@ function normalizeRestoreData(data) {
student_name: reqStr(x.student_name, 150),
group_id: reqInt(x.group_id),
description: reqStr(x.description, 100000),
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
description_ai: optAiText(x.description_ai, 100000),
ai_status: reqAiStatus(x.ai_status, 'skipped'),
ai_checked_at: optTs(x.ai_checked_at),
ai_error: optAiText(x.ai_error, 500),
photo_path: optUploadPath(x.photo_path, 255),
deleted_at: optTs(x.deleted_at),
created_at: optTs(x.created_at),
@@ -978,8 +1037,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
}
for (const x of ndata.entries) {
await client.query(
'INSERT INTO entries (id, student_name, group_id, description, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[x.id, x.student_name, x.group_id, x.description, x.photo_path, x.deleted_at, x.created_at]
'INSERT INTO entries (id, student_name, group_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
[x.id, x.student_name, x.group_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
);
}
for (const x of ndata.project_files) {
@@ -1050,6 +1109,23 @@ function normDates(o) {
if (o && o.date_to) o.date_to = o.date_to instanceof Date ? o.date_to.toISOString().slice(0, 10) : String(o.date_to).slice(0, 10);
return o;
}
function parseShareMessage(v) {
const s = typeof v === 'string' ? v.trim() : '';
if (s.length > 2000) throw new Error('Сообщение слишком длинное (макс. 2000 символов)');
return s || null;
}
function parseShareLinkUrl(v) {
const s = typeof v === 'string' ? v.trim() : '';
if (!s) return null;
if (s.length > 500) throw new Error('Ссылка слишком длинная (макс. 500 символов)');
let u;
try { u = new URL(s); } catch { throw new Error('Некорректная ссылка'); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Ссылка должна начинаться с http:// или https://');
return s;
}
app.get('/api/links', requireAuth, async (req, res) => {
const s = branchScope(req.user);
let where = '';
@@ -1074,6 +1150,13 @@ app.get('/api/links', requireAuth, async (req, res) => {
app.post('/api/links', requireAuth, async (req, res) => {
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
let message, linkUrl;
try {
message = parseShareMessage(req.body.message);
linkUrl = parseShareLinkUrl(req.body.link_url);
} catch (e) {
return res.status(400).json({ error: e.message });
}
if (req.user.role !== 'admin' && group_id && !(await groupBelongsToBranches(req.user, group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
@@ -1095,9 +1178,9 @@ app.post('/api/links', requireAuth, async (req, res) => {
expiresAt = defaultExp.toISOString();
}
const { rows } = await pool.query(
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash]
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password_hash, message, link_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash, message, linkUrl]
);
await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() });
res.status(201).json(normDates(rows[0]));
@@ -1106,6 +1189,13 @@ app.post('/api/links', requireAuth, async (req, res) => {
app.put('/api/links/:id', requireAuth, async (req, res) => {
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
let message, linkUrl;
try {
message = parseShareMessage(req.body.message);
linkUrl = parseShareLinkUrl(req.body.link_url);
} catch (e) {
return res.status(400).json({ error: e.message });
}
if (req.user.role !== 'admin') {
const { rows: lr } = await pool.query('SELECT group_id FROM share_links WHERE id = $1', [req.params.id]);
if (!lr.length) return res.status(404).json({ error: 'Не найдено' });
@@ -1137,9 +1227,9 @@ app.put('/api/links/:id', requireAuth, async (req, res) => {
expiresAt = null; // Clear expiry if null sent
}
}
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'anonymize_names = $6'];
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false];
let paramIdx = 7;
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'anonymize_names = $6', 'message = $7', 'link_url = $8'];
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, message, linkUrl];
let paramIdx = 9;
if (passwordHash !== undefined) {
fields.push(`access_password_hash = $${paramIdx++}`);
values.push(passwordHash);
@@ -1249,6 +1339,8 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
group_id: l.group_id,
date_from: l.date_from,
date_to: l.date_to,
message: l.message,
link_url: l.link_url,
created_at: l.created_at,
anonymize_names: l.anonymize_names,
entries,
@@ -1785,6 +1877,20 @@ app.get('/api/entries', requireAuth, async (req, res) => {
files = {};
}
rows.forEach(r => { r.files = files[r.id] || []; });
if (rows.length) {
const ids = rows.map(r => r.id);
const pRes = await pool.query(
'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = ANY($1) ORDER BY sort_order, id',
[ids]
);
const photos = {};
pRes.rows.forEach(p => { (photos[p.entry_id] = photos[p.entry_id] || []).push(p); });
rows.forEach(r => { r.photos = photos[r.id] || []; });
} else {
rows.forEach(r => { r.photos = []; });
}
res.json({ entries: rows, total });
});
@@ -2202,7 +2308,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
});
});
const entryFields = upload.fields([{ name: 'photo', maxCount: 1 }, { name: 'files', maxCount: 10 }]);
const entryFields = upload.fields([{ name: 'photo', maxCount: 10 }, { name: 'files', maxCount: 10 }]);
app.post('/api/entries', entryLimiter, (req, res, next) => {
entryFields(req, res, (err) => {
if (!err) return next();
@@ -2213,33 +2319,33 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
});
}, async (req, res) => {
const { student_name, group_id, description, website } = req.body;
const photo = req.files?.photo?.[0] || null;
const photos = req.files?.photo || [];
const projectFiles = req.files?.files || [];
if (website) {
removeUpload(photo);
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Spam detected' });
}
if (!student_name?.trim() || !group_id || !description?.trim()) {
removeUpload(photo);
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'All fields required' });
}
const totalBytes = (photo?.size || 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0);
const totalBytes = photos.reduce((s, f) => s + (f.size || 0), 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0);
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
removeUpload(photo);
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
}
const gid = Number.parseInt(group_id, 10);
if (!Number.isInteger(gid)) {
removeUpload(photo);
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Группа не найдена' });
}
const grpCheck = await pool.query('SELECT id FROM groups WHERE id = $1', [gid]);
if (!grpCheck.rows.length) {
removeUpload(photo);
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Группа не найдена' });
}
@@ -2250,13 +2356,14 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
[student_name.trim(), intervalMin]
);
if (dup.rows[0].n > 0) {
removeUpload(photo);
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
}
}
await convertPhoto(photo);
const photo_path = photo ? `/uploads/${photo.filename}` : null;
for (const p of photos) {
await convertPhoto(p);
}
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -2264,11 +2371,19 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
'INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
[student_name.trim()]
);
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
const { rows } = await client.query(
`INSERT INTO entries (student_name, group_id, description, photo_path)
VALUES ($1, $2, $3, $4) RETURNING *`,
[student_name.trim(), gid, description.trim(), photo_path]
`INSERT INTO entries (student_name, group_id, description, description_original, photo_path)
VALUES ($1, $2, $3, $3, $4) RETURNING *`,
[student_name.trim(), gid, description.trim(), mainPhotoPath]
);
for (let i = 0; i < photos.length; i++) {
const p = photos[i];
await client.query(
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
[rows[0].id, `/uploads/${p.filename}`, i]
);
}
for (const f of projectFiles) {
const token = crypto.randomBytes(16).toString('hex');
await client.query(
@@ -2277,10 +2392,11 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
);
}
await client.query('COMMIT');
res.status(201).json({ ...rows[0], files: projectFiles.length });
if (entryAutoChecker) entryAutoChecker.notify();
res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length });
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
removeUpload(photo);
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
console.error('POST /api/entries:', e);
res.status(500).json({ error: e.message });
@@ -2289,18 +2405,28 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
}
});
app.put('/api/entries/:id', requireAuth, async (req, res) => {
app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { student_name, group_id, description } = req.body;
const newPhotos = req.files || [];
for (const p of newPhotos) {
await convertPhoto(p);
}
const { rows } = await pool.query(
`UPDATE entries SET
student_name = COALESCE($1, student_name),
group_id = COALESCE($2, group_id),
description = COALESCE($3, description)
description = COALESCE($3, description),
description_original = COALESCE($3, description_original),
description_ai = CASE WHEN $3 IS NULL THEN description_ai ELSE NULL END,
ai_status = CASE WHEN $3 IS NULL THEN ai_status ELSE 'pending' END,
ai_error = CASE WHEN $3 IS NULL THEN ai_error ELSE NULL END,
ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END
WHERE id = $4 RETURNING *`,
[
student_name ? student_name.trim() : null,
@@ -2309,11 +2435,94 @@ app.put('/api/entries/:id', requireAuth, async (req, res) => {
req.params.id,
]
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
if (!rows.length) {
newPhotos.forEach(p => safeUnlink(p.path));
return res.status(404).json({ error: 'Not found' });
}
if (description && entryAutoChecker) entryAutoChecker.notify();
const { rows: existingPhotos } = await pool.query('SELECT id, photo_path FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id', [req.params.id]);
const nextSortOrder = existingPhotos.length;
for (let i = 0; i < newPhotos.length; i++) {
const p = newPhotos[i];
await pool.query(
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
[req.params.id, `/uploads/${p.filename}`, nextSortOrder + i]
);
}
if (!rows[0].photo_path && newPhotos.length) {
rows[0].photo_path = `/uploads/${newPhotos[0].filename}`;
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
}
await logAudit(req, 'entry.update', { id: req.params.id });
res.json(rows[0]);
});
app.get('/api/entries/:id/photos', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query(
'SELECT id, photo_path, caption, sort_order, created_at FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id',
[req.params.id]
);
res.json(rows);
});
app.delete('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query('SELECT photo_path, sort_order FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
const { photo_path: photoPath, sort_order: photoSort } = rows[0];
await pool.query('DELETE FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
safeUnlink(photoPath);
await pool.query('UPDATE entry_photos SET sort_order = sort_order - 1 WHERE entry_id = $1 AND sort_order > $2', [req.params.id, photoSort]);
const { rows: mainRows } = await pool.query('SELECT id FROM entries WHERE id = $1 AND photo_path = $2', [req.params.id, photoPath]);
if (mainRows.length) {
const { rows: nextRows } = await pool.query('SELECT photo_path FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id LIMIT 1', [req.params.id]);
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [nextRows.length ? nextRows[0].photo_path : null, req.params.id]);
}
await logAudit(req, 'entry.photo.delete', { entry_id: req.params.id, photo_id: req.params.photoId });
res.json({ ok: true });
});
app.put('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { caption, sort_order } = req.body;
const { rows } = await pool.query(
'UPDATE entry_photos SET caption = COALESCE($1, caption), sort_order = COALESCE($2, sort_order) WHERE id = $3 AND entry_id = $4 RETURNING *',
[caption ?? null, sort_order !== undefined ? sort_order : null, req.params.photoId, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
await logAudit(req, 'entry.photo.update', { entry_id: req.params.id, photo_id: req.params.photoId });
res.json(rows[0]);
});
app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query('SELECT photo_path FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
await logAudit(req, 'entry.photo.set_main', { entry_id: req.params.id, photo_id: req.params.photoId });
res.json({ ok: true, photo_path: rows[0].photo_path });
});
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
@@ -2369,6 +2578,142 @@ app.post('/api/ai/correct', requireAuth, async (req, res) => {
}
});
app.get('/api/ai/queue', requireAdmin, async (_, res) => {
const { rows } = await pool.query(
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
);
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
rows.forEach(r => { counts[r.ai_status] = r.n; });
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
res.json({
enabled,
counts,
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
model: process.env.AI_MODEL || 'qwen2.5-1.5b-instruct',
});
});
async function aiHealthCheck() {
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1500);
try {
const r = await fetch(`${AI_URL}/health`, { signal: controller.signal });
const latency_ms = Date.now() - startedAt;
if (!r.ok) return { reachable: false, latency_ms, error: `HTTP ${r.status}` };
return { reachable: true, latency_ms, error: null };
} catch (e) {
const latency_ms = Date.now() - startedAt;
const error = e && e.name === 'AbortError' ? 'timeout' : (e && e.message ? e.message : 'unreachable');
return { reachable: false, latency_ms, error };
} finally {
clearTimeout(timer);
}
}
app.get('/api/ai/status', requireAdmin, async (_, res) => {
const { rows } = await pool.query(
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
);
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
rows.forEach(r => { counts[r.ai_status] = r.n; });
const [pending, recent, errors] = await Promise.all([
pool.query(
`SELECT e.id, e.student_name, e.created_at, g.name AS group_name
FROM entries e JOIN groups g ON g.id = e.group_id
WHERE e.ai_status IN ('pending', 'processing') AND e.deleted_at IS NULL
ORDER BY e.id ASC LIMIT 20`
),
pool.query(
`SELECT e.id, e.student_name, e.ai_status, e.ai_checked_at, e.ai_error, g.name AS group_name,
(e.description_ai IS NOT NULL AND e.description_original IS NOT NULL AND e.description_ai <> e.description_original) AS changed
FROM entries e JOIN groups g ON g.id = e.group_id
WHERE e.ai_checked_at IS NOT NULL AND e.deleted_at IS NULL
ORDER BY e.ai_checked_at DESC LIMIT 30`
),
pool.query(
`SELECT e.id, e.student_name, e.ai_error, e.ai_checked_at, g.name AS group_name
FROM entries e JOIN groups g ON g.id = e.group_id
WHERE e.ai_status = 'error' AND e.deleted_at IS NULL
ORDER BY e.ai_checked_at DESC NULLS LAST, e.id DESC LIMIT 20`
),
]);
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
const service = await aiHealthCheck();
res.json({
enabled,
model: process.env.AI_MODEL || 'qwen2.5-1.5b-instruct',
ai_url: AI_URL,
service,
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
counts,
pending: pending.rows,
recent: recent.rows,
errors: errors.rows,
});
});
app.post('/api/ai/wake', requireAdmin, async (req, res) => {
if (entryAutoChecker) entryAutoChecker.notify();
await logAudit(req, 'ai.wake', {});
res.json({ ok: true });
});
app.post('/api/ai/enabled', requireAdmin, async (req, res) => {
const enabled = !!req.body?.enabled;
await pool.query(
`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[enabled ? 'true' : 'false']
);
if (enabled && entryAutoChecker) entryAutoChecker.notify();
await logAudit(req, 'ai.enabled', { enabled });
res.json({ ok: true, enabled });
});
app.post('/api/ai/requeue-failed', requireAdmin, async (req, res) => {
const { rowCount } = await pool.query(
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL
WHERE ai_status = 'error' AND deleted_at IS NULL`
);
if (entryAutoChecker) entryAutoChecker.notify();
await logAudit(req, 'ai.requeue-failed', { count: rowCount });
res.json({ ok: true, count: rowCount });
});
app.post('/api/entries/:id/ai/recheck', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query(
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE id = $1 RETURNING id`,
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
if (entryAutoChecker) entryAutoChecker.notify();
await logAudit(req, 'entry.ai.recheck', { id: req.params.id });
res.json({ ok: true });
});
app.post('/api/entries/:id/ai/revert', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query(
`UPDATE entries SET description = description_original, description_ai = NULL,
ai_status = 'reverted', ai_error = NULL, ai_checked_at = now()
WHERE id = $1 AND description_original IS NOT NULL RETURNING id`,
[req.params.id]
);
if (!rows.length) return res.status(400).json({ error: 'Оригинал текста недоступен' });
await logAudit(req, 'entry.ai.revert', { id: req.params.id });
res.json({ ok: true });
});
app.get('/api/trash', requireAuth, async (req, res) => {
const { limit, offset } = req.query;
const bw = branchScope(req.user);
@@ -2476,9 +2821,14 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (no TLS certs)`));
}
sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
(async () => {
try { await ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); }
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
try { await ensureAuditTable(); } catch (err) { console.error('Audit table:', err); }
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
entryAutoChecker.start();
console.log('AI auto-check worker started');
})();