feat(helpdesk): add ticket/debtor communication feed and chat timeline UI
This commit is contained in:
360
modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php
Normal file
360
modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?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';
|
||||
|
||||
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'] ?? false)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'soap_used' => false,
|
||||
'return_value' => 0,
|
||||
'communication_text' => '',
|
||||
'message_entries' => '',
|
||||
'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): array
|
||||
{
|
||||
$headers = [
|
||||
'Content-Type: text/xml; charset=utf-8',
|
||||
'Accept: text/xml,application/xml',
|
||||
'SOAPAction: "' . self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT . '"',
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user