const express = require('express'); const router = express.Router(); const pool = require('../db'); // 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(), 6000); 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}`); } return await response.json(); } catch (err) { clearTimeout(timeoutId); if (err.name === 'AbortError') { throw new Error('OTRS REST API request timed out (6s 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) { conditions.push(`t.ticket_state_id = $${paramIdx++}`); params.push(parseInt(state_id)); } 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] ); res.json({ tickets: result.rows, total, page: parseInt(page), per_page: parseInt(per_page), total_pages: Math.ceil(total / parseInt(per_page)), }); } 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' }); } // 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] ); res.json({ ticket: ticketResult.rows[0], articles: articlesResult.rows, attachments: attachmentsResult.rows, }); } 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 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 if (body || (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'; } } // 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 finalBody = body || 'File allegati in creazione'; const isHtml = body && /<[a-z][\s\S]*>/i.test(body); 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] ); // 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', contentBuffer, operatorId ] ); } } } await client.query('COMMIT'); 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); 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; // 1. Try to update via REST API if configured if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) { 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; // 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) { const result = await otrsRequest('PATCH', `/Ticket/${id}`, { Ticket: ticketFields }); 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 { 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; await client.query('BEGIN'); // Fetch current ticket for history comparison const currentResult = await client.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) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Ticket not found' }); } const current = currentResult.rows[0]; // 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]); } } if (setClauses.length === 0) { await client.query('ROLLBACK'); return res.json({ message: 'No changes detected' }); } // 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 ); // 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 ] ); } } await client.query('COMMIT'); res.json({ message: 'Ticket aggiornato! (via DB)' }); } catch (err) { await client.query('ROLLBACK'); console.error('Error updating ticket:', err); 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 } = 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) { 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); } const result = await otrsRequest('PATCH', `/Ticket/${id}`, payload); 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 } = 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 raw Buffer) const htmlBody = `${body}`; const binaryBody = Buffer.from(htmlBody, 'utf-8'); const contentSize = Buffer.byteLength(htmlBody, 'utf-8'); 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), binaryBody, 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'); res.status(201).json({ article_id: articleId, message: 'Nota aggiunta! (via DB)', }); } catch (err) { await client.query('ROLLBACK'); console.error('Error adding article:', err); 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) { 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 }); } 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'); 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); 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]; res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`); res.send(attachment.content); } 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'); res.json({ message: 'Ticket uniti con successo!' }); } catch (err) { await client.query('ROLLBACK'); console.error('Error merging tickets:', err); res.status(500).json({ error: err.message }); } finally { client.release(); } }); module.exports = router;