const express = require('express'); const router = express.Router(); const pool = require('../db'); const { db, logAttivita } = require('../activityDb'); // 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 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, 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 (queue_id) { conditions.push(`t.queue_id = $${paramIdx++}`); params.push(parseInt(queue_id)); } 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) { conditions.push(`t.ticket_priority_id = $${paramIdx++}`); params.push(parseInt(priority_id)); } if (user_id) { conditions.push(`t.user_id = $${paramIdx++}`); params.push(parseInt(user_id)); } 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)); } if (search) { 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(`%${search}%`); 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); } } 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 time_accounting 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 FROM article_data_mime_attachment WHERE article_id IN ( SELECT id FROM article WHERE ticket_id = $1 )`, [id] ); 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 || '10'; 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 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, NOW(), $12, NOW(), $12 ) 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, 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, NOW(), $9, NOW(), $9 )`, [ `%%`, historyTypeId, ticketId, type_id || 1, queue_id, user_id || 1, priority_id, state_id, operatorId ] ); // Create initial article if body or attachments are provided let finalBody = body; const isBodyEmpty = !finalBody || finalBody.trim() === '' || finalBody.trim() === '


'; 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, NOW(), $4, NOW(), $4 ) RETURNING id`, [ticketId, senderTypeId, channelId, 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, NOW(), $6, NOW(), $6 )`, [articleId, customerFrom, subject || title, finalBody, contentType, operatorId] ); // If HTML content, create article_data_mime_attachment for OTRS CE HTML rendering (file-1) if (isHtml) { const htmlBody = `${finalBody}`; 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, NOW(), $4, NOW(), $4 )`, [articleId, String(contentSize), base64Body, 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, NOW(), $6, NOW(), $6)`, [ articleId, att.filename, contentBuffer.length, att.content_type || 'application/octet-stream', att.content, // OTRS CE expects base64 string directly 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 } const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1; const timeUnit = parseFloat(updates.time_unit); // Fetch current ticket first (needed for both REST dummy fields and database fallback) let current; try { const currentResult = await pool.query( `SELECT ticket_state_id, ticket_priority_id, queue_id, 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.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) ticketFields.CustomerID = updates.customer_id; if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id; // If ticketFields is empty but we have a timeUnit to log, we must supply a dummy field (e.g. StateID) so the Ticket parameter is not empty if (Object.keys(ticketFields).length === 0 && !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 || (!isNaN(timeUnit) && timeUnit > 0)) { const reqBody = { Ticket: ticketFields }; if (!isNaN(timeUnit) && timeUnit > 0) { reqBody.Article = { CommunicationChannel: 'Internal', SenderType: 'agent', Subject: 'Consuntivazione', Body: 'Consuntivazione', ContentType: 'text/html; charset=utf8', TimeUnit: timeUnit, TimeUnits: timeUnit }; } const result = await otrsRequest('PATCH', `/Ticket/${id}`, reqBody); // Option 1: Log the time directly to the DB if the API succeeded but OTRS didn't save it if (!isNaN(timeUnit) && timeUnit > 0 && result && result.ArticleID) { try { await pool.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, result.ArticleID, timeUnit, operatorId] ); console.log(`[Time Accounting] Successfully logged ${timeUnit} minutes for ticket ${id} via DB insert.`); } catch (timeDbErr) { console.error('[Time Accounting] Failed to log time unit in database:', timeDbErr.message); } } 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, ...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'); // 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', '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) { 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 = NOW()`); setClauses.push(`change_by = $${pIdx++}`); 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 time_unit, we still update change_time/change_by await client.query( `UPDATE ticket SET change_time = NOW(), change_by = $1 WHERE id = $2`, [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', 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, NOW(), $9, NOW(), $9 )`, [ historyName, htResult.rows[0].id, parseInt(id), newTypeId, newQueueId, newOwnerId, newPriorityId, newStateId, operatorId ] ); } } // If time_unit is provided, insert a system note article and log the time if (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, NOW(), $4, NOW(), $4 ) RETURNING id`, [id, senderTypeId, channelId, 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 noteBody = 'Consuntivazione'; const htmlBody = `${noteBody}`; const binaryBody = Buffer.from(htmlBody, 'utf-8'); const contentSize = Buffer.byteLength(htmlBody, 'utf-8'); const base64Body = binaryBody.toString('base64'); // 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, NOW(), $6, NOW(), $6 )`, [articleId, 'OTRS Turbo Agent', 'Consuntivazione', htmlBody, contentPath, 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, NOW(), $4, NOW(), $4 )`, [articleId, String(contentSize), base64Body, operatorId] ); // Log the time in time_accounting 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, articleId, timeUnitVal, 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, ...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, 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 time_accounting 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 } = 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 (time_unit) { payload.Article.TimeUnit = parseFloat(time_unit); payload.Article.TimeUnits = parseFloat(time_unit); } if (attachments && Array.isArray(attachments)) { payload.Article.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); // Option 1: Log the time directly to the DB if the API succeeded but OTRS didn't save it if (time_unit && result && result.ArticleID) { try { await pool.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, result.ArticleID, parseFloat(time_unit), operatorId] ); console.log(`[Time Accounting] Successfully logged ${time_unit} minutes for ticket ${id} via DB insert.`); } catch (timeDbErr) { console.error('[Time Accounting] Failed to log time unit in database:', timeDbErr.message); } } 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.warn('Failed to add article via REST API, falling back to database insert:', restErr.message); // Fall through to standard direct database update below } } // 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 } = req.body; const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1; await client.query('BEGIN'); // Verify ticket exists const ticketCheck = await client.query('SELECT 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' }); } // 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, $4, 1, NOW(), $5, NOW(), $5 ) RETURNING id`, [id, senderTypeId, channelId, is_visible_for_customer ? 1 : 0, 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')}`; // 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, NOW(), $7, NOW(), $7 )`, [articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentType, contentPath, operatorId] ); // Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and base64 encoded text) const htmlBody = `${body}`; 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, NOW(), $4, NOW(), $4 )`, [articleId, String(contentSize), base64Body, 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, NOW(), $6, NOW(), $6)`, [ articleId, att.filename, contentBuffer.length, att.content_type || 'application/octet-stream', att.content, // base64 string directly operatorId ] ); } } // 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, NOW(), $4, NOW(), $4)`, [id, articleId, parsedTime, operatorId] ); } } // Update ticket change_time await client.query( `UPDATE ticket SET change_time = NOW(), change_by = $1 WHERE id = $2`, [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, NOW(), $10, NOW(), $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 ] ); } 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', }); }); 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.user_id !== undefined) ticketFields.OwnerID = updates.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', '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 }); } }); // 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]; // Decode base64 if necessary let contentBuffer; if (attachment.content) { let contentStr = ''; if (Buffer.isBuffer(attachment.content)) { contentStr = attachment.content.toString('utf-8'); } else if (typeof attachment.content === 'string') { contentStr = attachment.content; } const cleaned = contentStr.replace(/\s+/g, ''); const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; if (cleaned.length % 4 === 0 && base64Regex.test(cleaned)) { try { contentBuffer = Buffer.from(cleaned, 'base64'); } catch (e) { contentBuffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content); } } else { contentBuffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content); } } else { contentBuffer = Buffer.alloc(0); } const safeFilename = attachment.filename || 'attachment'; res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; 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); } }); // 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'); // Fetch target ticket details const targetRes = await client.query('SELECT tn, title 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; for (const sourceId of sourceIds) { // Fetch source ticket details const sourceRes = await client.query('SELECT tn, title FROM ticket WHERE id = $1', [sourceId]); if (sourceRes.rows.length === 0) continue; const sourceTn = sourceRes.rows[0].tn; const sourceTitle = sourceRes.rows[0].title; // 1. Move all articles of source to target await client.query( `UPDATE article SET ticket_id = $1, change_time = NOW(), change_by = $2 WHERE ticket_id = $3`, [targetId, operatorId, sourceId] ); // 2. Move all time accounting entries await client.query( `UPDATE time_accounting SET ticket_id = $1, change_time = NOW(), change_by = $2 WHERE ticket_id = $3`, [targetId, operatorId, sourceId] ); // 3. Mark source ticket as merged (state_id = 9) await client.query( `UPDATE ticket SET ticket_state_id = 9, change_time = NOW(), change_by = $1 WHERE id = $2`, [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, NOW(), $3)`, [String(sourceId), String(targetId), 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, NOW(), $2, NOW(), $2) RETURNING id`, [targetId, 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, NOW(), $4, NOW(), $4 )`, [articleId, mergeNoteBody, contentPath, operatorId] ); } 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(); } }); // ============================================================ // 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, 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 || '10'; 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 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, NOW(), $10, NOW(), $10 ) RETURNING id, tn`, [ tn, title, queueId, typeId, operatorId, priorityId, stateId, customerId, customerUserId, 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, NOW(), $9, NOW(), $9)`, ['%%', historyTypeId, ticketId, typeId || 1, queueId, operatorId, priorityId, stateId, 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, NOW(), $4, NOW(), $4) RETURNING id`, [ticketId, senderTypeId, channelId, 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 = `${body} (Mancanti: ${remaining} m)`; 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 ( 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, 'OTRS Turbo Agent', '', $2, $3, 'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $4, NOW(), $5, NOW(), $5 )`, [articleId, subject, htmlBody, contentPath, 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, NOW(), $4, NOW(), $4)`, [articleId, String(contentSize), base64Body, 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, NOW(), $4, NOW(), $4)`, [ticketId, articleId, remaining, operatorId] ); 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;