commit ad5b3fde246ca10dbfbc0af4ec1437d110e13e7b Author: Rob Fowell Date: Sun Mar 22 22:06:59 2026 +0000 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8028235 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Zoho OAuth2 Credentials +# Get these from your Zoho API Console: https://api-console.zoho.com/ + +ZOHO_CLIENT_ID=your_client_id_here +ZOHO_CLIENT_SECRET=your_client_secret_here +ZOHO_REDIRECT_URI=http://localhost/callback diff --git a/README.md b/README.md new file mode 100644 index 0000000..e22f145 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Zoho API Integration + +Modular PHP integration with Zoho APIs (Projects, Desk, and more). + +## Features + +- **Modular Architecture**: Easy to add new Zoho services +- **OAuth2 Authentication**: Secure token management with auto-refresh +- **Multiple Export Formats**: JSON and CSV support +- **Logging**: File-based logging for monitoring +- **Cron-Ready**: Built for hourly automated exports + +## Directory Structure + +``` +zoho-qwen/ +├── config/ +│ └── config.php # Configuration settings +├── src/ +│ ├── Core/ +│ │ └── ZohoClient.php # OAuth2 & HTTP client +│ ├── Interfaces/ +│ │ └── ZohoServiceInterface.php +│ ├── Services/ +│ │ ├── ProjectsService.php +│ │ └── DeskService.php +│ ├── Export/ +│ │ └── ExportManager.php +│ └── Utils/ +│ └── Logger.php +├── scripts/ +│ ├── authenticate.php # OAuth2 setup +│ └── export_projects.php # Export script +├── cron/ +│ └── hourly_export.sh # Cron wrapper +└── storage/ # Tokens, exports, logs (auto-created) +``` + +## Setup + +### 1. Install Requirements + +- PHP 8.0+ +- cURL extension + +### 2. Configure Environment + +```bash +cp .env.example .env +# Edit .env with your Zoho credentials +``` + +### 3. Register Zoho Application + +1. Visit [Zoho API Console](https://api-console.zoho.com/) +2. Create a new client +3. Select "Server-based Application" +4. Add your redirect URI +5. Copy Client ID and Client Secret to `.env` + +### 4. Authenticate + +```bash +# Generate authorization URL +php scripts/authenticate.php --service=projects + +# Visit the URL, authorize, then exchange the code: +php scripts/authenticate.php --service=projects --code=YOUR_CODE +``` + +### 5. Run Export + +```bash +# Manual export +php scripts/export_projects.php + +# Include task details +php scripts/export_projects.php --include-tasks + +# Export as CSV +php scripts/export_projects.php --format=csv +``` + +### 6. Setup Cron (Optional) + +```bash +# Edit the cron script path +nano cron/hourly_export.sh + +# Add to crontab (runs every hour) +crontab -e +# Add: 0 * * * * /path/to/cron/hourly_export.sh +``` + +## Adding New Zoho Services + +1. Add service config in `config/config.php`: + +```php +'desk' => [ + 'enabled' => true, + 'api_base' => 'https://desk.zoho.com/api/v1', + 'scopes' => 'ZohoDesk.tickets.READ', +], +``` + +2. Create service class implementing `ZohoServiceInterface`: + +```php +class DeskService implements ZohoServiceInterface +{ + // Implement required methods +} +``` + +3. Create export script for the service + +## Configuration + +Edit `config/config.php` to customize: + +- API domains (for EU/US data centers) +- OAuth scopes +- Export formats and locations +- Log levels + +## Troubleshooting + +**Token expired errors**: Re-run authentication script + +**401 Unauthorized**: Check OAuth scopes in config + +**Export directory not writable**: `chmod 755 storage/exports` + +## License + +MIT diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..f4ac3f5 --- /dev/null +++ b/config/config.php @@ -0,0 +1,42 @@ + [ + 'accounts_domain' => 'https://accounts.zoho.com', + 'api_domain' => 'https://www.zohoapis.com', + + // OAuth2 credentials + 'client_id' => getenv('ZOHO_CLIENT_ID') ?: '', + 'client_secret' => getenv('ZOHO_CLIENT_SECRET') ?: '', + 'redirect_uri' => getenv('ZOHO_REDIRECT_URI') ?: '', + + // Token storage + 'token_file' => __DIR__ . '/../storage/tokens.json', + + // Services configuration + 'projects' => [ + 'enabled' => true, + 'api_base' => 'https://www.zohoapis.com/projects/api/v3', + 'scopes' => 'ZohoProjects.projects.READ,ZohoProjects.tasks.READ', + ], + + 'desk' => [ + 'enabled' => false, + 'api_base' => 'https://desk.zoho.com/api/v1', + 'scopes' => 'ZohoDesk.tickets.READ,ZohoDesk.organizations.READ', + ], + ], + + // Export settings + 'export' => [ + 'output_dir' => __DIR__ . '/../storage/exports', + 'format' => 'json', // json, csv + ], + + // Logging + 'log_file' => __DIR__ . '/../storage/logs/zoho_sync.log', + 'log_level' => 'INFO', +]; diff --git a/cron/hourly_export.sh b/cron/hourly_export.sh new file mode 100644 index 0000000..a312857 --- /dev/null +++ b/cron/hourly_export.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# +# Cron job script to export Zoho Projects hourly +# Add to crontab: 0 * * * * /path/to/cron/hourly_export.sh +# + +set -e + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +PHP_BIN="${PHP_BIN:-php}" + +# Change to project directory +cd "$PROJECT_ROOT" + +# Log start +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting hourly Zoho Projects export" + +# Run the export script +"$PHP_BIN" scripts/export_projects.php --include-tasks + +# Log completion +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Export completed successfully" diff --git a/scripts/authenticate.php b/scripts/authenticate.php new file mode 100644 index 0000000..cab7dac --- /dev/null +++ b/scripts/authenticate.php @@ -0,0 +1,89 @@ +exchangeCodeForTokens($code, $service); + echo "Success! Tokens have been saved.\n"; + echo "You can now run the export script.\n"; + } catch (\Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); + } +} else { + // Generate authorization URL + try { + $authUrl = $client->getAuthorizationUrl($service); + echo "Authorization URL for Zoho {$service}:\n"; + echo "========================================\n"; + echo "{$authUrl}\n\n"; + echo "1. Visit this URL in your browser\n"; + echo "2. Authorize the application\n"; + echo "3. Copy the 'code' parameter from the redirect URL\n"; + echo "4. Run: php scripts/authenticate.php --service={$service} --code=YOUR_CODE\n"; + } catch (\Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); + } +} diff --git a/scripts/export_projects.php b/scripts/export_projects.php new file mode 100644 index 0000000..febec55 --- /dev/null +++ b/scripts/export_projects.php @@ -0,0 +1,77 @@ +info('Starting Zoho Projects export'); + + // Parse command line options + $options = getopt('', ['include-tasks', 'format:']); + $includeTasks = isset($options['include-tasks']); + $format = $options['format'] ?? $config['export']['format']; + + // Initialize Zoho client + $client = new ZohoClient($config); + + // Initialize Projects service + $projectsService = new ProjectsService($client, $config); + + // Fetch open projects + $logger->info('Fetching open projects', ['include_tasks' => $includeTasks]); + $projects = $projectsService->getOpenProjects($includeTasks); + + $logger->info('Fetched projects', ['count' => count($projects)]); + + // Export data + $exportManager = new ExportManager($config['export']['output_dir'], $format); + $filepath = $exportManager->export($projects, 'open_projects'); + + $logger->info('Export completed', ['filepath' => $filepath]); + + echo "Export completed successfully!\n"; + echo "Records exported: " . count($projects) . "\n"; + echo "Output file: {$filepath}\n"; + +} catch (\Exception $e) { + $logger->error('Export failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} diff --git a/src/Core/ZohoClient.php b/src/Core/ZohoClient.php new file mode 100644 index 0000000..7a2d8e5 --- /dev/null +++ b/src/Core/ZohoClient.php @@ -0,0 +1,251 @@ +config = $config; + $this->loadTokens(); + } + + /** + * Load tokens from storage + */ + private function loadTokens(): void + { + $tokenFile = $this->config['zoho']['token_file']; + + if (file_exists($tokenFile)) { + $tokens = json_decode(file_get_contents($tokenFile), true); + if ($tokens) { + $this->accessToken = $tokens['access_token'] ?? null; + $this->refreshToken = $tokens['refresh_token'] ?? null; + $this->tokenExpiry = $tokens['expires_at'] ?? null; + } + } + } + + /** + * Save tokens to storage + */ + private function saveTokens(): void + { + $tokenFile = $this->config['zoho']['token_file']; + $dir = dirname($tokenFile); + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $tokens = [ + 'access_token' => $this->accessToken, + 'refresh_token' => $this->refreshToken, + 'expires_at' => $this->tokenExpiry, + ]; + + file_put_contents($tokenFile, json_encode($tokens, JSON_PRETTY_PRINT)); + } + + /** + * Check if access token is expired or about to expire + */ + private function isTokenExpired(): bool + { + if (!$this->tokenExpiry) { + return true; + } + + // Refresh 5 minutes before actual expiry + return time() >= ($this->tokenExpiry - 300); + } + + /** + * Get valid access token, refreshing if necessary + */ + public function getAccessToken(): string + { + if ($this->isTokenExpired()) { + $this->refreshAccessToken(); + } + + return $this->accessToken; + } + + /** + * Initial OAuth2 authorization - generates authorization URL + */ + public function getAuthorizationUrl(string $service): string + { + $serviceConfig = $this->config['zoho'][$service] ?? null; + + if (!$serviceConfig) { + throw new \InvalidArgumentException("Service '{$service}' not configured"); + } + + $params = [ + 'client_id' => $this->config['zoho']['client_id'], + 'response_type' => 'code', + 'redirect_uri' => $this->config['zoho']['redirect_uri'], + 'scope' => $serviceConfig['scopes'], + 'access_type' => 'offline', + 'prompt' => 'consent', + ]; + + return $this->config['zoho']['accounts_domain'] . '/oauth/v2/auth?' . http_build_query($params); + } + + /** + * Exchange authorization code for tokens + */ + public function exchangeCodeForTokens(string $code, string $service): void + { + $serviceConfig = $this->config['zoho'][$service] ?? null; + + if (!$serviceConfig) { + throw new \InvalidArgumentException("Service '{$service}' not configured"); + } + + $response = $this->makeRequest( + $this->config['zoho']['accounts_domain'] . '/oauth/v2/token', + 'POST', + [ + 'client_id' => $this->config['zoho']['client_id'], + 'client_secret' => $this->config['zoho']['client_secret'], + 'code' => $code, + 'redirect_uri' => $this->config['zoho']['redirect_uri'], + 'grant_type' => 'authorization_code', + ] + ); + + $this->storeTokens($response); + } + + /** + * Refresh access token using refresh token + */ + private function refreshAccessToken(): void + { + if (!$this->refreshToken) { + throw new \RuntimeException('No refresh token available. Please re-authorize.'); + } + + $response = $this->makeRequest( + $this->config['zoho']['accounts_domain'] . '/oauth/v2/token', + 'POST', + [ + 'client_id' => $this->config['zoho']['client_id'], + 'client_secret' => $this->config['zoho']['client_secret'], + 'refresh_token' => $this->refreshToken, + 'grant_type' => 'refresh_token', + ] + ); + + // Refresh token may be rotated, update if present + if (isset($response['refresh_token'])) { + $this->refreshToken = $response['refresh_token']; + } + + $this->storeTokens($response, false); + } + + /** + * Store tokens from API response + */ + private function storeTokens(array $response, bool $includeRefresh = true): void + { + $this->accessToken = $response['access_token']; + + if ($includeRefresh && isset($response['refresh_token'])) { + $this->refreshToken = $response['refresh_token']; + } + + $this->tokenExpiry = time() + ($response['expires_in'] ?? 3600); + + $this->saveTokens(); + } + + /** + * Make authenticated API request to Zoho service + */ + public function request(ZohoServiceInterface $service, string $endpoint, string $method = 'GET', array $params = []): array + { + $url = rtrim($service->getApiBase(), '/') . '/' . ltrim($endpoint, '/'); + + $headers = [ + 'Authorization' => 'Zoho-oauthtoken ' . $this->getAccessToken(), + ]; + + return $this->makeRequest($url, $method, $params, $headers); + } + + /** + * Generic HTTP request handler + */ + private function makeRequest(string $url, string $method, array $params = [], array $headers = []): array + { + $ch = curl_init(); + + $headers = array_merge([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], $headers); + + $headerArray = []; + foreach ($headers as $key => $value) { + $headerArray[] = "{$key}: {$value}"; + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headerArray, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_TIMEOUT => 30, + ]); + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); + } elseif ($method === 'PUT') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); + } elseif ($method === 'DELETE') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + } elseif ($params && $method === 'GET') { + curl_setopt($ch, CURLOPT_URL, $url . '?' . http_build_query($params)); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + curl_close($ch); + + if ($error) { + throw new \RuntimeException("cURL error: {$error}"); + } + + $data = json_decode($response, true); + + if ($httpCode >= 400) { + throw new \RuntimeException( + "API request failed with status {$httpCode}: " . + ($data['message'] ?? $data['error'] ?? 'Unknown error') + ); + } + + return $data ?? []; + } +} diff --git a/src/Export/ExportManager.php b/src/Export/ExportManager.php new file mode 100644 index 0000000..1482b82 --- /dev/null +++ b/src/Export/ExportManager.php @@ -0,0 +1,109 @@ +outputDir = $outputDir; + $this->format = $format; + + if (!is_dir($this->outputDir)) { + mkdir($this->outputDir, 0755, true); + } + } + + /** + * Export data to file + */ + public function export(array $data, string $filename): string + { + $timestamp = date('Y-m-d_His'); + $fullFilename = "{$filename}_{$timestamp}.{$this->format}"; + $filepath = $this->outputDir . '/' . $fullFilename; + + $content = match ($this->format) { + 'csv' => $this->toCsv($data), + 'json' => $this->toJson($data), + default => throw new \InvalidArgumentException("Unsupported format: {$this->format}"), + }; + + file_put_contents($filepath, $content); + + return $filepath; + } + + /** + * Convert data to JSON format + */ + private function toJson(array $data): string + { + return json_encode([ + 'exported_at' => date('c'), + 'record_count' => count($data), + 'data' => $data, + ], JSON_PRETTY_PRINT); + } + + /** + * Convert data to CSV format + */ + private function toCsv(array $data): string + { + if (empty($data)) { + return ''; + } + + $output = fopen('php://temp', 'r+'); + + // Flatten nested arrays for CSV + $flattened = array_map(fn($row) => $this->flattenArray($row), $data); + + // Write headers + $headers = array_keys(reset($flattened)); + fputcsv($output, $headers); + + // Write data rows + foreach ($flattened as $row) { + fputcsv($output, array_values($row)); + } + + rewind($output); + $csv = stream_get_contents($output); + fclose($output); + + return $csv; + } + + /** + * Flatten nested array for CSV export + */ + private function flattenArray(array $array, string $prefix = ''): array + { + $result = []; + + foreach ($array as $key => $value) { + $newKey = $prefix ? "{$prefix}.{$key}" : $key; + + if (is_array($value)) { + // Check if it's a simple array (like assignees list) + if (array_values($value) === $value && !empty($value)) { + $result[$newKey] = implode('; ', $value); + } else { + $result = array_merge($result, $this->flattenArray($value, $newKey)); + } + } else { + $result[$newKey] = $value; + } + } + + return $result; + } +} diff --git a/src/Interfaces/ZohoServiceInterface.php b/src/Interfaces/ZohoServiceInterface.php new file mode 100644 index 0000000..882d5f8 --- /dev/null +++ b/src/Interfaces/ZohoServiceInterface.php @@ -0,0 +1,34 @@ +client = $client; + $this->config = $config['zoho']['desk']; + } + + public function getName(): string + { + return 'desk'; + } + + public function getScopes(): string + { + return $this->config['scopes']; + } + + public function getApiBase(): string + { + return $this->config['api_base']; + } + + /** + * Fetch tickets from Zoho Desk + */ + public function fetchData(array $params = []): array + { + $defaultParams = [ + 'status' => 'Open', + 'limit' => 200, + ]; + + $params = array_merge($defaultParams, $params); + + $response = $this->client->request($this, 'tickets', 'GET', $params); + + return $response['data'] ?? []; + } + + /** + * Transform ticket data for export + */ + public function transformData(array $data): array + { + return array_map(function ($ticket) { + return [ + 'ticket_id' => $ticket['id'] ?? null, + 'subject' => $ticket['subject'] ?? null, + 'description' => $ticket['description'] ?? null, + 'status' => $ticket['status'] ?? null, + 'priority' => $ticket['priority'] ?? null, + 'created_time' => $ticket['createdTime'] ?? null, + 'modified_time' => $ticket['modifiedTime'] ?? null, + 'due_by' => $ticket['dueBy'] ?? null, + 'contact' => [ + 'id' => $ticket['contactId'] ?? null, + 'name' => $ticket['contactName'] ?? null, + 'email' => $ticket['contactEmail'] ?? null, + ], + 'department' => $ticket['departmentId'] ?? null, + 'assignee' => [ + 'id' => $ticket['assigneeId'] ?? null, + 'name' => $ticket['assigneeName'] ?? null, + ], + ]; + }, $data); + } + + /** + * Get open tickets + */ + public function getOpenTickets(): array + { + $tickets = $this->fetchData(); + return $this->transformData($tickets); + } +} diff --git a/src/Services/ProjectsService.php b/src/Services/ProjectsService.php new file mode 100644 index 0000000..375be13 --- /dev/null +++ b/src/Services/ProjectsService.php @@ -0,0 +1,141 @@ +client = $client; + $this->config = $config['zoho']['projects']; + } + + public function getName(): string + { + return 'projects'; + } + + public function getScopes(): string + { + return $this->config['scopes']; + } + + public function getApiBase(): string + { + return $this->config['api_base']; + } + + /** + * Fetch all open projects from Zoho Projects + */ + public function fetchData(array $params = []): array + { + $defaultParams = [ + 'status' => '1', // 1 = Open/Active projects + 'per_page' => 200, + ]; + + $params = array_merge($defaultParams, $params); + + $response = $this->client->request($this, 'projects', 'GET', $params); + + return $response['data'] ?? []; + } + + /** + * Fetch project tasks for a specific project + */ + public function fetchProjectTasks(string $projectId, array $params = []): array + { + $defaultParams = [ + 'per_page' => 200, + ]; + + $params = array_merge($defaultParams, $params); + + $endpoint = "projects/{$projectId}/tasks"; + $response = $this->client->request($this, $endpoint, 'GET', $params); + + return $response['data'] ?? []; + } + + /** + * Transform project data for export + */ + public function transformData(array $data): array + { + return array_map(function ($project) { + return [ + 'project_id' => $project['project_id'] ?? null, + 'project_name' => $project['name'] ?? null, + 'description' => $project['description'] ?? null, + 'status' => $project['status'] ?? null, + 'start_date' => $project['start_date'] ?? null, + 'end_date' => $project['end_date'] ?? null, + 'created_date' => $project['created_date'] ?? null, + 'owner' => [ + 'id' => $project['owner']['id'] ?? null, + 'name' => $project['owner']['name'] ?? null, + ], + 'customer' => [ + 'id' => $project['customer']['id'] ?? null, + 'name' => $project['customer']['name'] ?? null, + ], + 'budget' => $project['budget'] ?? null, + 'currency' => $project['currency_code'] ?? null, + 'task_count' => $project['task_count']['total'] ?? null, + 'task_progress' => [ + 'completed' => $project['task_count']['completed'] ?? 0, + 'total' => $project['task_count']['total'] ?? 0, + ], + ]; + }, $data); + } + + /** + * Get open projects with optional task details + */ + public function getOpenProjects(bool $includeTasks = false): array + { + $projects = $this->fetchData(); + $transformed = $this->transformData($projects); + + if ($includeTasks) { + foreach ($transformed as &$project) { + $tasks = $this->fetchProjectTasks($project['project_id']); + $project['tasks'] = $this->transformTasks($tasks); + } + } + + return $transformed; + } + + /** + * Transform task data + */ + private function transformTasks(array $tasks): array + { + return array_map(function ($task) { + return [ + 'task_id' => $task['task_id'] ?? null, + 'task_name' => $task['name'] ?? null, + 'status' => $task['status'] ?? null, + 'priority' => $task['priority'] ?? null, + 'assignees' => array_map( + fn($a) => $a['name'] ?? null, + $task['assignees'] ?? [] + ), + 'due_date' => $task['due_date'] ?? null, + ]; + }, $tasks); + } +} diff --git a/src/Utils/Logger.php b/src/Utils/Logger.php new file mode 100644 index 0000000..b187dc8 --- /dev/null +++ b/src/Utils/Logger.php @@ -0,0 +1,79 @@ + 0, + 'INFO' => 1, + 'WARNING' => 2, + 'ERROR' => 3, + 'CRITICAL' => 4, + ]; + + private string $logFile; + private int $minLevel; + + public function __construct(string $logFile, string $minLevel = 'INFO') + { + $this->logFile = $logFile; + $this->minLevel = self::LEVELS[$minLevel] ?? self::LEVELS['INFO']; + + $dir = dirname($this->logFile); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + } + + public function debug(string $message, array $context = []): void + { + $this->log('DEBUG', $message, $context); + } + + public function info(string $message, array $context = []): void + { + $this->log('INFO', $message, $context); + } + + public function warning(string $message, array $context = []): void + { + $this->log('WARNING', $message, $context); + } + + public function error(string $message, array $context = []): void + { + $this->log('ERROR', $message, $context); + } + + public function critical(string $message, array $context = []): void + { + $this->log('CRITICAL', $message, $context); + } + + private function log(string $level, string $message, array $context = []): void + { + if (self::LEVELS[$level] < $this->minLevel) { + return; + } + + $timestamp = date('Y-m-d H:i:s'); + $formattedMessage = $this->interpolate($message, $context); + $logLine = "[{$timestamp}] [{$level}] {$formattedMessage}" . PHP_EOL; + + file_put_contents($this->logFile, $logLine, FILE_APPEND); + } + + private function interpolate(string $message, array $context = []): string + { + $replacements = []; + foreach ($context as $key => $value) { + $replacements['{' . $key . '}'] = is_scalar($value) ? $value : json_encode($value); + } + + return strtr($message, $replacements); + } +}