`).join('');
userSuggestionsDiv.style.display = 'block';
- // Bind click
+ // Use mousedown instead of click to fire before blur event
userSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.login) {
- item.addEventListener('click', () => {
+ item.addEventListener('mousedown', (e) => {
+ e.preventDefault(); // prevent input from losing focus before value is set
userSearchInput.value = item.dataset.name;
customerUserIdInput.value = item.dataset.login;
userSuggestionsDiv.style.display = 'none';
@@ -429,12 +435,18 @@ const TicketCreateView = {
}, 300);
});
userSearchInput.addEventListener('focus', () => {
- userSearchInput.value = '';
- customerUserIdInput.value = '';
- userSearchInput.dispatchEvent(new Event('input'));
+ if (!customerUserIdInput.value) {
+ // Only search if no user is selected yet
+ userSearchInput.dispatchEvent(new Event('input'));
+ }
+ });
+ userSearchInput.addEventListener('blur', () => {
+ // Small delay to allow mousedown on item to fire first
+ setTimeout(() => { userSuggestionsDiv.style.display = 'none'; }, 150);
});
}
+
// Owner Autocomplete (dynamic backend search)
let ownerDebounce;
if (ownerSearchInput) {
diff --git a/public/js/views/ticketDetail.js b/public/js/views/ticketDetail.js
index beb812f..afdcdda 100644
--- a/public/js/views/ticketDetail.js
+++ b/public/js/views/ticketDetail.js
@@ -455,11 +455,16 @@ const TicketDetailView = {
customerSearchInput.addEventListener('input', () => {
clearTimeout(customerDebounce);
const q = customerSearchInput.value.trim();
- // Do not block empty query to allow all results on focus
customerDebounce = setTimeout(async () => {
try {
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
+ console.log('[Frontend LDAP Search Detail] Users returned:', users);
+ // Prevent race conditions: discard results if the input value has changed
+ if (customerSearchInput.value.trim() !== q) {
+ console.log('[Frontend LDAP Search Detail] Discarding stale results for query:', q);
+ return;
+ }
if (users.length === 0) {
customerSuggestionsDiv.innerHTML = '
';
customerSuggestionsDiv.style.display = 'block';
@@ -467,17 +472,18 @@ const TicketDetailView = {
}
customerSuggestionsDiv.innerHTML = users.map(u => `
-
+
${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}
- (Login: ${App.escapeHtml(u.login)} | Azienda: ${App.escapeHtml(u.customer_id || '—')})
+ (Login: ${App.escapeHtml(u.login || '')} | Azienda: ${App.escapeHtml(u.customer_id || '—')})
`).join('');
customerSuggestionsDiv.style.display = 'block';
- // Bind click
+ // Use mousedown instead of click to fire before blur event
customerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.login) {
- item.addEventListener('click', () => {
+ item.addEventListener('mousedown', (e) => {
+ e.preventDefault(); // prevent input from losing focus before value is set
customerSearchInput.value = item.dataset.name;
customerUserIdInput.value = item.dataset.login;
customerIdInput.value = item.dataset.customerId || '';
@@ -493,22 +499,15 @@ const TicketDetailView = {
}, 300);
});
customerSearchInput.addEventListener('focus', () => {
- customerSearchInput.value = '';
- customerUserIdInput.value = '';
- customerIdInput.value = '';
- customerUserIdInput.dispatchEvent(new Event('change'));
- customerIdInput.dispatchEvent(new Event('change'));
- customerSearchInput.dispatchEvent(new Event('input'));
+ if (!customerUserIdInput.value) {
+ customerSearchInput.dispatchEvent(new Event('input'));
+ }
+ });
+ customerSearchInput.addEventListener('blur', () => {
+ setTimeout(() => { customerSuggestionsDiv.style.display = 'none'; }, 150);
});
}
- // Close suggestions on click outside
- document.addEventListener('click', (e) => {
- if (customerSearchInput && e.target !== customerSearchInput && e.target !== customerSuggestionsDiv) {
- customerSuggestionsDiv.style.display = 'none';
- }
- });
-
// Reset quick-edit
resetBtn.addEventListener('click', () => {
fields.forEach(el => {
diff --git a/routes/lookups.js b/routes/lookups.js
index 22be36a..6e44421 100644
--- a/routes/lookups.js
+++ b/routes/lookups.js
@@ -2,6 +2,9 @@ const express = require('express');
const router = express.Router();
const pool = require('../db');
+// In-memory cache for customer users fetched from LDAP
+let ldapCustomerUsersCache = [];
+
// Helper for OTRS CE GenericInterface REST API calls
async function otrsRequest(method, path, bodyData = {}) {
const OTRS_API_USER = process.env.OTRS_API_USER;
@@ -19,7 +22,7 @@ async function otrsRequest(method, path, bodyData = {}) {
};
const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 6000);
+ const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(url, {
@@ -38,7 +41,7 @@ async function otrsRequest(method, path, bodyData = {}) {
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
- throw new Error('OTRS REST API request timed out (6s limit exceeded)');
+ throw new Error('OTRS REST API request timed out (15s limit exceeded)');
}
throw err;
}
@@ -199,11 +202,94 @@ router.get('/customer-companies/search', async (req, res) => {
}
});
-// GET /api/customer-users/search — Search customer users (always from local DB)
+// GET /api/customer-users/search — Search customer users (supports LDAP via REST, fallbacks to DB)
router.get('/customer-users/search', async (req, res) => {
- try {
- const { q = '', customer_company_id } = req.query;
+ const { q = '', customer_company_id } = req.query;
+ // 1. Try to search via OTRS GenericInterface REST API if configured
+ if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
+ // Search the in-memory cache first if it contains data
+ if (ldapCustomerUsersCache.length > 0) {
+ const searchTerms = q.toLowerCase().split(/\s+/).filter(Boolean);
+ let matchedUsers = ldapCustomerUsersCache;
+
+ if (searchTerms.length > 0) {
+ matchedUsers = matchedUsers.filter(u => {
+ const login = (u.login || '').toLowerCase();
+ const email = (u.email || '').toLowerCase();
+ const first = (u.first_name || '').toLowerCase();
+ const last = (u.last_name || '').toLowerCase();
+ const company = (u.customer_id || '').toLowerCase();
+
+ return searchTerms.every(term =>
+ login.includes(term) ||
+ email.includes(term) ||
+ first.includes(term) ||
+ last.includes(term) ||
+ company.includes(term)
+ );
+ });
+ }
+
+ if (customer_company_id) {
+ matchedUsers = matchedUsers.filter(u => u.customer_id === customer_company_id);
+ }
+
+ return res.json(matchedUsers.slice(0, 20));
+ }
+
+ // Fallback to live API search if cache is not yet populated
+ try {
+ const searchPattern = q ? `*${q}*` : '*';
+ const searchPayload = {
+ Search: searchPattern,
+ Valid: 1
+ };
+ if (customer_company_id) {
+ searchPayload.CustomerID = customer_company_id;
+ }
+
+ const searchResult = await otrsRequest('POST', '/CustomerUserSearch', searchPayload);
+
+ const searchData = (searchResult && searchResult.Data) || searchResult;
+ if (searchData && searchData.CustomerUserID) {
+ let logins = searchData.CustomerUserID;
+ if (!Array.isArray(logins)) {
+ logins = [logins];
+ }
+ logins = logins.slice(0, 20); // limit to 20 results
+
+ // Fetch details for each login in parallel
+ const detailsPromises = logins.map(async (login) => {
+ try {
+ const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: login });
+ const getData = (getResult && getResult.Data) || getResult;
+ if (getData && getData.CustomerUser) {
+ const u = getData.CustomerUser;
+ return {
+ login: u.UserLogin,
+ email: u.UserEmail || '',
+ first_name: u.UserFirstname || '',
+ last_name: u.UserLastname || '',
+ customer_id: u.UserCustomerID || ''
+ };
+ }
+ } catch (getErr) {
+ console.warn(`Failed to fetch details for customer user ${login}:`, getErr.message);
+ }
+ return null;
+ });
+
+ const users = (await Promise.all(detailsPromises)).filter(u => u !== null);
+ return res.json(users);
+ }
+ } catch (restErr) {
+ console.warn('Failed to search customer users via REST API, falling back to database query:', restErr.message);
+ }
+ }
+
+ // 2. Fallback to local DB query
+ try {
let queryText;
let queryParams;
if (q) {
@@ -310,4 +396,88 @@ router.get('/states/search', async (req, res) => {
}
});
+// Helper function to sync customer users from LDAP (OTRS API) into in-memory cache
+async function syncCustomerUsers() {
+ if (!process.env.OTRS_API_URL || !process.env.OTRS_API_USER) {
+ console.log('[LDAP Sync] OTRS API not configured, skipping customer user sync.');
+ return 0;
+ }
+
+ console.log('[LDAP Sync] Starting customer user synchronization...');
+ try {
+ const searchResult = await otrsRequest('POST', '/CustomerUserSearch', { Search: '*', Valid: 1 });
+ const searchData = (searchResult && searchResult.Data) || searchResult;
+ if (!searchData || !searchData.CustomerUserID) {
+ console.log('[LDAP Sync] No customer users found to sync.');
+ return 0;
+ }
+
+ let logins = searchData.CustomerUserID;
+ if (!Array.isArray(logins)) {
+ logins = [logins];
+ }
+
+ console.log(`[LDAP Sync] Found ${logins.length} customer users. Fetching details...`);
+
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+ const newCache = [];
+ // Fetch details in batches of 3 to avoid overloading the OTRS CGI server
+ const batchSize = 3;
+ for (let i = 0; i < logins.length; i += batchSize) {
+ const batchLogins = logins.slice(i, i + batchSize);
+ await Promise.all(batchLogins.map(async (login) => {
+ let retries = 2;
+ while (retries >= 0) {
+ try {
+ const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: login });
+ const getData = (getResult && getResult.Data) || getResult;
+ if (getData && getData.CustomerUser) {
+ const u = getData.CustomerUser;
+ newCache.push({
+ login: u.UserLogin,
+ email: u.UserEmail || '',
+ first_name: u.UserFirstname || '',
+ last_name: u.UserLastname || '',
+ customer_id: u.UserCustomerID || ''
+ });
+ }
+ break; // Success, break retry loop
+ } catch (getErr) {
+ if (retries === 0) {
+ console.warn(`[LDAP Sync] Failed to sync customer user ${login} after retries:`, getErr.message);
+ } else {
+ await delay(200); // Wait 200ms before retrying
+ }
+ retries--;
+ }
+ }
+ }));
+ // Add a small delay between batches
+ await delay(50);
+ }
+
+ ldapCustomerUsersCache = newCache;
+ console.log(`[LDAP Sync] Completed. Synced ${ldapCustomerUsersCache.length} users to in-memory cache.`);
+ return ldapCustomerUsersCache.length;
+ } catch (err) {
+ console.error('[LDAP Sync] Error during customer user sync:', err);
+ throw err;
+ }
+}
+
+// POST /api/customer-users/sync — Sync customer users from LDAP
+router.post('/customer-users/sync', async (req, res) => {
+ try {
+ const count = await syncCustomerUsers();
+ res.json({ message: `Sincronizzazione completata! ${count} utenti sincronizzati.`, count });
+ } catch (err) {
+ res.status(500).json({ error: 'Errore durante la sincronizzazione', message: err.message });
+ }
+});
+
+// Run LDAP synchronization in the background on startup (after 5 seconds)
+setTimeout(() => {
+ syncCustomerUsers().catch(err => console.error('Startup LDAP sync failed:', err));
+}, 5000);
+
module.exports = router;
diff --git a/routes/tickets.js b/routes/tickets.js
index 8d044ce..32c2f8b 100644
--- a/routes/tickets.js
+++ b/routes/tickets.js
@@ -459,6 +459,18 @@ router.post('/', async (req, res) => {
const c = custRes.rows[0];
customerFrom = `${c.first_name} ${c.last_name} <${c.email}>`;
senderTypeName = 'customer';
+ } else if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
+ try {
+ const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: customer_user_id });
+ const getData = (getResult && getResult.Data) || getResult;
+ if (getData && getData.CustomerUser) {
+ const u = getData.CustomerUser;
+ customerFrom = `${u.UserFirstname || ''} ${u.UserLastname || ''} <${u.UserEmail || ''}>`.trim();
+ senderTypeName = 'customer';
+ }
+ } catch (apiErr) {
+ console.warn(`Failed to fetch LDAP customer details for ${customer_user_id}:`, apiErr.message);
+ }
}
}
@@ -1351,7 +1363,7 @@ router.get('/attachments/:id', async (req, res) => {
} else if (typeof attachment.content === 'string') {
contentStr = attachment.content;
}
-
+
const cleaned = contentStr.replace(/\s+/g, '');
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (cleaned.length % 4 === 0 && base64Regex.test(cleaned)) {