Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php
fs ea786f5341 feat(helpdesk): add multi-tenant BC connection settings with risk radar refinements
Introduce per-tenant override for helpdesk BC connection config.
New table helpdesk_tenant_settings stores tenant-specific credentials
with encrypted secrets (AES-256-GCM). EffectiveHelpdeskSettingsService
resolves global vs tenant config per request. Settings UI extended with
tenant override tab, toggle, and dual editing mode.

All runtime consumers (OData, SOAP, OAuth) read through the new
resolver. Token cache flushed on any config change. Default behavior
unchanged — tenants use global config until override explicitly enabled.

Also includes risk radar UI refinements: Stripe-style card redesign
with accent borders and score pills, removal of redundant KPI tiles
and search, and filtering of zero-score entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:29:59 +02:00

521 lines
19 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 EffectiveHelpdeskSettingsService $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) {
// Try to extract SOAP fault from error response body
$faultDetail = $this->extractSoapFault($rawBody);
return ['ok' => false, 'error' => 'SOAP request failed: HTTP ' . $httpCode . ($faultDetail !== '' ? ' — ' . $faultDetail : '')];
}
return $this->parseGetTicketFileResponse($rawBody);
}
private function buildGetTicketFileEnvelope(int $entryNo, string $contactNo): string
{
$escapedContactNo = htmlspecialchars($contactNo, ENT_XML1 | ENT_QUOTES, 'UTF-8');
// BC signature: GetTicketFile(TicketNo, EntryNo, ContactNo, ICISupportFilePort)
// The old monolith omitted ticketNo — BC resolves it from EntryNo.
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:ticketNo></urn:ticketNo>
<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 extractSoapFault(string $body): string
{
if ($body === '') {
return '';
}
$dom = new \DOMDocument();
$prev = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($body);
libxml_clear_errors();
libxml_use_internal_errors($prev);
if (!$loaded) {
return mb_substr(trim($body), 0, 200);
}
$xpath = new \DOMXPath($dom);
$faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0);
if ($faultNode instanceof \DOMNode) {
return trim((string) $faultNode->textContent);
}
return mb_substr(trim($body), 0, 200);
}
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;
}
}