Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 194
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConstantContactService
0.00% covered (danger)
0.00%
0 / 194
0.00% covered (danger)
0.00%
0 / 22
3782
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isConfigured
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isConnected
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getClientId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getClientSecret
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAppDomain
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRedirectUri
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generateOAuthState
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 validateOAuthState
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getAuthorizationUrl
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 exchangeCodeForTokens
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 refreshAccessToken
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 makeTokenRequest
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 saveTokens
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getValidAccessToken
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 apiRequest
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
156
 getAccountInfo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 createList
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 upsertContact
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 disconnect
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\ConstantContact\Controllers;
4
5/**
6 * Constant Contact Service
7 *
8 * Handles OAuth 2.0 authentication and API operations for Constant Contact V3 API.
9 * Supports the Authorization Code flow with refresh tokens.
10 *
11 * @see https://developer.constantcontact.com/api_guide/auth_overview.html
12 */
13class ConstantContactService
14{
15    /** @var \Store */
16    private $store;
17
18    /** @var string Authorization endpoint */
19    const AUTH_URL = 'https://authz.constantcontact.com/oauth2/default/v1/authorize';
20
21    /** @var string Token endpoint */
22    const TOKEN_URL = 'https://authz.constantcontact.com/oauth2/default/v1/token';
23
24    /** @var string API base URL */
25    const API_BASE_URL = 'https://api.cc.email/v3';
26
27    /** @var int Access token lifetime in seconds (24 hours minus buffer) */
28    const TOKEN_LIFETIME = 82800; // 23 hours
29
30    /** @var string Required OAuth scopes */
31    const SCOPES = 'contact_data campaign_data offline_access';
32
33    /**
34     * Constructor
35     *
36     * @param \Store $store
37     */
38    public function __construct(\Store $store)
39    {
40        $this->store = $store;
41    }
42
43    /**
44     * Check if OAuth credentials are configured in environment
45     *
46     * @return bool
47     */
48    public function isConfigured(): bool
49    {
50        return !empty($_ENV['CC_CLIENT_ID']) && !empty($_ENV['CC_CLIENT_SECRET']);
51    }
52
53    /**
54     * Check if the store has a valid connection
55     *
56     * @return bool
57     */
58    public function isConnected(): bool
59    {
60        return $this->store->getCcEnabled() &&
61               !empty($this->store->getCcToken()) &&
62               !empty($this->store->getCcRefreshToken());
63    }
64
65    /**
66     * Get the Client ID from environment
67     *
68     * @return string|null
69     */
70    private function getClientId(): ?string
71    {
72        return $_ENV['CC_CLIENT_ID'] ?? null;
73    }
74
75    /**
76     * Get the Client Secret from environment
77     *
78     * @return string|null
79     */
80    private function getClientSecret(): ?string
81    {
82        return $_ENV['CC_CLIENT_SECRET'] ?? null;
83    }
84
85    /**
86     * Get the application domain for callbacks
87     *
88     * @return string
89     */
90    public static function getAppDomain(): string
91    {
92        return $_ENV['APP_DOMAIN'] ?? 'https://www.buyerkiosk.com';
93    }
94
95    /**
96     * Get the OAuth redirect URI
97     *
98     * @return string
99     */
100    public function getRedirectUri(): string
101    {
102        return self::getAppDomain() . '/api/ccCallback.php';
103    }
104
105    /**
106     * Generate and store OAuth state parameter for CSRF protection
107     *
108     * @return string
109     */
110    public function generateOAuthState(): string
111    {
112        $stateData = [
113            'typeNum' => $this->store->getTypeNum(),
114            'timestamp' => time(),
115            'nonce' => bin2hex(random_bytes(16))
116        ];
117
118        $stateJson = json_encode($stateData);
119        $state = base64_encode($stateJson);
120
121        // Store in session for validation
122        if (session_status() === PHP_SESSION_NONE) {
123            session_start();
124        }
125        $_SESSION['cc_oauth_state'] = $state;
126        $_SESSION['cc_oauth_state_time'] = time();
127
128        return $state;
129    }
130
131    /**
132     * Validate OAuth state parameter
133     *
134     * @param string $state
135     * @return array|null Returns state data if valid, null otherwise
136     */
137    public static function validateOAuthState(string $state): ?array
138    {
139        if (session_status() === PHP_SESSION_NONE) {
140            session_start();
141        }
142
143        // Check if state matches
144        if (!isset($_SESSION['cc_oauth_state']) || $_SESSION['cc_oauth_state'] !== $state) {
145            return null;
146        }
147
148        // Check if state is not expired (15 minute window)
149        $stateTime = $_SESSION['cc_oauth_state_time'] ?? 0;
150        if (time() - $stateTime > 900) {
151            return null;
152        }
153
154        // Clear the state
155        unset($_SESSION['cc_oauth_state'], $_SESSION['cc_oauth_state_time']);
156
157        // Decode and return state data
158        $stateJson = base64_decode($state);
159        return json_decode($stateJson, true);
160    }
161
162    /**
163     * Get the authorization URL for OAuth flow
164     *
165     * @return string
166     */
167    public function getAuthorizationUrl(): string
168    {
169        $state = $this->generateOAuthState();
170
171        $params = [
172            'client_id' => $this->getClientId(),
173            'redirect_uri' => $this->getRedirectUri(),
174            'response_type' => 'code',
175            'scope' => self::SCOPES,
176            'state' => $state
177        ];
178
179        return self::AUTH_URL . '?' . http_build_query($params);
180    }
181
182    /**
183     * Exchange authorization code for access tokens
184     *
185     * @param string $code Authorization code from callback
186     * @return bool Success status
187     */
188    public function exchangeCodeForTokens(string $code): bool
189    {
190        $postData = [
191            'grant_type' => 'authorization_code',
192            'code' => $code,
193            'redirect_uri' => $this->getRedirectUri()
194        ];
195
196        $response = $this->makeTokenRequest($postData);
197
198        if (!$response || !isset($response['access_token'])) {
199            error_log("Constant Contact: Failed to exchange code for tokens");
200            return false;
201        }
202
203        return $this->saveTokens($response);
204    }
205
206    /**
207     * Refresh the access token using refresh token
208     *
209     * @return bool Success status
210     */
211    public function refreshAccessToken(): bool
212    {
213        $refreshToken = $this->store->getCcRefreshToken();
214        if (empty($refreshToken)) {
215            error_log("Constant Contact: No refresh token available for store " . $this->store->getTypeNum());
216            return false;
217        }
218
219        $postData = [
220            'grant_type' => 'refresh_token',
221            'refresh_token' => $refreshToken
222        ];
223
224        $response = $this->makeTokenRequest($postData);
225
226        if (!$response || !isset($response['access_token'])) {
227            error_log("Constant Contact: Failed to refresh token for store " . $this->store->getTypeNum());
228            return false;
229        }
230
231        return $this->saveTokens($response);
232    }
233
234    /**
235     * Make a token request to the OAuth server
236     *
237     * @param array $postData
238     * @return array|null
239     */
240    private function makeTokenRequest(array $postData): ?array
241    {
242        $clientId = $this->getClientId();
243        $clientSecret = $this->getClientSecret();
244
245        if (!$clientId || !$clientSecret) {
246            error_log("Constant Contact: Missing client credentials");
247            return null;
248        }
249
250        $ch = curl_init(self::TOKEN_URL);
251        curl_setopt_array($ch, [
252            CURLOPT_RETURNTRANSFER => true,
253            CURLOPT_POST => true,
254            CURLOPT_POSTFIELDS => http_build_query($postData),
255            CURLOPT_HTTPHEADER => [
256                'Content-Type: application/x-www-form-urlencoded',
257                'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret)
258            ],
259            CURLOPT_TIMEOUT => 30
260        ]);
261
262        $response = curl_exec($ch);
263        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
264        $error = curl_error($ch);
265        curl_close($ch);
266
267        if ($error) {
268            error_log("Constant Contact: cURL error - " . $error);
269            return null;
270        }
271
272        if ($httpCode !== 200) {
273            error_log("Constant Contact: Token request failed with HTTP " . $httpCode . " - " . $response);
274            return null;
275        }
276
277        return json_decode($response, true);
278    }
279
280    /**
281     * Save tokens to the store record
282     *
283     * @param array $tokenResponse
284     * @return bool
285     */
286    private function saveTokens(array $tokenResponse): bool
287    {
288        $accessToken = $tokenResponse['access_token'] ?? null;
289        $refreshToken = $tokenResponse['refresh_token'] ?? null;
290        $expiresIn = $tokenResponse['expires_in'] ?? self::TOKEN_LIFETIME;
291
292        if (!$accessToken) {
293            return false;
294        }
295
296        // Calculate expiration time
297        $expiration = date('Y-m-d H:i:s', time() + $expiresIn);
298
299        // Update store tokens
300        $this->store->setCcToken($accessToken);
301        if ($refreshToken) {
302            $this->store->setCcRefreshToken($refreshToken);
303        }
304        $this->store->setCcTokenExpiration($expiration);
305        $this->store->setCcEnabled(1);
306
307        return $this->store->updateCcTokens();
308    }
309
310    /**
311     * Get a valid access token, refreshing if necessary
312     *
313     * @return string|null
314     */
315    public function getValidAccessToken(): ?string
316    {
317        if (!$this->isConnected()) {
318            return null;
319        }
320
321        // Check if token is expired or will expire soon (within 5 minutes)
322        $expiration = $this->store->getCcTokenExpiration();
323        if ($expiration) {
324            $expirationTime = strtotime($expiration);
325            if ($expirationTime && (time() + 300) >= $expirationTime) {
326                // Token is expired or expiring soon, refresh it
327                if (!$this->refreshAccessToken()) {
328                    return null;
329                }
330            }
331        }
332
333        return $this->store->getCcToken();
334    }
335
336    /**
337     * Make an API request to Constant Contact
338     *
339     * @param string $method HTTP method
340     * @param string $endpoint API endpoint (without base URL)
341     * @param array|null $data Request data
342     * @return array|null
343     */
344    public function apiRequest(string $method, string $endpoint, ?array $data = null): ?array
345    {
346        $accessToken = $this->getValidAccessToken();
347        if (!$accessToken) {
348            error_log("Constant Contact: No valid access token for API request");
349            return null;
350        }
351
352        $url = self::API_BASE_URL . $endpoint;
353
354        $headers = [
355            'Authorization: Bearer ' . $accessToken,
356            'Content-Type: application/json',
357            'Accept: application/json'
358        ];
359
360        $ch = curl_init();
361        curl_setopt_array($ch, [
362            CURLOPT_URL => $url,
363            CURLOPT_RETURNTRANSFER => true,
364            CURLOPT_HTTPHEADER => $headers,
365            CURLOPT_TIMEOUT => 30
366        ]);
367
368        switch (strtoupper($method)) {
369            case 'POST':
370                curl_setopt($ch, CURLOPT_POST, true);
371                if ($data) {
372                    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
373                }
374                break;
375            case 'PUT':
376                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
377                if ($data) {
378                    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
379                }
380                break;
381            case 'DELETE':
382                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
383                break;
384            case 'GET':
385            default:
386                if ($data) {
387                    curl_setopt($ch, CURLOPT_URL, $url . '?' . http_build_query($data));
388                }
389                break;
390        }
391
392        $response = curl_exec($ch);
393        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
394        $error = curl_error($ch);
395        curl_close($ch);
396
397        if ($error) {
398            error_log("Constant Contact API: cURL error - " . $error);
399            return null;
400        }
401
402        if ($httpCode >= 400) {
403            error_log("Constant Contact API: Request failed with HTTP " . $httpCode . " - " . $response);
404            // Throw exception with error details so caller can handle it
405            throw new \Exception("Constant Contact API: Request failed with HTTP " . $httpCode . " - " . $response);
406        }
407
408        return json_decode($response, true);
409    }
410
411    /**
412     * Get account info for the connected user
413     *
414     * @return array|null
415     */
416    public function getAccountInfo(): ?array
417    {
418        return $this->apiRequest('GET', '/account/summary');
419    }
420
421    /**
422     * Get all contact lists
423     *
424     * @return array
425     */
426    public function getLists(): array
427    {
428        $response = $this->apiRequest('GET', '/contact_lists', [
429            'include_count' => 'true',
430            'status' => 'active'
431        ]);
432
433        return $response['lists'] ?? [];
434    }
435
436    /**
437     * Create a new contact list
438     *
439     * @param string $name List name
440     * @param string|null $description Optional description
441     * @return array|null The created list or null on failure
442     */
443    public function createList(string $name, ?string $description = null): ?array
444    {
445        $data = [
446            'name' => $name,
447            'favorite' => false
448        ];
449
450        if ($description) {
451            $data['description'] = $description;
452        }
453
454        return $this->apiRequest('POST', '/contact_lists', $data);
455    }
456
457    /**
458     * Get a specific contact list by ID
459     *
460     * @param string $listId
461     * @return array|null
462     */
463    public function getList(string $listId): ?array
464    {
465        return $this->apiRequest('GET', '/contact_lists/' . $listId);
466    }
467
468    /**
469     * Add or update a contact
470     *
471     * @param string $email
472     * @param string $firstName
473     * @param string $lastName
474     * @param string|null $listId Optional list ID to add contact to
475     * @return array|null
476     */
477    public function upsertContact(string $email, string $firstName, string $lastName, ?string $listId = null): ?array
478    {
479        // Trim and validate email
480        $email = trim($email);
481        $firstName = trim($firstName);
482        $lastName = trim($lastName);
483
484        // Validate email format
485        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
486            error_log("Constant Contact: Invalid email format - " . $email . " (hex: " . bin2hex($email) . ")");
487            throw new \Exception("Invalid email format: " . $email);
488        }
489
490        // Note: sign_up_form endpoint handles permission_to_send automatically
491        // based on the account's Confirmed Opt-In settings
492        $data = [
493            'email_address' => $email,
494            'first_name' => $firstName,
495            'last_name' => $lastName,
496            'create_source' => 'Account'
497        ];
498
499        // Use store's configured list if not specified
500        if (!$listId) {
501            $listId = $this->store->getCcList();
502        }
503
504        if ($listId) {
505            $data['list_memberships'] = [$listId];
506        }
507
508        return $this->apiRequest('POST', '/contacts/sign_up_form', $data);
509    }
510
511    /**
512     * Disconnect the Constant Contact integration
513     *
514     * @return bool
515     */
516    public function disconnect(): bool
517    {
518        $this->store->setCcToken(null);
519        $this->store->setCcRefreshToken(null);
520        $this->store->setCcTokenExpiration(null);
521        $this->store->setCcEnabled(0);
522        $this->store->setCcList(null);
523
524        return $this->store->updateCcTokens();
525    }
526}