Zammad sync performance + liitteiden näyttö tikettilistassa

- Rajoita artikkelien haku max 10 tikettiin per sync (loput on-demand)
- Curl timeout 15s + connect timeout 5s
- Frontend: IMAP ja Zammad haetaan rinnakkain (Promise.allSettled)
- Auto-refresh: Zammad sync ei blokkaa tikettien latausta
- Hakaneula-ikoni (📎) tikettilistassa kun viestissä on liitteitä
- has_attachments, source, zammad_group, ticket_number tikettilistaan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 08:56:13 +02:00
parent dc0ed5c75c
commit a69fed75e4
3 changed files with 52 additions and 23 deletions

30
api.php
View File

@@ -250,12 +250,13 @@ class ZammadClient {
$this->token = $token; $this->token = $token;
} }
private function request(string $method, string $endpoint, ?array $data = null): array { private function request(string $method, string $endpoint, ?array $data = null, int $timeout = 15): array {
$url = $this->url . '/api/v1/' . ltrim($endpoint, '/'); $url = $this->url . '/api/v1/' . ltrim($endpoint, '/');
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
'Authorization: Token token=' . $this->token, 'Authorization: Token token=' . $this->token,
'Content-Type: application/json', 'Content-Type: application/json',
@@ -3162,6 +3163,10 @@ switch ($action) {
foreach ($tickets as $t) { foreach ($tickets as $t) {
$msgCount = count($t['messages'] ?? []); $msgCount = count($t['messages'] ?? []);
$lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null; $lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null;
$hasAttachments = false;
foreach ($t['messages'] ?? [] as $m) {
if (!empty($m['attachments'])) { $hasAttachments = true; break; }
}
$list[] = [ $list[] = [
'id' => $t['id'], 'id' => $t['id'],
'subject' => $t['subject'], 'subject' => $t['subject'],
@@ -3184,6 +3189,10 @@ switch ($action) {
'message_count' => $msgCount, 'message_count' => $msgCount,
'last_message_type' => $lastMsg ? ($lastMsg['type'] ?? '') : '', 'last_message_type' => $lastMsg ? ($lastMsg['type'] ?? '') : '',
'last_message_time' => $lastMsg ? ($lastMsg['timestamp'] ?? '') : '', 'last_message_time' => $lastMsg ? ($lastMsg['timestamp'] ?? '') : '',
'has_attachments' => $hasAttachments,
'source' => $t['source'] ?? '',
'zammad_group' => $t['zammad_group'] ?? '',
'ticket_number' => $t['ticket_number'] ?? '',
]; ];
} }
} }
@@ -5366,6 +5375,8 @@ switch ($action) {
$created = 0; $created = 0;
$updated = 0; $updated = 0;
$messagesAdded = 0; $messagesAdded = 0;
$articlesFetched = 0;
$maxArticleFetches = 10; // Max tikettejä joille haetaan artikkelit per synk (loput on-demand)
// Zammad state → intran status // Zammad state → intran status
$stateMap = [ $stateMap = [
@@ -5378,6 +5389,8 @@ switch ($action) {
'1 low' => 'matala', '2 normal' => 'normaali', '3 high' => 'korkea', '1 low' => 'matala', '2 normal' => 'normaali', '3 high' => 'korkea',
]; ];
// Vaihe 1: Synkkaa kaikki tiketit (nopea — ei artikkeleja)
$newTicketIds = []; // ticketId => zammadId — artikkelit haetaan toisessa vaiheessa
foreach ($allTickets as $zt) { foreach ($allTickets as $zt) {
$zammadId = (int)$zt['id']; $zammadId = (int)$zt['id'];
$existing = dbGetTicketByZammadId($companyId, $zammadId); $existing = dbGetTicketByZammadId($companyId, $zammadId);
@@ -5407,6 +5420,7 @@ switch ($action) {
$zt['updated_at'] ? date('Y-m-d H:i:s', strtotime($zt['updated_at'])) : $now] $zt['updated_at'] ? date('Y-m-d H:i:s', strtotime($zt['updated_at'])) : $now]
); );
$created++; $created++;
$newTicketIds[$ticketId] = $zammadId;
} else { } else {
$ticketId = $existing['id']; $ticketId = $existing['id'];
$zammadUpdated = date('Y-m-d H:i:s', strtotime($zt['updated_at'] ?? 'now')); $zammadUpdated = date('Y-m-d H:i:s', strtotime($zt['updated_at'] ?? 'now'));
@@ -5417,14 +5431,15 @@ switch ($action) {
); );
$updated++; $updated++;
} }
}
// Synkkaa artikkelit vain uusille tiketeille (ei jokaiselle — liian hidas) // Vaihe 2: Hae artikkelit max N uusimmalle uudelle tiketille (loput on-demand)
// Olemassa olevien tikettien artikkelit haetaan on-demand kun käyttäjä avaa tiketin $articleQueue = array_slice($newTicketIds, 0, $maxArticleFetches, true);
if ($existing) continue; foreach ($articleQueue as $ticketId => $zammadId) {
try { try {
$articles = $z->getArticles($zammadId); $articles = $z->getArticles($zammadId);
$toEmail = ''; $toEmail = '';
$articlesFetched++;
foreach ($articles as $art) { foreach ($articles as $art) {
if (($art['internal'] ?? false)) continue; if (($art['internal'] ?? false)) continue;
// Poimi zammad_to_email ensimmäisestä asiakkaan viestistä // Poimi zammad_to_email ensimmäisestä asiakkaan viestistä
@@ -5480,12 +5495,15 @@ switch ($action) {
} }
} }
$skippedArticles = max(0, count($newTicketIds) - $maxArticleFetches);
echo json_encode([ echo json_encode([
'ok' => true, 'ok' => true,
'tickets_found' => count($allTickets), 'tickets_found' => count($allTickets),
'created' => $created, 'created' => $created,
'updated' => $updated, 'updated' => $updated,
'messages_added' => $messagesAdded, 'messages_added' => $messagesAdded,
'articles_fetched' => $articlesFetched,
'articles_deferred' => $skippedArticles, // Haetaan on-demand kun tiketti avataan
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
http_response_code(500); http_response_code(500);

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Noxus HUB</title> <title>Noxus HUB</title>
<link rel="stylesheet" href="style.css?v=20260313r"> <link rel="stylesheet" href="style.css?v=20260313s">
</head> </head>
<body> <body>
<!-- Login --> <!-- Login -->
@@ -2271,6 +2271,6 @@
</div> </div>
</div> </div>
<script src="script.js?v=20260313r"></script> <script src="script.js?v=20260313s"></script>
</body> </body>
</html> </html>

View File

@@ -1491,7 +1491,7 @@ function renderTickets() {
<td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td> <td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td>
<td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td> <td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td>
<td><span class="ticket-type ticket-type-${t.type || 'muu'}">${typeLabel}</span></td> <td><span class="ticket-type ticket-type-${t.type || 'muu'}">${typeLabel}</span></td>
<td>${prioBadge}${companyBadge}${t.ticket_number ? `<span style="color:#888;font-size:0.8rem;margin-right:0.3rem;">#${t.ticket_number}</span>` : ''}<strong>${esc(t.subject)}</strong></td> <td>${prioBadge}${companyBadge}${t.ticket_number ? `<span style="color:#888;font-size:0.8rem;margin-right:0.3rem;">#${t.ticket_number}</span>` : ''}${t.has_attachments ? '<span title="Liitteitä" style="color:#888;margin-right:0.3rem;">📎</span>' : ''}<strong>${esc(t.subject)}</strong></td>
<td>${esc(t.mailbox_name || t.from_name || t.from_email)}</td> <td>${esc(t.mailbox_name || t.from_name || t.from_email)}</td>
<td style="text-align:center;">${lastType} ${t.message_count}</td> <td style="text-align:center;">${lastType} ${t.message_count}</td>
<td class="nowrap" title="${esc((t.updated || '').substring(0, 16))}">${timeAgo(t.updated)}</td> <td class="nowrap" title="${esc((t.updated || '').substring(0, 16))}">${timeAgo(t.updated)}</td>
@@ -2145,22 +2145,33 @@ document.getElementById('btn-fetch-emails').addEventListener('click', async () =
status.textContent = 'Yhdistetään sähköpostipalvelimeen...'; status.textContent = 'Yhdistetään sähköpostipalvelimeen...';
try { try {
const result = await apiCall('ticket_fetch', 'POST'); // Hae IMAP ja Zammad rinnakkain — ei enää peräkkäin
let statusMsg = `Valmis! ${result.new_tickets} uutta tikettiä, ${result.threaded} ketjutettu viestiä.`; status.textContent = 'Haetaan sähköpostit ja synkataan Zammad...';
const [imapResult, zammadResult] = await Promise.allSettled([
apiCall('ticket_fetch', 'POST'),
apiCall('zammad_sync', 'POST', { full: true }),
]);
// Hae myös Zammadista (full sync) let parts = [];
let zammadMsg = ''; if (imapResult.status === 'fulfilled') {
try { const r = imapResult.value;
status.textContent = 'Synkataan Zammad...'; parts.push(`📧 ${r.new_tickets} uutta, ${r.threaded} ketjutettu`);
const zResult = await apiCall('zammad_sync', 'POST', { full: true }); } else if (imapResult.reason) {
if (zResult.created || zResult.updated || zResult.messages_added) { parts.push(`📧 Virhe: ${imapResult.reason.message || 'tuntematon'}`);
zammadMsg = ` Zammad: ${zResult.created} uutta, ${zResult.updated} päivitettyä, ${zResult.messages_added} viestiä.`; }
if (zammadResult.status === 'fulfilled' && zammadResult.value.ok) {
const z = zammadResult.value;
const zParts = [];
if (z.created) zParts.push(`${z.created} uutta`);
if (z.updated) zParts.push(`${z.updated} päivitettyä`);
if (z.messages_added) zParts.push(`${z.messages_added} viestiä`);
if (z.articles_deferred) zParts.push(`${z.articles_deferred} tiketin viestit haetaan kun avaat`);
if (zParts.length) parts.push(`🔗 Zammad: ${zParts.join(', ')}`);
} }
} catch (ze) { /* Zammad ei käytössä tai virhe — ohitetaan */ }
status.style.background = '#eafaf1'; status.style.background = '#eafaf1';
status.style.color = '#27ae60'; status.style.color = '#27ae60';
status.textContent = statusMsg + zammadMsg; status.textContent = parts.length ? parts.join(' | ') : 'Valmis — ei uusia viestejä.';
await loadTickets(); await loadTickets();
} catch (e) { } catch (e) {
status.style.background = '#fef2f2'; status.style.background = '#fef2f2';
@@ -2185,8 +2196,8 @@ function startTicketAutoRefresh() {
const supportActive = document.getElementById('tab-content-support').classList.contains('active'); const supportActive = document.getElementById('tab-content-support').classList.contains('active');
const listVisible = document.getElementById('ticket-list-view').style.display !== 'none'; const listVisible = document.getElementById('ticket-list-view').style.display !== 'none';
if (supportActive && listVisible) { if (supportActive && listVisible) {
// Synkkaa Zammad taustalla ennen tikettien latausta // Synkkaa Zammad taustalla JA lataa tiketit rinnakkain — ei blokkaa
try { await apiCall('zammad_sync', 'POST'); } catch (e) { /* Zammad ei käytössä */ } apiCall('zammad_sync', 'POST').catch(() => {});
loadTickets(); loadTickets();
} }
}, seconds * 1000); }, seconds * 1000);