forked from fa/breadcrumb-the-shire
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>
491 lines
18 KiB
PHP
491 lines
18 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Helpdesk\Service;
|
|
|
|
use MintyPHP\Http\SessionStoreInterface;
|
|
|
|
/**
|
|
* Gateway for Business Central SOAP codeunit calls used by helpdesk communication.
|
|
*/
|
|
class BcSoapGateway
|
|
{
|
|
private const CONNECT_TIMEOUT = 10;
|
|
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,
|
|
private readonly HelpdeskOAuthTokenService $oauthTokenService,
|
|
private readonly SessionStoreInterface $sessionStore
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* ok: bool,
|
|
* soap_used: bool,
|
|
* return_value: int,
|
|
* communication_text: string,
|
|
* message_entries: string,
|
|
* error?: string
|
|
* }
|
|
*/
|
|
public function getTicketCommunicationText(string $ticketNo, string $messageEntries = ''): array
|
|
{
|
|
$ticketNo = trim($ticketNo);
|
|
if ($ticketNo === '') {
|
|
return [
|
|
'ok' => false,
|
|
'soap_used' => false,
|
|
'return_value' => 0,
|
|
'communication_text' => '',
|
|
'message_entries' => '',
|
|
'error' => 'Missing ticket number',
|
|
];
|
|
}
|
|
|
|
if (!$this->settingsGateway->isConfigured()) {
|
|
return [
|
|
'ok' => false,
|
|
'soap_used' => false,
|
|
'return_value' => 0,
|
|
'communication_text' => '',
|
|
'message_entries' => '',
|
|
'error' => 'BC connection not configured',
|
|
];
|
|
}
|
|
|
|
$endpoint = $this->buildSoapEndpoint();
|
|
if ($endpoint === null) {
|
|
return [
|
|
'ok' => false,
|
|
'soap_used' => false,
|
|
'return_value' => 0,
|
|
'communication_text' => '',
|
|
'message_entries' => '',
|
|
'error' => 'Could not derive SOAP endpoint from BC URL',
|
|
];
|
|
}
|
|
|
|
$requestBody = $this->buildGetTicketCommunicationTextEnvelope($ticketNo, $messageEntries);
|
|
$request = $this->buildRequestContext($endpoint, $requestBody);
|
|
if (isset($request['error'])) {
|
|
return [
|
|
'ok' => false,
|
|
'soap_used' => false,
|
|
'return_value' => 0,
|
|
'communication_text' => '',
|
|
'message_entries' => '',
|
|
'error' => (string) $request['error'],
|
|
];
|
|
}
|
|
|
|
$response = $this->sendSoapRequest(
|
|
$endpoint,
|
|
self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT,
|
|
$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) {
|
|
$errorSnippet = trim((string) ($response['error'] ?? ''));
|
|
if ($errorSnippet === '' && $rawBody !== '') {
|
|
$errorSnippet = mb_substr($rawBody, 0, 250);
|
|
}
|
|
|
|
return [
|
|
'ok' => false,
|
|
'soap_used' => false,
|
|
'return_value' => 0,
|
|
'communication_text' => '',
|
|
'message_entries' => '',
|
|
'error' => 'BC SOAP request failed: HTTP ' . $httpCode . ($errorSnippet !== '' ? ' - ' . $errorSnippet : ''),
|
|
];
|
|
}
|
|
|
|
$parsed = $this->parseGetTicketCommunicationTextResponse($rawBody);
|
|
if (!$parsed['ok']) {
|
|
return [
|
|
'ok' => false,
|
|
'soap_used' => false,
|
|
'return_value' => 0,
|
|
'communication_text' => '',
|
|
'message_entries' => '',
|
|
'error' => isset($parsed['error']) ? (string) $parsed['error'] : 'Could not parse SOAP response',
|
|
];
|
|
}
|
|
|
|
$returnValue = (int) ($parsed['return_value'] ?? 0);
|
|
if ($returnValue >= 400) {
|
|
return [
|
|
'ok' => false,
|
|
'soap_used' => false,
|
|
'return_value' => $returnValue,
|
|
'communication_text' => (string) ($parsed['communication_text'] ?? ''),
|
|
'message_entries' => (string) ($parsed['message_entries'] ?? ''),
|
|
'error' => 'BC SOAP returned error code ' . $returnValue,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'soap_used' => true,
|
|
'return_value' => $returnValue,
|
|
'communication_text' => (string) ($parsed['communication_text'] ?? ''),
|
|
'message_entries' => (string) ($parsed['message_entries'] ?? ''),
|
|
];
|
|
}
|
|
|
|
private function buildGetTicketCommunicationTextEnvelope(string $ticketNo, string $messageEntries): string
|
|
{
|
|
$escapedTicketNo = htmlspecialchars($ticketNo, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
|
$normalizedMessageEntries = trim($messageEntries);
|
|
if ($normalizedMessageEntries === '') {
|
|
$normalizedMessageEntries = '0';
|
|
}
|
|
$escapedMessageEntries = htmlspecialchars($normalizedMessageEntries, 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:GetTicketCommunicationText>
|
|
<urn:ticketNo>{$escapedTicketNo}</urn:ticketNo>
|
|
<urn:communicationText></urn:communicationText>
|
|
<urn:messageEntries>{$escapedMessageEntries}</urn:messageEntries>
|
|
</urn:GetTicketCommunicationText>
|
|
</soap:Body>
|
|
</soap:Envelope>
|
|
XML;
|
|
}
|
|
|
|
/**
|
|
* @return array{headers?: array<int, string>, basic_user?: string, basic_password?: string, error?: string}
|
|
*/
|
|
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: "' . $soapAction . '"',
|
|
'Content-Length: ' . strlen($requestBody),
|
|
];
|
|
|
|
$authMode = $this->settingsGateway->getAuthMode();
|
|
if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
|
|
return [
|
|
'headers' => $headers,
|
|
'basic_user' => $this->settingsGateway->getBasicUser() ?? '',
|
|
'basic_password' => $this->settingsGateway->getBasicPassword() ?? '',
|
|
];
|
|
}
|
|
|
|
if ($authMode !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
|
|
return ['headers' => $headers];
|
|
}
|
|
|
|
$tenantId = $this->resolveCurrentTenantId();
|
|
if ($tenantId <= 0) {
|
|
return ['error' => 'OAuth2 mode requires active tenant context'];
|
|
}
|
|
|
|
$audience = $this->buildOAuthAudienceFromEndpoint($endpoint);
|
|
if ($audience === null) {
|
|
return ['error' => 'Could not build OAuth audience for SOAP endpoint'];
|
|
}
|
|
|
|
$token = $this->oauthTokenService->getAccessToken($tenantId, $audience);
|
|
if ($token === null || trim($token) === '') {
|
|
return ['error' => 'Could not obtain OAuth2 access token for SOAP request'];
|
|
}
|
|
|
|
$headers[] = 'Authorization: Bearer ' . $token;
|
|
|
|
return ['headers' => $headers];
|
|
}
|
|
|
|
/**
|
|
* @return array{ok: bool, return_value?: int, communication_text?: string, message_entries?: string, error?: string}
|
|
*/
|
|
private function parseGetTicketCommunicationTextResponse(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' => trim((string) $faultNode->textContent) ?: 'SOAP fault'];
|
|
}
|
|
|
|
$returnNode = $xpath->query("//*[local-name()='return_value']")->item(0);
|
|
$communicationNode = $xpath->query("//*[local-name()='communicationText']")->item(0);
|
|
$messageEntriesNode = $xpath->query("//*[local-name()='messageEntries']")->item(0);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'return_value' => $returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent))
|
|
? (int) trim((string) $returnNode->textContent)
|
|
: 0,
|
|
'communication_text' => $communicationNode instanceof \DOMNode ? (string) $communicationNode->textContent : '',
|
|
'message_entries' => $messageEntriesNode instanceof \DOMNode ? (string) $messageEntriesNode->textContent : '',
|
|
];
|
|
}
|
|
|
|
protected function sendSoapRequest(
|
|
string $endpoint,
|
|
string $soapAction,
|
|
string $requestBody,
|
|
array $headers,
|
|
string $basicUser,
|
|
string $basicPassword
|
|
): array {
|
|
$ch = curl_init();
|
|
if ($ch === false) {
|
|
return ['http_code' => 0, 'body' => '', 'error' => 'curl_init failed'];
|
|
}
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $endpoint,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
|
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $requestBody,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
]);
|
|
|
|
if ($basicUser !== '' || $basicPassword !== '') {
|
|
curl_setopt($ch, CURLOPT_USERPWD, $basicUser . ':' . $basicPassword);
|
|
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
|
}
|
|
|
|
$responseBody = curl_exec($ch);
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($ch);
|
|
unset($ch);
|
|
|
|
return [
|
|
'http_code' => $httpCode,
|
|
'body' => is_string($responseBody) ? $responseBody : '',
|
|
'error' => $error,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$baseUrl = trim($this->settingsGateway->getODataBaseUrl());
|
|
if ($baseUrl === '') {
|
|
return null;
|
|
}
|
|
|
|
$parsed = parse_url($baseUrl);
|
|
if (!is_array($parsed)) {
|
|
return null;
|
|
}
|
|
|
|
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
|
|
$host = trim((string) ($parsed['host'] ?? ''));
|
|
if ($host === '') {
|
|
return null;
|
|
}
|
|
|
|
$odataPath = '/' . ltrim((string) ($parsed['path'] ?? ''), '/');
|
|
$instancePath = (string) preg_replace('#/ODataV4(?:/.*)?$#i', '', $odataPath);
|
|
$instancePath = rtrim($instancePath !== '' ? $instancePath : '/BusinessCentral', '/');
|
|
if ($instancePath === '') {
|
|
$instancePath = '/BusinessCentral';
|
|
}
|
|
|
|
$odataPort = (int) ($parsed['port'] ?? 0);
|
|
$soapPort = $odataPort === 7048 ? 7047 : $odataPort;
|
|
|
|
$authority = $host;
|
|
if ($soapPort > 0 && !(($scheme === 'https' && $soapPort === 443) || ($scheme === 'http' && $soapPort === 80))) {
|
|
$authority .= ':' . $soapPort;
|
|
}
|
|
|
|
$company = rawurlencode($this->settingsGateway->getCompanyName());
|
|
|
|
return $scheme . '://' . $authority . $instancePath . '/WS/' . $company . '/Codeunit/' . self::CODEUNIT_NAME;
|
|
}
|
|
|
|
private function buildOAuthAudienceFromEndpoint(string $endpoint): ?string
|
|
{
|
|
$parsed = parse_url($endpoint);
|
|
if (!is_array($parsed)) {
|
|
return null;
|
|
}
|
|
|
|
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
|
|
$host = trim((string) ($parsed['host'] ?? ''));
|
|
if ($host === '') {
|
|
return null;
|
|
}
|
|
|
|
$port = (int) ($parsed['port'] ?? 0);
|
|
$authority = $host;
|
|
if ($port > 0 && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) {
|
|
$authority .= ':' . $port;
|
|
}
|
|
|
|
return $scheme . '://' . $authority;
|
|
}
|
|
|
|
private function resolveCurrentTenantId(): int
|
|
{
|
|
$session = $this->sessionStore->all();
|
|
$tenantId = (int) (($session['current_tenant']['id'] ?? 0));
|
|
|
|
return $tenantId > 0 ? $tenantId : 0;
|
|
}
|
|
}
|