'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 DEFAULT_INTERNAL_STATUSES = require('./public/js/statuses.js'); 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 ); CREATE TABLE IF NOT EXISTS internal_statuses ( key TEXT PRIMARY KEY, label TEXT NOT NULL, color_bg TEXT NOT NULL, color_border TEXT NOT NULL, color_text TEXT NOT NULL, color_dot TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, is_system INTEGER NOT NULL DEFAULT 0, system_role TEXT, is_active INTEGER NOT NULL DEFAULT 1, clear_on_update INTEGER NOT NULL DEFAULT 0, config_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_internal_statuses_sort ON internal_statuses(sort_order, key); CREATE INDEX IF NOT EXISTS idx_internal_statuses_role ON internal_statuses(system_role); CREATE TABLE IF NOT EXISTS intra_status_dict ( status_id TEXT PRIMARY KEY, name TEXT NOT NULL, updated_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 safeJsonParse(raw, fallback = {}) { try { const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : fallback; } catch { return fallback; } } function normalizeColor(value, fallback) { const v = String(value || '').trim(); return v || fallback; } function normalizeStatusKey(value) { return String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9_-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); } function normalizeStatusConfigForDb(item = {}, index = 0) { const key = normalizeStatusKey(item.key); const label = String(item.label || item.chipLabel || key || `status_${index + 1}`); const colorDot = normalizeColor(item.colorDot || item.chipDotColor || item.ispColor, '#1677ff'); return { key, label, color_bg: normalizeColor(item.colorBg, 'var(--surface2)'), color_border: normalizeColor(item.colorBorder, 'var(--border2)'), color_text: normalizeColor(item.colorText, 'var(--text2)'), color_dot: colorDot, sort_order: Number.isFinite(Number(item.sort_order)) ? Number(item.sort_order) : (index + 1) * 10, is_system: item.is_system ? 1 : 0, system_role: item.system_role || null, is_active: item.is_active === false ? 0 : 1, clear_on_update: item.clearOnUpdate ? 1 : 0, config_json: JSON.stringify(item), }; } const seedInternalStatusesTx = db.transaction(() => { const insertStmt = db.prepare(` INSERT OR IGNORE INTO internal_statuses( key,label,color_bg,color_border,color_text,color_dot,sort_order,is_system,system_role,is_active,clear_on_update,config_json,created_at,updated_at ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now')) `); const patchSystemStmt = db.prepare(` UPDATE internal_statuses SET is_system=?, system_role=CASE WHEN system_role IS NULL OR system_role='' THEN ? ELSE system_role END, updated_at=datetime('now') WHERE key=? `); const patchConfigStmt = db.prepare(` UPDATE internal_statuses SET config_json=?, updated_at=datetime('now') WHERE key=? AND (config_json IS NULL OR config_json='{}' OR config_json='') `); const patchColorsStmt = db.prepare(` UPDATE internal_statuses SET color_bg=COALESCE(NULLIF(color_bg,''), ?), color_border=COALESCE(NULLIF(color_border,''), ?), color_text=COALESCE(NULLIF(color_text,''), ?), color_dot=COALESCE(NULLIF(color_dot,''), ?), updated_at=datetime('now') WHERE key=? `); const patchLabelStmt = db.prepare(` UPDATE internal_statuses SET label=?, updated_at=datetime('now') WHERE key=? AND (label IS NULL OR label='' OR label='Можно закрыть') `); DEFAULT_INTERNAL_STATUSES.forEach((item, index) => { const row = normalizeStatusConfigForDb(item, index); if (!row.key) return; insertStmt.run( row.key, row.label, row.color_bg, row.color_border, row.color_text, row.color_dot, row.sort_order, row.is_system, row.system_role, row.is_active, row.clear_on_update, row.config_json, ); patchSystemStmt.run(row.is_system, row.system_role, row.key); patchConfigStmt.run(row.config_json, row.key); patchColorsStmt.run(row.color_bg, row.color_border, row.color_text, row.color_dot, row.key); }); patchLabelStmt.run('Выполнена', 'resolved'); }); seedInternalStatusesTx(); function hydrateIntraStatusDictFromCache() { const cacheRow = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); if (!cacheRow?.data) return; try { const tasks = JSON.parse(cacheRow.data || '[]'); const now = new Date().toISOString(); const upsert = db.prepare(` INSERT INTO intra_status_dict(status_id,name,updated_at) VALUES(?,?,?) ON CONFLICT(status_id) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at `); const tx = db.transaction(() => { tasks.forEach((task) => { const statusId = String(task.statusid || task.status || '').trim(); const statusName = String(task._statusname || task.statusname || '').trim(); if (!statusId || !statusName) return; upsert.run(statusId, statusName, now); }); }); tx(); } catch {} } hydrateIntraStatusDictFromCache(); function mapInternalStatusRowToConfig(row) { const cfg = safeJsonParse(row.config_json, {}); const key = String(row.key); const label = String(row.label || cfg.label || key); const dot = normalizeColor(row.color_dot || cfg.colorDot || cfg.chipDotColor || cfg.ispColor, '#1677ff'); return { ...cfg, key, label, chipLabel: label, sseLabel: cfg.sseLabel || label, exportLabel: cfg.exportLabel || label, btnLabel: cfg.btnLabel || label, chipClass: cfg.chipClass || `chip-${key}`, badgeClass: cfg.badgeClass || 'sb-custom', btnClass: cfg.btnClass || 'mbtn-custom', rowClass: cfg.rowClass || '', btnIcon: cfg.btnIcon || '•', chipDotColor: dot, ispColor: cfg.ispColor || dot, kbColor: cfg.kbColor || dot, kbShortcut: cfg.kbShortcut || '', clearOnUpdate: !!row.clear_on_update, colorBg: normalizeColor(row.color_bg, 'var(--surface2)'), colorBorder: normalizeColor(row.color_border, 'var(--border2)'), colorText: normalizeColor(row.color_text, 'var(--text2)'), colorDot: dot, is_system: !!row.is_system, system_role: row.system_role || null, sort_order: Number(row.sort_order || 0), is_active: !!row.is_active, }; } function getInternalStatuses({ includeInactive = true } = {}) { const where = includeInactive ? '' : 'WHERE is_active=1'; const rows = db.prepare(` SELECT key,label,color_bg,color_border,color_text,color_dot,sort_order,is_system,system_role,is_active,clear_on_update,config_json FROM internal_statuses ${where} ORDER BY sort_order ASC, key ASC `).all(); return rows.map(mapInternalStatusRowToConfig); } function getActiveStatusKeys() { return db.prepare('SELECT key FROM internal_statuses WHERE is_active=1').all().map((r) => String(r.key)); } function getInternalStatusByKey(key) { return db.prepare(` SELECT key,label,color_bg,color_border,color_text,color_dot,sort_order,is_system,system_role,is_active,clear_on_update,config_json FROM internal_statuses WHERE key=? `).get(String(key)); } function normalizeCsvIds(raw) { return String(raw || '') .split(',') .map((v) => String(v).trim()) .filter(Boolean) .filter((v, idx, arr) => arr.indexOf(v) === idx) .join(','); } 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/clients', auth, (req, res) => { const rows = db.prepare('SELECT client_id, name FROM client_dict ORDER BY name ASC').all(); res.json(rows); }); app.get('/api/client-users', auth, (req, res) => { const rows = db.prepare('SELECT group_id, name FROM client_users ORDER BY name ASC').all(); const result = {}; for (const row of rows) { const gid = String(row.group_id); if (!result[gid]) result[gid] = []; result[gid].push(row.name); } res.json(result); }); app.post('/api/admin/sync-client-users', auth, adminOnly, (req, res) => { refreshService.syncClientUsers() .then(() => { const count = db.prepare('SELECT COUNT(*) as c FROM client_users').get(); res.json({ ok: true, count: count.c }); }) .catch((e) => res.status(500).json({ error: e.message })); }); 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 ───────────────────────────────────────────────────────── app.get('/api/internal-statuses', auth, (req, res) => { const includeInactive = req.user.username === 'admin' && String(req.query.include_all || '') === '1'; const statuses = getInternalStatuses({ includeInactive }); res.json({ statuses }); }); app.post('/api/internal-statuses', auth, adminOnly, (req, res) => { const body = req.body || {}; const label = String(body.label || '').trim(); if (!label) return res.status(400).json({ error: 'label required' }); let key = normalizeStatusKey(body.key || label); if (!key) key = `status_${Date.now()}`; const exists = getInternalStatusByKey(key); if (exists) return res.status(400).json({ error: 'Статус с таким key уже существует' }); const sortOrder = Number.isFinite(Number(body.sort_order)) ? Number(body.sort_order) : Date.now(); const row = normalizeStatusConfigForDb({ key, label, colorBg: body.color_bg || body.colorBg || '#e6f4ff', colorBorder: body.color_border || body.colorBorder || '#91caff', colorText: body.color_text || body.colorText || '#0958d9', colorDot: body.color_dot || body.colorDot || '#1677ff', chipLabel: label, sseLabel: label, exportLabel: label, btnLabel: label, btnIcon: String(body.btn_icon || '•'), sort_order: sortOrder, is_system: false, system_role: null, is_active: body.is_active !== false, clearOnUpdate: !!body.clear_on_update, }, 0); db.prepare(` INSERT INTO internal_statuses( key,label,color_bg,color_border,color_text,color_dot,sort_order,is_system,system_role,is_active,clear_on_update,config_json,created_at,updated_at ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now')) `).run( row.key, row.label, row.color_bg, row.color_border, row.color_text, row.color_dot, row.sort_order, 0, null, row.is_active, row.clear_on_update, row.config_json, ); res.json({ ok: true, status: mapInternalStatusRowToConfig(getInternalStatusByKey(key)) }); }); app.put('/api/internal-statuses/:key', auth, adminOnly, (req, res) => { const key = normalizeStatusKey(req.params.key); const existing = getInternalStatusByKey(key); if (!existing) return res.status(404).json({ error: 'Статус не найден' }); const body = req.body || {}; const cfg = safeJsonParse(existing.config_json, {}); const nextLabel = String(body.label ?? existing.label).trim() || existing.label; const nextRow = { color_bg: normalizeColor(body.color_bg || body.colorBg, existing.color_bg), color_border: normalizeColor(body.color_border || body.colorBorder, existing.color_border), color_text: normalizeColor(body.color_text || body.colorText, existing.color_text), color_dot: normalizeColor(body.color_dot || body.colorDot, existing.color_dot), sort_order: Number.isFinite(Number(body.sort_order)) ? Number(body.sort_order) : Number(existing.sort_order || 0), is_active: body.is_active === undefined ? Number(existing.is_active) : (body.is_active ? 1 : 0), clear_on_update: body.clear_on_update === undefined ? Number(existing.clear_on_update) : (body.clear_on_update ? 1 : 0), }; const mergedCfg = { ...cfg, key: existing.key, label: nextLabel, chipLabel: nextLabel, sseLabel: nextLabel, exportLabel: nextLabel, btnLabel: nextLabel, chipDotColor: nextRow.color_dot, ispColor: nextRow.color_dot, colorBg: nextRow.color_bg, colorBorder: nextRow.color_border, colorText: nextRow.color_text, colorDot: nextRow.color_dot, sort_order: nextRow.sort_order, is_active: !!nextRow.is_active, clearOnUpdate: !!nextRow.clear_on_update, is_system: !!existing.is_system, system_role: existing.system_role || null, }; db.prepare(` UPDATE internal_statuses SET label=?, color_bg=?, color_border=?, color_text=?, color_dot=?, sort_order=?, is_active=?, clear_on_update=?, config_json=?, updated_at=datetime('now') WHERE key=? `).run( nextLabel, nextRow.color_bg, nextRow.color_border, nextRow.color_text, nextRow.color_dot, nextRow.sort_order, nextRow.is_active, nextRow.clear_on_update, JSON.stringify(mergedCfg), existing.key, ); res.json({ ok: true, status: mapInternalStatusRowToConfig(getInternalStatusByKey(existing.key)) }); }); app.delete('/api/internal-statuses/:key', auth, adminOnly, (req, res) => { const key = normalizeStatusKey(req.params.key); const existing = getInternalStatusByKey(key); if (!existing) return res.status(404).json({ error: 'Статус не найден' }); if (existing.is_system) return res.status(400).json({ error: 'Системный статус удалять нельзя' }); const tx = db.transaction(() => { db.prepare('DELETE FROM marks WHERE mark_type=?').run(existing.key); db.prepare('DELETE FROM internal_statuses WHERE key=?').run(existing.key); }); tx(); res.json({ ok: true }); }); app.get('/api/intra/status-dictionary', auth, adminOnly, (req, res) => { const items = db.prepare('SELECT status_id, name, updated_at FROM intra_status_dict ORDER BY name ASC, status_id ASC').all(); res.json({ items }); }); 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); }); app.post('/api/marks', auth, (req, res) => { const { task_id, mark_type, value, clear_others } = req.body || {}; const markType = String(mark_type || '').trim(); if (!task_id || !markType) return res.status(400).json({ error: 'Неверные параметры' }); const activeStatuses = new Set(getActiveStatusKeys()); if (!activeStatuses.has(markType)) return res.status(400).json({ error: 'Статус отключен или не найден' }); const now = new Date().toISOString(); const allStatusMarks = [...activeStatuses]; // Если clear_others=true — сбрасываем все остальные статусы одним проходом const clearedMarks = []; if (value && clear_others) { allStatusMarks.filter(t => t !== markType).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, markType, value ? 1 : 0, req.user.username, now); // Одно SSE-событие со списком сброшенных меток sseBroadcast('mark', { task_id, mark_type: markType, value: !!value, by: req.user.username, at: now, cleared_marks: clearedMarks, }); // Outbound sync: push mark label to IntraDesk additional field const settings = getSettings(); if (settings.sync_addfield_status === '1' && settings.api_key) { try { const cacheRow = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); const tasks = JSON.parse(cacheRow?.data || '[]'); const task = tasks.find((t) => String(t.id) === String(task_id)); if (task) { const statusLabel = value ? db.prepare('SELECT label FROM internal_statuses WHERE key=?').get(markType)?.label || null : null; refreshService.updateIntraAdditionalField(task.tasknumber, statusLabel, settings.api_key) .catch((e) => console.error('[Marks] IntraDesk addfield update error:', e.message)); } } catch (e) { console.error('[Marks] Outbound sync error:', e.message); } } res.json({ ok: true }); }); // ── IntraDesk additional field (direct set) ─────────────────────── app.post('/api/tasks/:taskId/intra-addfield', auth, async (req, res) => { const taskId = String(req.params.taskId || '').trim(); const { value } = req.body || {}; const statusLabel = value ? String(value).trim() : null; if (!taskId) return res.status(400).json({ error: 'task_id required' }); const s = getSettings(); if (!s.api_key) return res.status(400).json({ error: 'API ключ не задан' }); const cacheRow = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); const tasks = JSON.parse(cacheRow?.data || '[]'); const task = tasks.find((t) => String(t.id) === taskId); if (!task) return res.status(404).json({ error: 'Задача не найдена в кеше' }); try { await refreshService.updateIntraAdditionalField(task.tasknumber, statusLabel, s.api_key); } catch (e) { console.error('[AddField] IntraDesk update error:', e.message); return res.status(502).json({ error: `Ошибка IntraDesk: ${e.message}` }); } // Обновляем значение в локальном кеше немедленно task._addfield_status = statusLabel; const now = new Date().toISOString(); db.prepare('UPDATE tasks_cache SET data=?, updated_at=? WHERE id=1') .run(JSON.stringify(tasks), now); sseBroadcast('intra_addfield', { task_id: taskId, value: statusLabel, by: req.user.username }); res.json({ ok: true, value: statusLabel }); }); // ── 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', 'auto_reset_on_update', 'sync_reply_wait', 'sync_done_with_intra', 'intra_done_status_ids', 'sync_addfield_status', ]); 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', auto_reset_on_update: s.auto_reset_on_update || '0', sync_reply_wait: s.sync_reply_wait || '0', sync_done_with_intra: s.sync_done_with_intra || '0', intra_done_status_ids: s.intra_done_status_ids || '', sync_addfield_status: s.sync_addfield_status || '0', 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) return; if (k === 'intra_done_status_ids') { setSetting(k, normalizeCsvIds(v)); return; } if (k === 'auto_reset_on_update' || k === 'sync_reply_wait' || k === 'sync_done_with_intra' || k === 'sync_addfield_status') { setSetting(k, String(v) === '1' || v === true ? '1' : '0'); return; } 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`); });