false, 'status' => 0, 'body' => '', 'error' => 'Could not encode payload']; } $headerLines = ['Content-Type: application/json']; foreach ($headers as $name => $value) { if ($name === '' || $value === '') { continue; } $headerLines[] = $name . ': ' . $value; } $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headerLines), 'content' => $body, 'timeout' => $timeoutSeconds, 'ignore_errors' => true, ], ]); $responseBody = @file_get_contents($url, false, $context); $responseHeaders = $http_response_header ?? []; $status = 0; if (isset($responseHeaders[0]) && preg_match('#HTTP/\S+\s+(\d{3})#', $responseHeaders[0], $m) === 1) { $status = (int) $m[1]; } if ($responseBody === false) { $responseBody = ''; } return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status, 'body' => substr($responseBody, 0, 500), 'error' => ($status === 0) ? 'Request failed or timed out' : '', ]; } function build_n8n_webhook_headers(array $localEnv): array { $headers = []; $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); if ($secret !== '') { $headers['X-Webhook-Secret'] = $secret; $headers['X-N8N-Secret'] = $secret; $headers['X-API-Key'] = $secret; $headers['Authorization'] = 'Bearer ' . $secret; } return $headers; } function trigger_shipping_label_flow(array $order, array $localEnv): array { $url = derive_label_webhook_url($localEnv); if ($url === '') { return [ 'enabled' => false, 'ok' => false, 'message' => 'Label webhook URL not configured', ]; } $headers = build_n8n_webhook_headers($localEnv); throttle_webhook_channel('label', 10); $result = post_json($url, $order, $headers, 20); return [ 'enabled' => true, 'ok' => $result['ok'], 'status' => $result['status'], 'url' => $url, 'message' => $result['ok'] ? 'Label webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Label webhook returned non-2xx'), 'responseBody' => $result['body'], ]; } function trigger_excel_webhook(string $externalRef, array $localEnv): array { $url = derive_excel_webhook_url($localEnv); if ($url === '') { return [ 'enabled' => false, 'ok' => false, 'message' => 'Excel webhook URL not configured', ]; } $headers = build_n8n_webhook_headers($localEnv); throttle_webhook_channel('excel', 5); $result = post_json($url, ['Bestellnummer' => $externalRef], $headers, 20); return [ 'enabled' => true, 'ok' => $result['ok'], 'status' => $result['status'], 'url' => $url, 'message' => $result['ok'] ? 'Excel webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Excel webhook returned non-2xx'), 'responseBody' => $result['body'], ]; } function dispatch_order_import_webhooks(PDO $pdo, array $localEnv, int $limit = 20): array { $url = derive_excel_webhook_url($localEnv); if ($url === '') { return [ 'enabled' => false, 'ok' => false, 'processed' => 0, 'sent' => 0, 'failed' => 0, 'deadLetter' => 0, 'message' => 'Excel webhook URL not configured', ]; } $headers = build_n8n_webhook_headers($localEnv); $limit = max(1, min(100, $limit)); $summary = [ 'enabled' => true, 'ok' => false, 'processed' => 0, 'sent' => 0, 'failed' => 0, 'deadLetter' => 0, 'status' => 0, 'url' => $url, 'message' => 'No outbound order.imported event queued', 'responseBody' => '', ]; try { for ($i = 0; $i < $limit; $i++) { $pdo->beginTransaction(); $stmt = $pdo->query( "SELECT id, payload, attempt_count FROM outbound_webhook_event WHERE event_type = 'order.imported' AND status IN ('pending', 'failed') AND next_attempt_at <= NOW() ORDER BY created_at ASC, id ASC LIMIT 1 FOR UPDATE SKIP LOCKED" ); $event = $stmt !== false ? $stmt->fetch(PDO::FETCH_ASSOC) : false; if (!is_array($event)) { $pdo->commit(); break; } $eventId = (int) $event['id']; $attemptCount = max(0, (int) $event['attempt_count']) + 1; $payloadRaw = $event['payload']; $payload = []; if (is_string($payloadRaw) && $payloadRaw !== '') { try { $payload = json_decode($payloadRaw, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { $payload = []; } } elseif (is_array($payloadRaw)) { $payload = $payloadRaw; } $externalRef = trim((string) ($payload['externalRef'] ?? '')); if ($externalRef === '') { $update = $pdo->prepare( "UPDATE outbound_webhook_event SET status = 'dead_letter', last_attempt_at = NOW(), last_error = :last_error, next_attempt_at = NOW(), attempt_count = :attempt_count WHERE id = :id" ); $update->execute([ ':last_error' => 'Missing externalRef in outbound payload', ':attempt_count' => $attemptCount, ':id' => $eventId, ]); $pdo->commit(); $summary['processed']++; $summary['failed']++; $summary['deadLetter']++; $summary['ok'] = false; $summary['message'] = 'Outbound payload missing externalRef'; continue; } throttle_webhook_channel('excel', 5); $result = post_json($url, ['Bestellnummer' => $externalRef], $headers, 20); $summary['processed']++; $summary['status'] = $result['status']; $summary['responseBody'] = $result['body']; if ($result['ok']) { $update = $pdo->prepare( "UPDATE outbound_webhook_event SET status = 'sent', last_attempt_at = NOW(), last_error = NULL, sent_at = NOW(), next_attempt_at = NOW(), attempt_count = :attempt_count WHERE id = :id" ); $update->execute([ ':attempt_count' => $attemptCount, ':id' => $eventId, ]); $pdo->commit(); $summary['sent']++; if ($summary['failed'] === 0) { $summary['ok'] = true; } $summary['message'] = 'Excel webhook triggered'; continue; } $backoffSeconds = min(3600, 60 * (2 ** max(0, $attemptCount - 1))); $status = $attemptCount >= 5 ? 'dead_letter' : 'failed'; $nextAttemptAt = (new DateTimeImmutable('now')) ->modify('+' . $backoffSeconds . ' seconds') ->format('Y-m-d H:i:s'); $lastError = $result['error'] !== '' ? $result['error'] : ('HTTP ' . $result['status']); $update = $pdo->prepare( "UPDATE outbound_webhook_event SET status = :status, last_attempt_at = NOW(), last_error = :last_error, next_attempt_at = :next_attempt_at, attempt_count = :attempt_count WHERE id = :id" ); $update->execute([ ':status' => $status, ':last_error' => $lastError, ':next_attempt_at' => $nextAttemptAt, ':attempt_count' => $attemptCount, ':id' => $eventId, ]); $pdo->commit(); $summary['failed']++; $summary['ok'] = false; if ($status === 'dead_letter') { $summary['deadLetter']++; } $summary['message'] = $lastError; } return $summary; } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } $summary['ok'] = false; $summary['message'] = $e->getMessage(); return $summary; } }