AlkantarClanX12
| Current Path : /www/capitalgmcbuickregina_830/public/wp-content/plugins/leadbox/classes/ |
| Current File : /www/capitalgmcbuickregina_830/public/wp-content/plugins/leadbox/classes/os-api.php |
<?php if (!defined('ABSPATH')) exit;
/**
* LBT-1468 — HTTP client for the Leadbox Platform lead-intake endpoint
* (POST {url}/api/v1/leads, Bearer auth).
*
* Mirrors LB_API_Client::CreateLead()'s return contract exactly — the
* standardized { success, data, count, message, error_code, timestamp } array —
* so the router and the Ninja Forms action can consume either client without
* branching on which backend handled the lead.
*
* @see lead-intake-endpoint-contract.md (Desktop) for status/error mapping.
*/
final class LB_OS_API_Client
{
/** Matches the legacy client's 5s budget; OS pipeline is sub-second + async fan-out. */
const TIMEOUT_SECONDS = 5;
private $url;
private $token;
public function __construct($url, $token)
{
$this->url = $url;
$this->token = $token;
}
public static function getInstance()
{
$settings = LB_API_Settings::getInstance();
return new LB_OS_API_Client(
$settings->getLeadboxOsLeadIntakeUrl(),
$settings->getLeadboxOsLeadIntakeToken()
);
}
/**
* Same shape as LB_API_Client::create_response().
*/
private function create_response($success, $data, $message = null, $error_code = null, $http_status = null, $latency_ms = null)
{
return array(
'success' => $success,
'data' => $data,
'count' => is_array($data) ? count($data) : 0,
'message' => $message,
'error_code' => $error_code,
// LBT-1468 — surfaced for the dispatch log so the router can record the
// real HTTP status and latency without re-deriving them.
'http_status' => $http_status,
'latency_ms' => $latency_ms,
'timestamp' => time(),
);
}
/**
* POST a lead to Leadbox Platform.
*
* @param LB_Lead_Dto $lead
* @param bool $blocking When false, fire-and-forget (used by the 'both'
* dual-write mode so Leadbox Platform never blocks the legacy
* authoritative POST). Non-blocking cannot read a status.
* @return array Standardized response.
*/
public function CreateLead($lead, $blocking = true)
{
if (false === ($lead instanceof LB_Lead_Dto)) {
throw new Exception('$lead needs to be instance of Lead Object');
}
// The URL always resolves to a valid base (configured value or the default
// constant), so only the per-site token can be missing.
if (empty($this->token)) {
$this->log_error('Leadbox Platform lead-intake not configured', array(
'has_token' => false,
));
return $this->create_response(
false,
null,
'Leadbox Platform lead-intake token is not configured.',
'os_not_configured'
);
}
// LBT-1468 — resolve the vehicle's stock number from its (website) id so the
// Platform can match the vehicle by stock, which is stable across systems.
// Best-effort: never throws, never blocks the dispatch on failure.
self::maybe_enrich_stock($lead);
$payload = LB_Lead_Mapper::toLeadboxOs($lead);
$endpoint = $this->url . '/api/v1/leads';
$args = array(
'timeout' => self::TIMEOUT_SECONDS,
'blocking' => (bool) $blocking,
// LBT-1468 — relax TLS verification only for loopback endpoints so local
// dev can target a Platform over http(s)://localhost with a self-signed or
// no certificate. Production hosts are never loopback, so they keep
// verification on.
'sslverify' => !LB_API_Settings::isLoopbackHost(parse_url($this->url, PHP_URL_HOST)),
'headers' => array(
'Authorization' => 'Bearer ' . $this->token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
),
'body' => wp_json_encode($payload),
);
$start = microtime(true);
$result = wp_remote_post($endpoint, $args);
$latency = (int) round((microtime(true) - $start) * 1000);
// Transport-level failure (DNS, timeout, refused).
if (is_wp_error($result)) {
$this->log_error('Leadbox Platform lead-intake connection failed', array(
'endpoint' => $endpoint,
'latency_ms' => $latency,
'error' => $result->get_error_message(),
));
return $this->create_response(
false,
null,
'Unable to connect to Leadbox Platform. Please try again later.',
'network_error',
null,
$latency
);
}
// Fire-and-forget: WordPress returns immediately with no readable status.
if (!$blocking) {
return $this->create_response(true, null, 'Dispatched to Leadbox Platform (non-blocking).', null);
}
$status = (int) wp_remote_retrieve_response_code($result);
$body = wp_remote_retrieve_body($result);
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$decoded = array();
}
return $this->map_response($status, $decoded, $latency, $endpoint);
}
/**
* Map an HTTP status + decoded body to the standardized response shape.
*/
private function map_response($status, $decoded, $latency, $endpoint)
{
$message = isset($decoded['message']) ? $decoded['message'] : null;
if ($status >= 200 && $status < 300) {
$data = isset($decoded['data']) ? $decoded['data'] : $decoded;
$this->log_info('Leadbox Platform lead dispatched', array(
'status' => $status,
'latency_ms' => $latency,
));
return $this->create_response(true, $data, $message, null, $status, $latency);
}
switch ($status) {
case 400:
$error_code = 'spam_rejected';
break;
case 401:
$error_code = 'auth_failed';
break;
case 403:
$error_code = 'location_mismatch';
break;
case 422:
// OS distinguishes unresolvable-location from field validation via `error`.
$error_code = (isset($decoded['error']) && $decoded['error'] === 'location_unresolved')
? 'location_unresolved'
: 'validation_failed';
break;
case 429:
$error_code = 'rate_limited';
break;
default:
$error_code = ($status >= 500) ? 'server_error' : ('http_error_' . $status);
break;
}
if ($message === null) {
$message = 'Leadbox Platform rejected the lead (HTTP ' . $status . ').';
}
$this->log_error('Leadbox Platform lead failed', array(
'endpoint' => $endpoint,
'status' => $status,
'error_code' => $error_code,
'latency_ms' => $latency,
'message' => $message,
));
return $this->create_response(false, null, $message, $error_code, $status, $latency);
}
/**
* LBT-1468 — Best-effort enrichment: when the lead carries a numeric website
* vehicle id but no stock number, resolve the vehicle's `stocknumber` and add
* it to the DTO. The mapper then forwards it as AdditionalData.stock, which the
* Platform resolves the vehicle by — stable across systems, unlike the website's
* vehicle id (which does not match the Platform's own inventory ids).
*
* Resolution is FILE-FIRST: the per-vehicle inventory file
* (uploads/data/{id}.json) is the same source that renders the VDP, so its id
* matches the one we derive here, and it needs no external call (no SaaS API
* latency/timeout, no credentials). Only when that file is unavailable or
* carries no stock do we fall back to the inventory API (VehicleDetail).
*
* Never throws and never blocks the dispatch on failure (the lead is still
* sent, just without a resolved stock).
*
* @param LB_Lead_Dto $lead
* @return void
*/
private static function maybe_enrich_stock($lead)
{
if (!($lead instanceof LB_Lead_Dto)) {
return;
}
$vehicle_id = null;
$referrer = null;
foreach ($lead->ToArray() as $key => $value) {
$lc = strtolower($key);
if (($lc === 'stocknumber' || $lc === 'stock') && self::value_present($value)) {
return; // A stock is already present — nothing to resolve.
}
if ($lc === 'vehicleid' && is_numeric($value)) {
$vehicle_id = (int) $value;
}
if ($lc === 'referrer' && is_string($value)) {
$referrer = $value;
}
}
// Forms embedded on a VDP (clean permalink, no `?vehicle_id=`) submit an
// empty `vehicleid`. Fall back to the vehicle id in the page URL the plugin
// sends as `referrer`: the VDP slug ends in `-{id}` (see get_vdp_slug()).
if ($vehicle_id === null && $referrer !== null) {
$vehicle_id = self::vehicle_id_from_referrer($referrer);
}
if ($vehicle_id === null) {
return; // No vehicle id to resolve a stock from.
}
// 1) File-first: read the stock from the local inventory file (no external
// call, no timeout, same source as the rendered VDP).
$stock = self::stocknumber_from_local_inventory($vehicle_id);
$source = 'local_inventory_file';
// 2) Fallback to the inventory API only when the local file is missing or
// carries no stock (e.g. sites that render inventory straight from the API).
if ($stock === '' && class_exists('LB_API_Client')) {
try {
$resp = LB_API_Client::getInstance()->VehicleDetail($vehicle_id);
} catch (Exception $e) {
$resp = null;
}
if (is_array($resp) && !empty($resp['success']) && isset($resp['data'])) {
$stock = self::extract_stocknumber($resp['data']);
$source = 'inventory_api';
}
}
if ($stock === '') {
return;
}
// Merge into the DTO; LB_Lead_Mapper maps `stocknumber` -> AdditionalData.stock.
$lead->FromArray(array('stocknumber' => $stock));
if (function_exists('leadbox_logger')) {
leadbox_logger()->info('Leadbox Platform lead enriched with stock from vehicle id', array(
'vehicle_id' => $vehicle_id,
'source' => $source,
));
}
}
/**
* LBT-1468 — Resolve a vehicle's stock number from the local inventory file
* (uploads/data/{id}.json) via LB_Inventory_Manager, reusing the same reader
* that renders the VDPs. Returns '' when the manager is unavailable, the file
* is missing, or it carries no stock. Never throws.
*
* @param int $vehicle_id Website/file vehicle id (the per-vehicle file name).
* @return string Trimmed stock number, or '' if not resolvable from a file.
*/
private static function stocknumber_from_local_inventory($vehicle_id)
{
if (!class_exists('LB_Inventory_Manager')) {
return '';
}
try {
$vehicle = LB_Inventory_Manager::getInstance()->get_vehicle_by_id($vehicle_id);
} catch (Exception $e) {
return '';
}
return self::extract_stocknumber($vehicle);
}
/**
* Extract the vehicle id from a Leadbox VDP URL. Pure (no WordPress) so it is
* unit-testable. The VDP slug is built as
* `/{view|vue}/{condition}-{year}-{make}-{model}-{id}/` (see get_vdp_slug()),
* so the id is the last `-`-delimited token of the final path segment.
*
* @param mixed $referrer The page URL the lead was submitted from.
* @return int|null The vehicle id, or null if the URL is not a VDP slug.
*/
public static function vehicle_id_from_referrer($referrer)
{
if (!is_string($referrer) || $referrer === '') {
return null;
}
$path = parse_url($referrer, PHP_URL_PATH);
if (!is_string($path) || $path === '') {
return null;
}
$segments = array_values(array_filter(explode('/', $path), 'strlen'));
if (empty($segments)) {
return null;
}
$tokens = explode('-', end($segments));
$candidate = end($tokens);
return ctype_digit((string) $candidate) ? (int) $candidate : null;
}
/**
* Pull a stock number out of a vehicle-detail payload. Pure (no WordPress) so
* it is unit-testable. Handles the vehicle object at the top level or nested
* under a common wrapper key. Returns a trimmed string, or '' if absent.
*
* @param mixed $vehicle Decoded vehicle-detail data.
* @return string
*/
public static function extract_stocknumber($vehicle)
{
if (!is_array($vehicle)) {
return '';
}
if (isset($vehicle['stocknumber']) && is_scalar($vehicle['stocknumber'])) {
return trim((string) $vehicle['stocknumber']);
}
foreach (array('data', 'vehicle', 'result') as $k) {
if (isset($vehicle[$k]['stocknumber']) && is_scalar($vehicle[$k]['stocknumber'])) {
return trim((string) $vehicle[$k]['stocknumber']);
}
}
return '';
}
/**
* A value is present if non-null and not an empty / whitespace-only string.
*/
private static function value_present($value)
{
if ($value === null) {
return false;
}
if (is_string($value)) {
return trim($value) !== '';
}
return !empty($value);
}
private function log_info($message, $context)
{
if (function_exists('leadbox_logger')) {
leadbox_logger()->info($message, $context);
}
}
private function log_error($message, $context)
{
if (function_exists('leadbox_logger')) {
leadbox_logger()->error($message, $context);
}
}
}