Prima importazione

This commit is contained in:
2026-07-05 10:55:40 +02:00
commit 457c3eacf6
23 changed files with 6384 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
const express = require('express');
const router = express.Router();
const pool = require('../db');
// GET /api/dashboard/stats — Dashboard statistics
router.get('/stats', async (req, res) => {
try {
// All queries in parallel for speed
const [
byState,
byPriority,
byQueue,
todayCount,
weekCount,
totalOpen,
recentTickets,
escalated,
] = await Promise.all([
// Tickets by state (only open-ish states)
pool.query(
`SELECT ts.name AS state, tst.name AS state_type, COUNT(*) AS count
FROM ticket t
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
GROUP BY ts.name, tst.name
ORDER BY count DESC`
),
// Tickets by priority (open only)
pool.query(
`SELECT tp.name AS priority, tp.color, COUNT(*) AS count
FROM ticket t
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
GROUP BY tp.name, tp.color, tp.id
ORDER BY tp.id`
),
// Tickets by queue (open only, top 10)
pool.query(
`SELECT q.name AS queue, COUNT(*) AS count
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
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
GROUP BY q.name
ORDER BY count DESC
LIMIT 10`
),
// Created today
pool.query(
`SELECT COUNT(*) AS count FROM ticket
WHERE create_time >= CURRENT_DATE`
),
// Created this week
pool.query(
`SELECT COUNT(*) AS count FROM ticket
WHERE create_time >= date_trunc('week', CURRENT_DATE)`
),
// Total open
pool.query(
`SELECT COUNT(*) AS count
FROM ticket t
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')`
),
// 10 most recent tickets
pool.query(
`SELECT t.id, t.tn, t.title, ts.name AS state_name,
tp.name AS priority_name, tp.color AS priority_color,
q.name AS queue_name, t.create_time
FROM ticket t
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
JOIN queue q ON t.queue_id = q.id
ORDER BY t.create_time DESC
LIMIT 10`
),
// Escalated tickets
pool.query(
`SELECT COUNT(*) AS count FROM ticket
WHERE escalation_time > 0
AND escalation_time < EXTRACT(EPOCH FROM NOW())`
),
]);
res.json({
by_state: byState.rows,
by_priority: byPriority.rows,
by_queue: byQueue.rows,
created_today: parseInt(todayCount.rows[0].count),
created_this_week: parseInt(weekCount.rows[0].count),
total_open: parseInt(totalOpen.rows[0].count),
recent_tickets: recentTickets.rows,
escalated: parseInt(escalated.rows[0].count),
});
} catch (err) {
console.error('Error fetching dashboard stats:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+312
View File
@@ -0,0 +1,312 @@
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.replace(/\/$/, '')}${path.startsWith('/') ? path : '/' + path}`;
const payload = {
UserLogin: OTRS_API_USER,
Password: OTRS_API_PASSWORD,
...bodyData
};
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
}
return await response.json();
}
// GET /api/queues — Active queues
router.get('/queues', async (req, res) => {
try {
const result = await pool.query(
`SELECT q.id, q.name, q.comments
FROM queue q
WHERE q.valid_id = 1
ORDER BY q.name`
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching queues:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/states — Ticket states with state type
router.get('/states', async (req, res) => {
try {
const result = await pool.query(
`SELECT ts.id, ts.name, tst.name AS type_name
FROM ticket_state ts
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE ts.valid_id = 1
ORDER BY ts.id`
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching states:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/priorities — Ticket priorities
router.get('/priorities', async (req, res) => {
try {
const result = await pool.query(
`SELECT id, name, color
FROM ticket_priority
WHERE valid_id = 1
ORDER BY id`
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching priorities:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/users — Active agents/operators
router.get('/users', async (req, res) => {
try {
const result = await pool.query(
`SELECT id, login, first_name, last_name, title
FROM users
WHERE valid_id = 1
ORDER BY last_name, first_name`
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching users:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/types — Ticket types
router.get('/types', async (req, res) => {
try {
const result = await pool.query(
`SELECT id, name
FROM ticket_type
WHERE valid_id = 1
ORDER BY name`
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching types:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/lock-types — Ticket lock types
router.get('/lock-types', async (req, res) => {
try {
const result = await pool.query(
`SELECT id, name FROM ticket_lock_type WHERE valid_id = 1 ORDER BY id`
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching lock types:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/customer-companies/search — Search customer companies
router.get('/customer-companies/search', async (req, res) => {
try {
const { q } = req.query;
if (!q) {
return res.json([]);
}
const searchTerm = `%${q}%`;
const result = await pool.query(
`SELECT customer_id, name
FROM customer_company
WHERE valid_id = 1 AND (
customer_id ILIKE $1 OR
name ILIKE $1
)
ORDER BY name
LIMIT 20`,
[searchTerm]
);
res.json(result.rows);
} catch (err) {
console.error('Error searching customer companies:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/customer-users/search — Search customer users
router.get('/customer-users/search', async (req, res) => {
try {
const { q, customer_company_id } = req.query;
if (!q) {
return res.json([]);
}
// Try OTRS API first if configured
const OTRS_API_URL = process.env.OTRS_API_URL;
const OTRS_API_USER = process.env.OTRS_API_USER;
if (OTRS_API_URL && OTRS_API_USER) {
try {
const searchParams = {
Search: `*${q}*`,
Valid: 1
};
if (customer_company_id) {
searchParams.CustomerID = customer_company_id;
}
const searchRes = await otrsRequest('POST', '/CustomerUserSearch', searchParams);
let logins = [];
if (searchRes) {
if (Array.isArray(searchRes.CustomerUserID)) {
logins = searchRes.CustomerUserID;
} else if (searchRes.Data && Array.isArray(searchRes.Data.CustomerUserID)) {
logins = searchRes.Data.CustomerUserID;
} else if (Array.isArray(searchRes)) {
logins = searchRes;
}
}
if (logins.length > 0) {
// Limit to top 20 logins to avoid rate/performance issues
const limitedLogins = logins.slice(0, 20);
const detailPromises = limitedLogins.map(async (login) => {
try {
const detailRes = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: login });
const userObj = detailRes?.CustomerUser;
if (userObj) {
return {
login: userObj.UserLogin || login,
email: userObj.UserEmail || '',
first_name: userObj.UserFirstname || '',
last_name: userObj.UserLastname || '',
customer_id: userObj.UserCustomerID || ''
};
}
} catch (err) {
console.error(`Error fetching details for user ${login}:`, err.message);
}
return null;
});
const details = await Promise.all(detailPromises);
const validUsers = details.filter(u => u !== null);
if (validUsers.length > 0) {
return res.json(validUsers);
}
}
} catch (apiErr) {
console.warn('OTRS CustomerUserSearch API request failed, falling back to local DB:', apiErr.message);
}
}
// Fallback: local DB query
const searchTerm = `%${q}%`;
let queryText = `
SELECT login, email, first_name, last_name, customer_id
FROM customer_user
WHERE valid_id = 1 AND (
login ILIKE $1 OR
email ILIKE $1 OR
first_name ILIKE $1 OR
last_name ILIKE $1
)
`;
const queryParams = [searchTerm];
if (customer_company_id) {
queryText += ` AND customer_id = $2`;
queryParams.push(customer_company_id);
}
queryText += ` ORDER BY last_name, first_name LIMIT 20`;
const result = await pool.query(queryText, queryParams);
res.json(result.rows);
} catch (err) {
console.error('Error searching customer users:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/agents/search — Search active agents
router.get('/agents/search', async (req, res) => {
try {
const { q } = req.query;
const searchTerm = q ? `%${q}%` : '%';
const result = await pool.query(
`SELECT id, login, first_name, last_name
FROM users
WHERE valid_id = 1 AND (
login ILIKE $1 OR
first_name ILIKE $1 OR
last_name ILIKE $1
)
ORDER BY last_name, first_name
LIMIT 20`,
[searchTerm]
);
res.json(result.rows);
} catch (err) {
console.error('Error searching agents:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/queues/search — Search active queues
router.get('/queues/search', async (req, res) => {
try {
const { q } = req.query;
const searchTerm = q ? `%${q}%` : '%';
const result = await pool.query(
`SELECT id, name
FROM queue
WHERE valid_id = 1 AND name ILIKE $1
ORDER BY name
LIMIT 20`,
[searchTerm]
);
res.json(result.rows);
} catch (err) {
console.error('Error searching queues:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/states/search — Search active ticket states
router.get('/states/search', async (req, res) => {
try {
const { q } = req.query;
const searchTerm = q ? `%${q}%` : '%';
const result = await pool.query(
`SELECT id, name
FROM ticket_state
WHERE valid_id = 1 AND name ILIKE $1
ORDER BY name
LIMIT 20`,
[searchTerm]
);
res.json(result.rows);
} catch (err) {
console.error('Error searching states:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+868
View File
@@ -0,0 +1,868 @@
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 response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
}
return await response.json();
}
// ============================================================
// 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
} = 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 (search) {
conditions.push(`(t.title ILIKE $${paramIdx} OR t.tn 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,
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
${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 ASC`,
[id]
);
res.json({
ticket: ticketResult.rows[0],
articles: articlesResult.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
} = req.body;
await client.query('BEGIN');
// Generate ticket number: get next counter value
const counterResult = await client.query(
`INSERT INTO ticket_number_counter (counter, counter_uid, create_time)
VALUES (
COALESCE((SELECT MAX(counter) FROM ticket_number_counter), 0) + 1,
md5(random()::text || clock_timestamp()::text),
NOW()
)
RETURNING counter`
);
const counter = counterResult.rows[0].counter;
const now = new Date();
const tn = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${String(counter).padStart(10, '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 is provided
if (body) {
// Get sender type ID 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 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;
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,
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER,
NOW(), $5, NOW(), $5
)`,
[articleId, 'OTRS Turbo Agent', subject || title, body, 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;
// 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
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'];
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',
};
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 ASC`,
[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;
// 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: '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,
'', '', '',
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
NOW(), $6, NOW(), $6
)`,
[articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentPath, operatorId]
);
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and raw Buffer)
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');
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;
// 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'];
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();
}
});
module.exports = router;