'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`); });