629 lines
28 KiB
JavaScript
629 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 crypto = require('crypto');
|
||
const { createRefreshService } = require('./refresh-service');
|
||
|
||
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',
|
||
verify: (req, _res, buf) => {
|
||
req.rawBody = buf;
|
||
},
|
||
}));
|
||
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 row = db.prepare('SELECT task_id FROM tasks_index WHERE task_number=?').get(String(taskNumber));
|
||
if (row?.task_id) return String(row.task_id);
|
||
const cache = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get();
|
||
if (!cache) return null;
|
||
try {
|
||
const tasks = JSON.parse(cache.data);
|
||
const t = tasks.find((x) => String(x.tasknumber) === String(taskNumber));
|
||
return t ? String(t.id) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
// Возвращает tasknumber по task_id из кэша
|
||
function taskNumberById(taskId) {
|
||
const row = db.prepare('SELECT task_number FROM tasks_index WHERE task_id=?').get(String(taskId));
|
||
if (row?.task_number) return String(row.task_number);
|
||
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((x) => String(x.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 {} });
|
||
}
|
||
|
||
const refreshService = createRefreshService({
|
||
db,
|
||
fetch,
|
||
getSettings,
|
||
sseBroadcast,
|
||
logger: console,
|
||
});
|
||
|
||
function normalizeSignature(sigValue) {
|
||
const raw = String(sigValue || '').trim();
|
||
if (!raw) return '';
|
||
if (raw.startsWith('sha256=')) return raw.slice('sha256='.length).toLowerCase();
|
||
return raw.toLowerCase();
|
||
}
|
||
|
||
function verifyWebhookSignature(rawBody, secret, signatureHeader) {
|
||
const signature = normalizeSignature(signatureHeader);
|
||
if (!signature) return false;
|
||
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody || '');
|
||
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||
const expectedBuf = Buffer.from(expected, 'hex');
|
||
const actualBuf = Buffer.from(signature, 'hex');
|
||
if (!expectedBuf.length || expectedBuf.length !== actualBuf.length) return false;
|
||
return crypto.timingSafeEqual(expectedBuf, actualBuf);
|
||
}
|
||
|
||
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();
|
||
const sync = refreshService.getStatusSnapshot();
|
||
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',
|
||
cache_version: sync.cache_version,
|
||
refresh_in_progress: sync.refresh_in_progress,
|
||
last_success_at: sync.last_success_at,
|
||
last_error: sync.last_error,
|
||
last_full_reconcile_at: sync.last_full_reconcile_at,
|
||
refresh_mode: refreshService.getMode(),
|
||
});
|
||
});
|
||
|
||
// ── Tasks ─────────────────────────────────────────────────────────
|
||
app.get('/api/tasks', auth, (req, res) => {
|
||
const cache = db.prepare('SELECT * FROM tasks_cache WHERE id=1').get();
|
||
const sync = refreshService.getStatusSnapshot();
|
||
if (!cache) return res.json({ tasks: [], updated_at: null, cache_version: sync.cache_version });
|
||
res.json({ tasks: JSON.parse(cache.data), updated_at: cache.updated_at, cache_version: sync.cache_version });
|
||
});
|
||
|
||
app.post('/api/tasks/refresh', auth, async (req, res) => {
|
||
try {
|
||
const result = await refreshService.requestIncremental('manual_api_refresh', { wait: true });
|
||
res.json({ ok: true, ...result });
|
||
}
|
||
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 result = await refreshService.requestFull('manual_api_full_refresh', { wait: true });
|
||
res.json({ ok: true, ...result });
|
||
} catch (e) { console.error('Full refresh error:', e.message); res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
app.post('/api/webhooks/intradesk', async (req, res) => {
|
||
try {
|
||
if (refreshService.getMode() !== 'webhook_daily') {
|
||
return res.status(202).json({ ok: true, ignored: true, reason: 'refresh_mode_not_webhook_daily' });
|
||
}
|
||
|
||
const s = getSettings();
|
||
const webhookSecret = s.webhook_secret || process.env.WEBHOOK_SECRET || '';
|
||
if (!webhookSecret) return res.status(503).json({ error: 'Webhook secret is not configured' });
|
||
|
||
const signature = req.headers['x-webhook-signature'] || req.headers['x-signature'] || '';
|
||
if (!verifyWebhookSignature(req.rawBody, webhookSecret, signature)) {
|
||
return res.status(401).json({ error: 'Invalid webhook signature' });
|
||
}
|
||
|
||
const body = req.body || {};
|
||
const eventTypeRaw = body.event_type || body.eventType || body.type || body.event || '';
|
||
const { action, eventType } = refreshService.parseWebhookAction(eventTypeRaw);
|
||
const taskId = refreshService.extractWebhookTaskId(body);
|
||
|
||
if (action === 'incremental') {
|
||
await refreshService.requestIncremental(`webhook:${eventType}`, { wait: false });
|
||
return res.json({ ok: true, action: 'incremental_queued' });
|
||
}
|
||
|
||
if (action === 'remove') {
|
||
if (taskId) {
|
||
await refreshService.requestRemoveTask(taskId, `webhook:${eventType}`, { wait: false });
|
||
return res.json({ ok: true, action: 'task_removed_or_queued', task_id: String(taskId) });
|
||
}
|
||
await refreshService.requestFull(`webhook:${eventType}:no_task_id`, { wait: false });
|
||
return res.json({ ok: true, action: 'full_reconcile_queued' });
|
||
}
|
||
|
||
console.log(`[Webhook] Unknown event type: ${eventTypeRaw}`);
|
||
return res.json({ ok: true, ignored: true, reason: 'unknown_event_type', event_type: eventTypeRaw });
|
||
} catch (e) {
|
||
console.error('[Webhook] Error:', e.message);
|
||
return 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',
|
||
'refresh_mode',
|
||
'scan_page_size',
|
||
'daily_full_refresh_hour',
|
||
'webhook_secret',
|
||
'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',
|
||
refresh_mode: s.refresh_mode || 'incremental_scan',
|
||
scan_page_size: s.scan_page_size || '50',
|
||
daily_full_refresh_hour: s.daily_full_refresh_hour || '4',
|
||
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,
|
||
webhook_secret_set: !!s.webhook_secret,
|
||
});
|
||
});
|
||
|
||
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)); });
|
||
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 ───────────────────────────────────────────────
|
||
refreshService.startScheduler();
|
||
|
||
|
||
app.listen(PORT, () => { console.log(`\nServer listening on http://localhost:${PORT}\n`); });
|
||
|