feat(helpdesk): add ticket file download and inline image preview

New SOAP method getTicketFile() on BcSoapGateway calls BC's
GetTicketFile to retrieve file attachments as Base64, decodes in
memory and returns binary data without writing to disk.

New proxy endpoint helpdesk/ticket-file-data streams files directly
to the browser with correct MIME type. Images served inline, other
files as download attachment.

TicketCommunicationService now attaches file_entry_no and file_name
to file-type events. Frontend renders download links with bi-download
icon and inline image previews for jpg/png/gif attachments.

Works in both ticket detail and debitor detail communication feeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 22:49:01 +02:00
parent 659a6d71c5
commit 792f706d9e
12 changed files with 310 additions and 2 deletions

View File

@@ -304,5 +304,6 @@
"Queue": "Warteschlange", "Queue": "Warteschlange",
"trend_tooltip_up": "Mehr Tickets erhalten als gelöst im Zeitraum — Rückstau wächst.", "trend_tooltip_up": "Mehr Tickets erhalten als gelöst im Zeitraum — Rückstau wächst.",
"trend_tooltip_down": "Mehr Tickets gelöst als erhalten im Zeitraum — Rückstau wird abgebaut.", "trend_tooltip_down": "Mehr Tickets gelöst als erhalten im Zeitraum — Rückstau wird abgebaut.",
"Download file": "Datei herunterladen",
"Trend based on last {days} days": "Trend basiert auf den letzten {days} Tagen" "Trend based on last {days} days": "Trend basiert auf den letzten {days} Tagen"
} }

View File

@@ -304,5 +304,6 @@
"Queue": "Queue", "Queue": "Queue",
"trend_tooltip_up": "More tickets received than resolved in the period — backlog is growing.", "trend_tooltip_up": "More tickets received than resolved in the period — backlog is growing.",
"trend_tooltip_down": "More tickets resolved than received in the period — backlog is shrinking.", "trend_tooltip_down": "More tickets resolved than received in the period — backlog is shrinking.",
"Download file": "Download file",
"Trend based on last {days} days": "Trend based on last {days} days" "Trend based on last {days} days": "Trend based on last {days} days"
} }

View File

@@ -13,6 +13,7 @@ class BcSoapGateway
private const REQUEST_TIMEOUT = 30; private const REQUEST_TIMEOUT = 30;
private const CODEUNIT_NAME = 'ICISupportWebserviceAPI'; private const CODEUNIT_NAME = 'ICISupportWebserviceAPI';
private const SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketCommunicationText'; private const SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketCommunicationText';
private const SOAP_ACTION_GET_TICKET_FILE = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketFile';
public function __construct( public function __construct(
private readonly HelpdeskSettingsGateway $settingsGateway, private readonly HelpdeskSettingsGateway $settingsGateway,
@@ -167,12 +168,16 @@ XML;
/** /**
* @return array{headers?: array<int, string>, basic_user?: string, basic_password?: string, error?: string} * @return array{headers?: array<int, string>, basic_user?: string, basic_password?: string, error?: string}
*/ */
private function buildRequestContext(string $endpoint, string $requestBody): array private function buildRequestContext(string $endpoint, string $requestBody, string $soapAction = ''): array
{ {
if ($soapAction === '') {
$soapAction = self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT;
}
$headers = [ $headers = [
'Content-Type: text/xml; charset=utf-8', 'Content-Type: text/xml; charset=utf-8',
'Accept: text/xml,application/xml', 'Accept: text/xml,application/xml',
'SOAPAction: "' . self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT . '"', 'SOAPAction: "' . $soapAction . '"',
'Content-Length: ' . strlen($requestBody), 'Content-Length: ' . strlen($requestBody),
]; ];
@@ -290,6 +295,131 @@ XML;
]; ];
} }
/**
* Download a ticket file attachment via SOAP GetTicketFile.
*
* Returns the decoded binary data or an error. Does not write to disk.
*
* @return array{ok: bool, data?: string, filename?: string, error?: string}
*/
public function getTicketFile(int $entryNo, string $contactNo = ''): array
{
if ($entryNo <= 0) {
return ['ok' => false, 'error' => 'Invalid entry number'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
$endpoint = $this->buildSoapEndpoint();
if ($endpoint === null) {
return ['ok' => false, 'error' => 'Could not derive SOAP endpoint'];
}
$requestBody = $this->buildGetTicketFileEnvelope($entryNo, $contactNo);
$request = $this->buildRequestContext($endpoint, $requestBody, self::SOAP_ACTION_GET_TICKET_FILE);
if (isset($request['error'])) {
return ['ok' => false, 'error' => (string) $request['error']];
}
$response = $this->sendSoapRequest(
$endpoint,
self::SOAP_ACTION_GET_TICKET_FILE,
$requestBody,
(array) ($request['headers'] ?? []),
(string) ($request['basic_user'] ?? ''),
(string) ($request['basic_password'] ?? '')
);
$httpCode = (int) ($response['http_code'] ?? 0);
$rawBody = (string) ($response['body'] ?? '');
if ($httpCode < 200 || $httpCode >= 300) {
return ['ok' => false, 'error' => 'SOAP request failed: HTTP ' . $httpCode];
}
return $this->parseGetTicketFileResponse($rawBody);
}
private function buildGetTicketFileEnvelope(int $entryNo, string $contactNo): string
{
$escapedContactNo = htmlspecialchars($contactNo, ENT_XML1 | ENT_QUOTES, 'UTF-8');
return <<<XML
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
<soap:Body>
<urn:GetTicketFile>
<urn:entryNo>{$entryNo}</urn:entryNo>
<urn:contactNo>{$escapedContactNo}</urn:contactNo>
<urn:iCISupportFilePort></urn:iCISupportFilePort>
</urn:GetTicketFile>
</soap:Body>
</soap:Envelope>
XML;
}
/**
* @return array{ok: bool, data?: string, filename?: string, error?: string}
*/
private function parseGetTicketFileResponse(string $body): array
{
$body = trim($body);
if ($body === '') {
return ['ok' => false, 'error' => 'Empty SOAP response'];
}
$dom = new \DOMDocument();
$previousLibxmlState = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($body);
libxml_clear_errors();
libxml_use_internal_errors($previousLibxmlState);
if (!$loaded) {
return ['ok' => false, 'error' => 'Invalid SOAP XML response'];
}
$xpath = new \DOMXPath($dom);
$faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0);
if ($faultNode instanceof \DOMNode) {
return ['ok' => false, 'error' => 'SOAP fault: ' . trim((string) $faultNode->textContent)];
}
$returnNode = $xpath->query("//*[local-name()='return_value']")->item(0);
$returnValue = ($returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent)))
? (int) trim((string) $returnNode->textContent)
: 0;
if ($returnValue !== 100) {
return ['ok' => false, 'error' => 'BC returned error code ' . $returnValue];
}
// Collect Base64 parts
$partNodes = $xpath->query("//*[local-name()='PartData']");
$base64 = '';
if ($partNodes !== false) {
foreach ($partNodes as $node) {
$base64 .= trim((string) $node->textContent);
}
}
if ($base64 === '') {
return ['ok' => false, 'error' => 'No file data in response'];
}
// Handle data URI prefix (data:application/octet-stream;base64,...)
if (str_contains($base64, ',')) {
$base64 = substr($base64, strpos($base64, ',') + 1);
}
$decoded = base64_decode($base64, true);
if ($decoded === false) {
return ['ok' => false, 'error' => 'Base64 decode failed'];
}
return ['ok' => true, 'data' => $decoded];
}
private function buildSoapEndpoint(): ?string private function buildSoapEndpoint(): ?string
{ {
$baseUrl = trim($this->settingsGateway->getODataBaseUrl()); $baseUrl = trim($this->settingsGateway->getODataBaseUrl());

View File

@@ -287,6 +287,8 @@ class TicketCommunicationService
'type_variant' => $typeVariant, 'type_variant' => $typeVariant,
'state_subtype' => $stateSubtype, 'state_subtype' => $stateSubtype,
'message' => $message, 'message' => $message,
'file_entry_no' => $typeVariant === 'file' ? $entryNo : '',
'file_name' => $typeVariant === 'file' ? $this->extractFilenameFromMessage($message, $recordId) : '',
'source' => $soapMessage !== '' ? 'soap' : 'odata', 'source' => $soapMessage !== '' ? 'soap' : 'odata',
]; ];
@@ -1053,6 +1055,29 @@ class TicketCommunicationService
return $result; return $result;
} }
/**
* Extract a filename from the message or record ID of a file-type log entry.
*/
private function extractFilenameFromMessage(string $message, string $recordId): string
{
// Message itself may contain the filename
$trimmed = trim($message);
if ($trimmed !== '' && str_contains($trimmed, '.') && !str_contains($trimmed, ' ')) {
return $trimmed;
}
// Record_ID sometimes contains the filename
$recordTrimmed = trim($recordId);
if ($recordTrimmed !== '' && str_contains($recordTrimmed, '.')) {
// Extract last segment after colon or space that looks like a filename
if (preg_match('/([^\s:]+\.\w{2,5})$/i', $recordTrimmed, $m)) {
return $m[1];
}
}
return $trimmed !== '' ? $trimmed : '';
}
private function normalizeActorRole(string $createdByType, string $actor): string private function normalizeActorRole(string $createdByType, string $actor): string
{ {
$type = mb_strtolower(trim($createdByType)); $type = mb_strtolower(trim($createdByType));

View File

@@ -28,6 +28,7 @@ return [
['path' => 'helpdesk/debitor-ticket-categories-data', 'target' => 'helpdesk/debitor-ticket-categories-data'], ['path' => 'helpdesk/debitor-ticket-categories-data', 'target' => 'helpdesk/debitor-ticket-categories-data'],
['path' => 'helpdesk/ticket-log-data', 'target' => 'helpdesk/ticket-log-data'], ['path' => 'helpdesk/ticket-log-data', 'target' => 'helpdesk/ticket-log-data'],
['path' => 'helpdesk/ticket-communication-data', 'target' => 'helpdesk/ticket-communication-data'], ['path' => 'helpdesk/ticket-communication-data', 'target' => 'helpdesk/ticket-communication-data'],
['path' => 'helpdesk/ticket-file-data', 'target' => 'helpdesk/ticket-file-data'],
['path' => 'helpdesk/debitor-communication-data', 'target' => 'helpdesk/debitor-communication-data'], ['path' => 'helpdesk/debitor-communication-data', 'target' => 'helpdesk/debitor-communication-data'],
['path' => 'helpdesk/debitor-sales-dashboard-data', 'target' => 'helpdesk/debitor-sales-dashboard-data'], ['path' => 'helpdesk/debitor-sales-dashboard-data', 'target' => 'helpdesk/debitor-sales-dashboard-data'],
['path' => 'helpdesk/debitor-controlling-dashboard-data', 'target' => 'helpdesk/debitor-controlling-dashboard-data'], ['path' => 'helpdesk/debitor-controlling-dashboard-data', 'target' => 'helpdesk/debitor-controlling-dashboard-data'],

View File

@@ -33,6 +33,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
data-tickets-url="<?php e(lurl('helpdesk/debitor-tickets-data')); ?>" data-tickets-url="<?php e(lurl('helpdesk/debitor-tickets-data')); ?>"
data-ticket-base-url="<?php e(lurl('helpdesk/ticket/')); ?>" data-ticket-base-url="<?php e(lurl('helpdesk/ticket/')); ?>"
data-communication-url="<?php e(lurl('helpdesk/debitor-communication-data')); ?>" data-communication-url="<?php e(lurl('helpdesk/debitor-communication-data')); ?>"
data-file-download-url="<?php e(lurl('helpdesk/ticket-file-data')); ?>"
data-sales-dashboard-url="<?php e(lurl('helpdesk/debitor-sales-dashboard-data')); ?>" data-sales-dashboard-url="<?php e(lurl('helpdesk/debitor-sales-dashboard-data')); ?>"
data-controlling-dashboard-url="<?php e(lurl('helpdesk/debitor-controlling-dashboard-data')); ?>" data-controlling-dashboard-url="<?php e(lurl('helpdesk/debitor-controlling-dashboard-data')); ?>"
> >

View File

@@ -24,6 +24,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
<div class="app-details-container" <div class="app-details-container"
data-ticket-no="<?php e($ticketNo); ?>" data-ticket-no="<?php e($ticketNo); ?>"
data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>" data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>"
data-file-download-url="<?php e(lurl('helpdesk/ticket-file-data')); ?>"
> >
<section> <section>
<?php <?php

View File

@@ -0,0 +1,85 @@
<?php
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
$request = requestInput();
if ($request->method() !== 'GET') {
http_response_code(405);
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
return;
}
$entryNo = (int) $request->query('entryNo', '0');
$filename = trim((string) $request->query('filename', ''));
if ($entryNo <= 0) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'missing_entry_no']);
return;
}
$soapGateway = app(BcSoapGateway::class);
try {
$result = $soapGateway->getTicketFile($entryNo);
} catch (\Throwable) {
http_response_code(502);
Router::json(['ok' => false, 'error' => 'Failed to load file']);
return;
}
if (!$result['ok'] || !isset($result['data'])) {
http_response_code(404);
Router::json(['ok' => false, 'error' => $result['error'] ?? 'File not found']);
return;
}
$data = $result['data'];
// Derive MIME type from filename extension
$mimeMap = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'bmp' => 'image/bmp',
'ico' => 'image/x-icon',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'zip' => 'application/zip',
'txt' => 'text/plain',
'csv' => 'text/csv',
];
$ext = $filename !== '' ? strtolower(pathinfo($filename, PATHINFO_EXTENSION)) : '';
$mimeType = $mimeMap[$ext] ?? 'application/octet-stream';
$displayFilename = $filename !== '' ? $filename : 'attachment-' . $entryNo;
// Image types can be displayed inline, others force download
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'ico'];
$disposition = in_array($ext, $imageExtensions, true) ? 'inline' : 'attachment';
header('Content-Type: ' . $mimeType);
header('Content-Disposition: ' . $disposition . '; filename="' . addcslashes($displayFilename, '"\\') . '"');
header('Content-Length: ' . strlen($data));
header('Cache-Control: private, max-age=3600');
while (ob_get_level()) {
ob_end_clean();
}
echo $data;

View File

@@ -1307,6 +1307,42 @@
text-decoration: underline; text-decoration: underline;
} }
/* File attachment in chat */
.helpdesk-chat-file {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.helpdesk-chat-file-preview {
display: block;
max-width: 16rem;
margin-left: 1.35rem;
}
.helpdesk-chat-file-preview img {
display: block;
max-width: 100%;
height: auto;
border-radius: var(--app-radius, 0.375rem);
border: 1px solid var(--app-muted-border-color);
}
.helpdesk-chat-file-link {
display: inline-flex;
align-items: center;
gap: 0.35em;
margin-left: 1.35rem;
font-size: var(--text-sm, 0.875rem);
font-weight: 500;
color: var(--app-primary, #0d6efd);
text-decoration: none;
}
.helpdesk-chat-file-link:hover {
text-decoration: underline;
}
/* Ticket detail — communication feed in main content (no max-height) */ /* Ticket detail — communication feed in main content (no max-height) */
.helpdesk-ticket-detail-content .helpdesk-comm-feed { .helpdesk-ticket-detail-content .helpdesk-comm-feed {
max-height: none; max-height: none;

View File

@@ -70,6 +70,7 @@ export function renderCommunicationFeed(entries, options = {}) {
emptyMessage = 'No communication found.', emptyMessage = 'No communication found.',
showTicketNo = false, showTicketNo = false,
ticketBaseUrl = '', ticketBaseUrl = '',
fileDownloadUrl = '',
} = options; } = options;
const list = Array.isArray(entries) ? entries : []; const list = Array.isArray(entries) ? entries : [];
@@ -114,6 +115,28 @@ export function renderCommunicationFeed(entries, options = {}) {
continue; continue;
} }
// File attachment: render download link and inline image for images
if (typeVariant === 'file' && fileDownloadUrl && entry.file_entry_no) {
const fileName = entry.file_name || 'attachment';
const fileUrl = fileDownloadUrl + '?entryNo=' + encodeURIComponent(entry.file_entry_no) + '&filename=' + encodeURIComponent(fileName);
const ext = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp'];
const isImage = imageExts.includes(ext);
html += `<li class="helpdesk-chat-row helpdesk-chat-row--activity" data-variant="file">`;
html += `<div class="helpdesk-chat-activity helpdesk-chat-file" data-variant="file">`;
html += `<span class="helpdesk-chat-activity-pill">${esc(actor)} ${esc(t('attached a file'))}`;
if (timeLabel) html += ` ${esc(t('on'))} ${esc(timeLabel)}`;
html += `</span>`;
if (isImage) {
html += `<a href="${esc(fileUrl)}" target="_blank" class="helpdesk-chat-file-preview"><img src="${esc(fileUrl)}" alt="${esc(fileName)}" loading="lazy"></a>`;
}
html += `<a href="${esc(fileUrl)}" target="_blank" class="helpdesk-chat-file-link"><i class="bi bi-download"></i> ${esc(fileName)}</a>`;
html += '</div>';
html += '</li>';
continue;
}
const activityAction = activityActionLabel(typeVariant, type, stateSubtype, t); const activityAction = activityActionLabel(typeVariant, type, stateSubtype, t);
let activityText = `${actor} ${activityAction}`.trim(); let activityText = `${actor} ${activityAction}`.trim();
if (timeLabel) { if (timeLabel) {

View File

@@ -27,6 +27,7 @@ function init(container) {
const contactsUrl = container.dataset.contactsUrl || ''; const contactsUrl = container.dataset.contactsUrl || '';
const ticketBaseUrl = container.dataset.ticketBaseUrl || ''; const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
const communicationUrl = container.dataset.communicationUrl || ''; const communicationUrl = container.dataset.communicationUrl || '';
const fileDownloadUrl = container.dataset.fileDownloadUrl || '';
const salesDashboardUrl = container.dataset.salesDashboardUrl || ''; const salesDashboardUrl = container.dataset.salesDashboardUrl || '';
const refreshFromUrl = (() => { const refreshFromUrl = (() => {
const value = new URLSearchParams(window.location.search).get('refresh'); const value = new URLSearchParams(window.location.search).get('refresh');
@@ -1148,6 +1149,7 @@ function init(container) {
emptyMessage: 'No communication found.', emptyMessage: 'No communication found.',
showTicketNo: true, showTicketNo: true,
ticketBaseUrl, ticketBaseUrl,
fileDownloadUrl,
}); });
feedEl.innerHTML = html; feedEl.innerHTML = html;
} }

View File

@@ -18,6 +18,7 @@ if (container) {
function init(container) { function init(container) {
const ticketNo = container.dataset.ticketNo || ''; const ticketNo = container.dataset.ticketNo || '';
const communicationUrl = container.dataset.ticketCommunicationUrl || ''; const communicationUrl = container.dataset.ticketCommunicationUrl || '';
const fileDownloadUrl = container.dataset.fileDownloadUrl || '';
if (!ticketNo || !communicationUrl) return; if (!ticketNo || !communicationUrl) return;
@@ -77,6 +78,7 @@ function init(container) {
t, t,
emptyMessage: 'No communication found.', emptyMessage: 'No communication found.',
showTicketNo: false, showTicketNo: false,
fileDownloadUrl,
}); });
feedEl.innerHTML = html; feedEl.innerHTML = html;