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:
2026-04-02 17:48:27 +02:00
parent 5d07236758
commit a0d7670dd7
55 changed files with 5977 additions and 11 deletions

View 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);
}
}

View File

@@ -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,
];
}
}

View File

@@ -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']),
];
}
}

View File

@@ -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;
}
}

View File

@@ -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() === [];
}
}

View File

@@ -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')
);
}
}