feat: contatore tempo allocato, pagina miei ticket, unione ticket (da db)
This commit is contained in:
@@ -4,6 +4,7 @@ const pool = require('../db');
|
||||
|
||||
// GET /api/dashboard/stats — Dashboard statistics
|
||||
router.get('/stats', async (req, res) => {
|
||||
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||
try {
|
||||
// All queries in parallel for speed
|
||||
const [
|
||||
@@ -15,6 +16,7 @@ router.get('/stats', async (req, res) => {
|
||||
totalOpen,
|
||||
recentTickets,
|
||||
escalated,
|
||||
myOpenCount,
|
||||
] = await Promise.all([
|
||||
// Tickets by state (only open-ish states)
|
||||
pool.query(
|
||||
@@ -85,6 +87,16 @@ router.get('/stats', async (req, res) => {
|
||||
WHERE escalation_time > 0
|
||||
AND escalation_time < EXTRACT(EPOCH FROM NOW())`
|
||||
),
|
||||
// My open tickets count
|
||||
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')
|
||||
AND t.user_id = $1`,
|
||||
[activeAgentId]
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
@@ -96,6 +108,7 @@ router.get('/stats', async (req, res) => {
|
||||
total_open: parseInt(totalOpen.rows[0].count),
|
||||
recent_tickets: recentTickets.rows,
|
||||
escalated: parseInt(escalated.rows[0].count),
|
||||
total_my_open: parseInt(myOpenCount.rows[0].count),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching dashboard stats:', err);
|
||||
|
||||
+51
-69
@@ -17,16 +17,31 @@ async function otrsRequest(method, path, bodyData = {}) {
|
||||
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}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('OTRS REST API request timed out (6s limit exceeded)');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +110,32 @@ router.get('/users', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/config — Application config
|
||||
router.get('/config', (req, res) => {
|
||||
res.json({
|
||||
defaultAgentLogin: process.env.OTRS_API_USER || '',
|
||||
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/users/time-today — Get sum of today's time units for active agent
|
||||
router.get('/users/time-today', async (req, res) => {
|
||||
try {
|
||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||
const result = await pool.query(
|
||||
`SELECT COALESCE(SUM(time_unit), 0) AS total_today
|
||||
FROM time_accounting
|
||||
WHERE create_by = $1 AND DATE(create_time) = CURRENT_DATE`,
|
||||
[operatorId]
|
||||
);
|
||||
res.json({ totalToday: parseFloat(result.rows[0].total_today) });
|
||||
} catch (err) {
|
||||
console.error('Error fetching today\'s time units:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// GET /api/types — Ticket types
|
||||
router.get('/types', async (req, res) => {
|
||||
try {
|
||||
@@ -150,70 +191,11 @@ router.get('/customer-companies/search', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/customer-users/search — Search customer users
|
||||
// GET /api/customer-users/search — Search customer users (always from local DB)
|
||||
router.get('/customer-users/search', async (req, res) => {
|
||||
try {
|
||||
const { q = '', customer_company_id } = req.query;
|
||||
|
||||
// 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 ? `*${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
|
||||
let queryText;
|
||||
let queryParams;
|
||||
if (q) {
|
||||
|
||||
+178
-21
@@ -17,16 +17,31 @@ async function otrsRequest(method, path, bodyData = {}) {
|
||||
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}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('OTRS REST API request timed out (6s limit exceeded)');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +90,18 @@ router.get('/', async (req, res) => {
|
||||
params.push(new Date(date_to));
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`(t.title ILIKE $${paramIdx} OR t.tn ILIKE $${paramIdx})`);
|
||||
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++;
|
||||
}
|
||||
@@ -118,6 +144,7 @@ router.get('/', async (req, res) => {
|
||||
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
|
||||
@@ -128,6 +155,7 @@ router.get('/', async (req, res) => {
|
||||
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++}`,
|
||||
@@ -249,19 +277,29 @@ router.post('/', async (req, res) => {
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Generate ticket number: get next counter value
|
||||
// Generate ticket number: get next counter value for today (daily reset)
|
||||
const counterTodayResult = await client.query(
|
||||
`SELECT COALESCE(MAX(counter), 0) AS max_counter
|
||||
FROM ticket_number_counter
|
||||
WHERE create_time >= CURRENT_DATE`
|
||||
);
|
||||
const nextCounter = parseInt(counterTodayResult.rows[0].max_counter, 10) + 1;
|
||||
|
||||
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,
|
||||
$1,
|
||||
md5(random()::text || clock_timestamp()::text),
|
||||
NOW()
|
||||
)
|
||||
RETURNING counter`
|
||||
RETURNING counter`,
|
||||
[nextCounter]
|
||||
);
|
||||
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')}`;
|
||||
const systemId = process.env.OTRS_SYSTEM_ID || '10';
|
||||
const counterPadding = parseInt(process.env.OTRS_COUNTER_PADDING, 10) || 6;
|
||||
const tn = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}${String(counter).padStart(counterPadding, '0')}`;
|
||||
|
||||
// Determine lock type (1 = unlock by default)
|
||||
const lockId = 1;
|
||||
@@ -372,6 +410,8 @@ router.post('/', async (req, res) => {
|
||||
|
||||
const articleId = articleResult.rows[0].id;
|
||||
const finalBody = body || 'File allegati in creazione';
|
||||
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
|
||||
const contentType = isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO article_data_mime (
|
||||
@@ -380,10 +420,10 @@ router.post('/', async (req, res) => {
|
||||
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
|
||||
$5, EXTRACT(EPOCH FROM NOW())::INTEGER,
|
||||
NOW(), $6, NOW(), $6
|
||||
)`,
|
||||
[articleId, customerFrom, subject || title, finalBody, operatorId]
|
||||
[articleId, customerFrom, subject || title, finalBody, contentType, operatorId]
|
||||
);
|
||||
|
||||
// Insert attachments if any
|
||||
@@ -646,6 +686,9 @@ router.post('/:id/articles', async (req, res) => {
|
||||
const { subject, body, is_visible_for_customer = 0, time_unit } = req.body;
|
||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||
|
||||
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
|
||||
const contentType = isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
|
||||
|
||||
// 1. Try to add note via REST API if configured
|
||||
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
|
||||
try {
|
||||
@@ -656,7 +699,7 @@ router.post('/:id/articles', async (req, res) => {
|
||||
IsVisibleForCustomer: is_visible_for_customer ? '1' : '0',
|
||||
Subject: subject || 'Nota interna',
|
||||
Body: body,
|
||||
ContentType: 'text/plain; charset=utf8',
|
||||
ContentType: isHtml ? 'text/html; charset=utf8' : 'text/plain; charset=utf8',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -732,10 +775,10 @@ router.post('/:id/articles', async (req, res) => {
|
||||
) VALUES (
|
||||
$1, $2, '', '', '', '', $3, $4,
|
||||
'', '', '',
|
||||
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
||||
NOW(), $6, NOW(), $6
|
||||
$5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
|
||||
NOW(), $7, NOW(), $7
|
||||
)`,
|
||||
[articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentPath, operatorId]
|
||||
[articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentType, contentPath, operatorId]
|
||||
);
|
||||
|
||||
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and raw Buffer)
|
||||
@@ -991,4 +1034,118 @@ router.get('/attachments/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tickets/merge — Merge tickets (Issue #7)
|
||||
router.post('/merge', async (req, res) => {
|
||||
const { targetId, sourceIds } = req.body;
|
||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||
|
||||
if (!targetId || !sourceIds || !Array.isArray(sourceIds) || sourceIds.length === 0) {
|
||||
return res.status(400).json({ error: 'targetId e array sourceIds sono obbligatori' });
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Fetch target ticket details
|
||||
const targetRes = await client.query('SELECT tn, title FROM ticket WHERE id = $1', [targetId]);
|
||||
if (targetRes.rows.length === 0) {
|
||||
throw new Error(`Ticket di destinazione #${targetId} non trovato`);
|
||||
}
|
||||
const targetTn = targetRes.rows[0].tn;
|
||||
const targetTitle = targetRes.rows[0].title;
|
||||
|
||||
for (const sourceId of sourceIds) {
|
||||
// Fetch source ticket details
|
||||
const sourceRes = await client.query('SELECT tn, title FROM ticket WHERE id = $1', [sourceId]);
|
||||
if (sourceRes.rows.length === 0) continue;
|
||||
const sourceTn = sourceRes.rows[0].tn;
|
||||
const sourceTitle = sourceRes.rows[0].title;
|
||||
|
||||
// 1. Move all articles of source to target
|
||||
await client.query(
|
||||
`UPDATE article
|
||||
SET ticket_id = $1, change_time = NOW(), change_by = $2
|
||||
WHERE ticket_id = $3`,
|
||||
[targetId, operatorId, sourceId]
|
||||
);
|
||||
|
||||
// 2. Move all time accounting entries
|
||||
await client.query(
|
||||
`UPDATE time_accounting
|
||||
SET ticket_id = $1, change_time = NOW(), change_by = $2
|
||||
WHERE ticket_id = $3`,
|
||||
[targetId, operatorId, sourceId]
|
||||
);
|
||||
|
||||
// 3. Mark source ticket as merged (state_id = 9)
|
||||
await client.query(
|
||||
`UPDATE ticket
|
||||
SET ticket_state_id = 9, change_time = NOW(), change_by = $1
|
||||
WHERE id = $2`,
|
||||
[operatorId, sourceId]
|
||||
);
|
||||
|
||||
// 4. Create ParentChild relation link (source B is child/merged of target A)
|
||||
const relCheck = await client.query(
|
||||
`SELECT 1 FROM link_relation
|
||||
WHERE source_object_id = 1 AND source_key = $1
|
||||
AND target_object_id = 1 AND target_key = $2 AND type_id = 2`,
|
||||
[String(sourceId), String(targetId)]
|
||||
);
|
||||
|
||||
if (relCheck.rows.length === 0) {
|
||||
await client.query(
|
||||
`INSERT INTO link_relation (
|
||||
source_object_id, source_key, target_object_id, target_key,
|
||||
type_id, state_id, create_time, create_by
|
||||
) VALUES (1, $1, 1, $2, 2, 1, NOW(), $3)`,
|
||||
[String(sourceId), String(targetId), operatorId]
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Create internal system note inside target ticket documenting the merge
|
||||
const now = new Date();
|
||||
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Insert article
|
||||
const artResult = await client.query(
|
||||
`INSERT INTO article (
|
||||
ticket_id, article_sender_type_id, communication_channel_id,
|
||||
is_visible_for_customer, search_index_needs_rebuild,
|
||||
create_time, create_by, change_time, change_by
|
||||
) VALUES ($1, 1, 1, 0, 1, NOW(), $2, NOW(), $2) RETURNING id`,
|
||||
[targetId, operatorId]
|
||||
);
|
||||
const articleId = artResult.rows[0].id;
|
||||
|
||||
const mergeNoteBody = `Il ticket #${sourceTn} ("${sourceTitle}") è stato unito a questo ticket.`;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO article_data_mime (
|
||||
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
|
||||
a_message_id, a_in_reply_to, a_references,
|
||||
a_content_type, incoming_time, content_path,
|
||||
create_time, create_by, change_time, change_by
|
||||
) VALUES (
|
||||
$1, 'Sistema OTRS Turbo', '', '', '', '', 'Ticket Unito', $2,
|
||||
'', '', '',
|
||||
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
|
||||
NOW(), $4, NOW(), $4
|
||||
)`,
|
||||
[articleId, mergeNoteBody, contentPath, operatorId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ message: 'Ticket uniti con successo!' });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error merging tickets:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user