intra_monitor/server.js
dv d571581395
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
фикс статистики 4
2026-04-15 10:49:26 +03:00

1063 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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