feat(helpdesk): align module with core list/drawer standards
- add helpdesk module pages, services, settings and tests - standardize debtor list on drawer/grid contracts and robust filter drawer behavior - add helpdesk aside panel navigation and settings visibility provider - switch primary list slug to helpdesk/debitor and remove helpdesk/search compatibility - include required core contract updates for list contracts and detail/drawer integration
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk;
|
||||
|
||||
use MintyPHP\Service\Access\AuthorizationDecision;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
|
||||
final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
{
|
||||
public const ABILITY_ACCESS = 'helpdesk.access';
|
||||
public const ABILITY_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
|
||||
public const PERMISSION_ACCESS = 'helpdesk.access';
|
||||
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE], true);
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
{
|
||||
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
|
||||
if ($actorUserId <= 0) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
$permissionKey = match ($ability) {
|
||||
self::ABILITY_ACCESS => self::PERMISSION_ACCESS,
|
||||
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($permissionKey === null) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
if (!$this->permissionService->userHas($actorUserId, $permissionKey)) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
return AuthorizationDecision::allow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
|
||||
final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(HelpdeskAuthorizationPolicy::class, static fn (AppContainer $c): HelpdeskAuthorizationPolicy => new HelpdeskAuthorizationPolicy(
|
||||
$c->get(PermissionService::class)
|
||||
));
|
||||
|
||||
$container->set(HelpdeskSettingsGateway::class, static fn (AppContainer $c): HelpdeskSettingsGateway => new HelpdeskSettingsGateway(
|
||||
$c->get(SettingsMetadataGateway::class),
|
||||
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
|
||||
));
|
||||
|
||||
$container->set(BcODataGateway::class, static fn (AppContainer $c): BcODataGateway => new BcODataGateway(
|
||||
$c->get(HelpdeskSettingsGateway::class)
|
||||
));
|
||||
|
||||
$container->set(HelpdeskTokenRepository::class, static fn (AppContainer $c): HelpdeskTokenRepository => new HelpdeskTokenRepository(
|
||||
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
|
||||
));
|
||||
|
||||
$container->set(HelpdeskOAuthTokenService::class, static fn (AppContainer $c): HelpdeskOAuthTokenService => new HelpdeskOAuthTokenService(
|
||||
$c->get(HelpdeskSettingsGateway::class),
|
||||
$c->get(HelpdeskTokenRepository::class)
|
||||
));
|
||||
|
||||
$container->set(DebitorSearchService::class, static fn (AppContainer $c): DebitorSearchService => new DebitorSearchService(
|
||||
$c->get(BcODataGateway::class),
|
||||
$c->get(HelpdeskSettingsGateway::class)
|
||||
));
|
||||
|
||||
$container->set(DebitorDetailService::class, static fn (AppContainer $c): DebitorDetailService => new DebitorDetailService(
|
||||
$c->get(BcODataGateway::class),
|
||||
$c->get(HelpdeskSettingsGateway::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Providers;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
|
||||
final class HelpdeskLayoutProvider implements LayoutContextProvider
|
||||
{
|
||||
public function provide(array $session, AppContainer $container): array
|
||||
{
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return ['helpdesk.nav' => ['can_manage_settings' => false]];
|
||||
}
|
||||
|
||||
try {
|
||||
$authorizationService = $container->get(AuthorizationService::class);
|
||||
$canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', [
|
||||
'actor_user_id' => $userId,
|
||||
])->isAllowed();
|
||||
} catch (\Throwable) {
|
||||
$canManageSettings = false;
|
||||
}
|
||||
|
||||
return ['helpdesk.nav' => ['can_manage_settings' => $canManageSettings]];
|
||||
}
|
||||
}
|
||||
670
modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php
Normal file
670
modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php
Normal file
@@ -0,0 +1,670 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Gateway for Business Central OData V4 API calls.
|
||||
*
|
||||
* Reads debtors, contacts and tickets from BC published web services.
|
||||
* Uses Basic Auth by default; supports Bearer token for OAuth2 mode.
|
||||
*/
|
||||
class BcODataGateway
|
||||
{
|
||||
public const ENTITY_CUSTOMER = 'Integration_Customer_Card';
|
||||
public const ENTITY_CONTACT = 'Integration_Contact_Card';
|
||||
public const ENTITY_TICKETS = 'PBI_FP_Tickets';
|
||||
public const ENTITY_TICKET_LOG = 'PBI_FP_TicketLog';
|
||||
public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog';
|
||||
|
||||
private const CONNECT_TIMEOUT = 10;
|
||||
private const REQUEST_TIMEOUT = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search customers by name or number.
|
||||
*
|
||||
* Tries `contains()` first; falls back to `startswith()` if BC rejects
|
||||
* the filter (many BC OData endpoints do not support `contains`).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function searchCustomers(string $query): array
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($query);
|
||||
$upperQuery = mb_strtoupper($escaped);
|
||||
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';
|
||||
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
|
||||
|
||||
$strategies = [
|
||||
// Strategy 1: contains() — most flexible but not supported by all BC versions
|
||||
"contains(Search_Name,'" . $upperQuery . "') or contains(Name,'" . $escaped . "') or contains(No,'" . $escaped . "')",
|
||||
// Strategy 2: startswith() — universally supported fallback
|
||||
"startswith(Search_Name,'" . $upperQuery . "') or startswith(Name,'" . $escaped . "') or startswith(No,'" . $escaped . "')",
|
||||
// Strategy 3: range filter on Search_Name (last resort)
|
||||
"Search_Name ge '" . $upperQuery . "' and Search_Name lt '" . $upperQuery . "z'",
|
||||
];
|
||||
|
||||
$lastException = null;
|
||||
foreach ($strategies as $filter) {
|
||||
$url = $baseUrl . '?$filter=' . rawurlencode($filter) . '&$top=50' . $select;
|
||||
try {
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response !== null) {
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
// request() returned null → server/network error, propagate
|
||||
throw new \RuntimeException('BC OData request failed for URL: ' . $url);
|
||||
} catch (\RuntimeException $e) {
|
||||
$lastException = $e;
|
||||
// Filter not supported by this BC version (4xx) — try next strategy
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// All strategies failed — re-throw so DebitorSearchService shows error, not "no results"
|
||||
throw $lastException ?? new \RuntimeException('All OData search strategies failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read customer list for grid pagination with optional search/city filters.
|
||||
*
|
||||
* @return array{rows: array<int, array<string, mixed>>, total: int}
|
||||
*/
|
||||
public function listCustomers(
|
||||
string $search = '',
|
||||
string $city = '',
|
||||
int $limit = 10,
|
||||
int $offset = 0,
|
||||
string $order = 'Name',
|
||||
string $dir = 'asc'
|
||||
): array {
|
||||
$search = trim($search);
|
||||
$city = trim($city);
|
||||
$limit = max(1, min(100, $limit));
|
||||
$offset = max(0, $offset);
|
||||
$sortField = $this->normalizeCustomerSortField($order);
|
||||
$sortDir = strtolower(trim($dir)) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
|
||||
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';
|
||||
|
||||
$cityClause = '';
|
||||
if ($city !== '') {
|
||||
$escapedCity = $this->escapeODataString($city);
|
||||
$cityClause = "startswith(City,'" . $escapedCity . "')";
|
||||
}
|
||||
|
||||
$searchStrategies = [''];
|
||||
if ($search !== '') {
|
||||
$escapedSearch = $this->escapeODataString($search);
|
||||
$upperSearch = mb_strtoupper($escapedSearch);
|
||||
$searchStrategies = [
|
||||
"contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')",
|
||||
"startswith(Search_Name,'" . $upperSearch . "') or startswith(Name,'" . $escapedSearch . "') or startswith(No,'" . $escapedSearch . "')",
|
||||
"Search_Name ge '" . $upperSearch . "' and Search_Name lt '" . $upperSearch . "z'",
|
||||
];
|
||||
}
|
||||
$baseNonEmptyNameClause = "Name ne ''";
|
||||
|
||||
$lastException = null;
|
||||
foreach ($searchStrategies as $searchClause) {
|
||||
$clauses = [$baseNonEmptyNameClause];
|
||||
if ($searchClause !== '') {
|
||||
$clauses[] = '(' . $searchClause . ')';
|
||||
}
|
||||
if ($cityClause !== '') {
|
||||
$clauses[] = $cityClause;
|
||||
}
|
||||
|
||||
$url = $baseUrl
|
||||
. '?$top=' . $limit
|
||||
. '&$skip=' . $offset
|
||||
. '&$count=true'
|
||||
. '&$orderby=' . rawurlencode($sortField . ' ' . $sortDir)
|
||||
. $select;
|
||||
if ($clauses !== []) {
|
||||
$url .= '&$filter=' . rawurlencode(implode(' and ', $clauses));
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
throw new \RuntimeException('BC OData request failed for URL: ' . $url);
|
||||
}
|
||||
|
||||
$rows = $this->extractODataValues($response);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'total' => $this->resolveCustomerListTotal($response, $offset, $limit, count($rows)),
|
||||
];
|
||||
} catch (\RuntimeException $e) {
|
||||
$lastException = $e;
|
||||
|
||||
if ($search === '') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single customer by customer number.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getCustomer(string $customerNo): ?array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerNo);
|
||||
$filter = "No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code,Support_Type';
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$values = $this->extractODataValues($response);
|
||||
|
||||
return $values[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts for a customer by customer name.
|
||||
*
|
||||
* BC OData does not support filtering by IntegrationCustomerNo, so we
|
||||
* filter by Company_Name (the customer's name in the contact card).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getContactsForCustomer(string $customerNo, string $customerName = ''): array
|
||||
{
|
||||
$customerName = trim($customerName);
|
||||
if ($customerName === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerName);
|
||||
$filter = "Company_Name eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=100'
|
||||
. '&$select=No,Name,Type,Company_No,Company_Name,IntegrationCustomerNo,E_Mail,Phone_No,Mobile_Phone_No,Job_Title';
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets for a customer by customer name.
|
||||
*
|
||||
* Uses the PBI_FP_Tickets entity (FactPage / Power BI view) which supports
|
||||
* server-side $filter and $orderby — unlike the Card-based "Tickets" entity.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array
|
||||
{
|
||||
$customerName = trim($customerName);
|
||||
if ($customerName === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerName);
|
||||
$filter = "Company_Contact_Name eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=200'
|
||||
. '&$orderby=' . rawurlencode('Created_On desc')
|
||||
. '&$select=No,Description,Company_Contact_Name,Current_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date';
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ticket by ticket number.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getTicket(string $ticketNo): ?array
|
||||
{
|
||||
$ticketNo = trim($ticketNo);
|
||||
if ($ticketNo === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($ticketNo);
|
||||
$filter = "No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$select=No,Description,Company_Contact_Name,Current_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date';
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$values = $this->extractODataValues($response);
|
||||
|
||||
return $values[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the activity log for a ticket.
|
||||
*
|
||||
* Merges data from two BC entities:
|
||||
* - PBI_FP_TicketLog: detailed log (user, time, Record_ID)
|
||||
* - PBI_LV_SupportTicketLog: has human-readable State_Subtype (e.g. "In Bearbeitung")
|
||||
*
|
||||
* Entries are joined by Entry_No and returned newest first.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getTicketLog(string $ticketNo): array
|
||||
{
|
||||
$ticketNo = trim($ticketNo);
|
||||
if ($ticketNo === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($ticketNo);
|
||||
$filter = "Support_Ticket_No eq '" . $escaped . "'";
|
||||
|
||||
// Fetch detailed log (user, time, Record_ID)
|
||||
$detailUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$orderby=' . rawurlencode('Entry_No desc')
|
||||
. '&$top=100'
|
||||
. '&$select=Entry_No,Created_By_Type,Created_By,Creation_Date,Creation_Time,Support_Ticket_No,Type,Record_ID';
|
||||
|
||||
$detailResponse = $this->request('GET', $detailUrl);
|
||||
if ($detailResponse === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = $this->extractODataValues($detailResponse);
|
||||
if ($entries === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch status subtypes (human-readable state names)
|
||||
$lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$orderby=' . rawurlencode('Entry_No desc')
|
||||
. '&$top=100'
|
||||
. '&$select=Entry_No,State_Subtype';
|
||||
|
||||
$lvResponse = $this->request('GET', $lvUrl);
|
||||
$subtypeMap = [];
|
||||
if ($lvResponse !== null) {
|
||||
foreach ($this->extractODataValues($lvResponse) as $lv) {
|
||||
$entryNo = $lv['Entry_No'] ?? null;
|
||||
$subtype = (string) ($lv['State_Subtype'] ?? '');
|
||||
if ($entryNo !== null && $subtype !== '' && $subtype !== '0') {
|
||||
$subtypeMap[$entryNo] = $subtype;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge State_Subtype into detail entries
|
||||
foreach ($entries as &$entry) {
|
||||
$entryNo = $entry['Entry_No'] ?? null;
|
||||
$entry['State_Subtype'] = $subtypeMap[$entryNo] ?? '';
|
||||
}
|
||||
unset($entry);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection by fetching one customer record.
|
||||
*
|
||||
* @return array{ok: bool, error?: string, url?: string, http_code?: int}
|
||||
*/
|
||||
public function testConnection(): array
|
||||
{
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'Configuration incomplete'];
|
||||
}
|
||||
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
||||
. '?$top=1&$select=No';
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return ['ok' => false, 'error' => 'curl_init failed'];
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'OData-Version: 4.0',
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
$this->applyAuth($ch);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$curlErrno = curl_errno($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($curlErrno !== 0) {
|
||||
return ['ok' => false, 'error' => 'cURL error ' . $curlErrno . ': ' . $curlError, 'url' => $url];
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$hint = match ($httpCode) {
|
||||
401 => 'Unauthorized — check username/password',
|
||||
403 => 'Forbidden — user has no access to this endpoint',
|
||||
404 => 'Not found — check OData URL and company name',
|
||||
default => 'HTTP ' . $httpCode,
|
||||
};
|
||||
|
||||
return ['ok' => false, 'error' => $hint, 'url' => $url, 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
if (!is_string($responseBody)) {
|
||||
return ['ok' => false, 'error' => 'Empty response body', 'url' => $url, 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if (!is_array($decoded)) {
|
||||
return ['ok' => false, 'error' => 'Invalid JSON response', 'url' => $url, 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'url' => $url, 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnose OData calls for a specific customer number.
|
||||
*
|
||||
* Makes raw requests to all three entities and returns URLs, HTTP codes,
|
||||
* result counts, field names, and first records — without any filtering fallback.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function diagnoseCustomer(string $customerNo): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '' || !$this->settingsGateway->isConfigured()) {
|
||||
return ['error' => 'Not configured or empty customer number'];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
// 1. Customer entity
|
||||
$customerUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
||||
. '?$filter=' . rawurlencode("No eq '" . $customerNo . "'");
|
||||
$results['customer'] = $this->rawRequest($customerUrl);
|
||||
|
||||
// Get customer name for contact/ticket filter
|
||||
$customerName = '';
|
||||
if (($results['customer']['first_record']['Name'] ?? '') !== '') {
|
||||
$customerName = $results['customer']['first_record']['Name'];
|
||||
}
|
||||
|
||||
// 2. Contact entity — filter by Company_Name
|
||||
$contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT);
|
||||
if ($customerName !== '') {
|
||||
$contactFilteredUrl = $contactBaseUrl
|
||||
. '?$filter=' . rawurlencode("Company_Name eq '" . $customerName . "'")
|
||||
. '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title');
|
||||
$results['contact_filtered'] = $this->rawRequest($contactFilteredUrl);
|
||||
} else {
|
||||
$results['contact_filtered'] = ['error' => 'No customer name available for contact lookup'];
|
||||
}
|
||||
|
||||
// 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name
|
||||
$ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS);
|
||||
if ($customerName !== '') {
|
||||
$ticketsFilteredUrl = $ticketsBaseUrl
|
||||
. '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $customerName . "'")
|
||||
. '&$top=10&$orderby=' . rawurlencode('Created_On desc')
|
||||
. '&$select=' . rawurlencode('No,Description,Company_Contact_Name,Ticket_State,Created_On,Last_Activity_Date');
|
||||
$results['tickets_filtered'] = $this->rawRequest($ticketsFilteredUrl);
|
||||
} else {
|
||||
$results['tickets_filtered'] = ['error' => 'No customer name available for ticket lookup'];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HTTP request that returns diagnostic information (URL, status, body excerpt, field names).
|
||||
*
|
||||
* @return array{url: string, http_code: int, error?: string, count: int, fields: array<string>, first_record?: array<string, mixed>}
|
||||
*/
|
||||
private function rawRequest(string $url): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return ['url' => $url, 'http_code' => 0, 'error' => 'curl_init failed', 'count' => 0, 'fields' => []];
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$this->applyAuth($ch);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
unset($ch);
|
||||
|
||||
if (!is_string($body)) {
|
||||
return ['url' => $url, 'http_code' => $httpCode, 'error' => $curlError ?: 'Empty response', 'count' => 0, 'fields' => []];
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
// Return a snippet of the error body for diagnosis
|
||||
$snippet = mb_substr($body, 0, 500);
|
||||
|
||||
return ['url' => $url, 'http_code' => $httpCode, 'error' => $snippet, 'count' => 0, 'fields' => []];
|
||||
}
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
if (!is_array($decoded)) {
|
||||
return ['url' => $url, 'http_code' => $httpCode, 'error' => 'Invalid JSON', 'count' => 0, 'fields' => []];
|
||||
}
|
||||
|
||||
$values = $decoded['value'] ?? [];
|
||||
if (!is_array($values)) {
|
||||
$values = [];
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
$firstRecord = null;
|
||||
if ($values !== []) {
|
||||
$firstRecord = $values[0];
|
||||
$fields = array_keys($values[0]);
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'count' => count($values),
|
||||
'fields' => $fields,
|
||||
'first_record' => $firstRecord,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeCustomerSortField(string $order): string
|
||||
{
|
||||
return match (trim($order)) {
|
||||
'No' => 'No',
|
||||
'City' => 'City',
|
||||
default => 'Name',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
*/
|
||||
private function resolveCustomerListTotal(array $response, int $offset, int $limit, int $rowCount): int
|
||||
{
|
||||
$rawCount = $response['@odata.count'] ?? $response['odata.count'] ?? null;
|
||||
if (is_numeric($rawCount)) {
|
||||
return max(0, (int) $rawCount);
|
||||
}
|
||||
|
||||
$nextLink = trim((string) ($response['@odata.nextLink'] ?? $response['odata.nextLink'] ?? ''));
|
||||
if ($nextLink !== '') {
|
||||
return $offset + $rowCount + 1;
|
||||
}
|
||||
|
||||
if ($rowCount === 0) {
|
||||
return max(0, $offset);
|
||||
}
|
||||
|
||||
if ($rowCount < $limit) {
|
||||
return $offset + $rowCount;
|
||||
}
|
||||
|
||||
return $offset + $rowCount + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null Decoded JSON body, or null on failure.
|
||||
*/
|
||||
private function request(string $method, string $url): ?array
|
||||
{
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'OData-Version: 4.0',
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
$this->applyAuth($ch);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
unset($ch);
|
||||
|
||||
if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
|
||||
if ($httpCode >= 400 && $httpCode < 500) {
|
||||
throw new \RuntimeException('BC OData client error: HTTP ' . $httpCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply authentication to the cURL handle based on the configured auth mode.
|
||||
*
|
||||
* @param \CurlHandle $ch
|
||||
*/
|
||||
private function applyAuth($ch): void
|
||||
{
|
||||
$authMode = $this->settingsGateway->getAuthMode();
|
||||
|
||||
if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
|
||||
$user = $this->settingsGateway->getBasicUser() ?? '';
|
||||
$password = $this->settingsGateway->getBasicPassword() ?? '';
|
||||
curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password);
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
}
|
||||
// OAuth2 bearer token mode is handled externally via HelpdeskOAuthTokenService.
|
||||
// For V1, only Basic Auth is fully implemented.
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the 'value' array from an OData JSON response.
|
||||
*
|
||||
* @param array<string, mixed> $response
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function extractODataValues(array $response): array
|
||||
{
|
||||
$values = $response['value'] ?? [];
|
||||
if (!is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and escape a string for use inside an OData $filter string literal.
|
||||
*
|
||||
* Rejects characters that could manipulate OData filter logic (parentheses,
|
||||
* boolean operators). Only allows letters, digits, spaces, dots, hyphens,
|
||||
* underscores, umlauts and common punctuation.
|
||||
*
|
||||
* @throws \InvalidArgumentException If the value contains disallowed characters.
|
||||
*/
|
||||
private function escapeODataString(string $value): string
|
||||
{
|
||||
if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) {
|
||||
throw new \InvalidArgumentException('Search query contains invalid characters.');
|
||||
}
|
||||
|
||||
return str_replace("'", "''", $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Service for loading debtor detail data (master data, contacts, tickets).
|
||||
*
|
||||
* Provides both a combined loadDetail() method (legacy) and individual
|
||||
* methods for async loading via data endpoints.
|
||||
*/
|
||||
class DebitorDetailService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BcODataGateway $bcODataGateway,
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only the customer master data (1 OData call).
|
||||
*
|
||||
* Used by the main page load — contacts and tickets are loaded async.
|
||||
*
|
||||
* @return array{status: string, customer?: array<string, mixed>, error?: string}
|
||||
*/
|
||||
public function loadCustomer(string $customerNo): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '') {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$customer = $this->bcODataGateway->getCustomer($customerNo);
|
||||
} catch (\Throwable) {
|
||||
return ['status' => 'error', 'error' => 'BC connection failed'];
|
||||
}
|
||||
|
||||
if ($customer === null) {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'customer' => $customer,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load contacts for a customer (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, contacts?: array<int, array<string, mixed>>, error?: string}
|
||||
*/
|
||||
public function loadContacts(string $customerNo, string $customerName): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
$customerName = trim($customerName);
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
return ['ok' => false, 'error' => 'Missing customer number or name'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'contacts' => $contacts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tickets for a customer (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, tickets?: array<int, array<string, mixed>>, error?: string}
|
||||
*/
|
||||
public function loadTickets(string $customerNo, string $customerName): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
$customerName = trim($customerName);
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
return ['ok' => false, 'error' => 'Missing customer number or name'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'tickets' => $tickets];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load activity log for a ticket (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, log?: array<int, array<string, mixed>>, error?: string}
|
||||
*/
|
||||
public function loadTicketLog(string $ticketNo): array
|
||||
{
|
||||
$ticketNo = trim($ticketNo);
|
||||
|
||||
if ($ticketNo === '') {
|
||||
return ['ok' => false, 'error' => 'Missing ticket number'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$log = $this->bcODataGateway->getTicketLog($ticketNo);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'log' => $log];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full detail for a debtor: master data, contacts, and tickets.
|
||||
*
|
||||
* @deprecated Use loadCustomer() + async endpoints for contacts/tickets instead.
|
||||
*
|
||||
* @return array{status: string, customer?: array<string, mixed>, contacts?: array<int, array<string, mixed>>, tickets?: array<int, array<string, mixed>>, error?: string, contactsError?: string, ticketsError?: string}
|
||||
*/
|
||||
public function loadDetail(string $customerNo): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '') {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$customer = $this->bcODataGateway->getCustomer($customerNo);
|
||||
} catch (\Throwable) {
|
||||
return ['status' => 'error', 'error' => 'BC connection failed'];
|
||||
}
|
||||
|
||||
if ($customer === null) {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
// Load contacts — BC contacts are linked via Company_Name, not customer number.
|
||||
// IntegrationCustomerNo is not filterable in BC OData, so we use the customer name.
|
||||
$contacts = [];
|
||||
$contactsError = '';
|
||||
$customerName = (string) ($customer['Name'] ?? '');
|
||||
try {
|
||||
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
$contactsError = $e->getMessage();
|
||||
}
|
||||
|
||||
// Load tickets via PBI_FP_Tickets entity which supports server-side filtering.
|
||||
$tickets = [];
|
||||
$ticketsError = '';
|
||||
try {
|
||||
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
$ticketsError = $e->getMessage();
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'customer' => $customer,
|
||||
'contacts' => $contacts,
|
||||
'tickets' => $tickets,
|
||||
'contactsError' => $contactsError,
|
||||
'ticketsError' => $ticketsError,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Service for searching BC debtors via OData.
|
||||
*/
|
||||
class DebitorSearchService
|
||||
{
|
||||
public const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
public function __construct(
|
||||
private readonly BcODataGateway $bcODataGateway,
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search debtors by name or number.
|
||||
*
|
||||
* @return array{status: string, results: array<int, array<string, mixed>>, error?: string}
|
||||
*/
|
||||
public function search(string $query): array
|
||||
{
|
||||
$query = trim($query);
|
||||
|
||||
if ($query === '') {
|
||||
return ['status' => 'empty', 'results' => []];
|
||||
}
|
||||
|
||||
if (mb_strlen($query) < self::MIN_QUERY_LENGTH) {
|
||||
return ['status' => 'too_short', 'results' => []];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['status' => 'not_configured', 'results' => [], 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->bcODataGateway->searchCustomers($query);
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'error', 'results' => [], 'error' => 'BC connection failed'];
|
||||
}
|
||||
|
||||
if ($results === []) {
|
||||
return ['status' => 'no_results', 'results' => []];
|
||||
}
|
||||
|
||||
return ['status' => 'success', 'results' => $results];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build grid data for the helpdesk debtor list.
|
||||
*
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{status: string, rows: array<int, array<string, mixed>>, total: int, error?: string}
|
||||
*/
|
||||
public function listForGrid(array $filters): array
|
||||
{
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return [
|
||||
'status' => 'not_configured',
|
||||
'rows' => [],
|
||||
'total' => 0,
|
||||
'error' => 'BC connection not configured',
|
||||
];
|
||||
}
|
||||
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$city = trim((string) ($filters['city'] ?? ''));
|
||||
$limit = (int) ($filters['limit'] ?? 10);
|
||||
$offset = (int) ($filters['offset'] ?? 0);
|
||||
$order = (string) ($filters['order'] ?? 'Name');
|
||||
$dir = (string) ($filters['dir'] ?? 'asc');
|
||||
|
||||
if ($limit < 1) {
|
||||
$limit = 1;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->bcODataGateway->listCustomers($search, $city, $limit, $offset, $order, $dir);
|
||||
} catch (\Throwable) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'rows' => [],
|
||||
'total' => 0,
|
||||
'error' => 'BC connection failed',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'rows' => (array) $result['rows'],
|
||||
'total' => max(0, (int) $result['total']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* OAuth2 client_credentials token service for BC OData access.
|
||||
*
|
||||
* This is a skeleton for V1. Basic Auth is the default and recommended
|
||||
* auth mode. OAuth2 support can be activated in settings but the full
|
||||
* token flow (request, cache, refresh) is deferred to a future version.
|
||||
*/
|
||||
class HelpdeskOAuthTokenService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway,
|
||||
private readonly HelpdeskTokenRepository $tokenRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token for BC API calls.
|
||||
*
|
||||
* @return string|null The bearer token, or null if unavailable.
|
||||
*/
|
||||
public function getAccessToken(int $tenantId): ?string
|
||||
{
|
||||
if ($this->settingsGateway->getAuthMode() !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
$cached = $this->tokenRepository->findValidToken($tenantId);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Token request would go here in a future version.
|
||||
// For V1, return null to indicate OAuth2 is not yet fully implemented.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
|
||||
/**
|
||||
* Gateway for helpdesk BC connection settings stored in the DB settings table.
|
||||
*
|
||||
* All secrets (passwords, client secrets) are encrypted via SettingsCryptoGateway.
|
||||
* Setting keys are prefixed with 'helpdesk.' to avoid collisions.
|
||||
*/
|
||||
class HelpdeskSettingsGateway
|
||||
{
|
||||
public const KEY_AUTH_MODE = 'helpdesk.bc_auth_mode';
|
||||
public const KEY_ODATA_BASE_URL = 'helpdesk.bc_odata_base_url';
|
||||
public const KEY_COMPANY_NAME = 'helpdesk.bc_company_name';
|
||||
public const KEY_BASIC_USER = 'helpdesk.bc_basic_user';
|
||||
public const KEY_BASIC_PASSWORD_ENC = 'helpdesk.bc_basic_password_enc';
|
||||
public const KEY_OAUTH_TENANT_ID = 'helpdesk.bc_oauth_tenant_id';
|
||||
public const KEY_OAUTH_CLIENT_ID = 'helpdesk.bc_oauth_client_id';
|
||||
public const KEY_OAUTH_CLIENT_SECRET_ENC = 'helpdesk.bc_oauth_client_secret_enc';
|
||||
public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint';
|
||||
|
||||
public const AUTH_MODE_BASIC = 'basic';
|
||||
public const AUTH_MODE_OAUTH2 = 'oauth2';
|
||||
|
||||
public const DEFAULT_ODATA_BASE_URL = 'https://bc.icoreon.de:7048/BusinessCentral-NUP/ODataV4';
|
||||
public const DEFAULT_COMPANY_NAME = 'breadcrumb mediasolutions GmbH';
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingsMetadataGateway $settingsMetadataGateway,
|
||||
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAuthMode(): string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_AUTH_MODE);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value === self::AUTH_MODE_OAUTH2 ? self::AUTH_MODE_OAUTH2 : self::AUTH_MODE_BASIC;
|
||||
}
|
||||
|
||||
public function setAuthMode(string $mode): bool
|
||||
{
|
||||
$mode = trim($mode);
|
||||
if (!in_array($mode, [self::AUTH_MODE_BASIC, self::AUTH_MODE_OAUTH2], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(self::KEY_AUTH_MODE, $mode, 'helpdesk.bc_auth_mode');
|
||||
}
|
||||
|
||||
public function getODataBaseUrl(): string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_ODATA_BASE_URL);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_ODATA_BASE_URL;
|
||||
}
|
||||
|
||||
public function setODataBaseUrl(?string $url): bool
|
||||
{
|
||||
$url = trim((string) ($url ?? ''));
|
||||
if ($url !== '' && !preg_match('#^https://#i', $url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_ODATA_BASE_URL,
|
||||
$url !== '' ? rtrim($url, '/') : null,
|
||||
'helpdesk.bc_odata_base_url'
|
||||
);
|
||||
}
|
||||
|
||||
public function getCompanyName(): string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_COMPANY_NAME);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : self::DEFAULT_COMPANY_NAME;
|
||||
}
|
||||
|
||||
public function setCompanyName(?string $name): bool
|
||||
{
|
||||
$name = trim((string) ($name ?? ''));
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_COMPANY_NAME,
|
||||
$name !== '' ? $name : null,
|
||||
'helpdesk.bc_company_name'
|
||||
);
|
||||
}
|
||||
|
||||
public function getBasicUser(): ?string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_BASIC_USER);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function setBasicUser(?string $user): bool
|
||||
{
|
||||
$user = trim((string) ($user ?? ''));
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_BASIC_USER,
|
||||
$user !== '' ? $user : null,
|
||||
'helpdesk.bc_basic_user'
|
||||
);
|
||||
}
|
||||
|
||||
public function getBasicPassword(): ?string
|
||||
{
|
||||
$encrypted = $this->settingsMetadataGateway->getValue(self::KEY_BASIC_PASSWORD_ENC);
|
||||
$encrypted = trim((string) ($encrypted ?? ''));
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = $this->settingsCryptoGateway->decryptString($encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decrypted = trim($decrypted);
|
||||
|
||||
return $decrypted !== '' ? $decrypted : null;
|
||||
}
|
||||
|
||||
public function setBasicPassword(?string $password): bool
|
||||
{
|
||||
$password = trim((string) ($password ?? ''));
|
||||
if ($password === '') {
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_BASIC_PASSWORD_ENC,
|
||||
null,
|
||||
'helpdesk.bc_basic_password_enc'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$encrypted = $this->settingsCryptoGateway->encryptString($password);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_BASIC_PASSWORD_ENC,
|
||||
$encrypted,
|
||||
'helpdesk.bc_basic_password_enc'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOAuthTenantId(): ?string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TENANT_ID);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function setOAuthTenantId(?string $tenantId): bool
|
||||
{
|
||||
$tenantId = trim((string) ($tenantId ?? ''));
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_TENANT_ID,
|
||||
$tenantId !== '' ? $tenantId : null,
|
||||
'helpdesk.bc_oauth_tenant_id'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOAuthClientId(): ?string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_ID);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function setOAuthClientId(?string $clientId): bool
|
||||
{
|
||||
$clientId = trim((string) ($clientId ?? ''));
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_CLIENT_ID,
|
||||
$clientId !== '' ? $clientId : null,
|
||||
'helpdesk.bc_oauth_client_id'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOAuthClientSecret(): ?string
|
||||
{
|
||||
$encrypted = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_SECRET_ENC);
|
||||
$encrypted = trim((string) ($encrypted ?? ''));
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = $this->settingsCryptoGateway->decryptString($encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decrypted = trim($decrypted);
|
||||
|
||||
return $decrypted !== '' ? $decrypted : null;
|
||||
}
|
||||
|
||||
public function setOAuthClientSecret(?string $secret): bool
|
||||
{
|
||||
$secret = trim((string) ($secret ?? ''));
|
||||
if ($secret === '') {
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_CLIENT_SECRET_ENC,
|
||||
null,
|
||||
'helpdesk.bc_oauth_client_secret_enc'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$encrypted = $this->settingsCryptoGateway->encryptString($secret);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_CLIENT_SECRET_ENC,
|
||||
$encrypted,
|
||||
'helpdesk.bc_oauth_client_secret_enc'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOAuthTokenEndpoint(): ?string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TOKEN_ENDPOINT);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function setOAuthTokenEndpoint(?string $endpoint): bool
|
||||
{
|
||||
$endpoint = trim((string) ($endpoint ?? ''));
|
||||
if ($endpoint !== '' && !preg_match('#^https://#i', $endpoint)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_TOKEN_ENDPOINT,
|
||||
$endpoint !== '' ? $endpoint : null,
|
||||
'helpdesk.bc_oauth_token_endpoint'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full OData entity URL for a given entity set.
|
||||
*/
|
||||
public function buildEntityUrl(string $entitySet): string
|
||||
{
|
||||
$baseUrl = $this->getODataBaseUrl();
|
||||
$company = rawurlencode($this->getCompanyName());
|
||||
|
||||
return $baseUrl . "/Company('" . $company . "')/" . $entitySet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required settings for the current auth mode are present.
|
||||
*
|
||||
* @return array<string> List of missing setting descriptions (empty = valid)
|
||||
*/
|
||||
public function validateConfiguration(): array
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
if ($this->getBasicUser() === null && $this->getAuthMode() === self::AUTH_MODE_BASIC) {
|
||||
$missing[] = 'BC Basic Auth User';
|
||||
}
|
||||
if ($this->getBasicPassword() === null && $this->getAuthMode() === self::AUTH_MODE_BASIC) {
|
||||
$missing[] = 'BC Basic Auth Password';
|
||||
}
|
||||
if ($this->getAuthMode() === self::AUTH_MODE_OAUTH2) {
|
||||
if ($this->getOAuthClientId() === null) {
|
||||
$missing[] = 'BC OAuth2 Client ID';
|
||||
}
|
||||
if ($this->getOAuthClientSecret() === null) {
|
||||
$missing[] = 'BC OAuth2 Client Secret';
|
||||
}
|
||||
if ($this->getOAuthTokenEndpoint() === null) {
|
||||
$missing[] = 'BC OAuth2 Token Endpoint';
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the configuration is complete for the current auth mode.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->validateConfiguration() === [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
|
||||
/**
|
||||
* Repository for OAuth2 token cache (helpdesk_oauth_token_cache table).
|
||||
*
|
||||
* All tokens are stored encrypted and tenant-scoped.
|
||||
* This is part of the OAuth2 skeleton for V1.
|
||||
*/
|
||||
class HelpdeskTokenRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a valid (non-expired) cached token for the given tenant.
|
||||
*/
|
||||
public function findValidToken(int $tenantId): ?string
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = DB::selectOne(
|
||||
'SELECT access_token_encrypted FROM helpdesk_oauth_token_cache WHERE tenant_id = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1',
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
if (!is_array($result) || !isset($result['access_token_encrypted'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$encrypted = trim((string) $result['access_token_encrypted']);
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->settingsCryptoGateway->decryptString($encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new token in the cache (encrypted, tenant-scoped).
|
||||
*/
|
||||
public function storeToken(int $tenantId, string $accessToken, \DateTimeInterface $expiresAt): bool
|
||||
{
|
||||
if ($tenantId <= 0 || $accessToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$encrypted = $this->settingsCryptoGateway->encryptString($accessToken);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove existing tokens for this tenant first
|
||||
DB::delete(
|
||||
'DELETE FROM helpdesk_oauth_token_cache WHERE tenant_id = ?',
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
return (bool) DB::insert(
|
||||
'INSERT INTO helpdesk_oauth_token_cache (tenant_id, access_token_encrypted, expires_at) VALUES (?, ?, ?)',
|
||||
(string) $tenantId,
|
||||
$encrypted,
|
||||
$expiresAt->format('Y-m-d H:i:s')
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user