@@ -265,6 +315,117 @@ const Filters = {
});
}
+ // Customer User Multiselect Popover binding
+ const cuDropdown = document.getElementById('customer-user-multiselect-dropdown');
+ const cuPopover = document.getElementById('customer-user-multiselect-popover');
+ const cuSearchInput = document.getElementById('customer-user-search-input');
+ const cuItemsContainer = document.getElementById('customer-user-items-container');
+ const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
+ const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
+
+ const renderCustomerUserItems = () => {
+ if (!cuItemsContainer) return;
+ const q = (cuSearchInput ? cuSearchInput.value : '').toLowerCase().trim();
+ const selectedList = Array.isArray(this.state.customer_user_id)
+ ? this.state.customer_user_id
+ : (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
+
+ const filtered = (App.lookups.customerUsers || []).filter(u => {
+ const fullName = `${u.last_name} ${u.first_name} (${u.login})`.toLowerCase();
+ return fullName.includes(q);
+ });
+
+ cuItemsContainer.innerHTML = filtered.map(u => {
+ const isSelected = selectedList.includes(String(u.login));
+ const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
+ return `
+
+ `;
+ }).join('');
+
+ // Bind clicks to items
+ cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
+ item.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const login = item.dataset.value;
+ const currentSelected = Array.isArray(this.state.customer_user_id)
+ ? [...this.state.customer_user_id]
+ : (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
+
+ const idx = currentSelected.indexOf(login);
+ if (idx > -1) {
+ currentSelected.splice(idx, 1);
+ item.classList.remove('active');
+ item.style.background = '';
+ item.style.color = '';
+ } else {
+ currentSelected.push(login);
+ item.classList.add('active');
+ item.style.background = 'var(--accent-primary)';
+ item.style.color = '#fff';
+ }
+ this.state.customer_user_id = currentSelected.join(',');
+ });
+ });
+ };
+
+ if (cuDropdown && cuPopover) {
+ cuDropdown.addEventListener('click', (e) => {
+ e.stopPropagation();
+
+ // Close other popovers
+ const statePopover = document.getElementById('state-multiselect-popover');
+ if (statePopover) statePopover.style.display = 'none';
+
+ const isOpen = cuPopover.style.display === 'block';
+ cuPopover.style.display = isOpen ? 'none' : 'block';
+ if (!isOpen) {
+ if (cuSearchInput) {
+ cuSearchInput.value = '';
+ }
+ renderCustomerUserItems();
+ setTimeout(() => {
+ if (cuSearchInput) cuSearchInput.focus();
+ }, 50);
+ }
+ });
+
+ cuPopover.addEventListener('click', (e) => e.stopPropagation());
+
+ document.addEventListener('click', () => {
+ cuPopover.style.display = 'none';
+ });
+
+ if (cuSearchInput) {
+ cuSearchInput.addEventListener('input', () => {
+ renderCustomerUserItems();
+ });
+ }
+ }
+
+ if (cuOkBtn) {
+ cuOkBtn.addEventListener('click', () => {
+ this.save();
+ this.updateCustomerUserMultiselectLabel(App.lookups);
+ if (cuPopover) cuPopover.style.display = 'none';
+ if (onFilterChange) onFilterChange();
+ });
+ }
+
+ if (cuClearBtn) {
+ cuClearBtn.addEventListener('click', () => {
+ this.state.customer_user_id = '';
+ this.save();
+ this.updateCustomerUserMultiselectLabel(App.lookups);
+ if (cuSearchInput) cuSearchInput.value = '';
+ renderCustomerUserItems();
+ if (cuPopover) cuPopover.style.display = 'none';
+ if (onFilterChange) onFilterChange();
+ });
+ }
+
const resetBtn = document.getElementById('filter-reset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
@@ -282,7 +443,7 @@ const Filters = {
}
});
- // Also clear multiselect items and label
+ // Also clear state multiselect items and label
const statePopover = document.getElementById('state-multiselect-popover');
if (statePopover) {
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
@@ -293,6 +454,19 @@ const Filters = {
}
this.updateStateMultiselectLabel(App.lookups);
+ // Also clear customer user multiselect items and label
+ const cuPopover = document.getElementById('customer-user-multiselect-popover');
+ if (cuPopover) {
+ const searchInput = cuPopover.querySelector('#customer-user-search-input');
+ if (searchInput) searchInput.value = '';
+ cuPopover.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
+ item.classList.remove('active');
+ item.style.background = '';
+ item.style.color = '';
+ });
+ }
+ this.updateCustomerUserMultiselectLabel(App.lookups);
+
if (onFilterChange) onFilterChange();
});
}
diff --git a/public/js/views/ticketDetail.js b/public/js/views/ticketDetail.js
index 828d085..a3e23b3 100644
--- a/public/js/views/ticketDetail.js
+++ b/public/js/views/ticketDetail.js
@@ -208,7 +208,7 @@ const TicketDetailView = {
? `
`;
- const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1');
+ const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1' && att.filename !== 'file-2');
return `
@@ -827,6 +828,32 @@ const TicketDetailView = {
p.style.display = 'none';
});
});
+ // Delete Article Event Listeners
+ document.querySelectorAll('.btn-delete-article').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const articleId = btn.dataset.articleId;
+ const ok = await App.confirm(
+ "Elimina Articolo",
+ "Sei sicuro di voler eliminare questa nota/articolo? Tutti i file allegati e i tempi ad esso associati verranno rimossi permanentemente.",
+ { confirmText: 'Elimina', cancelText: 'Annulla' }
+ );
+ if (ok) {
+ try {
+ btn.disabled = true;
+ await App.api(`/api/tickets/articles/${articleId}`, {
+ method: 'DELETE'
+ });
+ Toast.success('Articolo/nota eliminato con successo!');
+ this.render(this.ticketId);
+ } catch (err) {
+ Toast.error('Errore durante l\'eliminazione: ' + err.message);
+ btn.disabled = false;
+ }
+ }
+ });
+ });
+
// HTML Mode Toggle Listener
const htmlToggle = document.getElementById('html-toggle');
if (htmlToggle) {
diff --git a/routes/lookups.js b/routes/lookups.js
index ffdae8c..4c62af7 100644
--- a/routes/lookups.js
+++ b/routes/lookups.js
@@ -192,30 +192,89 @@ router.get('/lock-types', async (req, res) => {
router.get('/customer-companies/search', async (req, res) => {
try {
const { q = '' } = req.query;
- let result;
- if (!q) {
- result = await pool.query(
- `SELECT customer_id, name
- FROM customer_company
- WHERE valid_id = 1
- ORDER BY name
- LIMIT 20`
- );
- } else {
- const searchTerm = `%${q}%`;
- 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]
- );
+
+ // 1. Fetch from local SQLite LDAP cache
+ let localRows = [];
+ try {
+ if (!q) {
+ localRows = db.prepare(`
+ SELECT DISTINCT customer_id AS customer_id, customer_id AS name
+ FROM customer_user_cache
+ WHERE customer_id IS NOT NULL AND customer_id != ''
+ ORDER BY customer_id
+ LIMIT 500
+ `).all();
+ } else {
+ const searchTerm = `%${q}%`;
+ localRows = db.prepare(`
+ SELECT DISTINCT customer_id AS customer_id, customer_id AS name
+ FROM customer_user_cache
+ WHERE customer_id IS NOT NULL AND customer_id != '' AND customer_id LIKE ?
+ ORDER BY customer_id
+ LIMIT 500
+ `).all(searchTerm);
+ }
+ } catch (e) {
+ console.warn('Failed to query local customer cache:', e.message);
}
- res.json(result.rows);
+
+ // 2. Fetch from OTRS Postgres DB
+ let dbRows = [];
+ try {
+ if (!q) {
+ const result = await pool.query(
+ `SELECT customer_id, name
+ FROM customer_company
+ WHERE valid_id = 1
+ ORDER BY name
+ LIMIT 500`
+ );
+ dbRows = result.rows;
+ } else {
+ 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 500`,
+ [searchTerm]
+ );
+ dbRows = result.rows;
+ }
+ } catch (e) {
+ console.warn('Failed to query OTRS customer_company table:', e.message);
+ }
+
+ // 3. Merge results and remove duplicates by customer_id
+ const seen = new Set();
+ const merged = [];
+
+ // Prioritize OTRS database rows (which might have better names)
+ for (const row of dbRows) {
+ const cid = String(row.customer_id).trim();
+ if (cid && !seen.has(cid.toLowerCase())) {
+ seen.add(cid.toLowerCase());
+ merged.push({ customer_id: cid, name: row.name || cid });
+ }
+ }
+
+ // Add local LDAP rows
+ for (const row of localRows) {
+ const cid = String(row.customer_id).trim();
+ if (cid && !seen.has(cid.toLowerCase())) {
+ seen.add(cid.toLowerCase());
+ merged.push({ customer_id: cid, name: row.name || cid });
+ }
+ }
+
+ // Sort alphabetically by name
+ merged.sort((a, b) => a.name.localeCompare(b.name, 'it', { sensitivity: 'base' }));
+
+ res.json(merged.slice(0, 500));
} catch (err) {
console.error('Error searching customer companies:', err);
res.status(500).json({ error: err.message });
@@ -226,6 +285,92 @@ router.get('/customer-companies/search', async (req, res) => {
router.get('/customer-users/search', async (req, res) => {
const { q = '', customer_company_id } = req.query;
+ // If q is empty, we return a merged list for populating filter dropdowns
+ if (!q) {
+ let localRows = [];
+ try {
+ if (customer_company_id) {
+ localRows = db.prepare(`
+ SELECT login, email, first_name, last_name, customer_id
+ FROM customer_user_cache
+ WHERE customer_id = ?
+ ORDER BY last_name, first_name
+ LIMIT 1000
+ `).all(customer_company_id);
+ } else {
+ localRows = db.prepare(`
+ SELECT login, email, first_name, last_name, customer_id
+ FROM customer_user_cache
+ ORDER BY last_name, first_name
+ LIMIT 1000
+ `).all();
+ }
+ } catch (e) {
+ console.warn('Failed to query local customer user cache:', e.message);
+ }
+
+ let dbRows = [];
+ try {
+ let queryText = `
+ SELECT login, email, first_name, last_name, customer_id
+ FROM customer_user
+ WHERE valid_id = 1
+ `;
+ let queryParams = [];
+ if (customer_company_id) {
+ queryText += ` AND customer_id = $1`;
+ queryParams.push(customer_company_id);
+ }
+ queryText += ` ORDER BY last_name, first_name LIMIT 1000`;
+
+ const result = await pool.query(queryText, queryParams);
+ dbRows = result.rows;
+ } catch (e) {
+ console.warn('Failed to query OTRS customer_user table:', e.message);
+ }
+
+ // Merge and deduplicate by login
+ const seen = new Set();
+ const merged = [];
+
+ for (const row of dbRows) {
+ const login = String(row.login).trim();
+ if (login && !seen.has(login.toLowerCase())) {
+ seen.add(login.toLowerCase());
+ merged.push({
+ login,
+ email: row.email || '',
+ first_name: row.first_name || '',
+ last_name: row.last_name || '',
+ customer_id: row.customer_id || ''
+ });
+ }
+ }
+
+ for (const row of localRows) {
+ const login = String(row.login).trim();
+ if (login && !seen.has(login.toLowerCase())) {
+ seen.add(login.toLowerCase());
+ merged.push({
+ login,
+ email: row.email || '',
+ first_name: row.first_name || '',
+ last_name: row.last_name || '',
+ customer_id: row.customer_id || ''
+ });
+ }
+ }
+
+ // Sort alphabetically by last name, first name
+ merged.sort((a, b) => {
+ const nameA = `${a.last_name} ${a.first_name}`.trim();
+ const nameB = `${b.last_name} ${b.first_name}`.trim();
+ return nameA.localeCompare(nameB, 'it', { sensitivity: 'base' });
+ });
+
+ return res.json(merged.slice(0, 1000));
+ }
+
// 1. Try to search via OTRS GenericInterface REST API if configured
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
try {
diff --git a/routes/tickets.js b/routes/tickets.js
index 65e806d..4e7d213 100644
--- a/routes/tickets.js
+++ b/routes/tickets.js
@@ -19,6 +19,24 @@ async function resolveAgentName(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, email, 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 email = u.email || '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;
@@ -72,7 +90,7 @@ async function otrsRequest(method, path, bodyData = {}) {
router.get('/', async (req, res) => {
try {
const {
- queue_id, state_id, priority_id, user_id, type_id,
+ 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
@@ -82,6 +100,23 @@ router.get('/', async (req, res) => {
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) {
conditions.push(`t.queue_id = $${paramIdx++}`);
params.push(parseInt(queue_id));
@@ -336,7 +371,11 @@ router.get('/:id', async (req, res) => {
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
+ 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]
@@ -725,22 +764,6 @@ router.patch('/:id', async (req, res) => {
}
const result = await otrsRequest('PATCH', `/Ticket/${id}`, reqBody);
- // Option 1: Log the time directly to the DB if the API succeeded but OTRS didn't save it
- if (!isNaN(timeUnit) && timeUnit > 0 && result && result.ArticleID) {
- try {
- await pool.query(
- `INSERT INTO time_accounting (
- ticket_id, article_id, time_unit,
- create_time, create_by, change_time, change_by
- ) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
- [id, result.ArticleID, timeUnit, operatorId]
- );
- console.log(`[Time Accounting] Successfully logged ${timeUnit} minutes for ticket ${id} via DB insert.`);
- } catch (timeDbErr) {
- console.error('[Time Accounting] Failed to log time unit in database:', timeDbErr.message);
- }
- }
-
const isClosing = updates.ticket_state_id && current.ticket_state_id !== updates.ticket_state_id;
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
@@ -911,6 +934,8 @@ router.patch('/:id', async (req, res) => {
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 (
@@ -924,7 +949,7 @@ router.patch('/:id', async (req, res) => {
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
NOW(), $6, NOW(), $6
)`,
- [articleId, 'OTRS Turbo Agent', 'Consuntivazione', htmlBody, contentPath, operatorId]
+ [articleId, fromHeader, 'Consuntivazione', htmlBody, contentPath, operatorId]
);
// Create article_data_mime_attachment for OTRS CE HTML rendering
@@ -995,7 +1020,11 @@ router.get('/:id/articles', async (req, res) => {
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
+ 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]
@@ -1047,21 +1076,7 @@ router.post('/:id/articles', async (req, res) => {
const result = await otrsRequest('PATCH', `/Ticket/${id}`, payload);
- // Option 1: Log the time directly to the DB if the API succeeded but OTRS didn't save it
- if (time_unit && result && result.ArticleID) {
- try {
- await pool.query(
- `INSERT INTO time_accounting (
- ticket_id, article_id, time_unit,
- create_time, create_by, change_time, change_by
- ) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
- [id, result.ArticleID, parseFloat(time_unit), operatorId]
- );
- console.log(`[Time Accounting] Successfully logged ${time_unit} minutes for ticket ${id} via DB insert.`);
- } catch (timeDbErr) {
- console.error('[Time Accounting] Failed to log time unit in database:', timeDbErr.message);
- }
- }
+
resolveAgentName(operatorId).then(agente_nome => {
logAttivita({
@@ -1072,14 +1087,24 @@ router.post('/:id/articles', async (req, res) => {
esito: 'successo',
});
});
+
return res.status(201).json({
message: 'Nota aggiunta! (via API REST)',
article_id: result.ArticleID,
result
});
} catch (restErr) {
- console.warn('Failed to add article via REST API, falling back to database insert:', restErr.message);
- // Fall through to standard direct database update below
+ 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 });
}
}
@@ -1090,6 +1115,62 @@ router.post('/:id/articles', async (req, res) => {
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
@@ -1129,6 +1210,8 @@ router.post('/:id/articles', async (req, res) => {
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 (
@@ -1142,7 +1225,7 @@ router.post('/:id/articles', async (req, res) => {
$5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
NOW(), $7, NOW(), $7
)`,
- [articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentType, contentPath, operatorId]
+ [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)
@@ -1433,6 +1516,90 @@ router.post('/articles/:articleId/retrodata-article', async (req, res) => {
}
});
+// 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 {
@@ -1902,6 +2069,7 @@ router.post('/auto-time', async (req, res) => {
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 (
@@ -1909,11 +2077,11 @@ router.post('/auto-time', async (req, res) => {
a_content_type, incoming_time, content_path,
create_time, create_by, change_time, change_by
) VALUES (
- $1, 'OTRS Turbo Agent', '', $2, $3,
- 'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $4,
- NOW(), $5, NOW(), $5
+ $1, $2, '', $3, $4,
+ 'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
+ NOW(), $6, NOW(), $6
)`,
- [articleId, subject, htmlBody, contentPath, operatorId]
+ [articleId, fromHeader, subject, htmlBody, contentPath, operatorId]
);
// Create article attachment (file-1) for OTRS CE HTML display