From 3f3885130ae07325ba0232d6d93a486e8b8d7cbb Mon Sep 17 00:00:00 2001 From: Gabriele Cimaschi Date: Wed, 26 Aug 2026 21:32:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20aggiunta=20possibilit=C3=A0=20di=20salv?= =?UTF-8?q?are=20delle=20note=20interne=20da=20caricare=20successivamente?= =?UTF-8?q?=20su=20OTRS.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- activityDb.js | 16 + public/index.html | 13 + public/js/app.js | 14 + public/js/views/noteTemporanee.js | 571 ++++++++++++++++++++++++++++++ routes/notes.js | 221 ++++++++++++ server.js | 2 + 6 files changed, 837 insertions(+) create mode 100644 public/js/views/noteTemporanee.js create mode 100644 routes/notes.js diff --git a/activityDb.js b/activityDb.js index df6e4d8..d825463 100644 --- a/activityDb.js +++ b/activityDb.js @@ -78,6 +78,22 @@ db.exec(` ) `); +db.exec(` + CREATE TABLE IF NOT EXISTS ticket_draft_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL DEFAULT 1, + ticket_id INTEGER, + ticket_tn TEXT, + ticket_title TEXT, + subject TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + time_units REAL DEFAULT 0, + note_type TEXT DEFAULT 'note-internal', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) +`); + db.exec(` CREATE TABLE IF NOT EXISTS email_address_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/public/index.html b/public/index.html index ceb0989..4bfda17 100644 --- a/public/index.html +++ b/public/index.html @@ -80,6 +80,18 @@ Ricerca Spudorata +
  • + + + + + + + + Note Temporanee + + +
  • @@ -242,6 +254,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 369b2f3..f46364d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -307,9 +307,15 @@ const App = { TicketListView.render(); } else if (hash === '#/ricerca-spudorata') { + document.getElementById('nav-ricerca-spudorata')?.classList.add('active'); titleEl.textContent = 'Ricerca Spudorata'; RicercaSpudorataView.render(); + } else if (hash === '#/note-temporanee') { + document.getElementById('nav-note-temporanee')?.classList.add('active'); + titleEl.textContent = 'Note Temporanee'; + NoteTemporaneeView.render(); + } else if (hash === '#/tickets/new') { document.getElementById('nav-new-ticket')?.classList.add('active'); titleEl.textContent = 'Nuovo Ticket'; @@ -843,6 +849,14 @@ const App = { if (myBadge) { myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : ''; } + + try { + const notes = await this.api('/api/notes'); + const notesBadge = document.getElementById('temp-notes-count'); + if (notesBadge) { + notesBadge.textContent = (notes && notes.length > 0) ? notes.length : ''; + } + } catch (_) {} } catch (e) { console.warn('Failed to update sidebar badges:', e); } diff --git a/public/js/views/noteTemporanee.js b/public/js/views/noteTemporanee.js new file mode 100644 index 0000000..45077ae --- /dev/null +++ b/public/js/views/noteTemporanee.js @@ -0,0 +1,571 @@ +/** + * Note Temporanee View (public/js/views/noteTemporanee.js) + * Temporary draft notes manager saved locally in SQLite until manually deleted or uploaded to OTRS. + * Uses Quill Rich Text Editor for advanced formatting matching Ticket Detail notes. + */ +const NoteTemporaneeView = { + currentNoteId: null, + savedNotes: [], + selectedTicket: null, + quill: null, + + async render() { + const container = document.getElementById('view-container'); + if (!container) return; + + container.innerHTML = ` +
    + + +
    +
    +
    +
    +
    πŸ“
    +
    +

    Nuova Nota Temporanea

    +

    + Le note vengono mantenute in locale finchΓ© non le elimini con il cestino πŸ—‘οΈ o le invii a OTRS πŸš€. +

    +
    +
    + +
    + + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    + + +
    +
    + ⏱ + +
    + +
    + + +
    + + + +
    + +
    +
    +
    + + +
    +
    +
    +

    + πŸ“‹ Note Salvate + 0 +

    + +
    + + +
    + + πŸ” +
    + + +
    +
    + Caricamento note... +
    +
    +
    +
    + +
    + `; + + this.initQuill(); + this.bindEvents(); + await this.loadSavedNotes(); + + // Check if URL hash has parameters like ?ticket_id=... or ?ticket_tn=... + const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || ''); + const ticketTnParam = urlParams.get('ticket_tn') || urlParams.get('tn'); + if (ticketTnParam) { + const input = document.getElementById('note-ticket-input'); + if (input) { + input.value = ticketTnParam; + this.resolveTicketInput(ticketTnParam); + } + } + }, + + initQuill() { + const editorEl = document.getElementById('note-body-editor'); + if (!editorEl) return; + + if (window.Quill) { + this.quill = new Quill('#note-body-editor', { + theme: 'snow', + placeholder: 'Scrivi il contenuto dettagliato della nota temporanea...', + modules: { + toolbar: [ + ['bold', 'italic', 'underline', 'strike'], + [{ 'list': 'ordered' }, { 'list': 'bullet' }], + [{ 'color': [] }, { 'background': [] }], + ['link', 'image'], + ['clean'] + ], + keyboard: { + bindings: { + tab: { + key: 'Tab', + handler: function () { + return true; + } + } + } + } + } + }); + } + }, + + getNoteBodyHtml() { + if (this.quill) { + const html = this.quill.root.innerHTML; + if (html === '


    ') return ''; + return html; + } + return ''; + }, + + setNoteBodyHtml(html) { + if (this.quill) { + this.quill.root.innerHTML = html || ''; + } + }, + + bindEvents() { + const saveBtn = document.getElementById('note-save-local-btn'); + const submitBtn = document.getElementById('note-submit-otrs-btn'); + const clearBtn = document.getElementById('note-clear-form-btn'); + const deleteBtn = document.getElementById('note-delete-current-btn'); + const refreshBtn = document.getElementById('notes-refresh-btn'); + const searchInput = document.getElementById('notes-sidebar-search'); + const ticketInput = document.getElementById('note-ticket-input'); + const ticketClearBtn = document.getElementById('note-ticket-clear-btn'); + + if (saveBtn) saveBtn.addEventListener('click', () => this.saveLocalNote()); + if (submitBtn) submitBtn.addEventListener('click', () => this.submitToOTRS()); + if (clearBtn) clearBtn.addEventListener('click', () => this.clearForm()); + if (deleteBtn) deleteBtn.addEventListener('click', () => { + if (this.currentNoteId) this.deleteNote(this.currentNoteId); + }); + if (refreshBtn) refreshBtn.addEventListener('click', () => this.loadSavedNotes()); + + if (searchInput) { + searchInput.addEventListener('input', () => this.renderSavedNotesList()); + } + + if (ticketInput) { + let debounceTimer = null; + ticketInput.addEventListener('input', () => { + const val = ticketInput.value.trim(); + if (ticketClearBtn) ticketClearBtn.style.display = val ? 'block' : 'none'; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + this.resolveTicketInput(val); + }, 400); + }); + } + + if (ticketClearBtn) { + ticketClearBtn.addEventListener('click', () => { + if (ticketInput) ticketInput.value = ''; + ticketClearBtn.style.display = 'none'; + this.selectedTicket = null; + this.updateTicketBadge(null); + }); + } + }, + + async resolveTicketInput(val) { + if (!val) { + this.selectedTicket = null; + this.updateTicketBadge(null); + return; + } + + try { + const res = await App.api(`/api/tickets?search=${encodeURIComponent(val)}&per_page=5`); + const tickets = res.tickets || []; + if (tickets.length > 0) { + const exact = tickets.find(t => String(t.tn) === String(val) || String(t.id) === String(val)) || tickets[0]; + this.selectedTicket = exact; + this.updateTicketBadge(exact); + } else { + this.selectedTicket = null; + this.updateTicketBadge(null, `Nessun ticket trovato per "${val}"`); + } + } catch (_) { + this.selectedTicket = null; + this.updateTicketBadge(null); + } + }, + + updateTicketBadge(ticket, errorMsg = '') { + const badge = document.getElementById('ticket-resolved-badge'); + if (!badge) return; + + if (ticket) { + badge.style.display = 'inline-block'; + badge.style.color = 'var(--accent-primary)'; + badge.innerHTML = `βœ“ Ticket #${ticket.tn} β€” ${App.escapeHtml(ticket.title ? (ticket.title.length > 40 ? ticket.title.substring(0, 37) + '...' : ticket.title) : '')}`; + } else if (errorMsg) { + badge.style.display = 'inline-block'; + badge.style.color = 'var(--error)'; + badge.innerHTML = App.escapeHtml(errorMsg); + } else { + badge.style.display = 'none'; + badge.innerHTML = ''; + } + }, + + async loadSavedNotes() { + try { + const listContainer = document.getElementById('saved-notes-list-container'); + if (listContainer) { + listContainer.innerHTML = '
    '; + } + + this.savedNotes = await App.api('/api/notes'); + this.renderSavedNotesList(); + + const countBadge = document.getElementById('notes-sidebar-count'); + if (countBadge) countBadge.textContent = this.savedNotes.length; + + // Update sidebar nav badge + const navBadge = document.getElementById('temp-notes-count'); + if (navBadge) navBadge.textContent = this.savedNotes.length || ''; + } catch (err) { + console.error('Failed to load saved notes:', err); + Toast.error('Errore nel caricamento delle note salvate'); + } + }, + + renderSavedNotesList() { + const listContainer = document.getElementById('saved-notes-list-container'); + if (!listContainer) return; + + const searchInput = document.getElementById('notes-sidebar-search'); + const searchVal = searchInput ? searchInput.value.trim().toLowerCase() : ''; + + let list = this.savedNotes || []; + if (searchVal) { + list = list.filter(n => + (n.ticket_tn && n.ticket_tn.toLowerCase().includes(searchVal)) || + (n.subject && n.subject.toLowerCase().includes(searchVal)) || + (n.body && n.body.toLowerCase().includes(searchVal)) || + (n.ticket_title && n.ticket_title.toLowerCase().includes(searchVal)) + ); + } + + if (list.length === 0) { + listContainer.innerHTML = ` +
    + ${searchVal ? 'Nessuna nota corrisponde alla ricerca' : 'Nessuna nota temporanea salvata'} +
    + `; + return; + } + + listContainer.innerHTML = list.map(n => { + const isSelected = String(n.id) === String(this.currentNoteId); + const isInternal = n.note_type === 'note-internal'; + const isEmail = n.note_type === 'email-external'; + const typeLabel = isInternal ? 'πŸ”’ Interna' : (isEmail ? 'βœ‰οΈ Email' : '🌐 Esterna'); + const timeStr = n.time_units > 0 ? `⏱ ${n.time_units}m` : ''; + + // Strip HTML tags for clean text preview + const tmpDiv = document.createElement('div'); + tmpDiv.innerHTML = n.body || ''; + const textPreview = tmpDiv.textContent || tmpDiv.innerText || ''; + + return ` +
    + +
    + ${n.ticket_tn ? ` + #${n.ticket_tn} + ` : ` + Senza Ticket + `} + ${App.formatDateTime(n.updated_at)} +
    + +
    + ${App.escapeHtml(n.subject || '(Senza titolo)')} +
    + +
    + ${App.escapeHtml(textPreview)} +
    + +
    +
    + ${typeLabel} + ${timeStr ? `${timeStr}` : ''} +
    + +
    + + + +
    +
    + +
    + `; + }).join(''); + + // Bind item actions + listContainer.querySelectorAll('.btn-edit-note').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.loadNoteIntoForm(parseInt(btn.dataset.id, 10)); + }); + }); + + listContainer.querySelectorAll('.btn-submit-note').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.submitNoteById(parseInt(btn.dataset.id, 10)); + }); + }); + + listContainer.querySelectorAll('.btn-delete-note').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.deleteNote(parseInt(btn.dataset.id, 10)); + }); + }); + + listContainer.querySelectorAll('.saved-note-card').forEach(card => { + card.addEventListener('click', () => { + this.loadNoteIntoForm(parseInt(card.dataset.noteId, 10)); + }); + }); + }, + + loadNoteIntoForm(noteId) { + const note = this.savedNotes.find(n => n.id === noteId); + if (!note) return; + + this.currentNoteId = note.id; + + const editorTitle = document.getElementById('editor-title-text'); + const ticketInput = document.getElementById('note-ticket-input'); + const subjectInput = document.getElementById('note-subject'); + const typeSelect = document.getElementById('note-type'); + const timeInput = document.getElementById('note-time-units'); + const deleteBtn = document.getElementById('note-delete-current-btn'); + const ticketClearBtn = document.getElementById('note-ticket-clear-btn'); + + if (editorTitle) editorTitle.textContent = `Modifica Nota #${note.id}`; + if (ticketInput) ticketInput.value = note.ticket_tn || (note.ticket_id ? String(note.ticket_id) : ''); + if (ticketClearBtn) ticketClearBtn.style.display = ticketInput && ticketInput.value ? 'block' : 'none'; + if (subjectInput) subjectInput.value = note.subject || ''; + if (typeSelect) typeSelect.value = note.note_type || 'note-internal'; + this.setNoteBodyHtml(note.body || ''); + if (timeInput) timeInput.value = note.time_units || 0; + if (deleteBtn) deleteBtn.style.display = 'inline-flex'; + + if (note.ticket_tn || note.ticket_id) { + this.resolveTicketInput(note.ticket_tn || String(note.ticket_id)); + } else { + this.selectedTicket = null; + this.updateTicketBadge(null); + } + + this.renderSavedNotesList(); + }, + + clearForm() { + this.currentNoteId = null; + + const editorTitle = document.getElementById('editor-title-text'); + const ticketInput = document.getElementById('note-ticket-input'); + const subjectInput = document.getElementById('note-subject'); + const typeSelect = document.getElementById('note-type'); + const timeInput = document.getElementById('note-time-units'); + const deleteBtn = document.getElementById('note-delete-current-btn'); + const ticketClearBtn = document.getElementById('note-ticket-clear-btn'); + + if (editorTitle) editorTitle.textContent = 'Nuova Nota Temporanea'; + if (ticketInput) ticketInput.value = ''; + if (ticketClearBtn) ticketClearBtn.style.display = 'none'; + if (subjectInput) subjectInput.value = ''; + if (typeSelect) typeSelect.value = 'note-internal'; + this.setNoteBodyHtml(''); + if (timeInput) timeInput.value = '0'; + if (deleteBtn) deleteBtn.style.display = 'none'; + + this.selectedTicket = null; + this.updateTicketBadge(null); + this.renderSavedNotesList(); + }, + + async saveLocalNote() { + const ticketInput = document.getElementById('note-ticket-input'); + const subjectInput = document.getElementById('note-subject'); + const typeSelect = document.getElementById('note-type'); + const timeInput = document.getElementById('note-time-units'); + + const bodyVal = this.getNoteBodyHtml(); + if (!bodyVal) { + Toast.error('Inserisci il corpo della nota prima di salvare'); + return; + } + + const payload = { + id: this.currentNoteId, + ticket_id: this.selectedTicket ? this.selectedTicket.id : null, + ticket_tn: ticketInput ? ticketInput.value.trim() : null, + ticket_title: this.selectedTicket ? this.selectedTicket.title : null, + subject: subjectInput ? subjectInput.value.trim() : '', + body: bodyVal, + time_units: timeInput ? parseFloat(timeInput.value) || 0 : 0, + note_type: typeSelect ? typeSelect.value : 'note-internal' + }; + + try { + const res = await App.api('/api/notes', { + method: 'POST', + body: payload + }); + + Toast.success('Nota temporanea salvata nel database locale!'); + this.currentNoteId = res.id; + await this.loadSavedNotes(); + } catch (err) { + console.error(err); + Toast.error('Errore nel salvataggio della nota: ' + err.message); + } + }, + + async submitToOTRS() { + const bodyVal = this.getNoteBodyHtml(); + if (!bodyVal) { + Toast.error('Inserisci il corpo della nota prima di inviare a OTRS'); + return; + } + + // First ensure the note is saved locally + await this.saveLocalNote(); + + if (!this.currentNoteId) return; + + await this.submitNoteById(this.currentNoteId); + }, + + async submitNoteById(noteId) { + const note = this.savedNotes.find(n => n.id === noteId); + if (!note) return; + + if (!note.ticket_tn && !note.ticket_id) { + Toast.error('Inserisci un ticket di riferimento (TN o ID) prima di inviare a OTRS'); + return; + } + + if (!confirm(`Sei sicuro di voler inviare questa nota a OTRS per il ticket #${note.ticket_tn || note.ticket_id}? La nota verrΓ  poi rimossa dal database locale.`)) { + return; + } + + try { + const res = await App.api(`/api/notes/${noteId}/submit`, { + method: 'POST' + }); + + Toast.success(res.message || 'Nota inviata con successo a OTRS!'); + if (String(this.currentNoteId) === String(noteId)) { + this.clearForm(); + } + await this.loadSavedNotes(); + + // Trigger daily timer refresh if time units were added + if (note.time_units > 0 && App.updateDailyTimer) { + App.updateDailyTimer(); + } + } catch (err) { + console.error(err); + Toast.error('Errore invio OTRS: ' + err.message); + } + }, + + async deleteNote(noteId) { + if (!confirm('Sei sicuro di voler eliminare questa nota temporanea dal database locale?')) { + return; + } + + try { + await App.api(`/api/notes/${noteId}`, { + method: 'DELETE' + }); + + Toast.success('Nota temporanea eliminata dal DB locale'); + if (String(this.currentNoteId) === String(noteId)) { + this.clearForm(); + } + await this.loadSavedNotes(); + } catch (err) { + console.error(err); + Toast.error('Errore eliminazione nota: ' + err.message); + } + } +}; diff --git a/routes/notes.js b/routes/notes.js new file mode 100644 index 0000000..409edd9 --- /dev/null +++ b/routes/notes.js @@ -0,0 +1,221 @@ +const express = require('express'); +const router = express.Router(); +const { db, logAttivita } = require('../activityDb'); +const pool = require('../db'); + +// Helper to resolve ticket info from PostgreSQL given ID or TN +async function resolveTicketInfo(ticketId, ticketTn) { + try { + if (ticketId) { + const res = await pool.query('SELECT id, tn, title FROM ticket WHERE id = $1', [ticketId]); + if (res.rows.length > 0) return res.rows[0]; + } else if (ticketTn) { + const res = await pool.query('SELECT id, tn, title FROM ticket WHERE tn = $1', [ticketTn.trim()]); + if (res.rows.length > 0) return res.rows[0]; + } + } catch (err) { + console.warn('[Notes API] Error resolving ticket info:', err.message); + } + return null; +} + +// GET /api/notes - List all draft notes for current agent +router.get('/', (req, res) => { + try { + const agentId = parseInt(req.headers['x-agent-id'] || '1', 10); + const notes = db.prepare(` + SELECT * FROM ticket_draft_notes + WHERE agent_id = ? + ORDER BY updated_at DESC + `).all(agentId); + + res.json(notes); + } catch (err) { + console.error('[Notes API] GET / failed:', err); + res.status(500).json({ error: 'Errore nel caricamento delle note temporanee', message: err.message }); + } +}); + +// GET /api/notes/:id - Get single draft note +router.get('/:id', (req, res) => { + try { + const note = db.prepare('SELECT * FROM ticket_draft_notes WHERE id = ?').get(req.params.id); + if (!note) { + return res.status(404).json({ error: 'Nota temporanea non trovata' }); + } + res.json(note); + } catch (err) { + res.status(500).json({ error: 'Errore nel recupero della nota', message: err.message }); + } +}); + +// POST /api/notes - Create or update a temporary draft note +router.post('/', async (req, res) => { + try { + const agentId = parseInt(req.headers['x-agent-id'] || '1', 10); + let { id, ticket_id, ticket_tn, ticket_title, subject, body, time_units, note_type } = req.body; + + if (!body || body.trim() === '') { + return res.status(400).json({ error: 'Il corpo della nota non puΓ² essere vuoto' }); + } + + // Resolve ticket info if missing TN or ID + const resolved = await resolveTicketInfo(ticket_id, ticket_tn); + if (resolved) { + ticket_id = resolved.id; + ticket_tn = resolved.tn; + ticket_title = resolved.title; + } + + const now = new Date().toISOString(); + + if (id) { + // Update existing note + db.prepare(` + UPDATE ticket_draft_notes + SET ticket_id = ?, ticket_tn = ?, ticket_title = ?, subject = ?, body = ?, time_units = ?, note_type = ?, updated_at = ? + WHERE id = ? AND agent_id = ? + `).run(ticket_id || null, ticket_tn || null, ticket_title || null, subject || '', body, parseFloat(time_units) || 0, note_type || 'note-internal', now, id, agentId); + + const updated = db.prepare('SELECT * FROM ticket_draft_notes WHERE id = ?').get(id); + return res.json(updated); + } else { + // Insert new note + const info = db.prepare(` + INSERT INTO ticket_draft_notes (agent_id, ticket_id, ticket_tn, ticket_title, subject, body, time_units, note_type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(agentId, ticket_id || null, ticket_tn || null, ticket_title || null, subject || '', body, parseFloat(time_units) || 0, note_type || 'note-internal', now, now); + + const inserted = db.prepare('SELECT * FROM ticket_draft_notes WHERE id = ?').get(info.lastInsertRowid); + return res.status(201).json(inserted); + } + } catch (err) { + console.error('[Notes API] POST / failed:', err); + res.status(500).json({ error: 'Errore nel salvataggio della nota temporanea', message: err.message }); + } +}); + +// DELETE /api/notes/:id - Delete a draft note permanently +router.delete('/:id', (req, res) => { + try { + const info = db.prepare('DELETE FROM ticket_draft_notes WHERE id = ?').run(req.params.id); + if (info.changes === 0) { + return res.status(404).json({ error: 'Nota temporanea non trovata' }); + } + res.json({ success: true, id: req.params.id }); + } catch (err) { + res.status(500).json({ error: 'Errore nell\'eliminazione della nota', message: err.message }); + } +}); + +// DELETE /api/notes/by-ticket/:identifier - Delete notes tied to ticket ID or TN +router.delete('/by-ticket/:identifier', async (req, res) => { + try { + const identifier = req.params.identifier; + const resolved = await resolveTicketInfo(parseInt(identifier, 10) || null, identifier); + + if (resolved) { + db.prepare('DELETE FROM ticket_draft_notes WHERE ticket_id = ? OR ticket_tn = ?').run(resolved.id, resolved.tn); + } else { + db.prepare('DELETE FROM ticket_draft_notes WHERE ticket_id = ? OR ticket_tn = ?').run(identifier, identifier); + } + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Errore nel ripristino/pulizia della nota', message: err.message }); + } +}); + +// POST /api/notes/:id/submit - Submit note directly to OTRS and remove from SQLite upon success +router.post('/:id/submit', async (req, res) => { + try { + const agentId = parseInt(req.headers['x-agent-id'] || '1', 10); + const note = db.prepare('SELECT * FROM ticket_draft_notes WHERE id = ? AND agent_id = ?').get(req.params.id, agentId); + + if (!note) { + return res.status(404).json({ error: 'Nota temporanea non trovata' }); + } + + if (!note.ticket_id && !note.ticket_tn) { + return res.status(400).json({ error: 'Impossibile inviare a OTRS: nessun ticket (TN/ID) associato a questa nota' }); + } + + const resolved = await resolveTicketInfo(note.ticket_id, note.ticket_tn); + if (!resolved) { + return res.status(400).json({ error: `Ticket non trovato nel sistema OTRS (TN: ${note.ticket_tn || ''}, ID: ${note.ticket_id || ''})` }); + } + + // Get agent login user + let userLogin = process.env.OTRS_API_USER || 'root@localhost'; + try { + const prefRes = await pool.query( + `SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`, + [agentId] + ); + if (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) { + userLogin = prefRes.rows[0].preferences_value; + } + } catch (_) {} + + // Prepare Article for OTRS REST API + const isInternal = note.note_type === 'note-internal'; + const isEmail = note.note_type === 'email-external'; + + let articleType = isInternal ? 'note-internal' : (isEmail ? 'email-external' : 'note-external'); + let senderType = isInternal ? 'agent' : (isEmail ? 'agent' : 'agent'); + + const articlePayload = { + ArticleType: articleType, + SenderType: senderType, + Subject: note.subject || `Nota per ticket ${resolved.tn}`, + Body: note.body, + ContentType: 'text/html; charset=UTF-8', + TimeUnit: parseFloat(note.time_units) || 0 + }; + + const otrsUrl = process.env.OTRS_API_URL || 'http://localhost/otrs/nph-genericinterface.pl/Webservice/GenericTicketConnectorREST'; + const otrsPassword = process.env.OTRS_API_PASSWORD || ''; + + const payload = { + UserLogin: userLogin, + Password: otrsPassword, + TicketID: resolved.id, + Article: articlePayload + }; + + const otrsRes = await fetch(`${otrsUrl}/TicketUpdate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const otrsData = await otrsRes.json(); + + if (otrsData && otrsData.Error) { + throw new Error(otrsData.Error.ErrorMessage || 'Errore durante l\'invio a OTRS'); + } + + // Log action + logAttivita( + agentId, + userLogin, + `Aggiunta nota a Ticket #${resolved.tn}`, + { ticket_id: resolved.id, ticket_tn: resolved.tn, subject: note.subject, time_units: note.time_units }, + 'successo' + ); + + // Delete note from local SQLite DB upon successful OTRS upload + db.prepare('DELETE FROM ticket_draft_notes WHERE id = ?').run(note.id); + + res.json({ + success: true, + message: `Nota inviata con successo a OTRS per il ticket #${resolved.tn} e rimossa dal database locale.`, + ticket_id: resolved.id, + ticket_tn: resolved.tn + }); + } catch (err) { + console.error('[Notes API] POST /:id/submit failed:', err); + res.status(500).json({ error: 'Errore durante l\'invio della nota a OTRS', message: err.message }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index c132b61..ee8f667 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,7 @@ const emailRouter = require('./routes/email'); const groupsRouter = require('./routes/groups'); const presetsRouter = require('./routes/presets'); const configRouter = require('./routes/config'); +const notesRouter = require('./routes/notes'); const app = express(); const PORT = process.env.PORT || 3000; @@ -33,6 +34,7 @@ app.use('/api/email', emailRouter); app.use('/api/groups', groupsRouter); app.use('/api/presets', presetsRouter); app.use('/api/config', configRouter); +app.use('/api/notes', notesRouter); // SPA fallback β€” serve index.html for all non-API routes app.get('*', (req, res) => {