Liitteiden näyttö Zammad-viesteissä + download proxy

- dbLoadTickets: attachments & zammad_article_id mukaan viestidataan
- ticket_detail on-demand: liitteiden metadata talteen (sama kuin sync)
- Uusi zammad_attachment proxy-endpoint liitteiden lataukseen
- JS: liitteet näkyvät tikettiviesteissä (ikoni, nimi, koko, latauslinkki)
- CSS: .msg-attachments ja .msg-attachment-link tyylit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 03:14:15 +02:00
parent 376912b9ff
commit dc0ed5c75c
5 changed files with 165 additions and 9 deletions

110
api.php
View File

@@ -3227,12 +3227,26 @@ switch ($action) {
if (($art['content_type'] ?? '') === 'text/html') { if (($art['content_type'] ?? '') === 'text/html') {
$body = strip_tags($body, '<br><p><div><a><b><i><strong><em><ul><ol><li>'); $body = strip_tags($body, '<br><p><div><a><b><i><strong><em><ul><ol><li>');
} }
// Liitteiden metadata
$attJson = '';
if (!empty($art['attachments'])) {
$atts = [];
foreach ($art['attachments'] as $att) {
$atts[] = [
'id' => $att['id'] ?? 0,
'filename' => $att['filename'] ?? '',
'size' => $att['size'] ?? 0,
'type' => $att['preferences']['Content-Type'] ?? ($att['content_type'] ?? 'application/octet-stream'),
];
}
$attJson = json_encode($atts);
}
_dbExecute( _dbExecute(
"INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, message_id, zammad_article_id) "INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, message_id, zammad_article_id, attachments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[$msgId, $ticket['id'], $msgType, $art['from'] ?? '', $art['from'] ?? '', $body, [$msgId, $ticket['id'], $msgType, $art['from'] ?? '', $art['from'] ?? '', $body,
$art['created_at'] ? date('Y-m-d H:i:s', strtotime($art['created_at'])) : date('Y-m-d H:i:s'), $art['created_at'] ? date('Y-m-d H:i:s', strtotime($art['created_at'])) : date('Y-m-d H:i:s'),
$art['message_id'] ?? '', $artId] $art['message_id'] ?? '', $artId, $attJson]
); );
} }
// Tallenna Zammad to-osoite tikettiin // Tallenna Zammad to-osoite tikettiin
@@ -5410,8 +5424,13 @@ switch ($action) {
try { try {
$articles = $z->getArticles($zammadId); $articles = $z->getArticles($zammadId);
$toEmail = '';
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ä
if (!$toEmail && ($art['sender'] ?? '') === 'Customer' && !empty($art['to'])) {
$toEmail = $art['to'];
}
$artId = (int)$art['id']; $artId = (int)$art['id'];
$existingMsg = dbGetMessageByZammadArticleId($ticketId, $artId); $existingMsg = dbGetMessageByZammadArticleId($ticketId, $artId);
if ($existingMsg) continue; if ($existingMsg) continue;
@@ -5423,18 +5442,38 @@ switch ($action) {
$body = strip_tags($body, '<br><p><div><a><b><i><strong><em><ul><ol><li>'); $body = strip_tags($body, '<br><p><div><a><b><i><strong><em><ul><ol><li>');
} }
// Liitteiden metadata
$attJson = '';
if (!empty($art['attachments'])) {
$atts = [];
foreach ($art['attachments'] as $att) {
$atts[] = [
'id' => $att['id'] ?? 0,
'filename' => $att['filename'] ?? '',
'size' => $att['size'] ?? 0,
'type' => $att['preferences']['Content-Type'] ?? ($att['content_type'] ?? 'application/octet-stream'),
];
}
$attJson = json_encode($atts);
}
_dbExecute( _dbExecute(
"INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, message_id, zammad_article_id) "INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, message_id, zammad_article_id, attachments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[$msgId, $ticketId, $msgType, [$msgId, $ticketId, $msgType,
$art['from'] ?? '', $art['from'] ?? '', $art['from'] ?? '', $art['from'] ?? '',
$body, $body,
$art['created_at'] ? date('Y-m-d H:i:s', strtotime($art['created_at'])) : date('Y-m-d H:i:s'), $art['created_at'] ? date('Y-m-d H:i:s', strtotime($art['created_at'])) : date('Y-m-d H:i:s'),
$art['message_id'] ?? '', $art['message_id'] ?? '',
$artId] $artId,
$attJson]
); );
$messagesAdded++; $messagesAdded++;
} }
// Tallenna zammad_to_email
if ($toEmail) {
_dbExecute("UPDATE tickets SET zammad_to_email = ? WHERE id = ?", [$toEmail, $ticketId]);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Artikkelihaku epäonnistui — jatka silti // Artikkelihaku epäonnistui — jatka silti
error_log("Zammad articles error for ticket $zammadId: " . $e->getMessage()); error_log("Zammad articles error for ticket $zammadId: " . $e->getMessage());
@@ -5454,6 +5493,65 @@ switch ($action) {
} }
break; break;
case 'zammad_attachment':
requireAuth();
$companyId = requireCompanyOrParam();
$ticketId = $_GET['ticket_id'] ?? '';
$articleId = (int)($_GET['article_id'] ?? 0);
$attachmentId = (int)($_GET['attachment_id'] ?? 0);
if (!$ticketId || !$articleId || !$attachmentId) {
http_response_code(400);
echo json_encode(['error' => 'Puuttuvat parametrit (ticket_id, article_id, attachment_id)']);
break;
}
// Varmista että tiketti kuuluu yritykselle
$ticket = _dbFetch("SELECT * FROM tickets WHERE id = ? AND company_id = ?", [$ticketId, $companyId]);
if (!$ticket || empty($ticket['zammad_ticket_id'])) {
http_response_code(404);
echo json_encode(['error' => 'Tikettiä ei löydy']);
break;
}
try {
$integ = dbGetIntegration($companyId, 'zammad');
if (!$integ || !$integ['enabled']) {
http_response_code(400);
echo json_encode(['error' => 'Zammad-integraatio ei käytössä']);
break;
}
// Lataa liite Zammad API:sta (binary download)
$zUrl = rtrim($integ['config']['url'], '/');
if (!preg_match('#^https?://#i', $zUrl)) $zUrl = 'https://' . $zUrl;
$zToken = $integ['config']['token'];
$url = "{$zUrl}/api/v1/ticket_attachment/{$ticket['zammad_ticket_id']}/{$articleId}/{$attachmentId}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Token token=' . $zToken,
],
]);
$fileData = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($httpCode >= 400 || $fileData === false) {
http_response_code(502);
echo json_encode(['error' => 'Liitteen lataus Zammadista epäonnistui (HTTP ' . $httpCode . ')']);
break;
}
// Hae tiedostonimi metadata:sta
$filename = $_GET['filename'] ?? 'attachment';
header('Content-Type: ' . ($contentType ?: 'application/octet-stream'));
header('Content-Disposition: attachment; filename="' . addslashes($filename) . '"');
header('Content-Length: ' . strlen($fileData));
echo $fileData;
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
break;
case 'zammad_reply': case 'zammad_reply':
$companyId = requireCompany(); $companyId = requireCompany();
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);

12
db.php
View File

@@ -679,6 +679,7 @@ function initDatabase(): void {
"ALTER TABLE availability_queries ADD COLUMN org VARCHAR(255) DEFAULT '' AFTER hostname", "ALTER TABLE availability_queries ADD COLUMN org VARCHAR(255) DEFAULT '' AFTER hostname",
"ALTER TABLE tickets ADD COLUMN to_email VARCHAR(255) DEFAULT '' AFTER from_name", "ALTER TABLE tickets ADD COLUMN to_email VARCHAR(255) DEFAULT '' AFTER from_name",
"ALTER TABLE tickets ADD COLUMN bcc TEXT DEFAULT '' AFTER cc", "ALTER TABLE tickets ADD COLUMN bcc TEXT DEFAULT '' AFTER cc",
"ALTER TABLE ticket_messages ADD COLUMN attachments TEXT DEFAULT '' AFTER zammad_article_id",
]; ];
foreach ($alters as $sql) { foreach ($alters as $sql) {
try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ } try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ }
@@ -1406,7 +1407,7 @@ function dbLoadTickets(string $companyId): array {
// Viestit // Viestit
$msgs = _dbFetchAll("SELECT * FROM ticket_messages WHERE ticket_id = ? ORDER BY timestamp", [$t['id']]); $msgs = _dbFetchAll("SELECT * FROM ticket_messages WHERE ticket_id = ? ORDER BY timestamp", [$t['id']]);
$t['messages'] = array_map(function($m) { $t['messages'] = array_map(function($m) {
return [ $msg = [
'id' => $m['id'], 'id' => $m['id'],
'type' => $m['type'], 'type' => $m['type'],
'from' => $m['from_email'], 'from' => $m['from_email'],
@@ -1415,6 +1416,15 @@ function dbLoadTickets(string $companyId): array {
'timestamp' => $m['timestamp'], 'timestamp' => $m['timestamp'],
'message_id' => $m['message_id'] ?? '', 'message_id' => $m['message_id'] ?? '',
]; ];
// Liitteiden metadata (JSON)
if (!empty($m['attachments'])) {
$msg['attachments'] = json_decode($m['attachments'], true) ?: [];
}
// Zammad article ID (tarvitaan liitteiden lataukseen)
if (!empty($m['zammad_article_id'])) {
$msg['zammad_article_id'] = (int)$m['zammad_article_id'];
}
return $msg;
}, $msgs); }, $msgs);
// Tagit // Tagit

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=20260313q"> <link rel="stylesheet" href="style.css?v=20260313r">
</head> </head>
<body> <body>
<!-- Login --> <!-- Login -->
@@ -2271,6 +2271,6 @@
</div> </div>
</div> </div>
<script src="script.js?v=20260313q"></script> <script src="script.js?v=20260313r"></script>
</body> </body>
</html> </html>

View File

@@ -1816,6 +1816,21 @@ async function showTicketDetail(id, companyId = '') {
const isNote = m.type === 'note'; const isNote = m.type === 'note';
const typeClass = (isOut || isAutoReply) ? 'ticket-msg-out' : (isNote ? 'ticket-msg-note' : 'ticket-msg-in'); const typeClass = (isOut || isAutoReply) ? 'ticket-msg-out' : (isNote ? 'ticket-msg-note' : 'ticket-msg-in');
const typeIcon = isAutoReply ? '&#9889; Automaattinen vastaus' : (isOut ? '&#8594; Lähetetty' : (isNote ? '&#128221; Muistiinpano' : '&#8592; Saapunut')); const typeIcon = isAutoReply ? '&#9889; Automaattinen vastaus' : (isOut ? '&#8594; Lähetetty' : (isNote ? '&#128221; Muistiinpano' : '&#8592; Saapunut'));
// Liitteet
let attachmentsHtml = '';
if (m.attachments && m.attachments.length > 0) {
const attItems = m.attachments.map(att => {
const sizeStr = att.size > 1048576 ? (att.size / 1048576).toFixed(1) + ' MB' : att.size > 1024 ? (att.size / 1024).toFixed(0) + ' KB' : att.size + ' B';
const icon = att.type && att.type.startsWith('image/') ? '🖼️' : att.type && att.type.includes('pdf') ? '📄' : '📎';
// Zammad-liite: lataa proxy-endpointin kautta
if (att.id && m.zammad_article_id) {
const dlUrl = `api.php?action=zammad_attachment&ticket_id=${encodeURIComponent(ticket.id)}&article_id=${m.zammad_article_id}&attachment_id=${att.id}&filename=${encodeURIComponent(att.filename)}`;
return `<a href="${dlUrl}" target="_blank" class="msg-attachment-link">${icon} ${esc(att.filename)} <span class="msg-att-size">(${sizeStr})</span></a>`;
}
return `<span class="msg-attachment-link">${icon} ${esc(att.filename)} <span class="msg-att-size">(${sizeStr})</span></span>`;
}).join('');
attachmentsHtml = `<div class="msg-attachments">${attItems}</div>`;
}
return `<div class="ticket-message ${typeClass}"> return `<div class="ticket-message ${typeClass}">
<div class="ticket-msg-header"> <div class="ticket-msg-header">
<span class="ticket-msg-type">${typeIcon}</span> <span class="ticket-msg-type">${typeIcon}</span>
@@ -1823,6 +1838,7 @@ async function showTicketDetail(id, companyId = '') {
<span class="ticket-msg-time">${esc(m.timestamp)}</span> <span class="ticket-msg-time">${esc(m.timestamp)}</span>
</div> </div>
<div class="ticket-msg-body">${m.type === 'email_in' || m.type === 'incoming' || m.type === 'outgoing' ? sanitizeHtml(m.body) : esc(m.body).replace(/\n/g, '<br>')}</div> <div class="ticket-msg-body">${m.type === 'email_in' || m.type === 'incoming' || m.type === 'outgoing' ? sanitizeHtml(m.body) : esc(m.body).replace(/\n/g, '<br>')}</div>
${attachmentsHtml}
</div>`; </div>`;
}).join(''); }).join('');

View File

@@ -1478,6 +1478,38 @@ span.empty {
word-break: break-word; word-break: break-word;
} }
/* Viestien liitteet */
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(0,0,0,0.08);
}
.msg-attachment-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem 0.6rem;
background: rgba(255,255,255,0.7);
border: 1px solid rgba(0,0,0,0.12);
border-radius: 6px;
font-size: 0.8rem;
color: #2563eb;
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.msg-attachment-link:hover {
background: rgba(37,99,235,0.08);
border-color: #2563eb;
}
.msg-att-size {
color: #888;
font-size: 0.75rem;
}
/* Ticket reply form */ /* Ticket reply form */
.ticket-reply-form { .ticket-reply-form {
padding-top: 1rem; padding-top: 1rem;