From 792f706d9ef6a0352e4cbf2bced8945ffd48bb6c Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 5 Apr 2026 22:49:01 +0200 Subject: [PATCH] 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) --- modules/helpdesk/i18n/default_de.json | 1 + modules/helpdesk/i18n/default_en.json | 1 + .../Module/Helpdesk/Service/BcSoapGateway.php | 134 +++++++++++++++++- .../Service/TicketCommunicationService.php | 25 ++++ modules/helpdesk/module.php | 1 + .../pages/helpdesk/debitor(default).phtml | 1 + .../pages/helpdesk/ticket(default).phtml | 1 + .../pages/helpdesk/ticket-file-data().php | 85 +++++++++++ modules/helpdesk/web/css/helpdesk.css | 36 +++++ .../helpdesk/web/js/helpdesk-communication.js | 23 +++ modules/helpdesk/web/js/helpdesk-detail.js | 2 + modules/helpdesk/web/js/helpdesk-ticket.js | 2 + 12 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 modules/helpdesk/pages/helpdesk/ticket-file-data().php diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 0ae52b8..06a1761 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -304,5 +304,6 @@ "Queue": "Warteschlange", "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.", + "Download file": "Datei herunterladen", "Trend based on last {days} days": "Trend basiert auf den letzten {days} Tagen" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 0882c69..98b59f2 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -304,5 +304,6 @@ "Queue": "Queue", "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.", + "Download file": "Download file", "Trend based on last {days} days": "Trend based on last {days} days" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php index c0a1939..6c1270d 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php @@ -13,6 +13,7 @@ class BcSoapGateway private const REQUEST_TIMEOUT = 30; 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_FILE = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketFile'; public function __construct( private readonly HelpdeskSettingsGateway $settingsGateway, @@ -167,12 +168,16 @@ XML; /** * @return array{headers?: array, 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 = [ 'Content-Type: text/xml; charset=utf-8', 'Accept: text/xml,application/xml', - 'SOAPAction: "' . self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT . '"', + 'SOAPAction: "' . $soapAction . '"', '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 << + + + + {$entryNo} + {$escapedContactNo} + + + + +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 { $baseUrl = trim($this->settingsGateway->getODataBaseUrl()); diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php index 0f6ea49..0f357d5 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php @@ -287,6 +287,8 @@ class TicketCommunicationService 'type_variant' => $typeVariant, 'state_subtype' => $stateSubtype, 'message' => $message, + 'file_entry_no' => $typeVariant === 'file' ? $entryNo : '', + 'file_name' => $typeVariant === 'file' ? $this->extractFilenameFromMessage($message, $recordId) : '', 'source' => $soapMessage !== '' ? 'soap' : 'odata', ]; @@ -1053,6 +1055,29 @@ class TicketCommunicationService 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 { $type = mb_strtolower(trim($createdByType)); diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 7bc496e..baea7e3 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -28,6 +28,7 @@ return [ ['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-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-sales-dashboard-data', 'target' => 'helpdesk/debitor-sales-dashboard-data'], ['path' => 'helpdesk/debitor-controlling-dashboard-data', 'target' => 'helpdesk/debitor-controlling-dashboard-data'], diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml index 19305ad..ed7ad7e 100644 --- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -33,6 +33,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null; data-tickets-url="" data-ticket-base-url="" data-communication-url="" + data-file-download-url="" data-sales-dashboard-url="" data-controlling-dashboard-url="" > diff --git a/modules/helpdesk/pages/helpdesk/ticket(default).phtml b/modules/helpdesk/pages/helpdesk/ticket(default).phtml index 6747149..78ca8da 100644 --- a/modules/helpdesk/pages/helpdesk/ticket(default).phtml +++ b/modules/helpdesk/pages/helpdesk/ticket(default).phtml @@ -24,6 +24,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
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; diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index a08dbaf..4aaadf7 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -1307,6 +1307,42 @@ 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) */ .helpdesk-ticket-detail-content .helpdesk-comm-feed { max-height: none; diff --git a/modules/helpdesk/web/js/helpdesk-communication.js b/modules/helpdesk/web/js/helpdesk-communication.js index fd7a8c3..8795c96 100644 --- a/modules/helpdesk/web/js/helpdesk-communication.js +++ b/modules/helpdesk/web/js/helpdesk-communication.js @@ -70,6 +70,7 @@ export function renderCommunicationFeed(entries, options = {}) { emptyMessage = 'No communication found.', showTicketNo = false, ticketBaseUrl = '', + fileDownloadUrl = '', } = options; const list = Array.isArray(entries) ? entries : []; @@ -114,6 +115,28 @@ export function renderCommunicationFeed(entries, options = {}) { 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 += `
  • `; + html += `
    `; + html += `${esc(actor)} ${esc(t('attached a file'))}`; + if (timeLabel) html += ` ${esc(t('on'))} ${esc(timeLabel)}`; + html += ``; + if (isImage) { + html += `${esc(fileName)}`; + } + html += ` ${esc(fileName)}`; + html += '
    '; + html += '
  • '; + continue; + } + const activityAction = activityActionLabel(typeVariant, type, stateSubtype, t); let activityText = `${actor} ${activityAction}`.trim(); if (timeLabel) { diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js index d62d65f..e341a32 100644 --- a/modules/helpdesk/web/js/helpdesk-detail.js +++ b/modules/helpdesk/web/js/helpdesk-detail.js @@ -27,6 +27,7 @@ function init(container) { const contactsUrl = container.dataset.contactsUrl || ''; const ticketBaseUrl = container.dataset.ticketBaseUrl || ''; const communicationUrl = container.dataset.communicationUrl || ''; + const fileDownloadUrl = container.dataset.fileDownloadUrl || ''; const salesDashboardUrl = container.dataset.salesDashboardUrl || ''; const refreshFromUrl = (() => { const value = new URLSearchParams(window.location.search).get('refresh'); @@ -1148,6 +1149,7 @@ function init(container) { emptyMessage: 'No communication found.', showTicketNo: true, ticketBaseUrl, + fileDownloadUrl, }); feedEl.innerHTML = html; } diff --git a/modules/helpdesk/web/js/helpdesk-ticket.js b/modules/helpdesk/web/js/helpdesk-ticket.js index 7902100..7e282d9 100644 --- a/modules/helpdesk/web/js/helpdesk-ticket.js +++ b/modules/helpdesk/web/js/helpdesk-ticket.js @@ -18,6 +18,7 @@ if (container) { function init(container) { const ticketNo = container.dataset.ticketNo || ''; const communicationUrl = container.dataset.ticketCommunicationUrl || ''; + const fileDownloadUrl = container.dataset.fileDownloadUrl || ''; if (!ticketNo || !communicationUrl) return; @@ -77,6 +78,7 @@ function init(container) { t, emptyMessage: 'No communication found.', showTicketNo: false, + fileDownloadUrl, }); feedEl.innerHTML = html;