1091 lines
45 KiB
JavaScript
1091 lines
45 KiB
JavaScript
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const express = require('express');
|
||
const Database = require('better-sqlite3');
|
||
const bcrypt = require('bcryptjs');
|
||
const jwt = require('jsonwebtoken');
|
||
const fetch = require('node-fetch');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const 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((syncMeta = {}) => {
|
||
const count = db.prepare('SELECT COUNT(*) as c FROM client_users').get();
|
||
res.json({
|
||
ok: true,
|
||
count: count.c,
|
||
fetched: Number(syncMeta.fetched || 0),
|
||
stored: Number(syncMeta.stored || 0),
|
||
odata_count: syncMeta.odata_count !== null && syncMeta.odata_count !== undefined
|
||
? Number(syncMeta.odata_count)
|
||
: null,
|
||
});
|
||
})
|
||
.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`); });
|
||
|