2217 lines
82 KiB
JavaScript
2217 lines
82 KiB
JavaScript
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: resolve agent details for a_from header (best-effort, fallback to 'OTRS Turbo Agent')
|
|
async function resolveAgentFromHeader(agentId, client = pool) {
|
|
try {
|
|
const r = await client.query(
|
|
`SELECT first_name, last_name, login FROM users WHERE id = $1`,
|
|
[agentId]
|
|
);
|
|
if (r.rows.length > 0) {
|
|
const u = r.rows[0];
|
|
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.login || 'Agent';
|
|
|
|
const prefRes = await client.query(
|
|
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
|
|
[agentId]
|
|
);
|
|
const email = (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) || 'agent@localhost';
|
|
return `"${fullName}" <${email}>`;
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
return 'OTRS Turbo Agent';
|
|
}
|
|
|
|
|
|
// Helper for OTRS CE GenericInterface REST API calls
|
|
async function otrsRequest(method, path, bodyData = {}) {
|
|
const OTRS_API_USER = process.env.OTRS_API_USER;
|
|
const OTRS_API_PASSWORD = process.env.OTRS_API_PASSWORD;
|
|
const OTRS_API_URL = process.env.OTRS_API_URL;
|
|
|
|
if (!OTRS_API_URL || !OTRS_API_USER) {
|
|
return null;
|
|
}
|
|
const url = `${OTRS_API_URL}${path}`;
|
|
const payload = {
|
|
UserLogin: OTRS_API_USER,
|
|
Password: OTRS_API_PASSWORD,
|
|
...bodyData
|
|
};
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal: controller.signal
|
|
});
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
|
}
|
|
const result = await response.json();
|
|
if (result && result.Error) {
|
|
throw new Error(`OTRS API Error: ${result.Error.ErrorMessage} (${result.Error.ErrorCode})`);
|
|
}
|
|
return result;
|
|
} catch (err) {
|
|
clearTimeout(timeoutId);
|
|
if (err.name === 'AbortError') {
|
|
throw new Error('OTRS REST API request timed out (15s limit exceeded)');
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// GET /api/tickets — List tickets with filters & pagination
|
|
// ============================================================
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const {
|
|
queue_id, state_id, priority_id, user_id, type_id, customer_user_id,
|
|
search, sort_by = 'create_time', sort_dir = 'DESC',
|
|
page = 1, per_page = 50,
|
|
date_from, date_to
|
|
} = req.query;
|
|
|
|
const conditions = [];
|
|
const params = [];
|
|
let paramIdx = 1;
|
|
|
|
if (customer_user_id) {
|
|
let logins = [];
|
|
if (Array.isArray(customer_user_id)) {
|
|
logins = customer_user_id.map(l => String(l).trim()).filter(Boolean);
|
|
} else if (typeof customer_user_id === 'string') {
|
|
logins = customer_user_id.split(',').map(l => l.trim()).filter(Boolean);
|
|
} else {
|
|
logins = [String(customer_user_id).trim()];
|
|
}
|
|
|
|
if (logins.length > 0) {
|
|
const placeholders = logins.map(() => `$${paramIdx++}`).join(', ');
|
|
conditions.push(`t.customer_user_id IN (${placeholders})`);
|
|
params.push(...logins);
|
|
}
|
|
}
|
|
|
|
if (queue_id) {
|
|
let queueIds = [];
|
|
if (Array.isArray(queue_id)) {
|
|
queueIds = queue_id.map(id => parseInt(id)).filter(id => !isNaN(id));
|
|
} else if (typeof queue_id === 'string') {
|
|
queueIds = queue_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
|
} else {
|
|
const parsed = parseInt(queue_id);
|
|
if (!isNaN(parsed)) queueIds.push(parsed);
|
|
}
|
|
|
|
if (queueIds.length > 0) {
|
|
const placeholders = queueIds.map(() => `$${paramIdx++}`).join(', ');
|
|
conditions.push(`t.queue_id IN (${placeholders})`);
|
|
params.push(...queueIds);
|
|
}
|
|
}
|
|
if (state_id) {
|
|
let stateIds = [];
|
|
if (Array.isArray(state_id)) {
|
|
stateIds = state_id.map(id => parseInt(id)).filter(id => !isNaN(id));
|
|
} else if (typeof state_id === 'string') {
|
|
stateIds = state_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
|
} else {
|
|
const parsed = parseInt(state_id);
|
|
if (!isNaN(parsed)) stateIds.push(parsed);
|
|
}
|
|
|
|
if (stateIds.length > 0) {
|
|
const placeholders = stateIds.map(() => `$${paramIdx++}`).join(', ');
|
|
conditions.push(`t.ticket_state_id IN (${placeholders})`);
|
|
params.push(...stateIds);
|
|
}
|
|
}
|
|
if (priority_id) {
|
|
let priorityIds = [];
|
|
if (Array.isArray(priority_id)) {
|
|
priorityIds = priority_id.map(id => parseInt(id)).filter(id => !isNaN(id));
|
|
} else if (typeof priority_id === 'string') {
|
|
priorityIds = priority_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
|
} else {
|
|
const parsed = parseInt(priority_id);
|
|
if (!isNaN(parsed)) priorityIds.push(parsed);
|
|
}
|
|
|
|
if (priorityIds.length > 0) {
|
|
const placeholders = priorityIds.map(() => `$${paramIdx++}`).join(', ');
|
|
conditions.push(`t.ticket_priority_id IN (${placeholders})`);
|
|
params.push(...priorityIds);
|
|
}
|
|
}
|
|
if (user_id) {
|
|
let userIds = [];
|
|
if (Array.isArray(user_id)) {
|
|
userIds = user_id.map(id => parseInt(id)).filter(id => !isNaN(id));
|
|
} else if (typeof user_id === 'string') {
|
|
userIds = user_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
|
} else {
|
|
const parsed = parseInt(user_id);
|
|
if (!isNaN(parsed)) userIds.push(parsed);
|
|
}
|
|
|
|
if (userIds.length > 0) {
|
|
const placeholders = userIds.map(() => `$${paramIdx++}`).join(', ');
|
|
conditions.push(`t.user_id IN (${placeholders})`);
|
|
params.push(...userIds);
|
|
}
|
|
}
|
|
if (type_id) {
|
|
conditions.push(`t.type_id = $${paramIdx++}`);
|
|
params.push(parseInt(type_id));
|
|
}
|
|
if (date_from) {
|
|
conditions.push(`t.create_time >= $${paramIdx++}`);
|
|
params.push(new Date(date_from));
|
|
}
|
|
if (date_to) {
|
|
conditions.push(`t.create_time <= $${paramIdx++}`);
|
|
params.push(new Date(date_to));
|
|
}
|
|
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 (
|
|
SELECT article_id, SUM(time_unit) AS time_unit
|
|
FROM time_accounting
|
|
GROUP BY article_id
|
|
) ta ON a.id = ta.article_id
|
|
WHERE a.ticket_id = $1
|
|
ORDER BY a.create_time DESC, a.id DESC`,
|
|
[id]
|
|
);
|
|
|
|
// Fetch attachments metadata for all articles in the ticket
|
|
const attachmentsResult = await pool.query(
|
|
`SELECT id, article_id, filename, content_size, content_type, disposition
|
|
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() === '<p><br></p>';
|
|
if (isBodyEmpty) {
|
|
finalBody = subject || title || 'Nuovo ticket';
|
|
}
|
|
|
|
if (finalBody || (attachments && attachments.length > 0)) {
|
|
// Determine sender type (customer vs agent) and sender name/email
|
|
let senderTypeName = 'agent';
|
|
let customerFrom = 'OTRS Turbo Agent';
|
|
|
|
if (customer_user_id) {
|
|
const custRes = await client.query(
|
|
`SELECT email, first_name, last_name FROM customer_user WHERE login = $1`,
|
|
[customer_user_id]
|
|
);
|
|
if (custRes.rows.length > 0) {
|
|
const c = custRes.rows[0];
|
|
customerFrom = `${c.first_name} ${c.last_name} <${c.email}>`;
|
|
senderTypeName = 'customer';
|
|
} else if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
|
|
try {
|
|
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: customer_user_id });
|
|
const getData = (getResult && getResult.Data) || getResult;
|
|
if (getData && getData.CustomerUser) {
|
|
const u = getData.CustomerUser;
|
|
customerFrom = `${u.UserFirstname || ''} ${u.UserLastname || ''} <${u.UserEmail || ''}>`.trim();
|
|
senderTypeName = 'customer';
|
|
}
|
|
} catch (apiErr) {
|
|
console.warn(`Failed to fetch LDAP customer details for ${customer_user_id}:`, apiErr.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get sender type ID
|
|
const senderResult = await client.query(
|
|
`SELECT id FROM article_sender_type WHERE name = $1`,
|
|
[senderTypeName]
|
|
);
|
|
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
|
|
|
|
// Get communication channel ID for "Internal"
|
|
const channelResult = await client.query(
|
|
`SELECT id FROM communication_channel WHERE name = 'Internal'`
|
|
);
|
|
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
|
|
|
|
const articleResult = await client.query(
|
|
`INSERT INTO article (
|
|
ticket_id, article_sender_type_id, communication_channel_id,
|
|
is_visible_for_customer, search_index_needs_rebuild,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES (
|
|
$1, $2, $3, 0, 1, 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 = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${finalBody}</body></html>`;
|
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
|
const base64Body = binaryBody.toString('base64');
|
|
|
|
await client.query(
|
|
`INSERT INTO article_data_mime_attachment (
|
|
article_id, filename, content_size, content_type, disposition, content,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES (
|
|
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
|
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, responsible_user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id
|
|
FROM ticket WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (currentResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Ticket non trovato' });
|
|
}
|
|
current = currentResult.rows[0];
|
|
} catch (dbErr) {
|
|
console.error('Error fetching current ticket:', dbErr);
|
|
return res.status(500).json({ error: dbErr.message });
|
|
}
|
|
|
|
// 1. Try to update via REST API if configured
|
|
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER && process.env.FORCE_DB_UPDATE !== 'true') {
|
|
try {
|
|
const ticketFields = {};
|
|
if (updates.ticket_state_id !== undefined) ticketFields.StateID = updates.ticket_state_id;
|
|
if (updates.ticket_priority_id !== undefined) ticketFields.PriorityID = updates.ticket_priority_id;
|
|
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
|
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
|
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
|
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
|
if (updates.title !== undefined) ticketFields.Title = updates.title;
|
|
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
|
|
if (updates.customer_id !== undefined) 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);
|
|
|
|
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', 'responsible_user_id', 'type_id', 'title', 'ticket_lock_id', 'customer_id', 'customer_user_id'];
|
|
for (const field of allowedFields) {
|
|
if (updates[field] !== undefined && updates[field] !== current[field]) {
|
|
setClauses.push(`${field} = $${pIdx++}`);
|
|
setParams.push(updates[field]);
|
|
}
|
|
}
|
|
|
|
const timeUnitVal = parseFloat(updates.time_unit);
|
|
const hasTimeUnit = !isNaN(timeUnitVal) && timeUnitVal > 0;
|
|
|
|
if (setClauses.length === 0 && !hasTimeUnit) {
|
|
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',
|
|
responsible_user_id: 'ResponsibleUpdate',
|
|
type_id: 'TypeUpdate',
|
|
ticket_lock_id: 'Lock',
|
|
customer_id: 'CustomerUpdate',
|
|
customer_user_id: 'CustomerUpdate',
|
|
};
|
|
|
|
for (const field of allowedFields) {
|
|
if (updates[field] !== undefined && updates[field] !== current[field]) {
|
|
const historyTypeName = historyTypeMap[field];
|
|
if (!historyTypeName) continue;
|
|
|
|
const htResult = await client.query(
|
|
`SELECT id FROM ticket_history_type WHERE name = $1`,
|
|
[historyTypeName]
|
|
);
|
|
if (htResult.rows.length === 0) continue;
|
|
|
|
const newStateId = updates.ticket_state_id || current.ticket_state_id;
|
|
const newPriorityId = updates.ticket_priority_id || current.ticket_priority_id;
|
|
const newQueueId = updates.queue_id || current.queue_id;
|
|
const newOwnerId = updates.user_id || current.user_id;
|
|
const newTypeId = updates.type_id || current.type_id || 1;
|
|
|
|
let historyName = '%%';
|
|
if (field === 'ticket_lock_id') {
|
|
historyName = updates[field] === 1 ? '%%unlock' : '%%lock';
|
|
}
|
|
|
|
await client.query(
|
|
`INSERT INTO ticket_history (
|
|
name, history_type_id, ticket_id, type_id, queue_id,
|
|
owner_id, priority_id, state_id,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8,
|
|
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 = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${noteBody}</body></html>`;
|
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
|
const base64Body = binaryBody.toString('base64');
|
|
|
|
const fromHeader = await resolveAgentFromHeader(operatorId, client);
|
|
|
|
// Create article_data_mime
|
|
await client.query(
|
|
`INSERT INTO article_data_mime (
|
|
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
|
|
a_message_id, a_in_reply_to, a_references,
|
|
a_content_type, incoming_time, content_path,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES (
|
|
$1, $2, '', '', '', '', $3, $4,
|
|
'', '', '',
|
|
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
|
NOW(), $6, NOW(), $6
|
|
)`,
|
|
[articleId, fromHeader, '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, adm.a_message_id, adm.a_references,
|
|
a.create_time,
|
|
creator.first_name AS creator_first, creator.last_name AS creator_last,
|
|
ta.time_unit
|
|
FROM article a
|
|
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
|
|
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
|
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
|
LEFT JOIN users creator ON a.create_by = creator.id
|
|
LEFT JOIN (
|
|
SELECT article_id, SUM(time_unit) AS time_unit
|
|
FROM time_accounting
|
|
GROUP BY article_id
|
|
) ta ON a.id = ta.article_id
|
|
WHERE a.ticket_id = $1
|
|
ORDER BY a.create_time DESC, a.id DESC`,
|
|
[id]
|
|
);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
console.error('Error fetching articles:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// POST /api/tickets/:id/articles — Add internal note
|
|
// ============================================================
|
|
router.post('/:id/articles', async (req, res) => {
|
|
const { id } = req.params;
|
|
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = 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);
|
|
|
|
|
|
|
|
resolveAgentName(operatorId).then(agente_nome => {
|
|
logAttivita({
|
|
agente_id: operatorId,
|
|
agente_nome,
|
|
titolo_azione: 'Aggiunta Nota',
|
|
azione: { ticket_id: id, subject, time_unit, has_attachments: !!(attachments && attachments.length) },
|
|
esito: 'successo',
|
|
});
|
|
});
|
|
|
|
return res.status(201).json({
|
|
message: 'Nota aggiunta! (via API REST)',
|
|
article_id: result.ArticleID,
|
|
result
|
|
});
|
|
} catch (restErr) {
|
|
console.error('Failed to add article via REST API:', restErr.message);
|
|
resolveAgentName(operatorId).then(agente_nome => {
|
|
logAttivita({
|
|
agente_id: operatorId,
|
|
agente_nome,
|
|
titolo_azione: 'Aggiunta Nota',
|
|
azione: { ticket_id: id, error: restErr.message },
|
|
esito: 'errore'
|
|
});
|
|
});
|
|
return res.status(500).json({ error: 'Errore durante l\'aggiunta della nota tramite API: ' + restErr.message });
|
|
}
|
|
}
|
|
|
|
// 2. Direct database update fallback
|
|
const client = await pool.connect();
|
|
try {
|
|
const { id } = req.params;
|
|
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = req.body;
|
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
|
|
|
// Check if this article was already inserted (e.g., via REST API or concurrent request)
|
|
const thirtySecondsAgo = new Date(Date.now() - 30000);
|
|
const recentCheck = await client.query(
|
|
`SELECT a.id
|
|
FROM article a
|
|
JOIN article_data_mime adm ON a.id = adm.article_id
|
|
WHERE a.ticket_id = $1
|
|
AND adm.a_subject = $2
|
|
AND adm.a_body = $3
|
|
AND a.create_time >= $4`,
|
|
[id, subject || 'Nota interna', body, thirtySecondsAgo]
|
|
);
|
|
|
|
if (recentCheck.rows.length > 0) {
|
|
const existingArticleId = recentCheck.rows[0].id;
|
|
console.log(`[Fallback Check] Found existing recent article (ID: ${existingArticleId}) in database. Skipping duplicate insert.`);
|
|
|
|
await client.query('BEGIN');
|
|
// If time_unit is provided, insert into time_accounting if not already present
|
|
if (time_unit !== undefined && time_unit !== null && time_unit !== '') {
|
|
const parsedTime = parseFloat(time_unit);
|
|
if (!isNaN(parsedTime) && parsedTime > 0) {
|
|
const timeCheck = await client.query(
|
|
`SELECT id FROM time_accounting WHERE ticket_id = $1 AND article_id = $2`,
|
|
[id, existingArticleId]
|
|
);
|
|
if (timeCheck.rows.length === 0) {
|
|
await client.query(
|
|
`INSERT INTO time_accounting (
|
|
ticket_id, article_id, time_unit,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
|
|
[id, existingArticleId, parsedTime, operatorId]
|
|
);
|
|
console.log(`[Fallback Check] Logged missing time accounting (${parsedTime} min) for existing article.`);
|
|
}
|
|
}
|
|
}
|
|
await client.query('COMMIT');
|
|
|
|
resolveAgentName(operatorId).then(agente_nome => {
|
|
logAttivita({
|
|
agente_id: operatorId,
|
|
agente_nome,
|
|
titolo_azione: 'Aggiunta Nota',
|
|
azione: { ticket_id: id, subject, time_unit, has_attachments: !!(attachments && attachments.length) },
|
|
esito: 'successo',
|
|
});
|
|
});
|
|
|
|
return res.status(201).json({
|
|
article_id: existingArticleId,
|
|
message: 'Nota aggiunta! (rilevata in DB)',
|
|
});
|
|
}
|
|
|
|
await client.query('BEGIN');
|
|
|
|
// Verify ticket exists
|
|
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')}`;
|
|
|
|
const fromHeader = await resolveAgentFromHeader(operatorId, client);
|
|
|
|
// Create article_data_mime
|
|
await client.query(
|
|
`INSERT INTO article_data_mime (
|
|
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
|
|
a_message_id, a_in_reply_to, a_references,
|
|
a_content_type, incoming_time, content_path,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES (
|
|
$1, $2, '', '', '', '', $3, $4,
|
|
'', '', '',
|
|
$5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
|
|
NOW(), $7, NOW(), $7
|
|
)`,
|
|
[articleId, fromHeader, 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 = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body}</body></html>`;
|
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
|
const base64Body = binaryBody.toString('base64');
|
|
|
|
await client.query(
|
|
`INSERT INTO article_data_mime_attachment (
|
|
article_id, filename, content_size, content_type, disposition, content,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES (
|
|
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/tickets/articles/:articleId — Delete an article/note
|
|
router.delete('/articles/:articleId', async (req, res) => {
|
|
const { articleId } = req.params;
|
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// 1. Set article_id in ticket_history to NULL to avoid constraint violation
|
|
await client.query(
|
|
`UPDATE ticket_history SET article_id = NULL WHERE article_id = $1`,
|
|
[articleId]
|
|
);
|
|
|
|
// Delete flags from article_flag
|
|
await client.query(
|
|
`DELETE FROM article_flag WHERE article_id = $1`,
|
|
[articleId]
|
|
);
|
|
|
|
// Delete search index entries from article_search_index
|
|
await client.query(
|
|
`DELETE FROM article_search_index WHERE article_id = $1`,
|
|
[articleId]
|
|
);
|
|
|
|
// 2. Delete time accounting entries
|
|
await client.query(
|
|
`DELETE FROM time_accounting WHERE article_id = $1`,
|
|
[articleId]
|
|
);
|
|
|
|
// 3. Delete attachments
|
|
await client.query(
|
|
`DELETE FROM article_data_mime_attachment WHERE article_id = $1`,
|
|
[articleId]
|
|
);
|
|
|
|
// 4. Delete mime data
|
|
await client.query(
|
|
`DELETE FROM article_data_mime WHERE article_id = $1`,
|
|
[articleId]
|
|
);
|
|
|
|
// 5. Delete article itself
|
|
const deleteRes = await client.query(
|
|
`DELETE FROM article WHERE id = $1 RETURNING ticket_id`,
|
|
[articleId]
|
|
);
|
|
|
|
const ticketId = deleteRes.rows.length > 0 ? deleteRes.rows[0].ticket_id : null;
|
|
|
|
await client.query('COMMIT');
|
|
|
|
resolveAgentName(operatorId).then(agente_nome => {
|
|
logAttivita({
|
|
agente_id: operatorId,
|
|
agente_nome,
|
|
titolo_azione: 'Eliminazione Articolo',
|
|
azione: { article_id: articleId, ticket_id: ticketId },
|
|
esito: 'successo',
|
|
});
|
|
});
|
|
|
|
res.json({ message: 'Articolo eliminato con successo!' });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error('Error deleting article:', err);
|
|
resolveAgentName(operatorId).then(agente_nome => {
|
|
logAttivita({
|
|
agente_id: operatorId,
|
|
agente_nome,
|
|
titolo_azione: 'Eliminazione Articolo',
|
|
azione: { article_id: articleId, error: err.message },
|
|
esito: 'errore',
|
|
});
|
|
});
|
|
res.status(500).json({ error: err.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// GET /api/tickets/attachments/:id — Download/View attachment
|
|
router.get('/attachments/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const result = await pool.query(
|
|
`SELECT filename, content_type, content
|
|
FROM article_data_mime_attachment
|
|
WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).send('Allegato non trovato.');
|
|
}
|
|
const attachment = result.rows[0];
|
|
|
|
// 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]
|
|
);
|
|
|
|
// Create internal system note inside source ticket B (the merged ticket)
|
|
const sourceArtResult = await client.query(
|
|
`INSERT INTO article (
|
|
ticket_id, article_sender_type_id, communication_channel_id,
|
|
is_visible_for_customer, search_index_needs_rebuild,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES ($1, 1, 1, 0, 1, NOW(), $2, NOW(), $2) RETURNING id`,
|
|
[sourceId, operatorId]
|
|
);
|
|
const sourceArticleId = sourceArtResult.rows[0].id;
|
|
const sourceMergeNoteText = `Merged Ticket ${sourceTn} to ${targetTn}`;
|
|
|
|
await client.query(
|
|
`INSERT INTO article_data_mime (
|
|
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
|
|
a_message_id, a_in_reply_to, a_references,
|
|
a_content_type, incoming_time, content_path,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES (
|
|
$1, 'Sistema OTRS Turbo', '', '', '', '', $2, $2,
|
|
'', '', '',
|
|
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
|
|
NOW(), $4, NOW(), $4
|
|
)`,
|
|
[sourceArticleId, sourceMergeNoteText, 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 = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body} (Mancanti: ${remaining} m)</body></html>`;
|
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
|
const base64Body = binaryBody.toString('base64');
|
|
const fromHeader = await resolveAgentFromHeader(operatorId, client);
|
|
|
|
await client.query(
|
|
`INSERT INTO article_data_mime (
|
|
article_id, a_from, a_to, a_subject, a_body,
|
|
a_content_type, incoming_time, content_path,
|
|
create_time, create_by, change_time, change_by
|
|
) VALUES (
|
|
$1, $2, '', $3, $4,
|
|
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
|
NOW(), $6, NOW(), $6
|
|
)`,
|
|
[articleId, fromHeader, 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;
|