597 lines
28 KiB
JavaScript
597 lines
28 KiB
JavaScript
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const express = require('express');
|
||
const Database = require('better-sqlite3');
|
||
const bcrypt = require('bcryptjs');
|
||
const jwt = require('jsonwebtoken');
|
||
const fetch = require('node-fetch');
|
||
const path = require('path');
|
||
|
||
const DEFAULT_JWT_SECRET = 'CHANGE_ME_IN_PRODUCTION_PLEASE';
|
||
const DEFAULT_ADMIN_SECRET = 'admin_setup_secret';
|
||
const DB_PATH = process.env.DB_PATH || path.join('.', 'data', 'intradesk.db');
|
||
|
||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||
|
||
const app = express();
|
||
const db = new Database(DB_PATH);
|
||
|
||
const JWT_SECRET = process.env.JWT_SECRET || DEFAULT_JWT_SECRET;
|
||
const ADMIN_SECRET = process.env.ADMIN_SECRET || DEFAULT_ADMIN_SECRET;
|
||
const PORT = process.env.PORT || 3000;
|
||
|
||
if (process.env.NODE_ENV === 'production') {
|
||
if (JWT_SECRET === DEFAULT_JWT_SECRET) {
|
||
throw new Error('JWT_SECRET must be set in production');
|
||
}
|
||
|
||
if (ADMIN_SECRET === DEFAULT_ADMIN_SECRET) {
|
||
throw new Error('ADMIN_SECRET must be set in production');
|
||
}
|
||
}
|
||
|
||
// ── Schema ───────────────────────────────────────────────────────
|
||
db.exec(`
|
||
PRAGMA journal_mode = WAL;
|
||
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS tasks_cache (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
data TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS marks (
|
||
task_id TEXT NOT NULL,
|
||
mark_type TEXT NOT NULL,
|
||
is_active INTEGER NOT NULL DEFAULT 1,
|
||
set_by TEXT NOT NULL,
|
||
set_at TEXT NOT NULL,
|
||
PRIMARY KEY (task_id, mark_type)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS notes (
|
||
task_id TEXT PRIMARY KEY,
|
||
text TEXT NOT NULL,
|
||
updated_by TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS task_comments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
task_id TEXT NOT NULL,
|
||
author TEXT NOT NULL,
|
||
text TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT,
|
||
deleted INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS mentions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
task_id TEXT NOT NULL,
|
||
comment_id INTEGER,
|
||
mentioned_user TEXT NOT NULL,
|
||
mentioned_by TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
read_at TEXT
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS forced_comments (
|
||
task_id TEXT PRIMARY KEY,
|
||
updatedat TEXT NOT NULL,
|
||
data TEXT,
|
||
fetched_at TEXT NOT NULL
|
||
);
|
||
`);
|
||
|
||
// ── Middleware ────────────────────────────────────────────────────
|
||
app.use(express.json({ limit: '10mb' }));
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
function auth(req, res, next) {
|
||
const header = req.headers.authorization || '';
|
||
const token = header.startsWith('Bearer ') ? header.slice(7) : (req.query.token || null);
|
||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||
try { req.user = jwt.verify(token, JWT_SECRET); next(); }
|
||
catch { res.status(401).json({ error: 'Token invalid or expired' }); }
|
||
}
|
||
|
||
function adminOnly(req, res, next) {
|
||
if (req.user.username !== 'admin') return res.status(403).json({ error: 'Только для администратора' });
|
||
next();
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────
|
||
function getSettings() {
|
||
return Object.fromEntries(db.prepare('SELECT key,value FROM settings').all().map(r => [r.key, r.value]));
|
||
}
|
||
function setSetting(k, v) {
|
||
db.prepare('INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)').run(k, v);
|
||
}
|
||
function parseMentions(text) {
|
||
return [...new Set((text.match(/@([\w\u0400-\u04ff]+)/g) || []).map(m => m.slice(1).toLowerCase()))];
|
||
}
|
||
// Ищем task_id по tasknumber из кэша
|
||
function taskIdByNumber(taskNumber) {
|
||
const cache = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get();
|
||
if (!cache) return null;
|
||
const tasks = JSON.parse(cache.data);
|
||
const t = tasks.find(t => String(t.tasknumber) === String(taskNumber));
|
||
return t ? String(t.id) : null;
|
||
}
|
||
// Возвращает tasknumber по task_id из кэша
|
||
function taskNumberById(taskId) {
|
||
try {
|
||
const cache = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get();
|
||
if (!cache) return taskId;
|
||
const tasks = JSON.parse(cache.data);
|
||
const t = tasks.find(t => String(t.id) === String(taskId));
|
||
return t ? String(t.tasknumber || t.id) : taskId;
|
||
} catch { return taskId; }
|
||
}
|
||
|
||
// ── SSE ───────────────────────────────────────────────────────────
|
||
const sseClients = new Map(); // key: username in lower-case
|
||
function sseAdd(u, res) {
|
||
const key = String(u).toLowerCase();
|
||
if (!sseClients.has(key)) sseClients.set(key, new Set());
|
||
sseClients.get(key).add(res);
|
||
}
|
||
function sseRemove(u, res) {
|
||
const key = String(u).toLowerCase();
|
||
sseClients.get(key)?.delete(res);
|
||
}
|
||
function sseBroadcast(event, data, except = null) {
|
||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
const skip = except ? String(except).toLowerCase() : null;
|
||
sseClients.forEach((clients, key) => {
|
||
if (skip && key === skip) return;
|
||
clients.forEach(r => { try { r.write(msg); } catch {} });
|
||
});
|
||
}
|
||
function sseSendTo(u, event, data) {
|
||
const key = String(u).toLowerCase();
|
||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
sseClients.get(key)?.forEach(r => { try { r.write(msg); } catch {} });
|
||
}
|
||
|
||
app.get('/healthz', (req, res) => {
|
||
try {
|
||
db.prepare('SELECT 1').get();
|
||
res.json({ status: 'ok' });
|
||
} catch (error) {
|
||
res.status(500).json({ status: 'error' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/events', auth, (req, res) => {
|
||
res.setHeader('Content-Type', 'text/event-stream');
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.setHeader('Connection', 'keep-alive');
|
||
res.setHeader('X-Accel-Buffering', 'no');
|
||
res.flushHeaders();
|
||
res.write(`: connected as ${req.user.username}\n\n`);
|
||
const ka = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
|
||
sseAdd(req.user.username, res);
|
||
req.on('close', () => { clearInterval(ka); sseRemove(req.user.username, res); });
|
||
});
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────
|
||
app.post('/api/auth/login', (req, res) => {
|
||
const { username, password } = req.body || {};
|
||
if (!username || !password) return res.status(400).json({ error: 'Заполните все поля' });
|
||
const user = db.prepare('SELECT * FROM users WHERE username=?').get(username);
|
||
if (!user || !bcrypt.compareSync(password, user.password_hash))
|
||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' });
|
||
res.json({ token, username: user.username, is_admin: user.username === 'admin' });
|
||
});
|
||
|
||
app.post('/api/auth/register', (req, res) => {
|
||
const { username, password, adminSecret } = req.body || {};
|
||
const hdr = req.headers.authorization || '';
|
||
const tok = hdr.startsWith('Bearer ') ? hdr.slice(7) : null;
|
||
let isAdmin = false;
|
||
if (tok) { try { isAdmin = jwt.verify(tok, JWT_SECRET).username === 'admin'; } catch {} }
|
||
if (adminSecret !== ADMIN_SECRET && !isAdmin) return res.status(403).json({ error: 'Нет прав' });
|
||
if (!username || !password) return res.status(400).json({ error: 'Заполните все поля' });
|
||
if (password.length < 6) return res.status(400).json({ error: 'Пароль минимум 6 символов' });
|
||
try {
|
||
db.prepare('INSERT INTO users(username,password_hash) VALUES(?,?)').run(username, bcrypt.hashSync(password, 12));
|
||
res.json({ ok: true });
|
||
} catch { res.status(400).json({ error: 'Пользователь уже существует' }); }
|
||
});
|
||
|
||
app.get('/api/auth/me', auth, (req, res) => {
|
||
res.json({ username: req.user.username, is_admin: req.user.username === 'admin' });
|
||
});
|
||
|
||
// ── Users ─────────────────────────────────────────────────────────
|
||
app.get('/api/users', auth, adminOnly, (req, res) => {
|
||
res.json(db.prepare('SELECT id,username,created_at FROM users').all());
|
||
});
|
||
app.get('/api/users/list', auth, (req, res) => {
|
||
res.json(db.prepare('SELECT username FROM users').all().map(u => u.username));
|
||
});
|
||
app.delete('/api/users/:id', auth, adminOnly, (req, res) => {
|
||
const u = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||
if (!u) return res.status(404).json({ error: 'Не найден' });
|
||
if (u.username === 'admin') return res.status(400).json({ error: 'Нельзя удалить admin' });
|
||
db.prepare('DELETE FROM users WHERE id=?').run(req.params.id);
|
||
res.json({ ok: true });
|
||
});
|
||
app.put('/api/users/:id/password', auth, adminOnly, (req, res) => {
|
||
const { password } = req.body || {};
|
||
if (!password || password.length < 6) return res.status(400).json({ error: 'Пароль минимум 6 символов' });
|
||
const u = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||
if (!u) return res.status(404).json({ error: 'Не найден' });
|
||
db.prepare('UPDATE users SET password_hash=? WHERE id=?').run(bcrypt.hashSync(password, 12), req.params.id);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── Status ────────────────────────────────────────────────────────
|
||
app.get('/api/status', auth, (req, res) => {
|
||
const cache = db.prepare('SELECT updated_at FROM tasks_cache WHERE id=1').get();
|
||
const s = getSettings();
|
||
res.json({
|
||
last_updated: cache?.updated_at || null,
|
||
auto_refresh_interval: parseInt(s.auto_refresh_interval) || 300,
|
||
unread_mode: s.unread_mode || 'highlight',
|
||
export_fields: s.export_fields || '',
|
||
export_status_mode: s.export_status_mode || 'A',
|
||
});
|
||
});
|
||
|
||
// ── Tasks ─────────────────────────────────────────────────────────
|
||
app.get('/api/tasks', auth, (req, res) => {
|
||
const cache = db.prepare('SELECT * FROM tasks_cache WHERE id=1').get();
|
||
if (!cache) return res.json({ tasks: [], updated_at: null });
|
||
res.json({ tasks: JSON.parse(cache.data), updated_at: cache.updated_at });
|
||
});
|
||
|
||
app.post('/api/tasks/refresh', auth, async (req, res) => {
|
||
try { res.json({ ok: true, count: await fetchAndCacheTasks(), updated_at: new Date().toISOString() }); }
|
||
catch (e) { console.error('Refresh error:', e.message); res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
app.post('/api/tasks/refresh/full', auth, adminOnly, async (req, res) => {
|
||
try {
|
||
const count = await fetchAndCacheAllTasks();
|
||
res.json({ ok: true, count, updated_at: new Date().toISOString() });
|
||
} catch (e) { console.error('Full refresh error:', e.message); res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// ── Marks ─────────────────────────────────────────────────────────
|
||
const _marksConfig = require('./public/js/statuses.js');
|
||
const ALLOWED_MARKS = new Set(_marksConfig.map(m => m.key));
|
||
|
||
app.get('/api/marks', auth, (req, res) => {
|
||
const r = {};
|
||
db.prepare('SELECT * FROM marks').all().forEach(m => {
|
||
if (!r[m.task_id]) r[m.task_id] = {};
|
||
r[m.task_id][m.mark_type] = { active: !!m.is_active, by: m.set_by, at: m.set_at };
|
||
});
|
||
res.json(r);
|
||
});
|
||
|
||
const ALL_STATUS_MARKS = _marksConfig.map(m => m.key);
|
||
|
||
app.post('/api/marks', auth, (req, res) => {
|
||
const { task_id, mark_type, value, clear_others } = req.body || {};
|
||
if (!task_id || !ALLOWED_MARKS.has(mark_type)) return res.status(400).json({ error: 'Неверные параметры' });
|
||
const now = new Date().toISOString();
|
||
|
||
// Если clear_others=true — сбрасываем все остальные статусы одним проходом
|
||
const clearedMarks = [];
|
||
if (value && clear_others) {
|
||
ALL_STATUS_MARKS.filter(t => t !== mark_type).forEach(t => {
|
||
const existing = db.prepare('SELECT is_active FROM marks WHERE task_id=? AND mark_type=?').get(task_id, t);
|
||
if (existing?.is_active) {
|
||
db.prepare('UPDATE marks SET is_active=0,set_by=?,set_at=? WHERE task_id=? AND mark_type=?')
|
||
.run(req.user.username, now, task_id, t);
|
||
clearedMarks.push(t);
|
||
}
|
||
});
|
||
}
|
||
|
||
db.prepare(`INSERT INTO marks(task_id,mark_type,is_active,set_by,set_at) VALUES(?,?,?,?,?)
|
||
ON CONFLICT(task_id,mark_type) DO UPDATE SET is_active=excluded.is_active,set_by=excluded.set_by,set_at=excluded.set_at`)
|
||
.run(task_id, mark_type, value ? 1 : 0, req.user.username, now);
|
||
|
||
// Одно SSE-событие со списком сброшенных меток
|
||
sseBroadcast('mark', {
|
||
task_id, mark_type, value: !!value, by: req.user.username, at: now,
|
||
cleared_marks: clearedMarks,
|
||
});
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── Notes ─────────────────────────────────────────────────────────
|
||
app.get('/api/notes', auth, (req, res) => {
|
||
const r = {};
|
||
db.prepare('SELECT * FROM notes').all().forEach(n => { r[n.task_id] = { text: n.text, by: n.updated_by, at: n.updated_at }; });
|
||
res.json(r);
|
||
});
|
||
|
||
app.post('/api/notes', auth, (req, res) => {
|
||
const { task_id, text } = req.body || {};
|
||
if (!task_id) return res.status(400).json({ error: 'task_id required' });
|
||
const now = new Date().toISOString();
|
||
const prev = db.prepare('SELECT text FROM notes WHERE task_id=?').get(task_id);
|
||
if (!text || !text.trim()) {
|
||
db.prepare('DELETE FROM notes WHERE task_id=?').run(task_id);
|
||
// Рассылаем всем (включая отправителя) для синхронизации всех вкладок
|
||
sseBroadcast('note', { task_id, text: '', by: req.user.username, at: now });
|
||
} else {
|
||
db.prepare(`INSERT INTO notes(task_id,text,updated_by,updated_at) VALUES(?,?,?,?)
|
||
ON CONFLICT(task_id) DO UPDATE SET text=excluded.text,updated_by=excluded.updated_by,updated_at=excluded.updated_at`)
|
||
.run(task_id, text.trim(), req.user.username, now);
|
||
sseBroadcast('note', { task_id, text: text.trim(), by: req.user.username, at: now });
|
||
try { processMentions(task_id, null, text.trim(), prev?.text || '', req.user.username, now); } catch(e) { console.error('processMentions error:', e.message); }
|
||
}
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── Comments ──────────────────────────────────────────────────────
|
||
// GET /api/comments/counts — точный маршрут ДОЛЖЕН быть до /:taskId
|
||
app.get('/api/comments/counts', auth, (req, res) => {
|
||
const rows = db.prepare(
|
||
'SELECT task_id, COUNT(*) as c FROM task_comments WHERE deleted=0 GROUP BY task_id'
|
||
).all();
|
||
const map = {};
|
||
rows.forEach(r => { map[r.task_id] = r.c; });
|
||
res.json(map);
|
||
});
|
||
|
||
// GET /api/comments/:taskId
|
||
app.get('/api/comments/:taskId', auth, (req, res) => {
|
||
const rows = db.prepare(
|
||
'SELECT * FROM task_comments WHERE task_id=? AND deleted=0 ORDER BY created_at ASC'
|
||
).all(req.params.taskId);
|
||
res.json(rows);
|
||
});
|
||
|
||
// POST /api/comments — новый комментарий
|
||
app.post('/api/comments', auth, (req, res) => {
|
||
const { task_id, text } = req.body || {};
|
||
if (!task_id || !text?.trim()) return res.status(400).json({ error: 'Нет текста' });
|
||
const now = new Date().toISOString();
|
||
const info = db.prepare(
|
||
'INSERT INTO task_comments(task_id,author,text,created_at) VALUES(?,?,?,?)'
|
||
).run(task_id, req.user.username, text.trim(), now);
|
||
const comment = db.prepare('SELECT * FROM task_comments WHERE id=?').get(info.lastInsertRowid);
|
||
sseBroadcast('comment_new', { comment, task_number: taskNumberById(task_id) }, req.user.username);
|
||
processMentions(task_id, comment.id, text.trim(), '', req.user.username, now);
|
||
res.json(comment);
|
||
});
|
||
|
||
// PUT /api/comments/:id — редактировать свой
|
||
app.put('/api/comments/:id', auth, (req, res) => {
|
||
const c = db.prepare('SELECT * FROM task_comments WHERE id=? AND deleted=0').get(req.params.id);
|
||
if (!c) return res.status(404).json({ error: 'Не найден' });
|
||
if (c.author !== req.user.username && req.user.username !== 'admin')
|
||
return res.status(403).json({ error: 'Нельзя редактировать чужой комментарий' });
|
||
const { text } = req.body || {};
|
||
if (!text?.trim()) return res.status(400).json({ error: 'Нет текста' });
|
||
const now = new Date().toISOString();
|
||
db.prepare('UPDATE task_comments SET text=?,updated_at=? WHERE id=?').run(text.trim(), now, c.id);
|
||
const updated = db.prepare('SELECT * FROM task_comments WHERE id=?').get(c.id);
|
||
sseBroadcast('comment_edit', { comment: updated, task_number: taskNumberById(c.task_id) });
|
||
processMentions(c.task_id, c.id, text.trim(), c.text, req.user.username, now);
|
||
res.json(updated);
|
||
});
|
||
|
||
// DELETE /api/comments/:id — удалить свой (soft delete)
|
||
app.delete('/api/comments/:id', auth, (req, res) => {
|
||
const c = db.prepare('SELECT * FROM task_comments WHERE id=? AND deleted=0').get(req.params.id);
|
||
if (!c) return res.status(404).json({ error: 'Не найден' });
|
||
if (c.author !== req.user.username && req.user.username !== 'admin')
|
||
return res.status(403).json({ error: 'Нельзя удалить чужой комментарий' });
|
||
db.prepare('UPDATE task_comments SET deleted=1 WHERE id=?').run(c.id);
|
||
sseBroadcast('comment_delete', { comment_id: c.id, task_id: c.task_id, task_number: taskNumberById(c.task_id) });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── Mentions ──────────────────────────────────────────────────────
|
||
function processMentions(task_id, comment_id, newText, prevText, byUser, now) {
|
||
const allUsers = db.prepare('SELECT username FROM users').all().map(u => u.username.toLowerCase());
|
||
const newMentions = parseMentions(newText).filter(u => allUsers.includes(u) && u !== byUser.toLowerCase());
|
||
const prevMentions= parseMentions(prevText);
|
||
const added = newMentions.filter(u => !prevMentions.includes(u));
|
||
const taskNumber = taskNumberById(task_id);
|
||
added.forEach(uLower => {
|
||
db.prepare('DELETE FROM mentions WHERE task_id=? AND mentioned_user=? AND read_at IS NULL AND (comment_id=? OR comment_id IS NULL)')
|
||
.run(task_id, uLower, comment_id);
|
||
db.prepare('INSERT INTO mentions(task_id,comment_id,mentioned_user,mentioned_by,created_at) VALUES(?,?,?,?,?)')
|
||
.run(task_id, comment_id, uLower, byUser, now);
|
||
const unread = db.prepare('SELECT COUNT(*) as c FROM mentions WHERE mentioned_user=? AND read_at IS NULL').get(uLower).c;
|
||
sseSendTo(uLower, 'mention', { task_id, task_number: taskNumber, by: byUser, at: now, unread_count: unread });
|
||
});
|
||
}
|
||
|
||
app.get('/api/mentions', auth, (req, res) => {
|
||
const rows = db.prepare('SELECT * FROM mentions WHERE mentioned_user=? ORDER BY created_at DESC').all(req.user.username);
|
||
res.json({ mentions: rows, unread: rows.filter(r => !r.read_at).length });
|
||
});
|
||
|
||
app.post('/api/mentions/read', auth, (req, res) => {
|
||
const { task_id } = req.body || {};
|
||
if (!task_id) return res.status(400).json({ error: 'task_id required' });
|
||
db.prepare('UPDATE mentions SET read_at=? WHERE task_id=? AND mentioned_user=? AND read_at IS NULL')
|
||
.run(new Date().toISOString(), task_id, req.user.username);
|
||
const unread = db.prepare('SELECT COUNT(*) as c FROM mentions WHERE mentioned_user=? AND read_at IS NULL').get(req.user.username).c;
|
||
res.json({ ok: true, unread });
|
||
});
|
||
|
||
// ── Settings ──────────────────────────────────────────────────────
|
||
const ALLOWED_SETTINGS = new Set([
|
||
'api_key',
|
||
'status_ids',
|
||
'event_types',
|
||
'auto_refresh_interval',
|
||
'unread_mode',
|
||
'export_fields',
|
||
'export_status_mode',
|
||
]);
|
||
|
||
app.get('/api/settings', auth, adminOnly, (req, res) => {
|
||
const s = getSettings();
|
||
res.json({
|
||
status_ids: s.status_ids || '68051,68046',
|
||
event_types: s.event_types || '',
|
||
auto_refresh_interval: s.auto_refresh_interval || '300',
|
||
unread_mode: s.unread_mode || 'highlight',
|
||
export_fields: s.export_fields || '',
|
||
export_status_mode: s.export_status_mode || 'B',
|
||
api_key_set: !!s.api_key,
|
||
});
|
||
});
|
||
|
||
app.post('/api/settings', auth, adminOnly, (req, res) => {
|
||
const body = req.body || {};
|
||
Object.entries(body).forEach(([k, v]) => { if (ALLOWED_SETTINGS.has(k) && v !== undefined) setSetting(k, String(v)); });
|
||
if (body.auto_refresh_interval !== undefined) setupAutoRefresh();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── History proxy ─────────────────────────────────────────────────
|
||
app.get('/api/tasks/history/:taskId', auth, async (req, res) => {
|
||
console.log(`History proxy`);
|
||
const s = getSettings();
|
||
if (!s.api_key) return res.status(400).json({ error: 'API ключ не задан' });
|
||
const evTypes = s.event_types ? s.event_types.split(',').map(t => `events=${t.trim()}`).join('&') : '';
|
||
const url = `https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime/${req.params.taskId}/full`
|
||
+ `?ApiKey=${s.api_key}&sortDirection=Desc&top=100` + (evTypes ? '&'+evTypes : '');
|
||
try {
|
||
const r = await fetch(url, { timeout: 15000 });
|
||
res.json({ entries: (await r.json()).data || [] });
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// ── Last comment proxy — с кешем в БД по updatedat ───────────────
|
||
app.get('/api/tasks/lastcomment/:taskId', auth, async (req, res) => {
|
||
console.log(`Last comment proxy`);
|
||
const s = getSettings();
|
||
if (!s.api_key) return res.status(400).json({ error: 'API ключ не задан' });
|
||
const taskId = req.params.taskId;
|
||
const updatedat = req.query.upd || null;
|
||
|
||
// Проверяем кеш: если updatedat совпадает — отдаём сразу без запроса к IntraDesk
|
||
if (updatedat) {
|
||
const cached = db.prepare(
|
||
'SELECT data FROM forced_comments WHERE task_id=? AND updatedat=?'
|
||
).get(taskId, updatedat);
|
||
if (cached !== undefined) {
|
||
return res.json({ entries: cached.data ? JSON.parse(cached.data) : [], cached: true });
|
||
}
|
||
}
|
||
|
||
// Кеш-промах — идём в IntraDesk
|
||
const url = `https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime/${taskId}/full`
|
||
+ `?ApiKey=${s.api_key}&sortDirection=Desc&top=5&events=50&events=55`;
|
||
try {
|
||
const r = await fetch(url, { timeout: 10000 });
|
||
const entries = (await r.json()).data || [];
|
||
|
||
// Сохраняем в БД (data=NULL если комментариев нет — чтобы не перезапрашивать)
|
||
if (updatedat) {
|
||
db.prepare(`
|
||
INSERT INTO forced_comments(task_id, updatedat, data, fetched_at) VALUES(?,?,?,?)
|
||
ON CONFLICT(task_id) DO UPDATE
|
||
SET updatedat=excluded.updatedat, data=excluded.data, fetched_at=excluded.fetched_at
|
||
`).run(taskId, updatedat, entries.length ? JSON.stringify(entries) : null, new Date().toISOString());
|
||
}
|
||
|
||
res.json({ entries });
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// ── IntraDesk fetch ───────────────────────────────────────────────
|
||
async function buildTasksFromApi(top) {
|
||
const s = getSettings();
|
||
if (!s.api_key) throw new Error('API ключ не задан.');
|
||
const executorFilter = [
|
||
'executorgroup eq 243025', // Поддержка, 1-ая линия
|
||
'executor eq 2227907', // Высоких Дмитрий
|
||
'executor eq 525111', // Абрамов Сергей
|
||
'executor eq 631481', // Железняков Антон
|
||
'executor eq 2222953', // Коваленко Артем
|
||
].join(' or ');
|
||
const fullFilter = `(${executorFilter})`;
|
||
const topParam = top > 0 ? top : 2000;
|
||
const url = `https://apigw.intradesk.ru/tasklist/odata/v3/tasks?ApiKey=${s.api_key}&$filter=${encodeURIComponent(`(${fullFilter})`)}&$orderby=updatedat%20desc&$top=${topParam}`;
|
||
console.log(`[IntraDesk] GET top=${topParam}`, url.replace(s.api_key, '***'));
|
||
const r = await fetch(url, { timeout: 30000 });
|
||
console.log('[IntraDesk] Status:', r.status, r.statusText);
|
||
if (!r.ok) throw new Error(`IntraDesk вернул ${r.status}: ${r.statusText}`);
|
||
const json = await r.json();
|
||
const tasks = json.value || [];
|
||
const dictMap = {};
|
||
(json.dictionaries || []).forEach(d => { dictMap[d.id] = d.name; });
|
||
tasks.forEach(task => {
|
||
if (task.clientid && dictMap[task.clientid]) task._clientname = dictMap[task.clientid];
|
||
if (task.executor && dictMap[task.executor]) task._executorname = dictMap[task.executor];
|
||
if (task.executorgroup && dictMap[task.executorgroup]) task._executorgroupname = dictMap[task.executorgroup];
|
||
// API возвращает статус в поле 'status' (числовой id), иногда 'statusid' — проверяем оба
|
||
const statusKey = task.statusid || task.status;
|
||
if (statusKey && dictMap[statusKey]) task._statusname = dictMap[statusKey];
|
||
});
|
||
return tasks;
|
||
}
|
||
|
||
// Quick refresh: fetch top-50 recent tasks and merge into cache
|
||
async function fetchAndCacheTasks() {
|
||
const fresh = await buildTasksFromApi(50);
|
||
const existing = (() => {
|
||
try { const c = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); return c ? JSON.parse(c.data) : []; }
|
||
catch { return []; }
|
||
})();
|
||
const byId = new Map(existing.map(t => [t.id, t]));
|
||
fresh.forEach(t => byId.set(t.id, t));
|
||
const merged = [...byId.values()].sort((a, b) => (b.updatedat||'') > (a.updatedat||'') ? 1 : -1);
|
||
const now = new Date().toISOString();
|
||
db.prepare(`INSERT INTO tasks_cache(id,data,updated_at) VALUES(1,?,?)
|
||
ON CONFLICT(id) DO UPDATE SET data=excluded.data,updated_at=excluded.updated_at`).run(JSON.stringify(merged), now);
|
||
sseBroadcast('tasks_updated', { updated_at: now, count: merged.length });
|
||
return merged.length;
|
||
}
|
||
|
||
// Full refresh: fetch all tasks and replace cache
|
||
async function fetchAndCacheAllTasks() {
|
||
const tasks = await buildTasksFromApi(0);
|
||
const now = new Date().toISOString();
|
||
db.prepare(`INSERT INTO tasks_cache(id,data,updated_at) VALUES(1,?,?)
|
||
ON CONFLICT(id) DO UPDATE SET data=excluded.data,updated_at=excluded.updated_at`).run(JSON.stringify(tasks), now);
|
||
sseBroadcast('tasks_updated', { updated_at: now, count: tasks.length });
|
||
return tasks.length;
|
||
}
|
||
|
||
// ── Auto-refresh ──────────────────────────────────────────────────
|
||
let autoRefreshTimer = null;
|
||
function setupAutoRefresh() {
|
||
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
|
||
const interval = parseInt(getSettings().auto_refresh_interval) || 0;
|
||
if (interval <= 0) { console.log('[AutoRefresh] Выключен'); return; }
|
||
autoRefreshTimer = setInterval(async () => {
|
||
try { console.log(`[${new Date().toLocaleTimeString('ru-RU')}] AutoRefresh: ${await fetchAndCacheTasks()} заявок`); }
|
||
catch (e) { console.error('[AutoRefresh] Ошибка:', e.message); }
|
||
}, interval * 1000);
|
||
console.log(`[AutoRefresh] Каждые ${interval}с`);
|
||
}
|
||
setupAutoRefresh();
|
||
|
||
app.listen(PORT, () => { console.log(`\nServer listening on http://localhost:${PORT}\n`); });
|