Files
otrs-turbo/routes/tickets.js
T

2821 lines
105 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.
const express = require('express');
const router = express.Router();
const pool = require('../db');
const { db, logAttivita } = require('../activityDb');
const { cleanAndDecodeText } = require('../utils/textDecoder');
// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
function getLocalTimestamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// Helper: resolve agent name from DB (best-effort, non-blocking)
async function resolveAgentName(agentId) {
try {
const r = await pool.query(
`SELECT first_name, last_name, login FROM users WHERE id = $1`,
[agentId]
);
if (r.rows.length > 0) {
const u = r.rows[0];
return (u.first_name || '') + ' ' + (u.last_name || '') || u.login || `Agent #${agentId}`;
}
} catch (_) { /* ignore */ }
return `Agent #${agentId}`;
}
// Helper: resolve agent details for a_from header (best-effort, fallback to 'OTRS Turbo Agent')
async function resolveAgentFromHeader(agentId, client = pool) {
try {
const r = await client.query(
`SELECT first_name, last_name, login FROM users WHERE id = $1`,
[agentId]
);
if (r.rows.length > 0) {
const u = r.rows[0];
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.login || 'Agent';
const prefRes = await client.query(
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
[agentId]
);
const email = (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) || 'agent@localhost';
return `"${fullName}" <${email}>`;
}
} catch (_) { /* ignore */ }
return 'OTRS Turbo Agent';
}
// Helper for OTRS CE GenericInterface REST API calls
async function otrsRequest(method, path, bodyData = {}) {
const OTRS_API_USER = process.env.OTRS_API_USER;
const OTRS_API_PASSWORD = process.env.OTRS_API_PASSWORD;
const OTRS_API_URL = process.env.OTRS_API_URL;
if (!OTRS_API_URL || !OTRS_API_USER) {
return null;
}
const url = `${OTRS_API_URL}${path}`;
const payload = {
UserLogin: OTRS_API_USER,
Password: OTRS_API_PASSWORD,
...bodyData
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
}
const result = await response.json();
if (result && result.Error) {
throw new Error(`OTRS API Error: ${result.Error.ErrorMessage} (${result.Error.ErrorCode})`);
}
return result;
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('OTRS REST API request timed out (15s limit exceeded)');
}
throw err;
}
}
// ============================================================
// GET /api/tickets — List tickets with filters & pagination
// ============================================================
router.get('/', async (req, res) => {
try {
const {
queue_id, state_id, priority_id, user_id, type_id, customer_user_id,
search, sub_search, sort_by = 'create_time', sort_dir = 'DESC',
page = 1, per_page = 50,
date_from, date_to
} = req.query;
const conditions = [];
const params = [];
let paramIdx = 1;
if (customer_user_id) {
let logins = [];
if (Array.isArray(customer_user_id)) {
logins = customer_user_id.map(l => String(l).trim()).filter(Boolean);
} else if (typeof customer_user_id === 'string') {
logins = customer_user_id.split(',').map(l => l.trim()).filter(Boolean);
} else {
logins = [String(customer_user_id).trim()];
}
if (logins.length > 0) {
const placeholders = logins.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.customer_user_id IN (${placeholders})`);
params.push(...logins);
}
}
if (queue_id) {
let queueIds = [];
if (Array.isArray(queue_id)) {
queueIds = queue_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof queue_id === 'string') {
queueIds = queue_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(queue_id);
if (!isNaN(parsed)) queueIds.push(parsed);
}
if (queueIds.length > 0) {
const placeholders = queueIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.queue_id IN (${placeholders})`);
params.push(...queueIds);
}
}
if (state_id) {
let stateIds = [];
if (Array.isArray(state_id)) {
stateIds = state_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof state_id === 'string') {
stateIds = state_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(state_id);
if (!isNaN(parsed)) stateIds.push(parsed);
}
if (stateIds.length > 0) {
const placeholders = stateIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.ticket_state_id IN (${placeholders})`);
params.push(...stateIds);
}
}
if (priority_id) {
let priorityIds = [];
if (Array.isArray(priority_id)) {
priorityIds = priority_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof priority_id === 'string') {
priorityIds = priority_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(priority_id);
if (!isNaN(parsed)) priorityIds.push(parsed);
}
if (priorityIds.length > 0) {
const placeholders = priorityIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.ticket_priority_id IN (${placeholders})`);
params.push(...priorityIds);
}
}
if (user_id) {
let userIds = [];
if (Array.isArray(user_id)) {
userIds = user_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof user_id === 'string') {
userIds = user_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(user_id);
if (!isNaN(parsed)) userIds.push(parsed);
}
if (userIds.length > 0) {
const placeholders = userIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.user_id IN (${placeholders})`);
params.push(...userIds);
}
}
if (type_id) {
conditions.push(`t.type_id = $${paramIdx++}`);
params.push(parseInt(type_id));
}
if (date_from) {
conditions.push(`t.create_time >= $${paramIdx++}`);
params.push(new Date(date_from));
}
if (date_to) {
conditions.push(`t.create_time <= $${paramIdx++}`);
params.push(new Date(date_to));
}
const searchTerm = search || sub_search;
if (searchTerm) {
conditions.push(`(
t.title ILIKE $${paramIdx} OR
t.tn ILIKE $${paramIdx} OR
EXISTS (
SELECT 1 FROM article art
LEFT JOIN article_data_mime adm ON art.id = adm.article_id
WHERE art.ticket_id = t.id AND (
adm.a_subject ILIKE $${paramIdx} OR
adm.a_body ILIKE $${paramIdx}
)
)
)`);
params.push(`%${searchTerm}%`);
paramIdx++;
}
const whereClause = conditions.length > 0
? 'WHERE ' + conditions.join(' AND ')
: '';
// Whitelist sortable columns
const sortableColumns = {
create_time: 't.create_time',
change_time: 't.change_time',
title: 't.title',
tn: 't.tn',
priority: 't.ticket_priority_id',
state: 't.ticket_state_id',
queue: 'q.name',
};
const sortColumn = sortableColumns[sort_by] || 't.create_time';
const sortDirection = sort_dir.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const offset = (parseInt(page) - 1) * parseInt(per_page);
// Count total
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM ticket t
JOIN queue q ON t.queue_id = q.id
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].total);
// Fetch tickets
const result = await pool.query(
`SELECT
t.id, t.tn, t.title,
t.queue_id, q.name AS queue_name,
t.ticket_state_id, ts.name AS state_name, tst.name AS state_type,
t.ticket_priority_id, tp.name AS priority_name, tp.color AS priority_color,
t.user_id, u.first_name AS owner_first, u.last_name AS owner_last,
t.type_id, tt.name AS type_name,
t.customer_id, t.customer_user_id,
cu.first_name AS customer_first, cu.last_name AS customer_last,
t.ticket_lock_id,
t.create_time, t.change_time,
t.escalation_time
FROM ticket t
JOIN queue q ON t.queue_id = q.id
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_state_type tst ON ts.type_id = tst.id
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN ticket_type tt ON t.type_id = tt.id
LEFT JOIN customer_user cu ON t.customer_user_id = cu.login
${whereClause}
ORDER BY ${sortColumn} ${sortDirection}
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
[...params, parseInt(per_page), offset]
);
let otrsBaseUrl = null;
const apiUrl = process.env.OTRS_API_URL;
if (apiUrl) {
const idx = apiUrl.indexOf('/otrs/');
if (idx !== -1) {
otrsBaseUrl = apiUrl.substring(0, idx + 6);
}
}
result.rows.forEach(t => {
if (t.title) t.title = cleanAndDecodeText(t.title);
if (t.customer_first) t.customer_first = cleanAndDecodeText(t.customer_first);
if (t.customer_last) t.customer_last = cleanAndDecodeText(t.customer_last);
});
res.json({
tickets: result.rows,
total,
page: parseInt(page),
per_page: parseInt(per_page),
total_pages: Math.ceil(total / parseInt(per_page)),
otrsBaseUrl,
});
} catch (err) {
console.error('Error fetching tickets:', err);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// GET /api/tickets/:id — Single ticket detail
// ============================================================
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const ticketResult = await pool.query(
`SELECT
t.id, t.tn, t.title,
t.queue_id, q.name AS queue_name,
t.ticket_state_id, ts.name AS state_name, tst.name AS state_type,
t.ticket_priority_id, tp.name AS priority_name, tp.color AS priority_color,
t.user_id, u.first_name AS owner_first, u.last_name AS owner_last, u.login AS owner_login,
t.responsible_user_id,
ru.first_name AS responsible_first, ru.last_name AS responsible_last,
t.type_id, tt.name AS type_name,
t.ticket_lock_id, tlt.name AS lock_name,
t.customer_id, t.customer_user_id,
t.service_id, t.sla_id,
t.escalation_time, t.escalation_update_time,
t.escalation_response_time, t.escalation_solution_time,
t.create_time, t.change_time,
cu.first_name AS customer_first, cu.last_name AS customer_last,
cu.email AS customer_email, cu.phone AS customer_phone
FROM ticket t
JOIN queue q ON t.queue_id = q.id
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_state_type tst ON ts.type_id = tst.id
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN users ru ON t.responsible_user_id = ru.id
LEFT JOIN ticket_type tt ON t.type_id = tt.id
LEFT JOIN ticket_lock_type tlt ON t.ticket_lock_id = tlt.id
LEFT JOIN customer_user cu ON t.customer_user_id = cu.login
WHERE t.id = $1`,
[id]
);
if (ticketResult.rows.length === 0) {
return res.status(404).json({ error: 'Ticket not found' });
}
const ticket = ticketResult.rows[0];
if (ticket.customer_user_id && !ticket.customer_first && process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
try {
// 1. Try to read from local SQLite cache in internal.db
let cachedUser = null;
try {
cachedUser = db.prepare('SELECT email, first_name, last_name, phone FROM customer_user_cache WHERE login = ?').get(ticket.customer_user_id);
} catch (dbErr) {
console.warn('[LDAP Cache Detail] SQLite cache read failed:', dbErr.message);
}
if (cachedUser) {
ticket.customer_first = cachedUser.first_name || '';
ticket.customer_last = cachedUser.last_name || '';
ticket.customer_email = cachedUser.email || '';
ticket.customer_phone = cachedUser.phone || '';
} else {
// 2. Fallback to live API fetch
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: ticket.customer_user_id });
const getData = (getResult && getResult.Data) || getResult;
if (getData && getData.CustomerUser) {
const u = getData.CustomerUser;
ticket.customer_first = u.UserFirstname || '';
ticket.customer_last = u.UserLastname || '';
ticket.customer_email = u.UserEmail || '';
ticket.customer_phone = u.UserPhone || '';
// 3. Store in SQLite cache for subsequent detail queries
try {
db.prepare(`
INSERT INTO customer_user_cache (login, email, first_name, last_name, customer_id, phone)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(login) DO UPDATE SET
email=excluded.email,
first_name=excluded.first_name,
last_name=excluded.last_name,
customer_id=excluded.customer_id,
phone=excluded.phone
`).run(
u.UserLogin,
u.UserEmail || '',
u.UserFirstname || '',
u.UserLastname || '',
u.UserCustomerID || '',
u.UserPhone || ''
);
} catch (cacheErr) {
console.warn('[LDAP Cache Detail] Failed to write fetched user to SQLite cache:', cacheErr.message);
}
}
}
} catch (apiErr) {
console.warn(`Failed to fetch LDAP customer details for detail view:`, apiErr.message);
}
}
// Fetch articles
const articlesResult = await pool.query(
`SELECT
a.id AS article_id,
a.ticket_id,
a.is_visible_for_customer,
ast.name AS sender_type,
cc.name AS channel_name,
adm.a_from, adm.a_to, adm.a_cc, adm.a_subject, adm.a_body,
adm.a_content_type, adm.incoming_time,
a.create_time,
creator.first_name AS creator_first, creator.last_name AS creator_last,
ta.time_unit
FROM article a
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
LEFT JOIN users creator ON a.create_by = creator.id
LEFT JOIN (
SELECT article_id, SUM(time_unit) AS time_unit
FROM time_accounting
GROUP BY article_id
) ta ON a.id = ta.article_id
WHERE a.ticket_id = $1
ORDER BY a.create_time DESC, a.id DESC`,
[id]
);
// Fetch attachments metadata for all articles in the ticket
const attachmentsResult = await pool.query(
`SELECT id, article_id, filename, content_size, content_type, disposition, content_id
FROM article_data_mime_attachment
WHERE article_id IN (
SELECT id FROM article WHERE ticket_id = $1
)`,
[id]
);
// Fetch HTML body attachment content for articles if present (e.g. file-1, file-2, text/html, html attachments)
const htmlAttachmentsRes = await pool.query(
`SELECT id, article_id, filename, content_type, content
FROM article_data_mime_attachment
WHERE article_id IN (SELECT id FROM article WHERE ticket_id = $1)
AND (
filename LIKE 'file-%'
OR filename LIKE '%.html'
OR filename LIKE '%.htm'
OR content_type LIKE '%html%'
OR content_type LIKE '%text%'
OR disposition = 'inline'
)
ORDER BY id ASC`,
[id]
);
const iconv = require('iconv-lite');
const decodeTextContent = (rawContent, contentTypeStr = '') => {
if (!rawContent) return '';
let rawBuf = null;
if (Buffer.isBuffer(rawContent)) {
rawBuf = rawContent;
} else if (typeof rawContent === 'string') {
const cleaned = rawContent.replace(/\s+/g, '');
if (cleaned.length > 20 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
try {
rawBuf = Buffer.from(cleaned, 'base64');
} catch (_) {
rawBuf = Buffer.from(rawContent, 'utf-8');
}
} else {
rawBuf = Buffer.from(rawContent, 'utf-8');
}
} else {
return String(rawContent);
}
// Detect charset from content-type header
let charset = 'utf-8';
const charsetMatch = (contentTypeStr || '').match(/charset=["']?([^"';\s]+)/i);
if (charsetMatch && charsetMatch[1]) {
charset = charsetMatch[1].toLowerCase();
}
let text = '';
try {
if (iconv.encodingExists(charset)) {
text = iconv.decode(rawBuf, charset);
} else {
text = rawBuf.toString('utf-8');
}
} catch (_) {
text = rawBuf.toString('utf-8');
}
// If UTF-8 decoding contains replacement character U+FFFD (), retry decoding as win1252/latin1
if (text.includes('\uFFFD') || text.includes('')) {
try {
const latin1Text = iconv.decode(rawBuf, 'windows-1252');
if (latin1Text && !latin1Text.includes('\uFFFD')) {
text = latin1Text;
}
} catch (_) {}
}
// Repair Double-UTF-8 / Mojibake (e.g., ’ -> ’, ù -> ù, è -> è, à -> à, à -> È)
if (/(?:Ã[\s\xa0¡-ÿ]|â€[™œ]|–)/.test(text)) {
try {
const reEncoded = iconv.encode(text, 'win1252');
const repaired = iconv.decode(reEncoded, 'utf-8');
if (repaired && !repaired.includes('\uFFFD')) {
text = repaired;
}
} catch (_) {}
}
return text;
};
if (ticket.title) ticket.title = cleanAndDecodeText(ticket.title);
if (ticket.customer_first) ticket.customer_first = cleanAndDecodeText(ticket.customer_first);
if (ticket.customer_last) ticket.customer_last = cleanAndDecodeText(ticket.customer_last);
const htmlBodyMap = {};
for (const row of htmlAttachmentsRes.rows) {
if (row.content) {
const contentStr = cleanAndDecodeText(row.content, row.content_type);
if (contentStr && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(contentStr)) {
const fname = (row.filename || '').toLowerCase();
if (!htmlBodyMap[row.article_id] || fname.startsWith('file-1') || fname.startsWith('file-2')) {
htmlBodyMap[row.article_id] = contentStr;
}
}
}
}
articlesResult.rows.forEach(a => {
// Decode article subjects, bodies, and sender/recipient headers
if (a.a_body) {
a.a_body = cleanAndDecodeText(a.a_body, a.a_content_type);
}
if (a.a_subject) {
a.a_subject = cleanAndDecodeText(a.a_subject);
}
if (a.a_from) {
a.a_from = cleanAndDecodeText(a.a_from);
}
if (a.a_to) {
a.a_to = cleanAndDecodeText(a.a_to);
}
if (a.a_cc) {
a.a_cc = cleanAndDecodeText(a.a_cc);
}
if (htmlBodyMap[a.article_id]) {
a.html_body = htmlBodyMap[a.article_id];
a.has_html = true;
} else if (a.a_body && ((a.a_content_type || '').toLowerCase().includes('html') || /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(a.a_body))) {
a.html_body = a.a_body;
a.has_html = true;
} else {
a.has_html = false;
}
});
let otrsWebUrl = null;
const apiUrl = process.env.OTRS_API_URL;
if (apiUrl) {
const idx = apiUrl.indexOf('/otrs/');
if (idx !== -1) {
const base = apiUrl.substring(0, idx + 6);
otrsWebUrl = `${base}index.pl?Action=AgentTicketZoom;TicketID=${id}`;
}
}
res.json({
ticket: ticketResult.rows[0],
articles: articlesResult.rows,
attachments: attachmentsResult.rows,
otrsWebUrl,
});
} catch (err) {
console.error('Error fetching ticket detail:', err);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// POST /api/tickets — Create new ticket
// ============================================================
router.post('/', async (req, res) => {
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const client = await pool.connect();
try {
const {
title, queue_id, state_id, priority_id, type_id,
user_id, customer_id, customer_user_id, body, subject,
responsible_user_id, attachments
} = req.body;
await client.query('BEGIN');
// Generate ticket number: get next counter value for today (daily reset)
const now = new Date();
const systemId = process.env.OTRS_SYSTEM_ID || String(operatorId);
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
// 1. Fetch current maximum counter in ticket table for today
const maxTicketResult = await client.query(
`SELECT tn FROM ticket WHERE tn LIKE $1`,
[`${datePrefix}%`]
);
let maxCounterFromTicketTable = 0;
for (const row of maxTicketResult.rows) {
const counterPart = row.tn.substring(datePrefix.length);
const parsed = parseInt(counterPart, 10);
if (!isNaN(parsed) && parsed > maxCounterFromTicketTable) {
maxCounterFromTicketTable = parsed;
}
}
// 2. Fetch current maximum counter in ticket_number_counter table for today
const counterTodayResult = await client.query(
`SELECT COALESCE(MAX(counter), 0) AS max_counter
FROM ticket_number_counter
WHERE create_time >= CURRENT_DATE`
);
const maxCounterFromCounterTable = parseInt(counterTodayResult.rows[0].max_counter, 10);
// 3. Compute the next counter (absolute max + 1)
const nextCounter = Math.max(maxCounterFromTicketTable, maxCounterFromCounterTable) + 1;
const counterResult = await client.query(
`INSERT INTO ticket_number_counter (counter, counter_uid, create_time)
VALUES (
$1,
md5(random()::text || clock_timestamp()::text),
NOW()
)
RETURNING counter`,
[nextCounter]
);
const counter = counterResult.rows[0].counter;
const counterPadding = parseInt(process.env.OTRS_COUNTER_PADDING, 10) || 6;
const tn = `${datePrefix}${String(counter).padStart(counterPadding, '0')}`;
// Determine lock type (1 = unlock by default)
const lockId = 1;
// Default responsible user = responsible_user_id or user_id or 1 (admin)
const responsibleUserId = responsible_user_id || user_id || 1;
// Operator user for create_by (X-Agent-ID header or default to 1)
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const localNow = getLocalTimestamp();
const ticketResult = await client.query(
`INSERT INTO ticket (
tn, title, queue_id, ticket_lock_id, type_id,
user_id, responsible_user_id,
ticket_priority_id, ticket_state_id,
customer_id, customer_user_id,
timeout, until_time,
escalation_time, escalation_update_time,
escalation_response_time, escalation_solution_time,
archive_flag,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9,
$10, $11,
0, 0,
0, 0,
0, 0,
0,
$12, $13, $12, $13
) RETURNING id, tn`,
[
tn, title, queue_id, lockId, type_id || null,
user_id || 1, responsibleUserId,
priority_id, state_id,
customer_id || null, customer_user_id || null,
localNow, operatorId
]
);
const ticketId = ticketResult.rows[0].id;
// Get the history type ID for "NewTicket"
const htResult = await client.query(
`SELECT id FROM ticket_history_type WHERE name = 'NewTicket'`
);
const historyTypeId = htResult.rows.length > 0 ? htResult.rows[0].id : 1;
// Insert ticket history
await client.query(
`INSERT INTO ticket_history (
name, history_type_id, ticket_id, type_id, queue_id,
owner_id, priority_id, state_id,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10, $9, $10
)`,
[
`%%`,
historyTypeId, ticketId, type_id || 1, queue_id,
user_id || 1, priority_id, state_id,
localNow, operatorId
]
);
// Create initial article if body or attachments are provided
let finalBody = body;
const isBodyEmpty = !finalBody || finalBody.trim() === '' || finalBody.trim() === '<p><br></p>';
if (isBodyEmpty) {
finalBody = subject || title || 'Nuovo ticket';
}
if (finalBody || (attachments && attachments.length > 0)) {
// Determine sender type (customer vs agent) and sender name/email
let senderTypeName = 'agent';
let customerFrom = 'OTRS Turbo Agent';
if (customer_user_id) {
const custRes = await client.query(
`SELECT email, first_name, last_name FROM customer_user WHERE login = $1`,
[customer_user_id]
);
if (custRes.rows.length > 0) {
const c = custRes.rows[0];
customerFrom = `${c.first_name} ${c.last_name} <${c.email}>`;
senderTypeName = 'customer';
} else if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
try {
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: customer_user_id });
const getData = (getResult && getResult.Data) || getResult;
if (getData && getData.CustomerUser) {
const u = getData.CustomerUser;
customerFrom = `${u.UserFirstname || ''} ${u.UserLastname || ''} <${u.UserEmail || ''}>`.trim();
senderTypeName = 'customer';
}
} catch (apiErr) {
console.warn(`Failed to fetch LDAP customer details for ${customer_user_id}:`, apiErr.message);
}
}
}
// Get sender type ID
const senderResult = await client.query(
`SELECT id FROM article_sender_type WHERE name = $1`,
[senderTypeName]
);
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
// Get communication channel ID for "Internal"
const channelResult = await client.query(
`SELECT id FROM communication_channel WHERE name = 'Internal'`
);
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
const articleResult = await client.query(
`INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, 0, 1, $4, $5, $4, $5
) RETURNING id`,
[ticketId, senderTypeId, channelId, localNow, operatorId]
);
const articleId = articleResult.rows[0].id;
const isHtml = finalBody && /<[a-z][\s\S]*>/i.test(finalBody);
const contentType = isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
await client.query(
`INSERT INTO article_data_mime (
article_id, a_from, a_to, a_subject, a_body,
a_content_type, incoming_time,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, '', $3, $4,
$5, EXTRACT(EPOCH FROM NOW())::INTEGER,
$6, $7, $6, $7
)`,
[articleId, customerFrom, subject || title, finalBody, contentType, localNow, operatorId]
);
// If HTML content, create article_data_mime_attachment for OTRS CE HTML rendering (file-1)
if (isHtml) {
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${finalBody}</body></html>`;
const binaryBody = Buffer.from(htmlBody, 'utf-8');
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
const base64Body = binaryBody.toString('base64');
await client.query(
`INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type, disposition, content,
create_time, create_by, change_time, change_by
) VALUES (
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
$4, $5, $4, $5
)`,
[articleId, String(contentSize), base64Body, localNow, operatorId]
);
}
// Insert attachments if any
if (attachments && Array.isArray(attachments)) {
for (const att of attachments) {
const contentBuffer = Buffer.from(att.content, 'base64');
await client.query(
`INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type, disposition, content,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, $4, 'attachment', $5, $6, $7, $6, $7)`,
[
articleId,
att.filename,
contentBuffer.length,
att.content_type || 'application/octet-stream',
att.content, // OTRS CE expects base64 string directly
localNow,
operatorId
]
);
}
}
}
await client.query('COMMIT');
// Log activity
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Creazione Ticket',
azione: { ticket_id: ticketId, tn: ticketResult.rows[0].tn, title, queue_id, state_id, priority_id, customer_id, customer_user_id },
esito: 'successo',
});
});
res.status(201).json({
id: ticketId,
tn: ticketResult.rows[0].tn,
message: 'Ticket created successfully',
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Error creating ticket:', err);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({ agente_id: operatorId, agente_nome, titolo_azione: 'Creazione Ticket', azione: { error: err.message }, esito: 'errore' });
});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// ============================================================
// PATCH /api/tickets/:id — Quick-edit ticket fields
// ============================================================
router.patch('/:id', async (req, res) => {
const { id } = req.params;
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, note }
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const timeUnit = parseFloat(updates.time_unit);
const noteSubjectText = typeof updates.note_subject === 'string' ? updates.note_subject.trim() : '';
const noteText = typeof updates.note === 'string' ? updates.note.trim() : (typeof updates.note_text === 'string' ? updates.note_text.trim() : '');
const hasNote = noteText.length > 0 || noteSubjectText.length > 0;
// Fetch current ticket first (needed for both REST dummy fields and database fallback)
let current;
try {
const currentResult = await pool.query(
`SELECT tn, ticket_state_id, ticket_priority_id, queue_id, user_id, responsible_user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id
FROM ticket WHERE id = $1`,
[id]
);
if (currentResult.rows.length === 0) {
return res.status(404).json({ error: 'Ticket non trovato' });
}
current = currentResult.rows[0];
} catch (dbErr) {
console.error('Error fetching current ticket:', dbErr);
return res.status(500).json({ error: dbErr.message });
}
// 1. Try to update via REST API if configured
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER && process.env.FORCE_DB_UPDATE !== 'true') {
try {
const ticketFields = {};
if (updates.ticket_state_id !== undefined) ticketFields.StateID = updates.ticket_state_id;
if (updates.ticket_priority_id !== undefined) ticketFields.PriorityID = updates.ticket_priority_id;
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
if (updates.title !== undefined) ticketFields.Title = updates.title;
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') ticketFields.CustomerUser = updates.customer_user_id;
// If ticketFields is empty but we have a note or timeUnit to log, supply a dummy field (StateID) so Ticket is not empty
if (Object.keys(ticketFields).length === 0 && (hasNote || (!isNaN(timeUnit) && timeUnit > 0))) {
ticketFields.StateID = current.ticket_state_id;
}
// Auto sblocco check
if (updates.ticket_state_id) {
const stateTypeRes = await pool.query(
`SELECT tst.name AS type_name
FROM ticket_state ts
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE ts.id = $1`,
[updates.ticket_state_id]
);
if (stateTypeRes.rows.length > 0) {
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
ticketFields.LockID = 1;
}
}
}
if (Object.keys(ticketFields).length > 0 || hasNote || (!isNaN(timeUnit) && timeUnit > 0)) {
const reqBody = {
Ticket: ticketFields
};
if (hasNote || (!isNaN(timeUnit) && timeUnit > 0)) {
const noteSubject = noteSubjectText || (hasNote ? noteText : 'Consuntivazione');
const noteBody = noteText || noteSubjectText || 'Consuntivazione';
const articleObj = {
CommunicationChannel: 'Internal',
SenderType: 'agent',
Subject: noteSubject,
Body: noteBody,
ContentType: 'text/html; charset=utf8'
};
if (!isNaN(timeUnit) && timeUnit > 0) {
articleObj.TimeUnit = timeUnit;
articleObj.TimeUnits = timeUnit;
}
reqBody.Article = articleObj;
}
const result = await otrsRequest('PATCH', `/Ticket/${id}`, reqBody);
const isClosing = updates.ticket_state_id && current.ticket_state_id !== updates.ticket_state_id;
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: isClosing ? 'Chiusura Ticket' : 'Modifica Rapida Ticket',
azione: { ticket_id: id, tn: current.tn, ...updates },
esito: 'successo',
});
});
return res.json({ message: 'Ticket aggiornato! (via API REST)', result });
}
return res.json({ message: 'Nessuna modifica rilevata' });
} catch (restErr) {
console.warn('Failed to update ticket via REST API, falling back to database update:', restErr.message);
// Fall through to standard direct database update below
}
}
// 2. Direct database update fallback
const client = await pool.connect();
try {
await client.query('BEGIN');
const localNow = getLocalTimestamp();
// If state is changing, check if the target state is a closed state type to auto-unlock
if (updates.ticket_state_id && updates.ticket_state_id !== current.ticket_state_id) {
const stateTypeRes = await client.query(
`SELECT tst.name AS type_name
FROM ticket_state ts
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE ts.id = $1`,
[updates.ticket_state_id]
);
if (stateTypeRes.rows.length > 0) {
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
updates.ticket_lock_id = 1; // 1 = unlock in OTRS
}
}
}
// Build dynamic UPDATE
const setClauses = [];
const setParams = [];
let pIdx = 1;
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'responsible_user_id', 'type_id', 'title', 'ticket_lock_id', 'customer_id', 'customer_user_id'];
for (const field of allowedFields) {
if (updates[field] !== undefined && updates[field] !== current[field]) {
setClauses.push(`${field} = $${pIdx++}`);
setParams.push(updates[field]);
}
}
const timeUnitVal = parseFloat(updates.time_unit);
const hasTimeUnit = !isNaN(timeUnitVal) && timeUnitVal > 0;
if (setClauses.length === 0 && !hasTimeUnit && !hasNote) {
await client.query('ROLLBACK');
return res.json({ message: 'No changes detected' });
}
if (setClauses.length > 0) {
// Always update change_time and change_by
setClauses.push(`change_time = $${pIdx++}`);
setClauses.push(`change_by = $${pIdx++}`);
setParams.push(localNow);
setParams.push(operatorId);
setParams.push(parseInt(id));
await client.query(
`UPDATE ticket SET ${setClauses.join(', ')} WHERE id = $${pIdx}`,
setParams
);
} else {
// If there are no fields modified but we have a note or time_unit, we still update change_time/change_by
await client.query(
`UPDATE ticket SET change_time = $1, change_by = $2 WHERE id = $3`,
[localNow, operatorId, id]
);
}
// Record history entries for each changed field
const historyTypeMap = {
ticket_state_id: 'StateUpdate',
ticket_priority_id: 'PriorityUpdate',
queue_id: 'Move',
user_id: 'OwnerUpdate',
responsible_user_id: 'ResponsibleUpdate',
type_id: 'TypeUpdate',
ticket_lock_id: 'Lock',
customer_id: 'CustomerUpdate',
customer_user_id: 'CustomerUpdate',
};
for (const field of allowedFields) {
if (updates[field] !== undefined && updates[field] !== current[field]) {
const historyTypeName = historyTypeMap[field];
if (!historyTypeName) continue;
const htResult = await client.query(
`SELECT id FROM ticket_history_type WHERE name = $1`,
[historyTypeName]
);
if (htResult.rows.length === 0) continue;
const newStateId = updates.ticket_state_id || current.ticket_state_id;
const newPriorityId = updates.ticket_priority_id || current.ticket_priority_id;
const newQueueId = updates.queue_id || current.queue_id;
const newOwnerId = updates.user_id || current.user_id;
const newTypeId = updates.type_id || current.type_id || 1;
let historyName = '%%';
if (field === 'ticket_lock_id') {
historyName = updates[field] === 1 ? '%%unlock' : '%%lock';
}
await client.query(
`INSERT INTO ticket_history (
name, history_type_id, ticket_id, type_id, queue_id,
owner_id, priority_id, state_id,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10, $9, $10
)`,
[
historyName,
htResult.rows[0].id, parseInt(id), newTypeId, newQueueId,
newOwnerId, newPriorityId, newStateId,
localNow, operatorId
]
);
}
}
// If note or time_unit is provided, insert a system note article and log the time
if (hasNote || hasTimeUnit) {
// Get sender type for "agent"
const senderResult = await client.query(
`SELECT id FROM article_sender_type WHERE name = 'agent'`
);
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
// Get channel for "Internal"
const channelResult = await client.query(
`SELECT id FROM communication_channel WHERE name = 'Internal'`
);
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
// Create article
const articleResult = await client.query(
`INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, 0, 1, $4, $5, $4, $5
) RETURNING id`,
[id, senderTypeId, channelId, localNow, operatorId]
);
const articleId = articleResult.rows[0].id;
const now = new Date();
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
const noteSubject = noteSubjectText || (hasNote ? noteText : 'Consuntivazione');
const noteBodyRaw = noteText || noteSubjectText || 'Consuntivazione';
const safeBody = noteBodyRaw.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br/>');
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${safeBody}</body></html>`;
const binaryBody = Buffer.from(htmlBody, 'utf-8');
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
const base64Body = binaryBody.toString('base64');
const fromHeader = await resolveAgentFromHeader(operatorId, client);
// Create article_data_mime
await client.query(
`INSERT INTO article_data_mime (
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
a_message_id, a_in_reply_to, a_references,
a_content_type, incoming_time, content_path,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, '', '', '', '', $3, $4,
'', '', '',
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
$6, $7, $6, $7
)`,
[articleId, fromHeader, noteSubject, htmlBody, contentPath, localNow, operatorId]
);
// Create article_data_mime_attachment for OTRS CE HTML rendering
await client.query(
`INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type, disposition, content,
create_time, create_by, change_time, change_by
) VALUES (
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
$4, $5, $4, $5
)`,
[articleId, String(contentSize), base64Body, localNow, operatorId]
);
// Log the time in time_accounting if provided
if (hasTimeUnit) {
await client.query(
`INSERT INTO time_accounting (
ticket_id, article_id, time_unit,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, $4, $5, $4, $5)`,
[id, articleId, timeUnitVal, localNow, operatorId]
);
}
}
await client.query('COMMIT');
const isClosingDB = updates.ticket_state_id && current.ticket_state_id !== updates.ticket_state_id;
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: isClosingDB ? 'Chiusura Ticket' : 'Modifica Rapida Ticket',
azione: { ticket_id: id, tn: current.tn, ...updates },
esito: 'successo',
});
});
res.json({ message: 'Ticket aggiornato! (via DB)' });
} catch (err) {
await client.query('ROLLBACK');
console.error('Error updating ticket:', err);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({ agente_id: operatorId, agente_nome, titolo_azione: 'Modifica Rapida Ticket', azione: { ticket_id: id, error: err.message }, esito: 'errore' });
});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// ============================================================
// GET /api/tickets/:id/articles — Articles for a ticket
// ============================================================
router.get('/:id/articles', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT
a.id AS article_id,
a.is_visible_for_customer,
ast.name AS sender_type,
cc.name AS channel_name,
adm.a_from, adm.a_to, adm.a_cc, adm.a_subject, adm.a_body,
adm.a_content_type, adm.incoming_time, adm.a_message_id, adm.a_references,
a.create_time,
creator.first_name AS creator_first, creator.last_name AS creator_last,
ta.time_unit
FROM article a
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
LEFT JOIN users creator ON a.create_by = creator.id
LEFT JOIN (
SELECT article_id, SUM(time_unit) AS time_unit
FROM time_accounting
GROUP BY article_id
) ta ON a.id = ta.article_id
WHERE a.ticket_id = $1
ORDER BY a.create_time DESC, a.id DESC`,
[id]
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching articles:', err);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// POST /api/tickets/:id/articles — Add internal note
// ============================================================
router.post('/:id/articles', async (req, res) => {
const { id } = req.params;
const { subject, body, is_visible_for_customer = 0, time_unit, attachments, ticket_state_id } = req.body;
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
const contentType = isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
// 1. Try to add note via REST API if configured
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER && process.env.FORCE_DB_UPDATE !== 'true') {
try {
const payload = {
Article: {
CommunicationChannel: 'Internal',
SenderType: 'agent',
IsVisibleForCustomer: is_visible_for_customer ? '1' : '0',
Subject: subject || 'Nota interna',
Body: body,
ContentType: isHtml ? 'text/html; charset=utf8' : 'text/plain; charset=utf8',
}
};
if (ticket_state_id) {
payload.Ticket = { StateID: parseInt(ticket_state_id, 10) };
try {
const stateTypeRes = await pool.query(
`SELECT tst.name AS type_name
FROM ticket_state ts
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE ts.id = $1`,
[ticket_state_id]
);
if (stateTypeRes.rows.length > 0) {
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
payload.Ticket.LockID = 1;
}
}
} catch (err) {
console.error('Error checking state type for REST API payload:', err);
}
}
if (time_unit) {
payload.Article.TimeUnit = parseFloat(time_unit);
payload.Article.TimeUnits = parseFloat(time_unit);
}
if (attachments && Array.isArray(attachments)) {
payload.Attachment = attachments.map(att => ({
Content: att.content,
ContentType: att.content_type || 'application/octet-stream',
Filename: att.filename
}));
}
const result = await otrsRequest('PATCH', `/Ticket/${id}`, payload);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Aggiunta Nota',
azione: { ticket_id: id, subject, time_unit, has_attachments: !!(attachments && attachments.length) },
esito: 'successo',
});
});
return res.status(201).json({
message: 'Nota aggiunta! (via API REST)',
article_id: result.ArticleID,
result
});
} catch (restErr) {
console.error('Failed to add article via REST API:', restErr.message);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Aggiunta Nota',
azione: { ticket_id: id, error: restErr.message },
esito: 'errore'
});
});
return res.status(500).json({ error: 'Errore durante l\'aggiunta della nota tramite API: ' + restErr.message });
}
}
// 2. Direct database update fallback
const client = await pool.connect();
try {
const { id } = req.params;
const { subject, body, is_visible_for_customer = 0, time_unit, attachments, ticket_state_id } = req.body;
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
// Check if this article was already inserted (e.g., via REST API or concurrent request)
const thirtySecondsAgo = new Date(Date.now() - 30000);
const recentCheck = await client.query(
`SELECT a.id
FROM article a
JOIN article_data_mime adm ON a.id = adm.article_id
WHERE a.ticket_id = $1
AND adm.a_subject = $2
AND adm.a_body = $3
AND a.create_time >= $4`,
[id, subject || 'Nota interna', body, thirtySecondsAgo]
);
if (recentCheck.rows.length > 0) {
const existingArticleId = recentCheck.rows[0].id;
console.log(`[Fallback Check] Found existing recent article (ID: ${existingArticleId}) in database. Skipping duplicate insert.`);
await client.query('BEGIN');
// If time_unit is provided, insert into time_accounting if not already present
if (time_unit !== undefined && time_unit !== null && time_unit !== '') {
const parsedTime = parseFloat(time_unit);
if (!isNaN(parsedTime) && parsedTime > 0) {
const timeCheck = await client.query(
`SELECT id FROM time_accounting WHERE ticket_id = $1 AND article_id = $2`,
[id, existingArticleId]
);
if (timeCheck.rows.length === 0) {
await client.query(
`INSERT INTO time_accounting (
ticket_id, article_id, time_unit,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
[id, existingArticleId, parsedTime, operatorId]
);
console.log(`[Fallback Check] Logged missing time accounting (${parsedTime} min) for existing article.`);
}
}
}
await client.query('COMMIT');
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Aggiunta Nota',
azione: { ticket_id: id, subject, time_unit, has_attachments: !!(attachments && attachments.length) },
esito: 'successo',
});
});
return res.status(201).json({
article_id: existingArticleId,
message: 'Nota aggiunta! (rilevata in DB)',
});
}
await client.query('BEGIN');
// Verify ticket exists & get current state
const ticketCheck = await client.query(
'SELECT id, ticket_state_id, ticket_priority_id, queue_id, user_id, type_id FROM ticket WHERE id = $1',
[id]
);
if (ticketCheck.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Ticket not found' });
}
const currentTicket = ticketCheck.rows[0];
// Check if ticket state should be updated
if (ticket_state_id && parseInt(ticket_state_id, 10) !== currentTicket.ticket_state_id) {
const newStateId = parseInt(ticket_state_id, 10);
const stateTypeRes = await client.query(
`SELECT tst.name AS type_name
FROM ticket_state ts
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE ts.id = $1`,
[newStateId]
);
let lockIdToSet = null;
if (stateTypeRes.rows.length > 0) {
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
lockIdToSet = 1;
}
}
if (lockIdToSet !== null) {
await client.query(
`UPDATE ticket SET ticket_state_id = $1, ticket_lock_id = $2, change_time = $3, change_by = $4 WHERE id = $5`,
[newStateId, lockIdToSet, getLocalTimestamp(), operatorId, id]
);
} else {
await client.query(
`UPDATE ticket SET ticket_state_id = $1, change_time = $2, change_by = $3 WHERE id = $4`,
[newStateId, getLocalTimestamp(), operatorId, id]
);
}
const htResult = await client.query(
`SELECT id FROM ticket_history_type WHERE name = 'StateUpdate'`
);
if (htResult.rows.length > 0) {
await client.query(
`INSERT INTO ticket_history (
name, history_type_id, ticket_id, type_id, queue_id,
owner_id, priority_id, state_id, create_time, create_by, change_time, change_by
) VALUES (
'%%', $1, $2, $3, $4,
$5, $6, $7, $8, $9, $8, $9
)`,
[
htResult.rows[0].id, id, currentTicket.type_id || 1, currentTicket.queue_id,
currentTicket.user_id, currentTicket.ticket_priority_id, newStateId,
getLocalTimestamp(), operatorId
]
);
}
}
// Get sender type for "agent"
const senderResult = await client.query(
`SELECT id FROM article_sender_type WHERE name = 'agent'`
);
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
// Get channel for "Internal"
const channelResult = await client.query(
`SELECT id FROM communication_channel WHERE name = 'Internal'`
);
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
const localNow = getLocalTimestamp();
// Create article
const articleResult = await client.query(
`INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, $4, 1, $5, $6, $5, $6
) RETURNING id`,
[id, senderTypeId, channelId, is_visible_for_customer ? 1 : 0, localNow, operatorId]
);
const articleId = articleResult.rows[0].id;
// Calculate date path for OTRS CE compatibility
const now = new Date();
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
const fromHeader = await resolveAgentFromHeader(operatorId, client);
// Create article_data_mime
await client.query(
`INSERT INTO article_data_mime (
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
a_message_id, a_in_reply_to, a_references,
a_content_type, incoming_time, content_path,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, '', '', '', '', $3, $4,
'', '', '',
$5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
$7, $8, $7, $8
)`,
[articleId, fromHeader, subject || 'Nota interna', body, contentType, contentPath, localNow, operatorId]
);
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and base64 encoded text)
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body}</body></html>`;
const binaryBody = Buffer.from(htmlBody, 'utf-8');
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
const base64Body = binaryBody.toString('base64');
await client.query(
`INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type, disposition, content,
create_time, create_by, change_time, change_by
) VALUES (
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
$4, $5, $4, $5
)`,
[articleId, String(contentSize), base64Body, localNow, operatorId]
);
// Insert additional attachments if any
if (attachments && Array.isArray(attachments)) {
for (const att of attachments) {
const contentBuffer = Buffer.from(att.content, 'base64');
await client.query(
`INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type, disposition, content,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, $4, 'attachment', $5, $7, $6, $7, $6)`,
[
articleId,
att.filename,
contentBuffer.length,
att.content_type || 'application/octet-stream',
att.content, // base64 string directly
operatorId,
localNow
]
);
}
}
// If time_unit is provided, insert into time_accounting
if (time_unit !== undefined && time_unit !== null && time_unit !== '') {
const parsedTime = parseFloat(time_unit);
if (!isNaN(parsedTime) && parsedTime > 0) {
await client.query(
`INSERT INTO time_accounting (
ticket_id, article_id, time_unit,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, $5, $4, $5, $4)`,
[id, articleId, parsedTime, operatorId, localNow]
);
}
}
await client.query(
`UPDATE ticket SET change_time = $1, change_by = $2 WHERE id = $3`,
[localNow, operatorId, id]
);
// Add history entry
const htResult = await client.query(
`SELECT id FROM ticket_history_type WHERE name = 'AddNote'`
);
if (htResult.rows.length > 0) {
const ticketData = await client.query(
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, type_id
FROM ticket WHERE id = $1`,
[id]
);
const t = ticketData.rows[0];
await client.query(
`INSERT INTO ticket_history (
name, history_type_id, ticket_id, article_id, type_id, queue_id,
owner_id, priority_id, state_id,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
$11, $10, $11, $10
)`,
[
`%%`,
htResult.rows[0].id, id, articleId, t.type_id || 1, t.queue_id,
t.user_id, t.ticket_priority_id, t.ticket_state_id,
operatorId, localNow
]
);
}
await client.query('COMMIT');
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Aggiunta Nota',
azione: { ticket_id: id, tn: t.tn, subject, time_unit, has_attachments: !!(attachments && attachments.length) },
esito: 'successo',
});
});
res.status(201).json({
article_id: articleId,
message: 'Nota aggiunta! (via DB)',
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Error adding article:', err);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({ agente_id: operatorId, agente_nome, titolo_azione: 'Aggiunta Nota', azione: { ticket_id: id, error: err.message }, esito: 'errore' });
});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// ============================================================
// PATCH /api/tickets/batch — Batch update multiple tickets
// ============================================================
router.patch('/batch/update', async (req, res) => {
const { ticket_ids, updates } = req.body;
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
if (!ticket_ids || !Array.isArray(ticket_ids) || ticket_ids.length === 0) {
return res.status(400).json({ error: 'ticket_ids array required' });
}
// 1. Try to update via REST API if configured
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER && process.env.FORCE_DB_UPDATE !== 'true') {
try {
const ticketFields = {};
if (updates.ticket_state_id !== undefined) ticketFields.StateID = updates.ticket_state_id;
if (updates.ticket_priority_id !== undefined) ticketFields.PriorityID = updates.ticket_priority_id;
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
// Auto sblocco check
if (updates.ticket_state_id) {
const stateTypeRes = await pool.query(
`SELECT tst.name AS type_name
FROM ticket_state ts
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE ts.id = $1`,
[updates.ticket_state_id]
);
if (stateTypeRes.rows.length > 0) {
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
ticketFields.LockID = 1;
}
}
}
if (Object.keys(ticketFields).length > 0) {
for (const ticketId of ticket_ids) {
await otrsRequest('PATCH', `/Ticket/${ticketId}`, {
Ticket: ticketFields
});
}
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Azione Massiva',
azione: { ticket_ids, updates },
esito: 'successo',
});
});
return res.json({
message: `${ticket_ids.length} ticket aggiornati! (via API REST)`,
updated_count: ticket_ids.length,
});
}
return res.status(400).json({ error: 'No valid update fields provided' });
} catch (restErr) {
console.warn('Failed to batch update tickets via REST API, falling back to database update:', restErr.message);
// Fall through to standard direct database update below
}
}
// 2. Direct database update fallback
const client = await pool.connect();
try {
await client.query('BEGIN');
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'type_id', 'user_id', 'responsible_user_id', 'customer_id', 'customer_user_id'];
const setClauses = [];
const setParams = [];
let pIdx = 1;
for (const field of allowedFields) {
if (updates[field] !== undefined) {
setClauses.push(`${field} = $${pIdx++}`);
setParams.push(updates[field]);
}
}
if (setClauses.length === 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'No valid update fields provided' });
}
setClauses.push(`change_time = NOW()`);
setClauses.push(`change_by = $${pIdx++}`);
setParams.push(operatorId);
// Build IN clause for ticket IDs
const idPlaceholders = ticket_ids.map((_, i) => `$${pIdx + i}`).join(', ');
setParams.push(...ticket_ids.map(id => parseInt(id)));
await client.query(
`UPDATE ticket SET ${setClauses.join(', ')} WHERE id IN (${idPlaceholders})`,
setParams
);
await client.query('COMMIT');
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Azione Massiva',
azione: { ticket_ids, updates },
esito: 'successo',
});
});
res.json({
message: `${ticket_ids.length} ticket aggiornati! (via DB)`,
updated_count: ticket_ids.length,
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Error batch updating tickets:', err);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({ agente_id: operatorId, agente_nome, titolo_azione: 'Azione Massiva', azione: { ticket_ids, error: err.message }, esito: 'errore' });
});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// POST /api/tickets/:id/retrodata-ticket — Retrodate ticket creation time
router.post('/:id/retrodata-ticket', async (req, res) => {
try {
const { id } = req.params;
const { create_time } = req.body;
if (!create_time) {
return res.status(400).json({ error: 'Specificare la data di creazione.' });
}
// Fetch TN for the ticket
const tnResult = await pool.query('SELECT tn FROM ticket WHERE id = $1', [id]);
if (tnResult.rows.length === 0) {
return res.status(404).json({ error: 'Ticket non trovato.' });
}
const tn = tnResult.rows[0].tn;
// Execute stored procedure
await pool.query('CALL prretrodataticket($1, $2)', [tn, new Date(create_time)]);
res.json({ message: 'Ticket retrodatato correttamente.' });
} catch (err) {
console.error('Errore in prretrodataticket:', err);
res.status(500).json({ error: err.message });
}
});
// POST /api/tickets/articles/:articleId/retrodata-article — Retrodate article creation time
router.post('/articles/:articleId/retrodata-article', async (req, res) => {
try {
const { articleId } = req.params;
const { create_time } = req.body;
if (!create_time) {
return res.status(400).json({ error: 'Specificare la data di creazione.' });
}
// Execute stored procedure
await pool.query('CALL prretrodataarticolo($1, $2)', [parseInt(articleId), new Date(create_time)]);
res.json({ message: 'Articolo retrodatato correttamente.' });
} catch (err) {
console.error('Errore in prretrodataarticolo:', err);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/tickets/articles/:articleId — Delete an article/note
router.delete('/articles/:articleId', async (req, res) => {
const { articleId } = req.params;
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Set article_id in ticket_history to NULL to avoid constraint violation
await client.query(
`UPDATE ticket_history SET article_id = NULL WHERE article_id = $1`,
[articleId]
);
// Delete flags from article_flag
await client.query(
`DELETE FROM article_flag WHERE article_id = $1`,
[articleId]
);
// Delete search index entries from article_search_index
await client.query(
`DELETE FROM article_search_index WHERE article_id = $1`,
[articleId]
);
// 2. Delete time accounting entries
await client.query(
`DELETE FROM time_accounting WHERE article_id = $1`,
[articleId]
);
// 3. Delete attachments
await client.query(
`DELETE FROM article_data_mime_attachment WHERE article_id = $1`,
[articleId]
);
// 4. Delete mime data
await client.query(
`DELETE FROM article_data_mime WHERE article_id = $1`,
[articleId]
);
// 5. Delete article itself
const deleteRes = await client.query(
`DELETE FROM article WHERE id = $1 RETURNING ticket_id`,
[articleId]
);
const ticketId = deleteRes.rows.length > 0 ? deleteRes.rows[0].ticket_id : null;
await client.query('COMMIT');
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Eliminazione Articolo',
azione: { article_id: articleId, ticket_id: ticketId },
esito: 'successo',
});
});
res.json({ message: 'Articolo eliminato con successo!' });
} catch (err) {
await client.query('ROLLBACK');
console.error('Error deleting article:', err);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Eliminazione Articolo',
azione: { article_id: articleId, error: err.message },
esito: 'errore',
});
});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// GET /api/tickets/attachments/:id — Download/View attachment
router.get('/attachments/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT filename, content_type, content
FROM article_data_mime_attachment
WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).send('Allegato non trovato.');
}
const attachment = result.rows[0];
let contentBuffer;
if (attachment.content) {
if (Buffer.isBuffer(attachment.content)) {
// Check for raw binary signatures (%PDF, JPEG, PNG, Zip)
const isRawBinary = attachment.content.length > 4 && (
(attachment.content[0] === 0x25 && attachment.content[1] === 0x50 && attachment.content[2] === 0x44 && attachment.content[3] === 0x46) || // %PDF
(attachment.content[0] === 0xFF && attachment.content[1] === 0xD8) || // JPEG
(attachment.content[0] === 0x89 && attachment.content[1] === 0x50) || // PNG
(attachment.content[0] === 0x50 && attachment.content[1] === 0x4B) // ZIP/DOCX/XLSX
);
if (isRawBinary) {
contentBuffer = attachment.content;
} else {
const str = attachment.content.toString('utf-8');
const cleaned = str.replace(/\s+/g, '');
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
try {
contentBuffer = Buffer.from(cleaned, 'base64');
} catch (_) {
contentBuffer = attachment.content;
}
} else {
contentBuffer = attachment.content;
}
}
} else if (typeof attachment.content === 'string') {
const cleaned = attachment.content.replace(/\s+/g, '');
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
try {
contentBuffer = Buffer.from(cleaned, 'base64');
} catch (_) {
contentBuffer = Buffer.from(attachment.content, 'utf-8');
}
} else {
contentBuffer = Buffer.from(attachment.content, 'utf-8');
}
} else {
contentBuffer = Buffer.from(attachment.content);
}
} else {
contentBuffer = Buffer.alloc(0);
}
const isDownload = req.query.download === '1' || req.query.disposition === 'attachment';
const disposition = isDownload ? 'attachment' : 'inline';
const safeFilename = attachment.filename || 'attachment';
res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"; filename*=UTF-8''${encodeURIComponent(safeFilename)}`);
res.send(contentBuffer);
} catch (err) {
console.error('Errore download allegato:', err);
res.status(500).send(err.message);
}
});
// PUT /api/tickets/attachments/:id — Update text attachment content
router.put('/attachments/:id', async (req, res) => {
try {
const { id } = req.params;
const { content } = req.body;
if (typeof content !== 'string') {
return res.status(400).json({ error: 'Contenuto non valido' });
}
const checkRes = await pool.query(
`SELECT filename, content_type FROM article_data_mime_attachment WHERE id = $1`,
[id]
);
if (checkRes.rows.length === 0) {
return res.status(404).json({ error: 'Allegato non trovato' });
}
const contentBuffer = Buffer.from(content, 'utf-8');
await pool.query(
`UPDATE article_data_mime_attachment SET content = $1, content_size = $2 WHERE id = $3`,
[contentBuffer, contentBuffer.length, id]
);
res.json({ message: 'Allegato di testo aggiornato con successo' });
} catch (err) {
console.error('Errore aggiornamento allegato:', err);
res.status(500).json({ error: err.message });
}
});
// POST /api/tickets/attachments/:id/open — Open attachment using OS default app (Word, Excel, etc.)
router.post('/attachments/:id/open', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT filename, content_type, content FROM article_data_mime_attachment WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Allegato non trovato' });
}
const attachment = result.rows[0];
let contentBuffer;
if (attachment.content) {
if (Buffer.isBuffer(attachment.content)) {
const isRawBinary = attachment.content.length > 4 && (
(attachment.content[0] === 0x25 && attachment.content[1] === 0x50 && attachment.content[2] === 0x44 && attachment.content[3] === 0x46) ||
(attachment.content[0] === 0xFF && attachment.content[1] === 0xD8) ||
(attachment.content[0] === 0x89 && attachment.content[1] === 0x50) ||
(attachment.content[0] === 0x50 && attachment.content[1] === 0x4B)
);
if (isRawBinary) {
contentBuffer = attachment.content;
} else {
const str = attachment.content.toString('utf-8');
const cleaned = str.replace(/\s+/g, '');
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
try { contentBuffer = Buffer.from(cleaned, 'base64'); } catch (_) { contentBuffer = attachment.content; }
} else {
contentBuffer = attachment.content;
}
}
} else if (typeof attachment.content === 'string') {
const cleaned = attachment.content.replace(/\s+/g, '');
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
try { contentBuffer = Buffer.from(cleaned, 'base64'); } catch (_) { contentBuffer = Buffer.from(attachment.content, 'utf-8'); }
} else {
contentBuffer = Buffer.from(attachment.content, 'utf-8');
}
} else {
contentBuffer = Buffer.from(attachment.content);
}
} else {
contentBuffer = Buffer.alloc(0);
}
const fs = require('fs');
const path = require('path');
const os = require('os');
const { exec } = require('child_process');
const tempDir = path.join(os.tmpdir(), 'otrs_turbo_attachments');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const safeName = (attachment.filename || `allegato_${id}`).replace(/[^a-zA-Z0-9._-]/g, '_');
const filePath = path.join(tempDir, `${id}_${safeName}`);
fs.writeFileSync(filePath, contentBuffer);
let command = `"" "${filePath}"`;
if (process.platform === 'win32') {
command = `start "" "${filePath}"`;
} else if (process.platform === 'darwin') {
command = `open "${filePath}"`;
} else {
command = `xdg-open "${filePath}"`;
}
exec(command, (err) => {
if (err) {
console.error('Error opening file via OS default app:', err);
return res.status(500).json({ error: 'Impossibile aprire il file con l\'applicazione di sistema: ' + err.message });
}
res.json({ message: 'File aperto con l\'applicazione predefinita di sistema', filePath });
});
} catch (err) {
console.error('Errore apertura allegato:', err);
res.status(500).json({ error: err.message });
}
});
// POST /api/tickets/merge — Merge tickets (Issue #7)
router.post('/merge', async (req, res) => {
const { targetId, sourceIds } = req.body;
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
if (!targetId || !sourceIds || !Array.isArray(sourceIds) || sourceIds.length === 0) {
return res.status(400).json({ error: 'targetId e array sourceIds sono obbligatori' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const localNow = getLocalTimestamp();
// Fetch target ticket details
const targetRes = await client.query(
'SELECT tn, title, queue_id, user_id, ticket_priority_id, ticket_state_id, type_id FROM ticket WHERE id = $1',
[targetId]
);
if (targetRes.rows.length === 0) {
throw new Error(`Ticket di destinazione #${targetId} non trovato`);
}
const targetTn = targetRes.rows[0].tn;
const targetTitle = targetRes.rows[0].title;
const targetRow = targetRes.rows[0];
// Fetch history type IDs
const htStateRes = await client.query(`SELECT id FROM ticket_history_type WHERE name = 'StateUpdate'`);
const stateUpdateTypeId = htStateRes.rows.length > 0 ? htStateRes.rows[0].id : 1;
const htNoteRes = await client.query(`SELECT id FROM ticket_history_type WHERE name = 'AddNote'`);
const addNoteTypeId = htNoteRes.rows.length > 0 ? htNoteRes.rows[0].id : 1;
for (const sourceId of sourceIds) {
// Fetch source ticket details
const sourceRes = await client.query(
'SELECT tn, title, queue_id, user_id, ticket_priority_id, type_id FROM ticket WHERE id = $1',
[sourceId]
);
if (sourceRes.rows.length === 0) continue;
const sourceTn = sourceRes.rows[0].tn;
const sourceTitle = sourceRes.rows[0].title;
const sourceRow = sourceRes.rows[0];
// 1. Move all articles of source to target
await client.query(
`UPDATE article
SET ticket_id = $1, change_time = $2, change_by = $3
WHERE ticket_id = $4`,
[targetId, localNow, operatorId, sourceId]
);
// 2. Move all time accounting entries
await client.query(
`UPDATE time_accounting
SET ticket_id = $1, change_time = $2, change_by = $3
WHERE ticket_id = $4`,
[targetId, localNow, operatorId, sourceId]
);
// 3. Mark source ticket as merged (state_id = 9)
await client.query(
`UPDATE ticket
SET ticket_state_id = 9, change_time = $1, change_by = $2
WHERE id = $3`,
[localNow, operatorId, sourceId]
);
// 4. Create ParentChild relation link (source B is child/merged of target A)
const relCheck = await client.query(
`SELECT 1 FROM link_relation
WHERE source_object_id = 1 AND source_key = $1
AND target_object_id = 1 AND target_key = $2 AND type_id = 2`,
[String(sourceId), String(targetId)]
);
if (relCheck.rows.length === 0) {
await client.query(
`INSERT INTO link_relation (
source_object_id, source_key, target_object_id, target_key,
type_id, state_id, create_time, create_by
) VALUES (1, $1, 1, $2, 2, 1, $3, $4)`,
[String(sourceId), String(targetId), localNow, operatorId]
);
}
// 5. Create internal system note inside target ticket documenting the merge
const now = new Date();
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
// Insert article
const artResult = await client.query(
`INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES ($1, 1, 1, 0, 1, $2, $3, $2, $3) RETURNING id`,
[targetId, localNow, operatorId]
);
const articleId = artResult.rows[0].id;
const mergeNoteBody = `Il ticket #${sourceTn} ("${sourceTitle}") è stato unito a questo ticket.`;
await client.query(
`INSERT INTO article_data_mime (
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
a_message_id, a_in_reply_to, a_references,
a_content_type, incoming_time, content_path,
create_time, create_by, change_time, change_by
) VALUES (
$1, 'Sistema OTRS Turbo', '', '', '', '', 'Ticket Unito', $2,
'', '', '',
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
$4, $5, $4, $5
)`,
[articleId, mergeNoteBody, contentPath, localNow, operatorId]
);
// Create internal system note inside source ticket B (the merged ticket)
const sourceArtResult = await client.query(
`INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES ($1, 1, 1, 0, 1, $2, $3, $2, $3) RETURNING id`,
[sourceId, localNow, operatorId]
);
const sourceArticleId = sourceArtResult.rows[0].id;
const sourceMergeNoteText = `Merged Ticket ${sourceTn} to ${targetTn}`;
await client.query(
`INSERT INTO article_data_mime (
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
a_message_id, a_in_reply_to, a_references,
a_content_type, incoming_time, content_path,
create_time, create_by, change_time, change_by
) VALUES (
$1, 'Sistema OTRS Turbo', '', '', '', '', $2, $2,
'', '', '',
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
$4, $5, $4, $5
)`,
[sourceArticleId, sourceMergeNoteText, contentPath, localNow, operatorId]
);
// 6. Record ticket history for source ticket (state changed to merged = 9)
await client.query(
`INSERT INTO ticket_history (
name, history_type_id, ticket_id, type_id, queue_id,
owner_id, priority_id, state_id,
create_time, create_by, change_time, change_by
) VALUES (
'%%merged%%', $1, $2, $3, $4,
$5, $6, 9,
$7, $8, $7, $8
)`,
[
stateUpdateTypeId, sourceId, sourceRow.type_id || 1, sourceRow.queue_id,
sourceRow.user_id || 1, sourceRow.ticket_priority_id || 1,
localNow, operatorId
]
);
// 7. Record ticket history for target ticket (note added for merge)
await client.query(
`INSERT INTO ticket_history (
name, history_type_id, ticket_id, article_id, type_id, queue_id,
owner_id, priority_id, state_id,
create_time, create_by, change_time, change_by
) VALUES (
'%%', $1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10, $9, $10
)`,
[
addNoteTypeId, targetId, articleId, targetRow.type_id || 1, targetRow.queue_id,
targetRow.user_id || 1, targetRow.ticket_priority_id || 1, targetRow.ticket_state_id || 1,
localNow, operatorId
]
);
}
// Update target ticket change_time & change_by
await client.query(
`UPDATE ticket SET change_time = $1, change_by = $2 WHERE id = $3`,
[localNow, operatorId, targetId]
);
await client.query('COMMIT');
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Unione Ticket',
azione: { target_id: targetId, source_ids: sourceIds },
esito: 'successo',
});
});
res.json({ message: 'Ticket uniti con successo!' });
} catch (err) {
await client.query('ROLLBACK');
console.error('Error merging tickets:', err);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({ agente_id: operatorId, agente_nome, titolo_azione: 'Unione Ticket', azione: { target_id: targetId, source_ids: sourceIds, error: err.message }, esito: 'errore' });
});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// ============================================================
// GET /api/tickets/time-accounting/summary — Summary of notes/emails and time per agent per day
// ============================================================
router.get('/time-accounting/summary', async (req, res) => {
try {
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const agentIdsRaw = req.query.agent_ids || req.query.agent_id || String(operatorId);
const agentIds = agentIdsRaw.split(',').map(x => parseInt(x.trim(), 10)).filter(x => !isNaN(x));
const dateStr = req.query.date || new Date().toISOString().slice(0, 10);
const rangeParam = req.query.range || '35';
const isAll = rangeParam === 'all';
let result;
if (isAll) {
result = await pool.query(
`SELECT
a.id AS article_id,
a.ticket_id,
a.create_by AS agent_id,
t.tn AS ticket_tn,
t.title AS ticket_title,
ast.name AS sender_type,
cc.name AS channel,
adm.a_subject,
adm.a_body,
adm.a_from,
adm.a_to,
a.create_time,
COALESCE(ta.time_unit, 0) AS time_unit
FROM article a
JOIN ticket t ON a.ticket_id = t.id
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
LEFT JOIN (
SELECT article_id, SUM(time_unit) AS time_unit
FROM time_accounting
GROUP BY article_id
) ta ON a.id = ta.article_id
WHERE a.create_by = ANY($1::int[])
ORDER BY a.create_time DESC, a.id DESC`,
[agentIds]
);
} else {
const targetDate = new Date(`${dateStr}T00:00:00Z`);
let startDateObj, endDateObj;
if (rangeParam === 'month') {
const y = targetDate.getUTCFullYear();
const m = targetDate.getUTCMonth();
startDateObj = new Date(Date.UTC(y, m, 1));
endDateObj = new Date(Date.UTC(y, m + 1, 0));
} else {
const daysBack = parseInt(rangeParam, 10) || 35;
startDateObj = new Date(targetDate);
startDateObj.setDate(startDateObj.getDate() - daysBack);
endDateObj = new Date(targetDate);
endDateObj.setDate(endDateObj.getDate() + 5);
}
const startTime = `${startDateObj.toISOString().slice(0, 10)} 00:00:00`;
const endTime = `${endDateObj.toISOString().slice(0, 10)} 23:59:59`;
result = await pool.query(
`SELECT
a.id AS article_id,
a.ticket_id,
a.create_by AS agent_id,
t.tn AS ticket_tn,
t.title AS ticket_title,
ast.name AS sender_type,
cc.name AS channel,
adm.a_subject,
adm.a_body,
adm.a_from,
adm.a_to,
a.create_time,
COALESCE(ta.time_unit, 0) AS time_unit
FROM article a
JOIN ticket t ON a.ticket_id = t.id
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
LEFT JOIN (
SELECT article_id, SUM(time_unit) AS time_unit
FROM time_accounting
GROUP BY article_id
) ta ON a.id = ta.article_id
WHERE a.create_by = ANY($1::int[])
AND a.create_time >= $2
AND a.create_time <= $3
ORDER BY a.create_time DESC, a.id DESC`,
[agentIds, startTime, endTime]
);
}
const dailyTotals = {};
const dailyTotalsByAgent = {};
const dayArticles = [];
let totalMinutes = 0;
result.rows.forEach(r => {
let isoDate = '';
if (r.create_time instanceof Date) {
const y = r.create_time.getUTCFullYear();
const m = String(r.create_time.getUTCMonth() + 1).padStart(2, '0');
const d = String(r.create_time.getUTCDate()).padStart(2, '0');
isoDate = `${y}-${m}-${d}`;
} else {
isoDate = String(r.create_time || '').slice(0, 10);
}
const timeVal = parseFloat(r.time_unit || 0);
const agId = r.agent_id;
dailyTotals[isoDate] = (dailyTotals[isoDate] || 0) + timeVal;
if (!dailyTotalsByAgent[agId]) {
dailyTotalsByAgent[agId] = {};
}
dailyTotalsByAgent[agId][isoDate] = (dailyTotalsByAgent[agId][isoDate] || 0) + timeVal;
if (isoDate === dateStr) {
dayArticles.push(r);
totalMinutes += timeVal;
}
});
const targetMinutes = parseInt(process.env.DAILY_TARGET_TIME || process.env.daily_target_time || process.env.Daily_Target_Time, 10) || 480;
const workDaysRaw = process.env.WORK_DAYS || '1,2,3,4,5';
const workDays = workDaysRaw.split(',').map(x => parseInt(x.trim(), 10)).filter(x => !isNaN(x));
res.json({
agent_id: agentIds[0],
agent_ids: agentIds,
date: dateStr,
total_minutes: totalMinutes,
target_minutes: targetMinutes,
work_days: workDays,
articles: dayArticles,
daily_totals: dailyTotals,
daily_totals_by_agent: dailyTotalsByAgent,
});
} catch (err) {
console.error('Error fetching time accounting summary:', err);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// PUT /api/articles/:articleId/time — Update time accounted for an article
// ============================================================
router.put('/articles/:articleId/time', async (req, res) => {
const { articleId } = req.params;
const { time_unit } = req.body;
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const parsedTime = parseFloat(time_unit);
if (isNaN(parsedTime) || parsedTime < 0) {
return res.status(400).json({ error: 'Il tempo specificato deve essere un numero maggiore o uguale a 0' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Check if a record already exists in time_accounting
const existing = await client.query(
`SELECT id FROM time_accounting WHERE article_id = $1`,
[articleId]
);
if (existing.rows.length > 0) {
// Update
await client.query(
`UPDATE time_accounting
SET time_unit = $1, change_time = NOW(), change_by = $2
WHERE article_id = $3`,
[parsedTime, operatorId, articleId]
);
} else {
// Find ticket_id from article
const artRes = await client.query(
`SELECT ticket_id FROM article WHERE id = $1`,
[articleId]
);
if (artRes.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Articolo non trovato' });
}
const ticketId = artRes.rows[0].ticket_id;
// Insert
await client.query(
`INSERT INTO time_accounting (
ticket_id, article_id, time_unit,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
[ticketId, articleId, parsedTime, operatorId]
);
}
await client.query('COMMIT');
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Modifica Tempo Articolo',
azione: { article_id: articleId, time_unit: parsedTime },
esito: 'successo',
});
});
res.json({ message: 'Tempo aggiornato con successo!' });
} catch (err) {
await client.query('ROLLBACK');
console.error('Error updating article time:', err);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({ agente_id: operatorId, agente_nome, titolo_azione: 'Modifica Tempo Articolo', azione: { article_id: articleId, error: err.message }, esito: 'errore' });
});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// ============================================================
// POST /api/tickets/auto-time — Automatic end-of-day time accounting ticket
// ============================================================
router.post('/auto-time', async (req, res) => {
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
// Verify time
const minTimeStr = process.env.AUTO_TIME_MIN_HOUR || '18:00';
const [minHour, minMin] = minTimeStr.split(':').map(x => parseInt(x, 10));
const now = new Date();
const currentHour = now.getHours();
const currentMin = now.getMinutes();
let isPastTime = false;
if (currentHour > minHour) {
isPastTime = true;
} else if (currentHour === minHour && currentMin >= minMin) {
isPastTime = true;
}
if (!isPastTime) {
return res.status(400).json({ error: `La consuntivazione automatica è consentita solo dopo le ore ${minTimeStr}` });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Calculate remaining time
const targetTime = parseInt(process.env.DAILY_TARGET_TIME || process.env.daily_target_time || process.env.Daily_Target_Time, 10) || 480;
const timeTodayRes = await client.query(
`SELECT COALESCE(SUM(time_unit), 0) AS total_today
FROM time_accounting
WHERE create_by = $1 AND DATE(create_time) = CURRENT_DATE`,
[operatorId]
);
const totalToday = parseFloat(timeTodayRes.rows[0].total_today);
const remaining = Math.max(0, targetTime - totalToday);
if (remaining <= 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'La soglia giornaliera è già stata raggiunta o superata' });
}
// 2. Resolve parameters from configuration / OTRS DB
const queueName = process.env.AUTO_TIME_QUEUE || 'Assistenza';
const typeName = process.env.AUTO_TIME_TYPE || 'Default';
const title = process.env.AUTO_TIME_TITLE || 'Consuntivazione Automatica fine giornata';
const subject = process.env.AUTO_TIME_SUBJECT || 'Consuntivazione automatica ore mancanti';
const body = process.env.AUTO_TIME_BODY || 'Consuntivazione automatica fine giornata.';
const configCustomerUser = process.env.AUTO_TIME_CUSTOMER_USER;
// Resolve agent login for owner / customer fallback
const agentRes = await client.query(
`SELECT login, first_name, last_name FROM users WHERE id = $1`,
[operatorId]
);
if (agentRes.rows.length === 0) {
throw new Error(`Impossibile trovare l'agente attivo con ID #${operatorId}`);
}
const agentLogin = agentRes.rows[0].login;
const customerUserId = configCustomerUser || agentLogin;
// Resolve CustomerID
let customerId = 'Self-Service';
const custRes = await client.query(
`SELECT customer_id FROM customer_user WHERE login = $1`,
[customerUserId]
);
if (custRes.rows.length > 0) {
customerId = custRes.rows[0].customer_id;
}
// Resolve state ID (closed successful / closed fallback)
const stateRes = await client.query(
`SELECT id FROM ticket_state WHERE name = 'closed successful' OR name = 'chiuso con successo' LIMIT 1`
);
let stateId = stateRes.rows[0]?.id;
if (!stateId) {
const stateFallback = await client.query(
`SELECT ts.id FROM ticket_state ts JOIN ticket_state_type tst ON ts.type_id = tst.id WHERE tst.name = 'closed' LIMIT 1`
);
stateId = stateFallback.rows[0]?.id || 3;
}
// Resolve queue ID
const queueRes = await client.query(
`SELECT id FROM queue WHERE name = $1 LIMIT 1`,
[queueName]
);
const queueId = queueRes.rows[0]?.id || 1;
// Resolve type ID
const typeRes = await client.query(
`SELECT id FROM ticket_type WHERE name = $1 LIMIT 1`,
[typeName]
);
const typeId = typeRes.rows[0]?.id || null;
// Resolve priority ID (3 normal)
const priorityRes = await client.query(
`SELECT id FROM ticket_priority WHERE name = '3 normal' LIMIT 1`
);
const priorityId = priorityRes.rows[0]?.id || 3;
// Generate ticket number (daily reset)
const now = new Date();
const systemId = process.env.OTRS_SYSTEM_ID || String(operatorId);
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
const maxTicketResult = await client.query(
`SELECT tn FROM ticket WHERE tn LIKE $1`,
[`${datePrefix}%`]
);
let maxCounterFromTicketTable = 0;
for (const row of maxTicketResult.rows) {
const counterPart = row.tn.substring(datePrefix.length);
const parsed = parseInt(counterPart, 10);
if (!isNaN(parsed) && parsed > maxCounterFromTicketTable) {
maxCounterFromTicketTable = parsed;
}
}
const counterTodayResult = await client.query(
`SELECT COALESCE(MAX(counter), 0) AS max_counter
FROM ticket_number_counter
WHERE create_time >= CURRENT_DATE`
);
const maxCounterFromCounterTable = parseInt(counterTodayResult.rows[0].max_counter, 10);
const nextCounter = Math.max(maxCounterFromTicketTable, maxCounterFromCounterTable) + 1;
const counterResult = await client.query(
`INSERT INTO ticket_number_counter (counter, counter_uid, create_time)
VALUES ($1, md5(random()::text || clock_timestamp()::text), NOW())
RETURNING counter`,
[nextCounter]
);
const counter = counterResult.rows[0].counter;
const counterPadding = parseInt(process.env.OTRS_COUNTER_PADDING, 10) || 6;
const tn = `${datePrefix}${String(counter).padStart(counterPadding, '0')}`;
// 3. Create Ticket (lock = 1 [unlock], owner & responsible = operatorId)
const localNow = getLocalTimestamp();
const ticketResult = await client.query(
`INSERT INTO ticket (
tn, title, queue_id, ticket_lock_id, type_id,
user_id, responsible_user_id,
ticket_priority_id, ticket_state_id,
customer_id, customer_user_id,
timeout, until_time,
escalation_time, escalation_update_time,
escalation_response_time, escalation_solution_time,
archive_flag,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, 1, $4,
$5, $5,
$6, $7,
$8, $9,
0, 0,
0, 0,
0, 0,
0,
$10, $11, $10, $11
) RETURNING id, tn`,
[
tn, title, queueId, typeId,
operatorId,
priorityId, stateId,
customerId, customerUserId,
localNow, operatorId
]
);
const ticketId = ticketResult.rows[0].id;
// Ticket History for NewTicket
const htResult = await client.query(
`SELECT id FROM ticket_history_type WHERE name = 'NewTicket'`
);
const historyTypeId = htResult.rows.length > 0 ? htResult.rows[0].id : 1;
await client.query(
`INSERT INTO ticket_history (
name, history_type_id, ticket_id, type_id, queue_id,
owner_id, priority_id, state_id,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $9, $10)`,
['%%', historyTypeId, ticketId, typeId || 1, queueId, operatorId, priorityId, stateId, localNow, operatorId]
);
// 4. Create Article (Internal note)
const senderResult = await client.query(
`SELECT id FROM article_sender_type WHERE name = 'agent'`
);
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
const channelResult = await client.query(
`SELECT id FROM communication_channel WHERE name = 'Internal'`
);
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
const articleResult = await client.query(
`INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, 0, 1, $4, $5, $4, $5) RETURNING id`,
[ticketId, senderTypeId, channelId, localNow, operatorId]
);
const articleId = articleResult.rows[0].id;
// Create article mime data
const nowMime = new Date();
const contentPath = `${nowMime.getFullYear()}/${String(nowMime.getMonth() + 1).padStart(2, '0')}/${String(nowMime.getDate()).padStart(2, '0')}`;
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body} (Mancanti: ${remaining} m)</body></html>`;
const binaryBody = Buffer.from(htmlBody, 'utf-8');
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
const base64Body = binaryBody.toString('base64');
const fromHeader = await resolveAgentFromHeader(operatorId, client);
await client.query(
`INSERT INTO article_data_mime (
article_id, a_from, a_to, a_subject, a_body,
a_content_type, incoming_time, content_path,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, '', $3, $4,
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
$6, $7, $6, $7
)`,
[articleId, fromHeader, subject, htmlBody, contentPath, localNow, operatorId]
);
// Create article attachment (file-1) for OTRS CE HTML display
await client.query(
`INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type, disposition, content,
create_time, create_by, change_time, change_by
) VALUES ($1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3, $4, $5, $4, $5)`,
[articleId, String(contentSize), base64Body, localNow, operatorId]
);
// 5. Create Time Accounting Entry for remaining minutes
await client.query(
`INSERT INTO time_accounting (
ticket_id, article_id, time_unit,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, $4, $5, $4, $5)`,
[ticketId, articleId, remaining, localNow, operatorId]
);
// 6. Close tickets in default group 'CHIUDI A FINE GIORNATA'
try {
const closeGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
if (closeGroup) {
const groupMembers = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(closeGroup.id);
if (groupMembers.length > 0) {
const groupTicketIds = groupMembers.map(m => m.ticket_id);
const idPlaceholders = groupTicketIds.map((_, i) => `$${i + 1}`).join(', ');
// Update ticket state to closed in OTRS DB
await client.query(
`UPDATE ticket SET ticket_state_id = $${groupTicketIds.length + 1}, change_time = $${groupTicketIds.length + 2}, change_by = $${groupTicketIds.length + 3} WHERE id IN (${idPlaceholders})`,
[...groupTicketIds, stateId, localNow, operatorId]
);
console.log(`[Auto-Time] Closed ${groupTicketIds.length} tickets from group 'CHIUDI A FINE GIORNATA'`);
}
}
} catch (grpCloseErr) {
console.error('[Auto-Time] Error closing tickets from CHIUDI A FINE GIORNATA group:', grpCloseErr);
}
await client.query('COMMIT');
// Log activity
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Consuntivazione Automatica fine giornata',
azione: { ticket_id: ticketId, tn, minutes: remaining },
esito: 'successo',
});
});
res.status(201).json({
id: ticketId,
tn,
minutes: remaining,
message: 'Consuntivazione automatica completata con successo!'
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Error in auto-time sheet creation:', err);
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
agente_id: operatorId,
agente_nome,
titolo_azione: 'Consuntivazione Automatica fine giornata',
azione: { error: err.message },
esito: 'errore'
});
});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
module.exports = router;