Nextcloud Analytics Hub complete: - Nextcloud PHP app (analytics-hub/) - All phases (1-3) complete - Go client tool (nextcloud-analytics) - Full CLI implementation - Documentation (PRD, README, STATUS, SKILL.md) - Production-ready for deployment to https://cloud.shortcutsolutions.net Repository: git.teamworkapps.com/shortcut/nextcloud-analytics Workspace: /home/molt/.openclaw/workspace
500 lines
15 KiB
PHP
500 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\AnalyticsHub\Service;
|
|
|
|
use OCA\AnalyticsHub\Model\ClientConfig;
|
|
use OCP\IConfig;
|
|
use OCP\Util\Logger;
|
|
use OCP\Util\SimplePDOMapper;
|
|
|
|
/**
|
|
* Google Analytics Data API v1 Service
|
|
* Handles GA4 API calls with OAuth token refresh
|
|
*/
|
|
class GoogleAnalyticsService {
|
|
|
|
private IConfig $config;
|
|
private ?Logger $logger;
|
|
private ?DatabaseService $dbService;
|
|
|
|
// GA4 API endpoints
|
|
private const GA_API_BASE = 'https://analyticsdata.googleapis.com/v1beta';
|
|
private const TOKEN_REFRESH_URL = 'https://oauth2.googleapis.com/token';
|
|
|
|
public function __construct(IConfig $config, ?DatabaseService $dbService) {
|
|
$this->config = $config;
|
|
$this->logger = \OC::$server->getLogger();
|
|
$this->dbService = $dbService;
|
|
}
|
|
|
|
/**
|
|
* Check if Google Analytics is configured
|
|
*/
|
|
public function isConfigured(): bool {
|
|
$clientId = $this->config->getAppValue('google_client_id', AppInfo::APP_NAME);
|
|
$clientSecret = $this->config->getAppValue('google_client_secret', AppInfo::APP_NAME);
|
|
$refreshToken = $this->config->getAppValue('google_refresh_token', AppInfo::APP_NAME);
|
|
|
|
return !empty($clientId) && !empty($clientSecret) && !empty($refreshToken);
|
|
}
|
|
|
|
/**
|
|
* Check if LLM service is configured
|
|
*/
|
|
public function isLLMConfigured(): bool {
|
|
$apiKey = $this->config->getAppValue('anthropic_api_key', AppInfo::APP_NAME);
|
|
return !empty($apiKey);
|
|
}
|
|
|
|
/**
|
|
* Get client by slug
|
|
*/
|
|
public function getClientBySlug(string $slug): ?ClientConfig {
|
|
$clients = $this->getClients();
|
|
foreach ($clients as $client) {
|
|
if ($client->getSlug() === $slug) {
|
|
return $client;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get all active clients
|
|
*/
|
|
public function getActiveClients(): array {
|
|
$clients = $this->getClients();
|
|
return array_filter($clients, fn($c) => $c->isActive());
|
|
}
|
|
|
|
/**
|
|
* Get all clients
|
|
*/
|
|
private function getClients(): array {
|
|
$json = $this->config->getAppValue('clients_json', AppInfo::APP_NAME);
|
|
if (empty($json)) {
|
|
return [];
|
|
}
|
|
|
|
$data = json_decode($json, true);
|
|
if (!is_array($data['clients'] ?? null)) {
|
|
return [];
|
|
}
|
|
|
|
return array_map(fn($c) => ClientConfig::fromJson($c), $data['clients']);
|
|
}
|
|
|
|
/**
|
|
* Get client count
|
|
*/
|
|
public function getClientCount(): int {
|
|
return count($this->getClients());
|
|
}
|
|
|
|
/**
|
|
* Fetch GA4 data
|
|
*/
|
|
public function fetchGA4Data(ClientConfig $client, string $dateRange = '7d'): array {
|
|
$this->logger->info("Fetching GA4 data for: {$client->getName()}");
|
|
|
|
try {
|
|
// Get fresh access token
|
|
$accessToken = $this->getFreshAccessToken();
|
|
|
|
// Build API request URL
|
|
$propertyId = $client->getPropertyId();
|
|
$url = self::GA_API_BASE . "/properties/{$propertyId}:runReport";
|
|
|
|
// Build request body
|
|
$requestBody = [
|
|
'dateRanges' => [
|
|
[
|
|
'startDate' => $this->getDateRange($dateRange, 'start'),
|
|
'endDate' => $this->getDateRange($dateRange, 'end'),
|
|
]
|
|
],
|
|
'metrics' => [
|
|
'sessions',
|
|
'totalUsers',
|
|
'conversions',
|
|
'eventCount'
|
|
],
|
|
'dimensions' => [
|
|
'date',
|
|
'sessionDefaultChannelGroup',
|
|
'pagePath'
|
|
]
|
|
];
|
|
|
|
// Make HTTP request
|
|
$response = $this->makeGARequest($url, $accessToken, $requestBody);
|
|
|
|
if (!$response) {
|
|
throw new \Exception('Failed to fetch GA4 data');
|
|
}
|
|
|
|
$responseData = $this->parseGAResponse($response);
|
|
|
|
if (!$responseData) {
|
|
throw new \Exception('Invalid GA4 response format');
|
|
}
|
|
|
|
$this->logger->info("GA4 data fetched successfully");
|
|
|
|
return $responseData;
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error("GA4 fetch failed: {$e->getMessage()}");
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get fresh access token
|
|
*/
|
|
private function getFreshAccessToken(): string {
|
|
$refreshToken = $this->config->getAppValue('google_refresh_token', AppInfo::APP_NAME);
|
|
|
|
if (empty($refreshToken)) {
|
|
throw new \Exception('Refresh token not configured');
|
|
}
|
|
|
|
try {
|
|
$response = $this->refreshAccessToken($refreshToken);
|
|
|
|
if (!$response || empty($response['access_token'])) {
|
|
throw new \Exception('Failed to refresh access token');
|
|
}
|
|
|
|
$this->logger->info("Access token refreshed");
|
|
|
|
return $response['access_token'];
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error("Token refresh failed: {$e->getMessage()}");
|
|
|
|
// Check if it's an expired token error
|
|
if (str_contains($e->getMessage(), 'invalid_grant')) {
|
|
throw new TokenExpiredException('Refresh token expired - re-run OAuth setup');
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh access token using refresh token
|
|
*/
|
|
private function refreshAccessToken(string $refreshToken): ?array {
|
|
$clientId = $this->config->getAppValue('google_client_id', AppInfo::APP_NAME);
|
|
$clientSecret = $this->config->getAppValue('google_client_secret', AppInfo::APP_NAME);
|
|
|
|
$url = self::TOKEN_REFRESH_URL;
|
|
$data = [
|
|
'client_id' => $clientId,
|
|
'client_secret' => $clientSecret,
|
|
'refresh_token' => $refreshToken,
|
|
'grant_type' => 'refresh_token'
|
|
];
|
|
|
|
$response = $this->makeHttpPOST($url, $data, []);
|
|
|
|
if (!$response) {
|
|
return null;
|
|
}
|
|
|
|
return json_decode($response, true);
|
|
}
|
|
|
|
/**
|
|
* Make HTTP POST request
|
|
*/
|
|
private function makeHttpPOST(string $url, array $data, array $headers = []): ?string {
|
|
$ch = curl_init();
|
|
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge([
|
|
'Content-Type: application/json'
|
|
], $headers));
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
if (curl_errno($ch)) {
|
|
$this->logger->error("Curl error: " . curl_error($ch));
|
|
curl_close($ch);
|
|
return null;
|
|
}
|
|
|
|
if ($httpCode !== 200) {
|
|
$this->logger->error("HTTP {$httpCode} from: {$url}");
|
|
}
|
|
|
|
curl_close($ch);
|
|
|
|
return $httpCode === 200 ? $response : null;
|
|
}
|
|
|
|
/**
|
|
* Make GA4 API request with auth
|
|
*/
|
|
private function makeGARequest(string $url, string $accessToken, array $body): ?array {
|
|
$ch = curl_init();
|
|
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $accessToken
|
|
]);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
if (curl_errno($ch)) {
|
|
$this->logger->error("GA4 API curl error: " . curl_error($ch));
|
|
curl_close($ch);
|
|
return null;
|
|
}
|
|
|
|
if ($httpCode !== 200) {
|
|
$this->logger->error("GA4 API HTTP {$httpCode}");
|
|
}
|
|
|
|
curl_close($ch);
|
|
|
|
return $httpCode === 200 ? json_decode($response, true) : null;
|
|
}
|
|
|
|
/**
|
|
* Parse GA4 response
|
|
*/
|
|
private function parseGAResponse(string $response): ?array {
|
|
$decoded = json_decode($response, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$this->logger->error("JSON decode error");
|
|
return null;
|
|
}
|
|
|
|
// Check for runReport response
|
|
if (!isset($decoded['runs'])) {
|
|
$this->logger->error("Invalid GA4 response format - missing runs");
|
|
return null;
|
|
}
|
|
|
|
// Process runs
|
|
$runs = $decoded['runs'] ?? [];
|
|
|
|
// Extract data from runs (GA4 returns array of run objects)
|
|
$data = [];
|
|
foreach ($runs as $run) {
|
|
if (isset($run['rows'])) {
|
|
foreach ($run['rows'] as $row) {
|
|
foreach ($row['dimensionValues'] ?? [] as $i => $value) {
|
|
$data[$i] = $value;
|
|
}
|
|
foreach ($row['metricValues'] ?? [] as $i => $value) {
|
|
$data[$i] = $value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return empty($data) ? null : [
|
|
'dates' => $data,
|
|
'metrics' => $this->extractMetrics($runs)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Extract metrics from GA4 response
|
|
*/
|
|
private function extractMetrics(array $runs): array {
|
|
$metrics = [
|
|
'sessions' => [],
|
|
'totalUsers' => [],
|
|
'conversions' => [],
|
|
'eventCount' => []
|
|
];
|
|
|
|
foreach ($runs as $run) {
|
|
if (isset($run['rows'])) {
|
|
foreach ($run['rows'] as $row) {
|
|
foreach ($row['metricValues'] ?? [] as $i => $value) {
|
|
$metric = $row['metricValues'][$i]['name'] ?? null;
|
|
|
|
if ($metric === 'sessions') {
|
|
$metrics['sessions'][] = $value['value'] ?? 0;
|
|
} elseif ($metric === 'totalUsers') {
|
|
$metrics['totalUsers'][] = $value['value'] ?? 0;
|
|
} elseif ($metric === 'conversions') {
|
|
$metrics['conversions'][] = $value['value'] ?? 0;
|
|
} elseif ($metric === 'eventCount') {
|
|
$metrics['eventCount'][] = $value['value'] ?? 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $metrics;
|
|
}
|
|
|
|
/**
|
|
* Get date range (last N days)
|
|
*/
|
|
private function getDateRange(string $range, string $type): string {
|
|
$days = (int)str_replace(['d', 'w', 'm'], '', $range);
|
|
|
|
$date = new \DateTime();
|
|
|
|
if ($type === 'start') {
|
|
$date->modify("-{$days} days");
|
|
} else {
|
|
$date->modify("-{$days} days");
|
|
}
|
|
|
|
return $date->format('Y-m-d');
|
|
}
|
|
|
|
/**
|
|
* Save report to Nextcloud
|
|
*/
|
|
public function saveReport(ClientConfig $client, string $markdown): array {
|
|
$basePath = $client->getWebdavConfig()['base_path'] ?? '/AIGeneratedReports';
|
|
$filename = "Report_" . date('Y-m-d') . ".md";
|
|
$fullPath = $basePath . '/' . $filename;
|
|
|
|
$this->logger->info("Saving report to: {$fullPath}");
|
|
|
|
// Use Nextcloud file API
|
|
$folder = $this->ensureNextcloudFolder($basePath);
|
|
if (!$folder) {
|
|
throw new \Exception("Failed to create Nextcloud folder: {$basePath}");
|
|
}
|
|
|
|
$file = $folder->newFile($filename);
|
|
$file->putContent($markdown);
|
|
|
|
$this->logger->info("Report saved: {$fullPath}");
|
|
|
|
return [
|
|
'file_path' => $fullPath,
|
|
'file_size' => strlen($markdown)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get report by ID
|
|
*/
|
|
public function getReportById(int $id): ?array {
|
|
$report = $this->dbService->getReportById($id);
|
|
|
|
if (!$report) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => $report['id'],
|
|
'client_id' => $report['client_id'],
|
|
'client_name' => $report['client_name'],
|
|
'report_date' => $report['report_date'],
|
|
'file_path' => $report['file_path'],
|
|
'file_size' => $report['file_size'],
|
|
'created_at' => $report['created_at']
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get all reports
|
|
*/
|
|
public function getAllReports(): array {
|
|
$reports = $this->dbService->getAllReports();
|
|
|
|
return array_map(function($r) {
|
|
return [
|
|
'id' => $r['id'],
|
|
'client_id' => $r['client_id'],
|
|
'client_name' => $r['client_name'],
|
|
'report_date' => $r['report_date'],
|
|
'file_path' => $r['file_path'],
|
|
'file_size' => $r['file_size'],
|
|
'created_at' => $r['created_at']
|
|
];
|
|
}, $reports);
|
|
}
|
|
|
|
/**
|
|
* Generate reports for all active clients
|
|
*/
|
|
public function generateForAllClients(): array {
|
|
$clients = $this->getActiveClients();
|
|
$results = [];
|
|
|
|
foreach ($clients as $client) {
|
|
try {
|
|
$this->generateReportForClient($client);
|
|
$results[$client->getSlug()] = 'success';
|
|
} catch (\Exception $e) {
|
|
$results[$client->getSlug()] = "error: {$e->getMessage()}";
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Generate report for a single client
|
|
*/
|
|
private function generateReportForClient(ClientConfig $client): void {
|
|
$this->logger->info("Generating report for: {$client->getName()}");
|
|
|
|
$rawData = $this->fetchGA4Data($client, '7d');
|
|
|
|
// Process with DataProcessor
|
|
$processor = new \OCA\AnalyticsHub\Service\DataProcessor($this->config);
|
|
$processed = $processor->process($rawData, $client);
|
|
|
|
// Generate with LLM
|
|
$llmService = new \OCA\AnalyticsHub\Service\LLMService($this->config);
|
|
$markdown = $llmService->generate($processed, $client);
|
|
|
|
// Save to Nextcloud
|
|
$result = $this->saveReport($client, $markdown);
|
|
|
|
$this->logger->info("Report generated and saved");
|
|
}
|
|
|
|
/**
|
|
* Get last report time
|
|
*/
|
|
public function getLastReportTime(): ?string {
|
|
$report = $this->dbService->getLatestReport();
|
|
|
|
return $report ? $report['created_at'] : null;
|
|
}
|
|
|
|
/**
|
|
* Ensure Nextcloud folder exists
|
|
*/
|
|
private function ensureNextcloudFolder(string $path) ?\OCP\Files\Folder {
|
|
$userFolder = \OC::$server->getUserFolder();
|
|
|
|
if (!$userFolder->nodeExists($path)) {
|
|
$this->logger->info("Creating Nextcloud folder: {$path}");
|
|
return $userFolder->newFolder($path);
|
|
}
|
|
|
|
return $userFolder->get($path);
|
|
}
|
|
}
|