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:
110
api.php
110
api.php
@@ -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
12
db.php
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
16
script.js
16
script.js
@@ -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 ? '⚡ Automaattinen vastaus' : (isOut ? '→ Lähetetty' : (isNote ? '📝 Muistiinpano' : '← Saapunut'));
|
const typeIcon = isAutoReply ? '⚡ Automaattinen vastaus' : (isOut ? '→ Lähetetty' : (isNote ? '📝 Muistiinpano' : '← 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('');
|
||||||
|
|
||||||
|
|||||||
32
style.css
32
style.css
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user