ferrero-naming-tool-nv/public-v2/DamClient.php
nickviljoen 58a1a8fdb5 Add project source files and documentation
Includes backend API, public-v2 frontend, database migrations,
Composer config, and deployment/implementation docs.
Config files with credentials are excluded via .gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:19:12 +02:00

483 lines
18 KiB
PHP

<?php
/**
* DAM Client - OpenText DAM API Integration with mTLS V2 Authentication
* Handles campaign queries and status updates
*/
class DamClient {
private $baseUrl; // Direct DAM URL
private $mtlsOAuthUrl; // mTLS OAuth endpoint
private $certPath; // Path to PFX certificate
private $certPassword; // Certificate password
private $clientId; // OAuth client ID
private $clientSecret; // OAuth client secret
private $accessToken; // OAuth token obtained via mTLS
private $tokenExpiry; // Token expiration timestamp
private $timeout; // Request timeout in seconds
/**
* Initialize DAM Client with mTLS V2 configuration
*/
public function __construct($config) {
$this->baseUrl = $config['dam']['base_url'];
$this->mtlsOAuthUrl = $config['dam']['mtls_oauth_url'];
$this->certPath = $config['dam']['mtls_cert_path'];
$this->certPassword = $config['dam']['mtls_cert_password'];
$this->clientId = $config['dam']['client_id'];
$this->clientSecret = $config['dam']['client_secret'];
$this->timeout = $config['dam']['timeout'] ?? 30;
$this->accessToken = null;
$this->tokenExpiry = 0;
// Validate configuration
if (empty($this->baseUrl)) {
throw new Exception("DAM base URL not configured");
}
if (empty($this->mtlsOAuthUrl)) {
throw new Exception("mTLS OAuth URL not configured");
}
if (empty($this->certPath) || !file_exists($this->certPath)) {
throw new Exception("mTLS certificate not found: " . $this->certPath);
}
if (empty($this->clientId) || empty($this->clientSecret)) {
throw new Exception("OAuth client credentials not configured");
}
}
/**
* Get OAuth access token using client certificate (mTLS V2)
* Token is cached and automatically refreshed when expired
*/
private function getAccessToken() {
// Return cached token if still valid
if ($this->accessToken && time() < $this->tokenExpiry) {
return $this->accessToken;
}
// Request new token via mTLS
return $this->requestOAuthTokenViaMTLS();
}
/**
* Request OAuth token using client certificate
*/
private function requestOAuthTokenViaMTLS() {
error_log("DAM Client: Requesting OAuth token via mTLS from: " . $this->mtlsOAuthUrl);
// Convert PFX to PEM format
$pemCert = $this->convertPfxToPem($this->certPath, $this->certPassword);
// Prepare OAuth request body
$postData = http_build_query([
'grant_type' => 'client_credentials',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret
]);
// Initialize cURL with client certificate
$ch = curl_init($this->mtlsOAuthUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_SSLCERT => $pemCert['cert'],
CURLOPT_SSLKEY => $pemCert['key'],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json'
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
// Clean up temporary PEM files
@unlink($pemCert['cert']);
@unlink($pemCert['key']);
if ($error) {
throw new Exception("mTLS OAuth request failed: " . $error);
}
if ($httpCode !== 200) {
throw new Exception("mTLS OAuth failed: HTTP " . $httpCode . " - " . $response);
}
$data = json_decode($response, true);
if (!isset($data['access_token'])) {
throw new Exception("No access token in OAuth response");
}
// Cache token with 60 second buffer before expiry
$this->accessToken = $data['access_token'];
$expiresIn = $data['expires_in'] ?? 3600;
$this->tokenExpiry = time() + $expiresIn - 60;
error_log("DAM Client: OAuth token obtained (expires in " . $expiresIn . "s)");
return $this->accessToken;
}
/**
* Convert PFX certificate to PEM format
* Returns array with paths to temporary cert and key files
*/
private function convertPfxToPem($pfxPath, $password) {
// Read PFX file
$pfxContent = file_get_contents($pfxPath);
if ($pfxContent === false) {
throw new Exception("Failed to read PFX certificate: " . $pfxPath);
}
// Parse PFX
$certs = [];
if (!openssl_pkcs12_read($pfxContent, $certs, $password)) {
throw new Exception("Failed to parse PFX certificate - check password");
}
// Create temporary PEM files
$tempDir = sys_get_temp_dir();
$certFile = tempnam($tempDir, 'dam_cert_') . '.pem';
$keyFile = tempnam($tempDir, 'dam_key_') . '.pem';
// Write certificate
if (file_put_contents($certFile, $certs['cert']) === false) {
throw new Exception("Failed to write certificate to temp file");
}
// Write private key
if (file_put_contents($keyFile, $certs['pkey']) === false) {
@unlink($certFile);
throw new Exception("Failed to write private key to temp file");
}
return [
'cert' => $certFile,
'key' => $keyFile
];
}
/**
* Make authenticated API request to DAM
*/
private function makeApiRequest($method, $url, $data = null) {
$token = $this->getAccessToken();
error_log("DAM API Request: " . $method . " " . $url);
$ch = curl_init($url);
$headers = [
'Authorization: Bearer ' . $token,
'Accept: application/json'
];
$curlOpts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => false, // Disable SSL verification for OAuth/Hybrid
CURLOPT_HTTPHEADER => $headers,
];
if ($method === 'POST' || $method === 'PUT' || $method === 'PATCH') {
$curlOpts[CURLOPT_CUSTOMREQUEST] = $method;
if ($data !== null) {
$headers[] = 'Content-Type: application/json';
$curlOpts[CURLOPT_POSTFIELDS] = json_encode($data);
$curlOpts[CURLOPT_HTTPHEADER] = $headers;
}
} elseif ($method === 'GET') {
$curlOpts[CURLOPT_HTTPGET] = true;
}
curl_setopt_array($ch, $curlOpts);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
error_log("DAM API Response: HTTP " . $httpCode);
if ($error) {
throw new Exception("API request failed: " . $error);
}
return [
'status' => $httpCode,
'body' => $response,
'data' => json_decode($response, true)
];
}
/**
* Search for campaigns by status
*
* @param string $status Campaign status (A1, A2, A3, etc.)
* @param string $campaignType 'Local Adaptation' or 'Global comm'
* @return array List of campaigns
*/
public function searchCampaigns($status = 'A2', $campaignType = 'Local Adaptation') {
// Build search condition matching Python implementation
$searchCondition = [
'search_condition_list' => [
'search_condition' => [
[
'type' => 'com.artesia.search.SearchScalarCondition',
'metadata_field_id' => 'ARTESIA.FIELD.CONTAINER TYPE NAME',
'relational_operator_id' => 'ARTESIA.OPERATOR.CHAR.CONTAINS',
'value' => 'GLOBALCAMPAING',
'left_paren' => '(',
'right_paren' => ')'
],
[
'type' => 'com.artesia.search.SearchScalarCondition',
'metadata_field_id' => 'FERRERO.FIELD.CAMPAIGN TYPE',
'relational_operator_id' => 'ARTESIA.OPERATOR.CHAR.CONTAINS',
'value' => $campaignType,
'relational_operator' => 'and'
]
]
]
];
// URL encode search condition
$searchConditionEncoded = urlencode(json_encode($searchCondition));
// Build URL - include page_size to avoid DAM default limit (~10)
$url = $this->baseUrl . '/v6/search/text?load_type=metadata&search_config_id=18&page_size=200&search_condition_list=' . $searchConditionEncoded;
// Make request
$response = $this->makeApiRequest('GET', $url);
if ($response['status'] !== 200) {
throw new Exception("Campaign search failed: HTTP " . $response['status']);
}
$data = $response['data'];
$allCampaigns = [];
// Extract campaigns from response
if (isset($data['search_result_resource'])) {
$campaigns = $data['search_result_resource']['asset_list'] ?? [];
// Fallback to search_result_element_list
if (empty($campaigns)) {
$results = $data['search_result_resource']['search_result']['search_result_element_list'] ?? [];
$campaigns = array_map(function($r) {
return $r['resource'] ?? [];
}, $results);
}
// Extract campaign info and filter by status
foreach ($campaigns as $asset) {
$campaign = $this->extractCampaignInfo($asset);
// Filter by status if provided
if ($status === null || $campaign['status'] === $status) {
$allCampaigns[] = $campaign;
}
}
}
error_log("DAM Client: Found " . count($allCampaigns) . " campaigns with status " . $status);
return $allCampaigns;
}
/**
* Extract campaign information from asset data
*/
private function extractCampaignInfo($asset) {
$campaign = [
'asset_id' => $asset['asset_id'] ?? null,
'campaign_name' => $asset['name'] ?? null,
'campaign_id' => null,
'status' => null,
'brand' => null,
'market' => null
];
// Extract metadata fields
if (isset($asset['metadata']['metadata_element_list'])) {
foreach ($asset['metadata']['metadata_element_list'] as $category) {
if (isset($category['metadata_element_list'])) {
foreach ($category['metadata_element_list'] as $field) {
$fieldId = $field['id'] ?? '';
$value = $this->extractFieldValue($field);
switch ($fieldId) {
case 'ARTESIA.FIELD.NAME':
case 'INER_NAME_GENERIC':
$campaign['campaign_name'] = $value;
break;
case 'FERRERO.FIELD.CAMPAIGN ID':
case 'FERRERO.FIELD.CAMPAIGN_ID':
$campaign['campaign_id'] = $value;
break;
case 'CONTENT.SCALING.STATUS':
$campaign['status'] = $value;
break;
case 'FERRERO.FIELD.CAMPAIGN_BRAND':
$campaign['brand'] = $value;
break;
case 'FERRERO.FIELD.CAMPAIGN_MARKET':
$campaign['market'] = $value;
break;
}
}
}
}
}
return $campaign;
}
/**
* Extract value from metadata field structure
*/
private function extractFieldValue($field) {
if (!isset($field['value'])) {
return null;
}
$val = $field['value'];
if (is_array($val)) {
if (isset($val['value']) && is_array($val['value'])) {
if (isset($val['value']['value'])) {
return $val['value']['value'];
} elseif (isset($val['value']['field_value']['value'])) {
return $val['value']['field_value']['value'];
}
}
}
return is_string($val) ? $val : null;
}
/**
* Update campaign status (Two-Step Process)
*
* Step 1: Update campaign metadata
* Step 2: Trigger downstream notification
*
* @param string $campaignId Campaign asset ID (folder ID)
* @param string $newStatus New status (A3, A4, etc.)
* @return array Success status and message
*/
public function updateCampaignStatus($campaignId, $newStatus) {
error_log("DAM Client: Starting campaign CS update - Campaign: " . $campaignId . " to status: " . $newStatus);
// Single call to folders_scaling_update which handles both
// the metadata update and workflow trigger in one operation
$payload = [
'folderId' => $campaignId,
'contentScalingStatus' => $newStatus
];
$url = $this->baseUrl . '/v6/folders_scaling_update';
error_log("DAM Client: URL: " . $url);
error_log("DAM Client: Payload: " . json_encode($payload));
$response = $this->makeApiRequest('PATCH', $url, $payload);
// Success codes: 200, 201, or 202
$success = in_array($response['status'], [200, 201, 202]);
if (!$success) {
error_log("DAM Client: CS update FAILED - HTTP " . $response['status']);
error_log("DAM Client: Error response: " . $response['body']);
return [
'success' => false,
'error' => 'Folders scaling update failed: ' . ($response['data']['error_description'] ?? 'HTTP ' . $response['status'])
];
}
error_log("DAM Client: CS update SUCCESS - Campaign status updated and workflow triggered");
return [
'success' => true,
'message' => 'Campaign status updated to ' . $newStatus . ' via content scaling'
];
}
/**
* Update campaign status ONLY (Single-Step Process - No V6 Folder Scaling)
*
* This method only performs Step 1 (metadata update) without triggering
* the downstream V6 folder scaling notification.
*
* @param string $campaignId Campaign asset ID (folder ID)
* @param string $newStatus New status (A3, A4, etc.)
* @return array Success status and message
*/
public function updateCampaignStatusOnly($campaignId, $newStatus) {
error_log("DAM Client: Starting STATUS-ONLY campaign update - Campaign: " . $campaignId . " to status: " . $newStatus);
error_log("DAM Client: NOTE - V6 folder scaling notification will NOT be triggered");
// Build metadata update payload matching Python implementation
$payload = [
'edited_folder' => [
'data' => [
'metadata' => [
[
'id' => 'CONTENT.SCALING.STATUS',
'type' => 'com.artesia.metadata.MetadataField',
'value' => [
'cascading_domain_value' => false,
'domain_value' => true,
'value' => [
'type' => 'string',
'value' => $newStatus
]
]
]
]
]
]
];
// Make request using PATCH to folders endpoint with lock_strategy
$url = $this->baseUrl . '/v6/folders/' . $campaignId . '?lock_strategy=optimistic';
error_log("DAM Client: Status-only URL: " . $url);
error_log("DAM Client: Status-only Payload: " . json_encode($payload));
$response = $this->makeApiRequest('PATCH', $url, $payload);
if ($response['status'] !== 200) {
error_log("DAM Client: STATUS-ONLY UPDATE FAILED - HTTP " . $response['status']);
error_log("DAM Client: Error response: " . $response['body']);
return [
'success' => false,
'error' => 'Status update failed: ' . ($response['data']['error_description'] ?? 'HTTP ' . $response['status'])
];
}
error_log("DAM Client: STATUS-ONLY UPDATE SUCCESS - Campaign metadata updated (no V6 scaling notification)");
return [
'success' => true,
'message' => 'Campaign status updated to ' . $newStatus . ' (status only - no V6 scaling notification)'
];
}
/**
* Test DAM connection
*/
public function testConnection() {
try {
$token = $this->getAccessToken();
return !empty($token);
} catch (Exception $e) {
error_log("DAM Client: Connection test failed - " . $e->getMessage());
return false;
}
}
}