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;