Add AI auto-check worker, share link message/link fields, and update journal/settings UI
This commit is contained in:
@@ -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');
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user