Compare commits
2
Commits
3b06609fcb
...
5b914eb198
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b914eb198 | ||
|
|
9a91c3d9f5 |
@@ -30,6 +30,9 @@ LDAP_SYNC_INTERVAL_HOURS=24
|
||||
# Imposta a true per forzare l'aggiornamento diretto del DB per tutte le modifiche ai ticket bypassando l'API REST (esclusa la ricerca LDAP)
|
||||
FORCE_DB_UPDATE=false
|
||||
|
||||
# Tempo di attesa in millisecondi prima della verifica a database dopo l'inserimento nota via API (default 3000)
|
||||
OTRS_API_FALLBACK_WAIT_MS=3000
|
||||
|
||||
# Chiave per cifrare le frasi nel database locale (NON CANCELLARE O MODIFICARE SE CI SONO DATI CRIPTATI)
|
||||
CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc
|
||||
|
||||
@@ -44,3 +47,20 @@ AUTO_TIME_TITLE=Consuntivazione Automatica fine giornata
|
||||
AUTO_TIME_SUBJECT=Consuntivazione automatica ore mancanti
|
||||
AUTO_TIME_BODY=Consuntivazione eseguita automaticamente per il completamento delle ore lavorative giornaliere.
|
||||
AUTO_TIME_CUSTOMER_USER=client_generic
|
||||
|
||||
# --- Microsoft Graph API per invio email (metodo primario - Exchange con 2FA) ---
|
||||
AZURE_TENANT_ID=your_tenant_id_here
|
||||
AZURE_CLIENT_ID=your_client_id_here
|
||||
AZURE_CLIENT_SECRET=your_client_secret_here
|
||||
AZURE_MAIL_SENDER=helpdesk@example.com
|
||||
|
||||
# --- SMTP Classico (fallback se Graph API non disponibile - lasciare vuoto per disabilitare) ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=helpdesk@example.com
|
||||
|
||||
# --- BCC automatico OTRS per tracciamento ticket ---
|
||||
OTRS_MAIL_BCC=helpdesk@example.com
|
||||
@@ -35,6 +35,18 @@ db.exec(`
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS email_signatures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL DEFAULT '',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
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'))
|
||||
)
|
||||
`);
|
||||
|
||||
try {
|
||||
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
|
||||
} catch (e) {
|
||||
|
||||
Generated
+10
@@ -14,6 +14,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.13.0"
|
||||
}
|
||||
},
|
||||
@@ -898,6 +899,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.13.0"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -1157,6 +1157,7 @@ body {
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
@@ -89,6 +89,15 @@
|
||||
<span>Storico Attività Turbo</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#/signatures" class="nav-link" data-view="signatures" id="nav-signatures">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
<span>Firme Email</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
@@ -175,6 +184,8 @@
|
||||
<script src="/js/views/ticketCreate.js"></script>
|
||||
<script src="/js/views/ticketBulk.js"></script>
|
||||
<script src="/js/views/activityLog.js"></script>
|
||||
<script src="/js/views/emailCompose.js"></script>
|
||||
<script src="/js/views/signatures.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
+151
-6
@@ -14,6 +14,10 @@ const App = {
|
||||
demotivationalPhrases: [],
|
||||
motivationalPhrases: [],
|
||||
|
||||
get currentAgentId() {
|
||||
return parseInt(localStorage.getItem('activeAgentId') || '1', 10);
|
||||
},
|
||||
|
||||
/** Initialize the application */
|
||||
init() {
|
||||
this.initTheme();
|
||||
@@ -143,6 +147,11 @@ const App = {
|
||||
titleEl.textContent = 'Storico Attività';
|
||||
ActivityLogView.render();
|
||||
|
||||
} else if (hash === '#/signatures') {
|
||||
document.getElementById('nav-signatures')?.classList.add('active');
|
||||
titleEl.textContent = 'Firme Email';
|
||||
SignaturesView.render();
|
||||
|
||||
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
||||
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
||||
document.getElementById('nav-tickets')?.classList.add('active');
|
||||
@@ -186,8 +195,8 @@ const App = {
|
||||
if (cached) {
|
||||
try {
|
||||
this.lookups = JSON.parse(cached);
|
||||
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined) {
|
||||
throw new Error('Outdated config cache (missing autoTimeMinHour)');
|
||||
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined || !this.lookups.customerUsers || !this.lookups.customer_users_version_1) {
|
||||
throw new Error('Outdated config cache (missing autoTimeMinHour or customerUsers)');
|
||||
}
|
||||
this.lookupsLoaded = true;
|
||||
return;
|
||||
@@ -198,16 +207,17 @@ const App = {
|
||||
}
|
||||
|
||||
try {
|
||||
const [queues, states, priorities, users, types, config] = await Promise.all([
|
||||
const [queues, states, priorities, users, types, config, customerUsers] = await Promise.all([
|
||||
this.api('/api/queues'),
|
||||
this.api('/api/states'),
|
||||
this.api('/api/priorities'),
|
||||
this.api('/api/users'),
|
||||
this.api('/api/types'),
|
||||
this.api('/api/config').catch(() => ({ defaultAgentLogin: '' })),
|
||||
this.api('/api/customer-users/search?q=').catch(() => []),
|
||||
]);
|
||||
|
||||
this.lookups = { queues, states, priorities, users, types, config };
|
||||
this.lookups = { queues, states, priorities, users, types, config, customerUsers, customer_users_version_1: true };
|
||||
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
|
||||
this.lookupsLoaded = true;
|
||||
} catch (err) {
|
||||
@@ -464,15 +474,18 @@ const App = {
|
||||
const triggerAction = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const confirmed = confirm(`Sei sicuro di voler effettuare la consuntivazione automatica di ${remaining} minuti rimanenti di oggi? Verrà creato un ticket chiuso a tuo carico.`);
|
||||
const confirmed = await this.confirm(
|
||||
'Consuntivazione Automatica',
|
||||
`Sei sicuro di voler effettuare la consuntivazione automatica di ${remaining} minuti rimanenti di oggi? Verrà creato un ticket chiuso a tuo carico.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
Toast.success('Consuntivazione in corso...');
|
||||
const res = await this.api('/api/tickets/auto-time', { method: 'POST' });
|
||||
Toast.success(res.message || 'Consuntivazione completata!');
|
||||
this.updateDailyTimer();
|
||||
this.route();
|
||||
await this.alert('Consuntivazione Completata', res.message || 'La consuntivazione automatica è stata completata con successo.');
|
||||
} catch (err) {
|
||||
Toast.error('Errore consuntivazione automatica: ' + err.message);
|
||||
}
|
||||
@@ -576,6 +589,138 @@ const App = {
|
||||
console.warn('Failed to update sidebar badges:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/** Custom confirm dialog in the center of the screen */
|
||||
confirm(title, message, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100vw';
|
||||
overlay.style.height = '100vh';
|
||||
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||
overlay.style.backdropFilter = 'blur(4px)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.zIndex = '99999';
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.transition = 'opacity 0.2s ease';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.style.background = 'var(--bg-card, #1e1e2e)';
|
||||
card.style.border = '1px solid var(--border-subtle, #313244)';
|
||||
card.style.borderRadius = 'var(--radius-lg, 12px)';
|
||||
card.style.padding = 'var(--space-lg, 24px)';
|
||||
card.style.width = '100%';
|
||||
card.style.maxWidth = '400px';
|
||||
card.style.boxShadow = 'var(--shadow-lg, 0 10px 30px rgba(0,0,0,0.5))';
|
||||
card.style.transform = 'scale(0.9)';
|
||||
card.style.transition = 'transform 0.2s ease';
|
||||
card.className = 'confirm-dialog-card';
|
||||
|
||||
card.innerHTML = `
|
||||
<h3 style="margin-top: 0; margin-bottom: var(--space-xs, 8px); color: var(--text-primary, #cdd6f4); font-size: 1.2rem; font-weight: 600;">${title}</h3>
|
||||
<p style="margin-bottom: var(--space-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
|
||||
<div style="display: flex; gap: var(--space-sm, 12px); justify-content: flex-end;">
|
||||
<button id="confirm-btn-cancel" class="btn btn-ghost" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px);">${options.cancelText || 'Annulla'}</button>
|
||||
<button id="confirm-btn-ok" class="btn btn-danger" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.confirmText || 'Conferma'}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Trigger animations
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.opacity = '1';
|
||||
card.style.transform = 'scale(1)';
|
||||
});
|
||||
|
||||
const cleanUp = (result) => {
|
||||
overlay.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.9)';
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const btnCancel = card.querySelector('#confirm-btn-cancel');
|
||||
const btnOk = card.querySelector('#confirm-btn-ok');
|
||||
|
||||
btnCancel.addEventListener('click', () => cleanUp(false));
|
||||
btnOk.addEventListener('click', () => cleanUp(true));
|
||||
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) cleanUp(false);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/** Custom alert dialog in the center of the screen */
|
||||
alert(title, message, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100vw';
|
||||
overlay.style.height = '100vh';
|
||||
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||
overlay.style.backdropFilter = 'blur(4px)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.zIndex = '99999';
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.transition = 'opacity 0.2s ease';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.style.background = 'var(--bg-card, #1e1e2e)';
|
||||
card.style.border = '1px solid var(--border-subtle, #313244)';
|
||||
card.style.borderRadius = 'var(--radius-lg, 12px)';
|
||||
card.style.padding = 'var(--space-lg, 24px)';
|
||||
card.style.width = '100%';
|
||||
card.style.maxWidth = '400px';
|
||||
card.style.boxShadow = 'var(--shadow-lg, 0 10px 30px rgba(0,0,0,0.5))';
|
||||
card.style.transform = 'scale(0.9)';
|
||||
card.style.transition = 'transform 0.2s ease';
|
||||
card.className = 'alert-dialog-card';
|
||||
|
||||
card.innerHTML = `
|
||||
<h3 style="margin-top: 0; margin-bottom: var(--space-xs, 8px); color: var(--text-primary, #cdd6f4); font-size: 1.2rem; font-weight: 600;">${title}</h3>
|
||||
<p style="margin-bottom: var(--space-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
|
||||
<div style="display: flex; justify-content: flex-end;">
|
||||
<button id="alert-btn-ok" class="btn btn-primary" style="height: 36px; font-size: 0.9rem; padding: 0 20px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.okText || 'OK'}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.opacity = '1';
|
||||
card.style.transform = 'scale(1)';
|
||||
});
|
||||
|
||||
const cleanUp = () => {
|
||||
overlay.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.9)';
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
resolve();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
card.querySelector('#alert-btn-ok').addEventListener('click', cleanUp);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) cleanUp();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Start the app when DOM is ready
|
||||
|
||||
@@ -11,6 +11,7 @@ const Filters = {
|
||||
state_id: '',
|
||||
priority_id: '',
|
||||
user_id: '',
|
||||
customer_user_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
},
|
||||
@@ -19,6 +20,7 @@ const Filters = {
|
||||
state_id: '',
|
||||
priority_id: '',
|
||||
user_id: '',
|
||||
customer_user_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
}
|
||||
@@ -60,7 +62,7 @@ const Filters = {
|
||||
|
||||
/** Reset all filters */
|
||||
reset() {
|
||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', date_from: '', date_to: '' };
|
||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', customer_user_id: '', date_from: '', date_to: '' };
|
||||
if (this.currentMode === 'my') {
|
||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
this.state.user_id = activeAgentId;
|
||||
@@ -107,6 +109,36 @@ const Filters = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Helper to compute trigger button label text for customer users */
|
||||
getCustomerUserMultiselectLabel(lookups) {
|
||||
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(',') : []);
|
||||
|
||||
if (selectedList.length === 0) {
|
||||
return 'Tutti';
|
||||
}
|
||||
const selectedNames = (lookups.customerUsers || [])
|
||||
.filter(u => selectedList.includes(String(u.login)))
|
||||
.map(u => `${u.last_name} ${u.first_name}`);
|
||||
|
||||
if (selectedNames.length === (lookups.customerUsers || []).length) {
|
||||
return 'Tutti';
|
||||
} else if (selectedNames.length <= 2) {
|
||||
return selectedNames.join(', ');
|
||||
} else {
|
||||
return `${selectedNames.length} selezionati`;
|
||||
}
|
||||
},
|
||||
|
||||
/** Update customer user label DOM element dynamically */
|
||||
updateCustomerUserMultiselectLabel(lookups) {
|
||||
const labelEl = document.getElementById('customer-user-multiselect-label');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = this.getCustomerUserMultiselectLabel(lookups);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render filter bar HTML.
|
||||
* @param {Object} lookups - { queues, states, priorities, users }
|
||||
@@ -123,6 +155,7 @@ const Filters = {
|
||||
};
|
||||
|
||||
const currentLabel = this.getStateMultiselectLabel(lookups);
|
||||
const customerUserLabel = this.getCustomerUserMultiselectLabel(lookups);
|
||||
|
||||
return `
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
@@ -174,6 +207,23 @@ const Filters = {
|
||||
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Utente Cliente</span>
|
||||
<div class="multiselect-dropdown" id="customer-user-multiselect-dropdown" style="min-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="customer-user-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:180px;">${App.escapeHtml(customerUserLabel)}</span>
|
||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||
</div>
|
||||
<div class="multiselect-popover" id="customer-user-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||
<input type="text" id="customer-user-search-input" placeholder="Cerca utente..." style="width:100%; padding:6px 8px; font-size:0.8rem; background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); margin-bottom:6px; box-sizing:border-box; color:var(--text-primary); font-family:inherit;" autocomplete="off" />
|
||||
<div id="customer-user-items-container" style="display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||
<!-- Populated dynamically -->
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||
<button type="button" class="btn btn-ghost btn-xs" id="customer-user-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||
<button type="button" class="btn btn-primary btn-xs" id="customer-user-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Da Data/Ora</span>
|
||||
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
@@ -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 `
|
||||
<div class="customer-user-multiselect-item ${isSelected ? 'active' : ''}" data-value="${u.login}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||
${App.escapeHtml(u.last_name)} ${App.escapeHtml(u.first_name)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(u.login)})</span>
|
||||
</div>
|
||||
`;
|
||||
}).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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* emailCompose.js
|
||||
* Modal per la composizione e invio email dal contesto di un ticket.
|
||||
* Utilizza Quill.js per l'editor HTML, supporta allegati e immagini inline.
|
||||
*/
|
||||
|
||||
const EmailCompose = (() => {
|
||||
let quillEditor = null;
|
||||
let attachmentsList = [];
|
||||
let currentOptions = {};
|
||||
|
||||
// ── CSS ──────────────────────────────────────────────────────────────────────
|
||||
function injectStyles() {
|
||||
if (document.getElementById('email-compose-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'email-compose-styles';
|
||||
style.textContent = `
|
||||
#email-compose-overlay {
|
||||
position: fixed; inset: 0; z-index: 9000;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
|
||||
|
||||
#email-compose-modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl, 14px);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.35);
|
||||
width: min(860px, 95vw);
|
||||
max-height: 92vh;
|
||||
display: flex; flex-direction: column;
|
||||
animation: slideUp 0.18s ease;
|
||||
}
|
||||
@keyframes slideUp { from { transform: translateY(20px); opacity:0 } to { transform: translateY(0); opacity:1 } }
|
||||
|
||||
#email-compose-modal .ec-header {
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#email-compose-modal .ec-title {
|
||||
font-size: 1rem; font-weight: 600; color: var(--text-primary);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
#email-compose-modal .ec-body {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
#email-compose-modal .ec-field {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
#email-compose-modal .ec-label {
|
||||
font-size: 0.75rem; font-weight: 600; color: var(--text-secondary);
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
#email-compose-modal .ec-tags-input {
|
||||
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 10px; min-height: 36px; cursor: text;
|
||||
}
|
||||
#email-compose-modal .ec-tags-input:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb,99,102,241),0.12);
|
||||
}
|
||||
#email-compose-modal .ec-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: var(--accent-primary); color: #fff;
|
||||
border-radius: 4px; padding: 2px 6px; font-size: 0.78rem;
|
||||
}
|
||||
#email-compose-modal .ec-tag button {
|
||||
background: none; border: none; color: rgba(255,255,255,0.8);
|
||||
cursor: pointer; padding: 0; line-height: 1; font-size: 0.9rem;
|
||||
}
|
||||
#email-compose-modal .ec-tag button:hover { color: #fff; }
|
||||
#email-compose-modal .ec-tag-input {
|
||||
border: none; outline: none; background: transparent;
|
||||
font-size: 0.88rem; color: var(--text-primary);
|
||||
min-width: 160px; flex: 1;
|
||||
}
|
||||
#email-compose-modal .ec-editor-wrapper {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
#email-compose-modal .ec-editor-wrapper .ql-toolbar {
|
||||
border: none; border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
#email-compose-modal .ec-editor-wrapper .ql-container {
|
||||
border: none; min-height: 220px; font-size: 0.9rem;
|
||||
}
|
||||
#email-compose-modal .ec-attachments-zone {
|
||||
border: 2px dashed var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
font-size: 0.82rem; color: var(--text-muted);
|
||||
}
|
||||
#email-compose-modal .ec-attachments-zone:hover,
|
||||
#email-compose-modal .ec-attachments-zone.drag-over {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(var(--accent-rgb,99,102,241),0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
#email-compose-modal .ec-file-list {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
#email-compose-modal .ec-file-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 0.82rem; padding: 4px 8px;
|
||||
background: var(--bg-secondary); border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
#email-compose-modal .ec-file-item button {
|
||||
margin-left: auto; background: none; border: none; cursor: pointer;
|
||||
color: var(--text-muted); font-size: 0.85rem; padding: 0 2px;
|
||||
}
|
||||
#email-compose-modal .ec-file-item button:hover { color: var(--error); }
|
||||
#email-compose-modal .ec-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
flex-shrink: 0; gap: 10px;
|
||||
}
|
||||
#email-compose-modal select.ec-select {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 12px; font-size: 0.85rem;
|
||||
color: var(--text-primary); min-width: 180px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ── Tag Input Helper ──────────────────────────────────────────────────────────
|
||||
function makeTagInput(containerId, initialEmails = []) {
|
||||
const container = document.getElementById(containerId);
|
||||
const tags = [...initialEmails];
|
||||
|
||||
function render() {
|
||||
const inputEl = container.querySelector('.ec-tag-input');
|
||||
const currentVal = inputEl ? inputEl.value : '';
|
||||
container.innerHTML = '';
|
||||
tags.forEach((email, idx) => {
|
||||
const tagEl = document.createElement('span');
|
||||
tagEl.className = 'ec-tag';
|
||||
tagEl.innerHTML = `${App.escapeHtml(email)}<button type="button" data-idx="${idx}">✕</button>`;
|
||||
tagEl.querySelector('button').addEventListener('click', () => {
|
||||
tags.splice(idx, 1);
|
||||
render();
|
||||
});
|
||||
container.appendChild(tagEl);
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.className = 'ec-tag-input';
|
||||
input.type = 'text';
|
||||
input.placeholder = tags.length ? '' : 'email@esempio.com, premi Invio';
|
||||
input.value = currentVal;
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if ((e.key === 'Enter' || e.key === ',') && input.value.trim()) {
|
||||
e.preventDefault();
|
||||
const val = input.value.trim().replace(/,$/, '');
|
||||
if (val && !tags.includes(val)) tags.push(val);
|
||||
input.value = '';
|
||||
render();
|
||||
} else if (e.key === 'Backspace' && !input.value && tags.length) {
|
||||
tags.pop();
|
||||
render();
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
if (input.value.trim()) {
|
||||
const val = input.value.trim().replace(/,$/, '');
|
||||
if (val && !tags.includes(val)) tags.push(val);
|
||||
input.value = '';
|
||||
render();
|
||||
}
|
||||
});
|
||||
container.appendChild(input);
|
||||
container.addEventListener('click', () => input.focus());
|
||||
}
|
||||
|
||||
render();
|
||||
return { getTags: () => [...tags], addTag: (email) => { if (!tags.includes(email)) { tags.push(email); render(); } } };
|
||||
}
|
||||
|
||||
// ── Build Modal HTML ──────────────────────────────────────────────────────────
|
||||
function buildModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'email-compose-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div id="email-compose-modal">
|
||||
<div class="ec-header">
|
||||
<div class="ec-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
Invia Email
|
||||
</div>
|
||||
<button id="ec-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);padding:4px;font-size:1.2rem;" title="Chiudi">✕</button>
|
||||
</div>
|
||||
<div class="ec-body">
|
||||
<div class="ec-field">
|
||||
<label class="ec-label">A (To)</label>
|
||||
<div class="ec-tags-input" id="ec-to-container"></div>
|
||||
</div>
|
||||
<div class="ec-field">
|
||||
<label class="ec-label">CC</label>
|
||||
<div class="ec-tags-input" id="ec-cc-container"></div>
|
||||
</div>
|
||||
<div class="ec-field">
|
||||
<label class="ec-label">Oggetto</label>
|
||||
<input type="text" id="ec-subject" class="form-input" style="margin-bottom:0;" placeholder="Oggetto email" />
|
||||
</div>
|
||||
<div style="display:flex; gap:16px;">
|
||||
<div class="ec-field" style="flex:1;">
|
||||
<label class="ec-label">Firma</label>
|
||||
<select id="ec-signature-select" class="ec-select" style="width:100%; min-width:unset;">
|
||||
<option value="">— Nessuna firma —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ec-field" style="flex:1;">
|
||||
<label class="ec-label">Tieni helpdesk in copia</label>
|
||||
<select id="ec-helpdesk-cc-select" class="ec-select" style="width:100%; min-width:unset;">
|
||||
<option value="1">Sì (BCC automatico)</option>
|
||||
<option value="0">No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-field">
|
||||
<label class="ec-label">Corpo</label>
|
||||
<div class="ec-editor-wrapper">
|
||||
<div id="ec-quill-editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-field">
|
||||
<label class="ec-label">Allegati</label>
|
||||
<div class="ec-attachments-zone" id="ec-drop-zone">
|
||||
📎 Trascina file qui o clicca per selezionare
|
||||
</div>
|
||||
<input type="file" id="ec-file-input" multiple style="display:none;" />
|
||||
<div class="ec-file-list" id="ec-file-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-footer">
|
||||
<button class="btn btn-ghost btn-sm" id="ec-cancel">Annulla</button>
|
||||
<button class="btn btn-primary btn-sm" id="ec-send" style="display:flex;align-items:center;gap:6px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
|
||||
</svg>
|
||||
Invia
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return overlay;
|
||||
}
|
||||
|
||||
// ── File List Renderer ────────────────────────────────────────────────────────
|
||||
function renderFileList() {
|
||||
const list = document.getElementById('ec-file-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = attachmentsList.map((f, idx) => `
|
||||
<div class="ec-file-item">
|
||||
📎 <strong>${App.escapeHtml(f.filename)}</strong>
|
||||
<span style="color:var(--text-muted);font-size:0.75rem;">(${Math.round(f.content.length * 0.75 / 1024)} KB)</span>
|
||||
<button data-idx="${idx}" title="Rimuovi">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
list.querySelectorAll('button[data-idx]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
attachmentsList.splice(parseInt(btn.dataset.idx, 10), 1);
|
||||
renderFileList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load Signatures ───────────────────────────────────────────────────────────
|
||||
async function loadSignatures(agentId, selectEl) {
|
||||
try {
|
||||
const sigs = await App.api(`/api/email/signatures?agent_id=${agentId}`);
|
||||
selectEl.innerHTML = '<option value="">— Nessuna firma —</option>';
|
||||
sigs.forEach(sig => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = sig.id;
|
||||
opt.textContent = sig.name + (sig.is_default ? ' ★' : '');
|
||||
opt.dataset.html = sig.body_html;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
// Pre-select default
|
||||
const defSig = sigs.find(s => s.is_default);
|
||||
if (defSig) {
|
||||
selectEl.value = defSig.id;
|
||||
return defSig.body_html;
|
||||
}
|
||||
} catch (e) { console.warn('[EmailCompose] Signatures load error:', e); }
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── Open ─────────────────────────────────────────────────────────────────────
|
||||
async function open(options = {}) {
|
||||
injectStyles();
|
||||
attachmentsList = [];
|
||||
currentOptions = options;
|
||||
|
||||
// Remove existing
|
||||
const existing = document.getElementById('email-compose-overlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = buildModal();
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Init tag inputs
|
||||
const initialTo = options.customerEmail ? [options.customerEmail] : [];
|
||||
const toTagsCtrl = makeTagInput('ec-to-container', initialTo);
|
||||
const ccTagsCtrl = makeTagInput('ec-cc-container', []);
|
||||
|
||||
// Subject
|
||||
const subjectEl = document.getElementById('ec-subject');
|
||||
const tn = options.ticketTn || '';
|
||||
const title = options.ticketTitle || '';
|
||||
subjectEl.value = tn ? `Re: [Ticket#${tn}] ${title}` : title;
|
||||
|
||||
// Signature select
|
||||
const sigSelect = document.getElementById('ec-signature-select');
|
||||
const agentId = App.currentAgentId || 0;
|
||||
const defaultSigHtml = await loadSignatures(agentId, sigSelect);
|
||||
|
||||
// Quill editor
|
||||
quillEditor = new Quill('#ec-quill-editor', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Scrivi il testo della email...',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||
['link', 'image'],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
['clean']
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
// Insert initial body and signature
|
||||
let initialHtml = '';
|
||||
if (options.initialBodyHtml) {
|
||||
initialHtml += options.initialBodyHtml;
|
||||
} else {
|
||||
initialHtml += '<p><br></p>';
|
||||
}
|
||||
if (defaultSigHtml) {
|
||||
initialHtml += '<!-- sig -->' + defaultSigHtml;
|
||||
}
|
||||
quillEditor.clipboard.dangerouslyPasteHTML(initialHtml);
|
||||
quillEditor.setSelection(0, 0);
|
||||
|
||||
// Signature change
|
||||
sigSelect.addEventListener('change', () => {
|
||||
const selectedOpt = sigSelect.options[sigSelect.selectedIndex];
|
||||
const sigHtml = selectedOpt ? (selectedOpt.dataset.html || '') : '';
|
||||
// Replace signature: get current body, strip old signature (after first <br>), append new
|
||||
const currentHtml = quillEditor.root.innerHTML;
|
||||
const sigMarker = '<!-- sig -->';
|
||||
const baseHtml = currentHtml.includes(sigMarker)
|
||||
? currentHtml.split(sigMarker)[0]
|
||||
: currentHtml;
|
||||
const newHtml = baseHtml + (sigHtml ? sigMarker + sigHtml : '');
|
||||
quillEditor.clipboard.dangerouslyPasteHTML(newHtml);
|
||||
});
|
||||
|
||||
// File drag & drop
|
||||
const dropZone = document.getElementById('ec-drop-zone');
|
||||
const fileInput = document.getElementById('ec-file-input');
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); dropZone.classList.remove('drag-over');
|
||||
processFiles(Array.from(e.dataTransfer.files));
|
||||
});
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
processFiles(Array.from(e.target.files));
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
// Close handlers
|
||||
document.getElementById('ec-close').addEventListener('click', close);
|
||||
document.getElementById('ec-cancel').addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
// Send
|
||||
document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl));
|
||||
}
|
||||
|
||||
function processFiles(files) {
|
||||
for (const file of files) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result.split(',')[1];
|
||||
attachmentsList.push({ filename: file.name, content: base64, contentType: file.type });
|
||||
renderFileList();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send ─────────────────────────────────────────────────────────────────────
|
||||
async function sendEmail(toCtrl, ccCtrl) {
|
||||
const to = toCtrl.getTags();
|
||||
const cc = ccCtrl.getTags();
|
||||
const subject = document.getElementById('ec-subject').value.trim();
|
||||
const bodyHtml = quillEditor ? quillEditor.root.innerHTML : '';
|
||||
|
||||
if (!to.length) { Toast.warning('Inserisci almeno un destinatario (campo A)'); return; }
|
||||
if (!subject) { Toast.warning('Inserisci l\'oggetto della email'); return; }
|
||||
|
||||
const sendBtn = document.getElementById('ec-send');
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;"></div> Invio...';
|
||||
|
||||
try {
|
||||
const agentId = App.currentAgentId || 0;
|
||||
const payload = {
|
||||
ticketId: currentOptions.ticketId,
|
||||
to, cc, subject, bodyHtml,
|
||||
attachments: attachmentsList,
|
||||
agentId,
|
||||
keepHelpdeskCopy: document.getElementById('ec-helpdesk-cc-select').value === '1',
|
||||
};
|
||||
|
||||
const res = await App.api('/api/email/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
Toast.success(`Email inviata a ${to.join(', ')}`);
|
||||
close();
|
||||
} catch (err) {
|
||||
Toast.error('Errore invio email: ' + err.message);
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg> Invia';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close ─────────────────────────────────────────────────────────────────────
|
||||
function close() {
|
||||
const overlay = document.getElementById('email-compose-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
if (quillEditor) { quillEditor = null; }
|
||||
attachmentsList = [];
|
||||
}
|
||||
|
||||
return { open, close };
|
||||
})();
|
||||
window.EmailCompose = EmailCompose;
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* signatures.js
|
||||
* Pagina di gestione delle firme email per agente.
|
||||
* Usa Quill.js come editor WYSIWYG.
|
||||
*/
|
||||
|
||||
const SignaturesView = {
|
||||
agentId: null,
|
||||
signatures: [],
|
||||
editingId: null,
|
||||
signatureQuill: null,
|
||||
|
||||
async render() {
|
||||
this.agentId = App.currentAgentId || 0;
|
||||
const container = document.getElementById('view-container');
|
||||
container.innerHTML = `
|
||||
<div style="max-width: 820px; margin: 0 auto; padding: var(--space-xl) var(--space-lg);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-xl);">
|
||||
<div>
|
||||
<h2 style="margin:0; font-size:1.25rem; font-weight:700; color:var(--text-primary);">✉️ Le Mie Firme Email</h2>
|
||||
<p style="margin:4px 0 0; font-size:0.85rem; color:var(--text-muted);">Gestisci le firme da allegare automaticamente alle email inviate dal ticket.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="btn-new-signature">
|
||||
+ Nuova Firma
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista firme -->
|
||||
<div id="signatures-list" style="display:flex; flex-direction:column; gap:var(--space-md);"></div>
|
||||
|
||||
<!-- Modal Editor -->
|
||||
<div id="signature-editor-modal" style="display:none; position:fixed; inset:0; z-index:8000; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
|
||||
<div style="background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-xl); box-shadow:0 24px 80px rgba(0,0,0,0.3); width:min(720px,94vw); max-height:90vh; display:flex; flex-direction:column;">
|
||||
<div style="padding:16px 20px 12px; border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
|
||||
<div style="font-size:1rem; font-weight:600; color:var(--text-primary);" id="sig-modal-title">Nuova Firma</div>
|
||||
<button id="sig-modal-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:1.2rem;">✕</button>
|
||||
</div>
|
||||
<div style="padding:16px 20px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:12px;">
|
||||
<div>
|
||||
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Nome Firma</label>
|
||||
<input type="text" id="sig-name" class="form-input" placeholder="es. Firma Professionale" style="margin-bottom:0; margin-top:4px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Contenuto</label>
|
||||
<div style="margin-top:4px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); overflow:hidden; background:var(--bg-tertiary);">
|
||||
<div id="sig-quill-editor" style="min-height:200px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:0.88rem; color:var(--text-secondary); cursor:pointer;">
|
||||
<input type="checkbox" id="sig-is-default" />
|
||||
Imposta come firma predefinita
|
||||
</label>
|
||||
</div>
|
||||
<div style="padding:12px 20px; border-top:1px solid var(--border-subtle); display:flex; justify-content:flex-end; gap:10px; flex-shrink:0;">
|
||||
<button class="btn btn-ghost btn-sm" id="sig-cancel">Annulla</button>
|
||||
<button class="btn btn-primary btn-sm" id="sig-save">Salva Firma</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('btn-new-signature').addEventListener('click', () => this.openEditor(null));
|
||||
|
||||
await this.loadSignatures();
|
||||
},
|
||||
|
||||
async loadSignatures() {
|
||||
try {
|
||||
this.signatures = await App.api(`/api/email/signatures?agent_id=${this.agentId}`);
|
||||
this.renderList();
|
||||
} catch (err) {
|
||||
Toast.error('Errore caricamento firme: ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
renderList() {
|
||||
const list = document.getElementById('signatures-list');
|
||||
if (!list) return;
|
||||
|
||||
if (!this.signatures.length) {
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">✉️</div>
|
||||
<div class="empty-state-text">Nessuna firma configurata</div>
|
||||
<div class="empty-state-sub">Crea la tua prima firma per velocizzare l'invio delle email.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = this.signatures.map(sig => `
|
||||
<div class="card" style="padding: var(--space-lg);">
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:${sig.body_html ? 'var(--space-md)' : '0'};">
|
||||
<div>
|
||||
<div style="font-weight:600; font-size:0.95rem; color:var(--text-primary); display:flex; align-items:center; gap:8px;">
|
||||
${App.escapeHtml(sig.name)}
|
||||
${sig.is_default ? '<span style="font-size:0.72rem; background:var(--accent-primary); color:#fff; padding:2px 7px; border-radius:20px;">Predefinita</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:2px;">
|
||||
Creata: ${new Date(sig.created_at).toLocaleDateString('it-IT')}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px; flex-shrink:0;">
|
||||
${!sig.is_default ? `<button class="btn btn-ghost btn-sm sig-btn-default" data-id="${sig.id}" style="height:30px; font-size:0.78rem;">★ Predefinita</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm sig-btn-edit" data-id="${sig.id}" style="height:30px; font-size:0.78rem;">✏️ Modifica</button>
|
||||
<button class="btn btn-ghost btn-sm sig-btn-delete" data-id="${sig.id}" style="height:30px; font-size:0.78rem; color:var(--error);">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
${sig.body_html ? `
|
||||
<div style="border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:10px 14px; background:var(--bg-secondary); max-height:120px; overflow:hidden; position:relative;">
|
||||
<div style="font-size:0.82rem; color:var(--text-secondary);">${sig.body_html}</div>
|
||||
<div style="position:absolute;bottom:0;left:0;right:0;height:40px;background:linear-gradient(transparent,var(--bg-secondary));"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Bind buttons
|
||||
list.querySelectorAll('.sig-btn-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.openEditor(parseInt(btn.dataset.id, 10)));
|
||||
});
|
||||
list.querySelectorAll('.sig-btn-delete').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.deleteSig(parseInt(btn.dataset.id, 10)));
|
||||
});
|
||||
list.querySelectorAll('.sig-btn-default').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.setDefault(parseInt(btn.dataset.id, 10)));
|
||||
});
|
||||
},
|
||||
|
||||
openEditor(id) {
|
||||
this.editingId = id;
|
||||
const sig = id ? this.signatures.find(s => s.id === id) : null;
|
||||
|
||||
const modal = document.getElementById('signature-editor-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
document.getElementById('sig-modal-title').textContent = id ? 'Modifica Firma' : 'Nuova Firma';
|
||||
document.getElementById('sig-name').value = sig ? sig.name : '';
|
||||
document.getElementById('sig-is-default').checked = sig ? !!sig.is_default : false;
|
||||
|
||||
// Init or reset Quill
|
||||
if (this.signatureQuill) {
|
||||
this.signatureQuill.root.innerHTML = sig ? (sig.body_html || '') : '';
|
||||
} else {
|
||||
this.signatureQuill = new Quill('#sig-quill-editor', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Inserisci la tua firma...',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ 'color': [] }],
|
||||
['link', 'image'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
if (sig && sig.body_html) {
|
||||
this.signatureQuill.clipboard.dangerouslyPasteHTML(sig.body_html);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('sig-modal-close').onclick = () => this.closeEditor();
|
||||
document.getElementById('sig-cancel').onclick = () => this.closeEditor();
|
||||
document.getElementById('sig-save').onclick = () => this.saveSig();
|
||||
|
||||
modal.onclick = (e) => { if (e.target === modal) this.closeEditor(); };
|
||||
},
|
||||
|
||||
closeEditor() {
|
||||
const modal = document.getElementById('signature-editor-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
this.editingId = null;
|
||||
},
|
||||
|
||||
async saveSig() {
|
||||
const name = document.getElementById('sig-name').value.trim();
|
||||
const body_html = this.signatureQuill ? this.signatureQuill.root.innerHTML : '';
|
||||
const is_default = document.getElementById('sig-is-default').checked ? 1 : 0;
|
||||
|
||||
if (!name) { Toast.warning('Inserisci un nome per la firma'); return; }
|
||||
|
||||
const saveBtn = document.getElementById('sig-save');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Salvataggio...';
|
||||
|
||||
try {
|
||||
const payload = { agent_id: this.agentId, name, body_html, is_default };
|
||||
|
||||
if (this.editingId) {
|
||||
await App.api(`/api/email/signatures/${this.editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
Toast.success('Firma aggiornata');
|
||||
} else {
|
||||
await App.api('/api/email/signatures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
Toast.success('Firma creata');
|
||||
}
|
||||
|
||||
this.closeEditor();
|
||||
await this.loadSignatures();
|
||||
} catch (err) {
|
||||
Toast.error('Errore salvataggio: ' + err.message);
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Salva Firma';
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSig(id) {
|
||||
if (!confirm('Eliminare questa firma?')) return;
|
||||
try {
|
||||
await App.api(`/api/email/signatures/${id}`, { method: 'DELETE' });
|
||||
Toast.success('Firma eliminata');
|
||||
await this.loadSignatures();
|
||||
} catch (err) {
|
||||
Toast.error('Errore eliminazione: ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
async setDefault(id) {
|
||||
try {
|
||||
await App.api(`/api/email/signatures/${id}/default`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ agent_id: this.agentId }),
|
||||
});
|
||||
Toast.success('Firma impostata come predefinita');
|
||||
await this.loadSignatures();
|
||||
} catch (err) {
|
||||
Toast.error('Errore: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.SignaturesView = SignaturesView;
|
||||
@@ -52,10 +52,14 @@ const TicketDetailView = {
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
|
||||
// Display ticket number in the topbar
|
||||
// Display ticket number in the topbar with OTRS link if available
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl) {
|
||||
titleEl.innerHTML = `Ticket #${id}`;
|
||||
if (data.otrsWebUrl) {
|
||||
titleEl.innerHTML = `<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; display:inline-flex; align-items:center; gap:6px;" title="Apri in OTRS">Ticket #${ticket.tn || id} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;opacity:0.75;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg></a>`;
|
||||
} else {
|
||||
titleEl.innerHTML = `Ticket #${ticket.tn || id}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.originalValues = {
|
||||
@@ -184,6 +188,11 @@ const TicketDetailView = {
|
||||
</div>
|
||||
<div style="display:flex; gap:var(--space-md); align-items:center;">
|
||||
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
|
||||
<button class="btn btn-ghost btn-sm" id="btn-open-email-compose" style="height:32px; display:flex; align-items:center; gap:var(--space-xs); border-color:var(--accent-secondary); color:var(--accent-secondary);"
|
||||
data-ticket-id="${ticket.id}" data-ticket-tn="${ticket.tn}" data-ticket-title="${App.escapeHtml(ticket.title)}" data-customer-email="${App.escapeHtml(ticket.customer_email || '')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
Invia Email
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" id="note-send" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
||||
Invia Nota
|
||||
@@ -208,7 +217,7 @@ const TicketDetailView = {
|
||||
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
||||
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
||||
|
||||
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 `
|
||||
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
||||
@@ -218,6 +227,8 @@ const TicketDetailView = {
|
||||
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
|
||||
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
|
||||
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
||||
<button class="btn-delete-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Elimina Articolo">🗑️</button>
|
||||
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Rispondi via email (Quota questo articolo)">✉️</button>
|
||||
</div>
|
||||
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
||||
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
|
||||
@@ -408,7 +419,7 @@ const TicketDetailView = {
|
||||
this.noteQuill = null;
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
this.bindEvents(ticket, articles, container);
|
||||
|
||||
} catch (err) {
|
||||
container.innerHTML = `
|
||||
@@ -422,7 +433,7 @@ const TicketDetailView = {
|
||||
}
|
||||
},
|
||||
|
||||
bindEvents() {
|
||||
bindEvents(ticket, articles, container) {
|
||||
// Quick-edit change detection
|
||||
const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id');
|
||||
const saveBtn = document.getElementById('qe-save');
|
||||
@@ -669,6 +680,63 @@ const TicketDetailView = {
|
||||
});
|
||||
}
|
||||
|
||||
// Email Compose Button
|
||||
const btnEmailCompose = document.getElementById('btn-open-email-compose');
|
||||
if (btnEmailCompose) {
|
||||
btnEmailCompose.addEventListener('click', () => {
|
||||
if (window.EmailCompose) {
|
||||
EmailCompose.open({
|
||||
ticketId: parseInt(btnEmailCompose.dataset.ticketId, 10),
|
||||
ticketTn: btnEmailCompose.dataset.ticketTn,
|
||||
ticketTitle: btnEmailCompose.dataset.ticketTitle,
|
||||
customerEmail: btnEmailCompose.dataset.customerEmail,
|
||||
});
|
||||
} else {
|
||||
Toast.error('Modulo email non disponibile');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Article Email Reply Buttons
|
||||
document.querySelectorAll('.btn-email-article').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const articleId = parseInt(btn.dataset.articleId, 10);
|
||||
const article = articles.find(art => art.article_id == articleId);
|
||||
if (!article) return;
|
||||
|
||||
if (window.EmailCompose) {
|
||||
const hasHtml = (article.a_content_type || '').toLowerCase().includes('html') || article.a_body.includes('</') || article.a_body.includes('/>');
|
||||
const quotedBody = hasHtml ? article.a_body : App.escapeHtml(article.a_body || '').replace(/\n/g, '<br>');
|
||||
|
||||
const initialBodyHtml = `
|
||||
<p><br></p>
|
||||
<p>Il ${App.formatDateTime(article.create_time)}, <strong>${App.escapeHtml(article.a_from || 'Sistema')}</strong> ha scritto:</p>
|
||||
<blockquote style="border-left: 2px solid var(--border-subtle, #444); padding-left: 12px; margin-left: 8px; color: var(--text-secondary);">
|
||||
${quotedBody}
|
||||
</blockquote>
|
||||
<p><br></p>
|
||||
`;
|
||||
|
||||
// Extract sender email if possible for CC/To
|
||||
let customerEmail = ticket.customer_email || '';
|
||||
const matchEmail = (article.a_from || '').match(/<([^>]+)>/) || (article.a_from || '').match(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/);
|
||||
if (matchEmail) {
|
||||
customerEmail = matchEmail[1];
|
||||
}
|
||||
|
||||
EmailCompose.open({
|
||||
ticketId: ticket.id,
|
||||
ticketTn: ticket.tn,
|
||||
ticketTitle: ticket.title,
|
||||
customerEmail: customerEmail,
|
||||
initialBodyHtml: initialBodyHtml,
|
||||
});
|
||||
} else {
|
||||
Toast.error('Modulo email non disponibile');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Retrodate Ticket Event Listeners
|
||||
const btnEditTicketDate = document.getElementById('btn-edit-ticket-date');
|
||||
const ticketDateEditor = document.getElementById('ticket-date-editor');
|
||||
@@ -827,6 +895,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) {
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const pool = require('../db');
|
||||
const { db } = require('../activityDb');
|
||||
const { sendMail } = require('../utils/mailer');
|
||||
|
||||
// ─── SIGNATURES ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/email/signatures — list signatures for a given agent
|
||||
router.get('/signatures', (req, res) => {
|
||||
try {
|
||||
const { agent_id } = req.query;
|
||||
if (!agent_id) return res.status(400).json({ error: 'agent_id è obbligatorio' });
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT id, name, body_html, is_default, created_at, updated_at
|
||||
FROM email_signatures
|
||||
WHERE agent_id = ?
|
||||
ORDER BY is_default DESC, name ASC
|
||||
`).all(parseInt(agent_id, 10));
|
||||
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[Email] Error listing signatures:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/email/signatures — create a new signature
|
||||
router.post('/signatures', (req, res) => {
|
||||
try {
|
||||
const { agent_id, name, body_html, is_default = 0 } = req.body;
|
||||
if (!agent_id || !name) return res.status(400).json({ error: 'agent_id e name sono obbligatori' });
|
||||
|
||||
// If new signature is default, reset others for this agent
|
||||
if (is_default) {
|
||||
db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10));
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO email_signatures (agent_id, name, body_html, is_default)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(parseInt(agent_id, 10), name, body_html || '', is_default ? 1 : 0);
|
||||
|
||||
res.json({ id: result.lastInsertRowid, agent_id, name, body_html, is_default });
|
||||
} catch (err) {
|
||||
console.error('[Email] Error creating signature:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/email/signatures/:id — update a signature
|
||||
router.put('/signatures/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { agent_id, name, body_html, is_default } = req.body;
|
||||
|
||||
// If new signature is default, reset others for this agent
|
||||
if (is_default && agent_id) {
|
||||
db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10));
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE email_signatures
|
||||
SET name = ?, body_html = ?, is_default = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?
|
||||
`).run(name, body_html || '', is_default ? 1 : 0, parseInt(id, 10));
|
||||
|
||||
const updated = db.prepare(`SELECT * FROM email_signatures WHERE id = ?`).get(parseInt(id, 10));
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error('[Email] Error updating signature:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/email/signatures/:id/default — set a signature as default
|
||||
router.patch('/signatures/:id/default', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { agent_id } = req.body;
|
||||
|
||||
if (agent_id) {
|
||||
db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10));
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE email_signatures SET is_default = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?
|
||||
`).run(parseInt(id, 10));
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[Email] Error setting default signature:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/email/signatures/:id — delete a signature
|
||||
router.delete('/signatures/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
db.prepare(`DELETE FROM email_signatures WHERE id = ?`).run(parseInt(id, 10));
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[Email] Error deleting signature:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── SEND EMAIL ───────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/email/send — send an email for a ticket
|
||||
router.post('/send', async (req, res) => {
|
||||
const {
|
||||
ticketId,
|
||||
to,
|
||||
cc = [],
|
||||
subject: customSubject,
|
||||
bodyHtml,
|
||||
attachments = [],
|
||||
inlineImages = [],
|
||||
agentId,
|
||||
agentName = 'Agente',
|
||||
keepHelpdeskCopy = true,
|
||||
} = req.body;
|
||||
|
||||
if (!ticketId) return res.status(400).json({ error: 'ticketId è obbligatorio' });
|
||||
if (!to || !to.length) return res.status(400).json({ error: 'Il campo "to" è obbligatorio' });
|
||||
if (!bodyHtml) return res.status(400).json({ error: 'Il corpo della email è obbligatorio' });
|
||||
|
||||
try {
|
||||
// 1. Fetch ticket number and title for subject
|
||||
const ticketResult = await pool.query(
|
||||
`SELECT tn, title FROM ticket WHERE id = $1`,
|
||||
[ticketId]
|
||||
);
|
||||
if (!ticketResult.rows.length) return res.status(404).json({ error: 'Ticket non trovato' });
|
||||
|
||||
const { tn, title } = ticketResult.rows[0];
|
||||
const subject = customSubject || `Re: [Ticket#${tn}] ${title}`;
|
||||
|
||||
// 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true)
|
||||
const bcc = [];
|
||||
if (keepHelpdeskCopy) {
|
||||
const otrsBcc = process.env.OTRS_MAIL_BCC;
|
||||
if (otrsBcc) bcc.push(otrsBcc);
|
||||
}
|
||||
|
||||
// 3. Send via configured mailer (Graph API or SMTP)
|
||||
await sendMail({ to, cc, bcc, subject, bodyHtml, attachments, inlineImages });
|
||||
|
||||
// 4. Log internal note in OTRS ticket via DB (email sent record)
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const agentLoginResult = agentId
|
||||
? await pool.query(`SELECT login, first_name, last_name FROM users WHERE id = $1`, [agentId])
|
||||
: { rows: [] };
|
||||
|
||||
const agentUser = agentLoginResult.rows[0];
|
||||
let agentEmail = 'agent@localhost';
|
||||
if (agentId) {
|
||||
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) {
|
||||
agentEmail = prefRes.rows[0].preferences_value;
|
||||
}
|
||||
}
|
||||
|
||||
const aFrom = agentUser
|
||||
? `"${agentUser.first_name} ${agentUser.last_name}" <${agentEmail}>`
|
||||
: agentName;
|
||||
|
||||
const toList = to.join(', ');
|
||||
const noteBody = `Email inviata a: ${toList}${cc.length ? `\nCC: ${cc.join(', ')}` : ''}`;
|
||||
|
||||
// Insert article via DB (internal note to log email dispatch)
|
||||
const artInsert = await pool.query(`
|
||||
INSERT INTO article (
|
||||
ticket_id, article_sender_type_id, communication_channel_id,
|
||||
is_visible_for_customer, a_from, a_to, a_subject, a_body,
|
||||
content_path, incoming_time, create_time, create_by, change_time, change_by
|
||||
) VALUES (
|
||||
$1, 1, 2, 0, $2, $3, $4, $5,
|
||||
'/', $6, NOW(), $7, NOW(), $7
|
||||
) RETURNING id`,
|
||||
[ticketId, aFrom, toList, `[Email inviata] ${subject}`, noteBody, now, agentId || 1]
|
||||
);
|
||||
|
||||
const articleId = artInsert.rows[0]?.id;
|
||||
|
||||
if (articleId) {
|
||||
await pool.query(`
|
||||
INSERT INTO article_data_mime (article_id, a_from, a_to, a_cc, a_subject, a_body, a_content_type, incoming_time, create_time, create_by, change_time, change_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'text/plain; charset=utf-8', $7, NOW(), $8, NOW(), $8)`,
|
||||
[articleId, aFrom, toList, cc.join(', '), subject, noteBody, now, agentId || 1]
|
||||
);
|
||||
}
|
||||
} catch (noteErr) {
|
||||
console.warn('[Email] Nota interna OTRS non inserita (non bloccante):', noteErr.message);
|
||||
}
|
||||
|
||||
console.log(`[Email] ✅ Email inviata per ticket #${tn} a: ${to.join(', ')}`);
|
||||
res.json({ success: true, subject, to });
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Email] ❌ Errore invio email:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+168
-23
@@ -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 {
|
||||
|
||||
+215
-42
@@ -19,6 +19,29 @@ 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, login FROM users WHERE id = $1`,
|
||||
[agentId]
|
||||
);
|
||||
if (r.rows.length > 0) {
|
||||
const u = r.rows[0];
|
||||
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.login || 'Agent';
|
||||
|
||||
const prefRes = await client.query(
|
||||
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
|
||||
[agentId]
|
||||
);
|
||||
const email = (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) || 'agent@localhost';
|
||||
return `"${fullName}" <${email}>`;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
return 'OTRS Turbo Agent';
|
||||
}
|
||||
|
||||
|
||||
// Helper for OTRS CE GenericInterface REST API calls
|
||||
async function otrsRequest(method, path, bodyData = {}) {
|
||||
const OTRS_API_USER = process.env.OTRS_API_USER;
|
||||
@@ -72,7 +95,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 +105,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 +376,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 +769,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 +939,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 +954,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 +1025,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 +1081,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 +1092,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 +1120,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 +1215,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 +1230,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 +1521,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 +2074,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 +2082,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
|
||||
|
||||
@@ -7,6 +7,7 @@ const ticketsRouter = require('./routes/tickets');
|
||||
const lookupsRouter = require('./routes/lookups');
|
||||
const dashboardRouter = require('./routes/dashboard');
|
||||
const activityRouter = require('./routes/activity');
|
||||
const emailRouter = require('./routes/email');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -23,6 +24,7 @@ app.use('/api/tickets', ticketsRouter);
|
||||
app.use('/api', lookupsRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/attivita', activityRouter);
|
||||
app.use('/api/email', emailRouter);
|
||||
|
||||
// SPA fallback — serve index.html for all non-API routes
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* utils/graphMailer.js
|
||||
* Invia email tramite Microsoft Graph API (per Exchange con 2FA/OAuth2).
|
||||
* Gestisce automaticamente il token OAuth2 con cache e refresh.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
let cachedToken = null;
|
||||
let tokenExpiresAt = 0;
|
||||
|
||||
/**
|
||||
* Ottiene un access token OAuth2 da Microsoft (client credentials flow).
|
||||
* Il token viene cachato per circa 55 minuti per evitare richieste continue.
|
||||
*/
|
||||
async function getAccessToken() {
|
||||
const now = Date.now();
|
||||
if (cachedToken && now < tokenExpiresAt) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } = process.env;
|
||||
|
||||
const url = `https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
client_id: AZURE_CLIENT_ID,
|
||||
client_secret: AZURE_CLIENT_SECRET,
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
grant_type: 'client_credentials',
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`[Graph Auth] Errore ottenendo token OAuth2: ${res.status} ${errText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = data.access_token;
|
||||
// Scade in data.expires_in secondi, refresh 5 minuti prima
|
||||
tokenExpiresAt = now + (data.expires_in - 300) * 1000;
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invia una email tramite Microsoft Graph API.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string[]} options.to - Destinatari (array di email)
|
||||
* @param {string[]} [options.cc] - CC (array di email)
|
||||
* @param {string[]} [options.bcc] - BCC (array di email)
|
||||
* @param {string} options.subject - Oggetto email
|
||||
* @param {string} options.bodyHtml - Corpo HTML
|
||||
* @param {Array} [options.attachments] - [{ filename, content (base64), contentType }]
|
||||
* @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [] }) {
|
||||
const sender = process.env.AZURE_MAIL_SENDER;
|
||||
if (!sender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env');
|
||||
|
||||
const token = await getAccessToken();
|
||||
|
||||
const toRecipients = to.map(addr => ({
|
||||
emailAddress: { address: addr }
|
||||
}));
|
||||
const ccRecipients = cc.map(addr => ({
|
||||
emailAddress: { address: addr }
|
||||
}));
|
||||
const bccRecipients = bcc.map(addr => ({
|
||||
emailAddress: { address: addr }
|
||||
}));
|
||||
|
||||
// Costruisce gli allegati (file + immagini inline)
|
||||
const allAttachments = [
|
||||
...attachments.map(a => ({
|
||||
'@odata.type': '#microsoft.graph.fileAttachment',
|
||||
name: a.filename,
|
||||
contentType: a.contentType || 'application/octet-stream',
|
||||
contentBytes: a.content, // già base64
|
||||
})),
|
||||
...inlineImages.map(img => ({
|
||||
'@odata.type': '#microsoft.graph.fileAttachment',
|
||||
name: img.cid,
|
||||
contentId: img.cid,
|
||||
contentType: img.contentType || 'image/png',
|
||||
contentBytes: img.content, // già base64
|
||||
isInline: true,
|
||||
})),
|
||||
];
|
||||
|
||||
const payload = {
|
||||
message: {
|
||||
subject,
|
||||
body: {
|
||||
contentType: 'HTML',
|
||||
content: bodyHtml,
|
||||
},
|
||||
toRecipients,
|
||||
ccRecipients,
|
||||
bccRecipients,
|
||||
attachments: allAttachments,
|
||||
},
|
||||
saveToSentItems: false,
|
||||
};
|
||||
|
||||
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/sendMail`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.status === 202) {
|
||||
return; // Successo (Graph API risponde con 202 No Content)
|
||||
}
|
||||
|
||||
const errText = await res.text();
|
||||
throw new Error(`[Graph Mail] Errore invio email: ${res.status} ${errText}`);
|
||||
}
|
||||
|
||||
module.exports = { sendMail };
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* utils/mailer.js
|
||||
* Factory: seleziona il metodo di invio email corretto in base alla configurazione .env.
|
||||
* Priorità: Graph API (se AZURE_TENANT_ID configurato) → SMTP (se SMTP_HOST configurato)
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
function getMailer() {
|
||||
if (process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET) {
|
||||
return require('./graphMailer');
|
||||
}
|
||||
if (process.env.SMTP_HOST) {
|
||||
return require('./smtpMailer');
|
||||
}
|
||||
throw new Error('[Mailer] Nessun metodo di invio email configurato. Impostare AZURE_TENANT_ID oppure SMTP_HOST nel .env.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invia una email usando il metodo configurato (Graph API o SMTP).
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string[]} options.to
|
||||
* @param {string[]} [options.cc]
|
||||
* @param {string[]} [options.bcc]
|
||||
* @param {string} options.subject
|
||||
* @param {string} options.bodyHtml
|
||||
* @param {Array} [options.attachments]
|
||||
* @param {Array} [options.inlineImages]
|
||||
*/
|
||||
async function sendMail(options) {
|
||||
const mailer = getMailer();
|
||||
return mailer.sendMail(options);
|
||||
}
|
||||
|
||||
module.exports = { sendMail };
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* utils/smtpMailer.js
|
||||
* Fallback SMTP per l'invio email via nodemailer.
|
||||
* Usato se AZURE_TENANT_ID non è configurato ma SMTP_HOST lo è.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
let _transporter = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (_transporter) return _transporter;
|
||||
|
||||
_transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
return _transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invia una email tramite SMTP (nodemailer).
|
||||
* Stessa interfaccia di graphMailer.sendMail.
|
||||
*/
|
||||
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [] }) {
|
||||
const transporter = getTransporter();
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to: to.join(', '),
|
||||
cc: cc.length ? cc.join(', ') : undefined,
|
||||
bcc: bcc.length ? bcc.join(', ') : undefined,
|
||||
subject,
|
||||
html: bodyHtml,
|
||||
attachments: [
|
||||
...attachments.map(a => ({
|
||||
filename: a.filename,
|
||||
content: Buffer.from(a.content, 'base64'),
|
||||
contentType: a.contentType || 'application/octet-stream',
|
||||
})),
|
||||
...inlineImages.map(img => ({
|
||||
filename: img.cid,
|
||||
cid: img.cid,
|
||||
content: Buffer.from(img.content, 'base64'),
|
||||
contentType: img.contentType || 'image/png',
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
}
|
||||
|
||||
module.exports = { sendMail };
|
||||
Reference in New Issue
Block a user