freescout2ant/Services/CrmApiClient.php
Claude 3c3a092f7a
Track failed Ameise archive attempts and add retry tooling
Previously a thread could be skipped silently in ConversationArchiver
(no customer match, ambiguous match, API/network error, attachment
failure) without any record. Default-off logging hid the cause, and the
queue job swallowed exceptions, so messages "archived" in FreeScout
never surfaced as missing in Ameise.

Now every archive attempt records a row in the new crm_archive_attempts
table with status, sanitized HTTP response, attempt counter and
resolution timestamp. ArchiveThreadsJob retries transient failures up to
5 times with exponential backoff; ameise:list-failed-archives and
ameise:retry-failed-archives expose the queue from the CLI; the Ameise
settings page lists open failures with retry/dismiss buttons.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU73s4KM5JZqVWQYPpK3cP
2026-06-16 23:06:49 +00:00

306 lines
12 KiB
PHP

<?php
namespace Modules\AmeiseModule\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException as Exception;
class CrmApiClient
{
private $base_url;
private $tokenService;
private $ameiseLogStatus;
private $client;
public function __construct(TokenService $tokenService)
{
$this->tokenService = $tokenService;
$this->base_url = (config('ameisemodule.ameise_mode') == 'test' ? 'https://mitarbeiterwebservice-maklerinfo.inte.dionera.dev/service/bd/employee/1.0/rest/' : 'https://mitarbeiterwebservice.maklerinfo.biz/service/bd/employee/1.0/rest/');
$this->ameiseLogStatus = config('ameisemodule.ameise_log_status');
$this->client = new Client();
}
private function getAccessToken()
{
return $this->tokenService->getAccessToken();
}
private function checkTokenError()
{
$result = $this->getAccessToken();
if ($result && ($resultArray = json_decode($result, true)) && isset($resultArray['error'])) {
return $resultArray;
}
return null;
}
private function apiGet($path, $logContext, array $options = [], $errorReturn = [], $useBaseOnly = false)
{
try {
$tokenError = $this->checkTokenError();
if ($tokenError) {
return $tokenError;
}
$url = $useBaseOnly ? $this->base_url . $path : $this->maUrl($path);
$this->ameiseLogStatus && \Helper::log($logContext, 'Sending GET request to: ' . $url);
$requestOptions = array_merge([
'headers' => [
'Authorization' => 'Bearer ' . $this->getAccessToken(),
],
], $options);
$response = $this->client->get($url, $requestOptions);
$this->ameiseLogStatus && \Helper::log($logContext, 'Response status: ' . $response->getStatusCode());
if ($response->getStatusCode() === 200) {
return json_decode($response->getBody(), true);
} elseif ($response->getStatusCode() === 401) {
$this->tokenService->disconnectAmeise();
} else {
$errorResponse = json_decode($response->getBody(), true);
$this->ameiseLogStatus && \Helper::log($logContext, 'Request failed with status code: ' . $response->getStatusCode());
$this->ameiseLogStatus && \Helper::log(
$logContext,
'Error response: ' . json_encode($this->sanitizeLogData($errorResponse))
);
}
$this->ameiseLogStatus && \Helper::log($logContext, 'Request completed.');
} catch (Exception $e) {
$this->ameiseLogStatus && \Helper::logException($e, $logContext);
if ($e->getCode() === 401) {
$this->tokenService->disconnectAmeise();
}
if ($e->hasResponse()) {
$body = (string) $e->getResponse()->getBody();
$this->ameiseLogStatus && \Helper::log($logContext, 'Error body: ' . $this->sanitizeLogText($body));
}
return $errorReturn;
}
return [];
}
private function apiPost($path, $logContext, array $options = [], $errorReturn = [], $useBaseOnly = false)
{
try {
$tokenError = $this->checkTokenError();
if ($tokenError) {
return $tokenError;
}
$url = $useBaseOnly ? $this->base_url . $path : $this->maUrl($path);
$this->ameiseLogStatus && \Helper::log($logContext, 'Sending POST request to: ' . $url);
$requestOptions = array_merge([
'headers' => [
'Authorization' => 'Bearer ' . $this->getAccessToken(),
],
], $options);
$response = $this->client->post($url, $requestOptions);
$this->ameiseLogStatus && \Helper::log($logContext, 'Response status: ' . $response->getStatusCode());
if ($response->getStatusCode() === 200) {
return json_decode($response->getBody(), true);
} elseif ($response->getStatusCode() === 401) {
$this->tokenService->disconnectAmeise();
} else {
$errorResponse = json_decode($response->getBody(), true);
$this->ameiseLogStatus && \Helper::log($logContext, 'Request failed with status code: ' . $response->getStatusCode());
$this->ameiseLogStatus && \Helper::log(
$logContext,
'Error response: ' . json_encode($this->sanitizeLogData($errorResponse))
);
}
$this->ameiseLogStatus && \Helper::log($logContext, 'Request completed.');
} catch (Exception $e) {
$this->ameiseLogStatus && \Helper::logException($e, $logContext);
if ($e->getCode() === 401) {
$this->tokenService->disconnectAmeise();
}
if ($e->hasResponse()) {
$body = (string) $e->getResponse()->getBody();
$this->ameiseLogStatus && \Helper::log($logContext, 'Error body: ' . $this->sanitizeLogText($body));
}
return $errorReturn;
}
return [];
}
private function maUrl($path)
{
return $this->base_url . $this->tokenService->getMa() . '/' . $path;
}
public function fetchUserByIdOrName($data)
{
$searchQuery = rawurlencode($data);
return $this->apiGet(
'kunden/_search?q=' . $searchQuery,
'fetch_user_id_name'
);
}
public function fetchUserDetail($id, $endPoints)
{
return $this->apiGet(
'kunden/' . $id . '/' . $endPoints,
'user_end_points'
);
}
public function fetchUserByEmail($email)
{
$response = $this->apiPost(
'kunden/_search',
'fetch_user_email',
['json' => ['mail' => $email]]
);
if (!empty($response)) {
return $response;
}
return $this->apiGet(
'kunden/_search',
'fetch_user_email_fallback',
['query' => ['mail' => $email]]
);
}
public function getContracts($customerId)
{
return $this->apiGet(
'kunden/' . $customerId . '/vertraege',
'get_contracts',
[],
['error' => 'redirect', 'url' => $this->tokenService->getAuthUrl()]
);
}
public function getContactEndPoints($end_points)
{
return $this->apiGet(
$end_points,
'contracts_end_points',
[],
[],
true
);
}
public function archiveConversation($data)
{
return $this->archiveConversationDetailed($data)['ok'];
}
/**
* Sends the archive request and returns a structured result so callers
* can persist the failure reason (HTTP status, response body, exception).
*
* @return array{ok: bool, http_status: ?int, body: ?string, exception: ?string, token_error: bool}
*/
public function archiveConversationDetailed($data)
{
$result = [
'ok' => false,
'http_status' => null,
'body' => null,
'exception' => null,
'token_error' => false,
];
try {
$tokenError = $this->checkTokenError();
if ($tokenError) {
$this->ameiseLogStatus && \Helper::log('conversation_archive', 'Archive conversation skipped because token contains an error.');
$result['token_error'] = true;
$result['body'] = $this->sanitizeLogText(json_encode($tokenError) ?: '');
return $result;
}
$this->ameiseLogStatus && \Helper::log('conversation_archive', 'Archive conversation request called.');
$headers = [
'X-Dio-Betreff' => $data['subject'],
'x-dio-metadaten' => json_encode($data['x-dio-metadaten']),
'X-Dio-Typ' => $data['type'],
'Content-Type' => $data['Content-Type'] ?? 'text/plain; charset="utf-8"',
'X-Dio-Zuordnungen' => json_encode($data['X-Dio-Zuordnungen']),
'X-Dio-Datum' => $data['X-Dio-Datum'],
'Authorization' => 'Bearer ' . $this->getAccessToken(),
];
$response = $this->client->request('POST', $this->maUrl('archiveintraege'), [
'headers' => $headers,
'body' => $data['body'],
]);
$statusCode = $response->getStatusCode();
$result['http_status'] = $statusCode;
$this->ameiseLogStatus && \Helper::log('conversation_archive', 'Response status: ' . $statusCode);
if ($statusCode >= 200 && $statusCode < 300) {
$result['ok'] = true;
return $result;
} elseif ($statusCode === 401) {
$this->tokenService->disconnectAmeise();
}
$rawBody = (string) $response->getBody();
$result['body'] = $this->sanitizeLogText($rawBody);
$errorResponse = json_decode($rawBody, true);
$this->ameiseLogStatus && \Helper::log('conversation_archive', 'Request failed with status code: ' . $statusCode);
$this->ameiseLogStatus && \Helper::log(
'conversation_archive',
'Error response: ' . json_encode($this->sanitizeLogData($errorResponse))
);
} catch (Exception $e) {
$body = $e->hasResponse() ? (string) $e->getResponse()->getBody() : '';
$result['http_status'] = $e->hasResponse() ? $e->getResponse()->getStatusCode() : null;
$result['body'] = $this->sanitizeLogText($body);
$result['exception'] = $this->sanitizeLogText($e->getMessage());
$this->ameiseLogStatus && \Helper::log('conversation_archive', 'Error body: ' . $result['body']);
$this->ameiseLogStatus && \Helper::logException($e, 'conversation_archive');
if ($e->getCode() === 401) {
$this->tokenService->disconnectAmeise();
}
}
return $result;
}
private function sanitizeLogData($data)
{
if (is_array($data)) {
$sanitized = [];
foreach ($data as $key => $value) {
if (is_string($key) && $this->isSensitiveKey($key)) {
$sanitized[$key] = $this->valueFingerprint($value);
continue;
}
$sanitized[$key] = $this->sanitizeLogData($value);
}
return $sanitized;
}
if (is_object($data)) {
return $this->sanitizeLogData((array) $data);
}
if (is_string($data)) {
return $this->sanitizeLogText($data);
}
return $data;
}
private function sanitizeLogText(string $text): string
{
$pattern = '/(Bearer\s+)([A-Za-z0-9\-\._~\+\/]+=*)/i';
return preg_replace_callback($pattern, function (array $matches) {
return $matches[1] . $this->valueFingerprint($matches[2]);
}, $text) ?? $text;
}
private function isSensitiveKey(string $key): bool
{
$normalized = strtolower($key);
return in_array($normalized, ['access_token', 'refresh_token', 'id_token', 'authorization', 'token'], true);
}
private function valueFingerprint($value): string
{
if (!is_string($value) || $value === '') {
return '[redacted]';
}
return '[fingerprint:sha256:' . substr(hash('sha256', $value), 0, 12) . ']';
}
}