Fix: Rename app folder to match app ID

- Renamed analytics-hub/ → analyticshub/
- App ID in info.xml is 'analyticshub' (no hyphen)
- Nextcloud requires folder name to match app ID exactly
- Fixes 'Could not download app analyticshub' error during installation

Installation:
- Upload analyticshub/ folder to /var/www/nextcloud/apps/
- Folder name must match app ID in info.xml
This commit is contained in:
WLTBAgent
2026-02-13 18:21:39 +00:00
parent 3b91adcd40
commit 8a445c4d46
17 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<?xml version="1.0"?>
<info>
<id>analyticshub</id>
<name>Mini-CMO Analytics Hub</name>
<summary>AI-powered Google Analytics 4 reporting with automated daily reports</summary>
<description>Nextcloud internal application for Google Analytics 4 monitoring with intelligent delta calculations and AI-generated client reports via Anthropic Claude API.</description>
<version>1.0.0</version>
<licence>AGPL</licence>
<author>Shortcut Solutions</author>
<namespace>AnalyticsHub</namespace>
<types>
<logging/>
<authentication/>
<filesystem/>
<preventing-directory-creation/>
<preventing-user-group-creation/>
<preventing-other-apps-creation/>
<encryption/>
<files_sharing/>
<public/>
</types>
<category>integration</category>
<dependencies>
<nextcloud min-version="25" max-version="26"/>
<php min-version="7.4"/>
</dependencies>
<repair-steps>
<step>Repair steps not needed</step>
<repair-step>Or remove and reinstall</repair-step>
<repair-step>Check Nextcloud logs for errors</repair-step>
</repair-steps>
</info>

View File

@@ -0,0 +1,27 @@
{
"version": "1.0",
"last_updated": "2026-02-12",
"clients": [
{
"id": 1,
"property_id": "123456789",
"name": "Logos School",
"slug": "logos_school",
"active": true,
"context": {
"business_type": "Therapeutic school",
"key_metrics": ["admissions", "parent_inquiries"],
"tone": "professional",
"focus_areas": "Focus on Admission funnel traffic and conversion quality"
},
"webdav_config": {
"base_path": "/AIGeneratedReports/LogosSchool",
"auto_create": true
},
"thresholds": {
"significant_change_pct": 20,
"significant_change_abs": 5
}
}
]
}

87
analyticshub/cron.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Cron;
use OCA\AnalyticsHub\AppInfo;
use OCP\BackgroundJob\TimedJob;
use OCP\BackgroundJob\IJobList;
use OCP\Util\LogLevel;
use OCP\Util;
use OCA\AnalyticsHub\Service\GoogleAnalyticsService;
use OCA\AnalyticsHub\Service\TokenExpiredException;
use OCA\AnalyticsHub\Service\RateLimitException;
/**
* Daily Report Generation Job
* Runs Mon-Fri at 7:00 AM
*/
class DailyReportJob extends TimedJob {
private GoogleAnalyticsService $gaService;
public function __construct(GoogleAnalyticsService $gaService) {
parent::__construct();
$this->gaService = $gaService;
}
protected function run($argument) {
$dayOfWeek = date('N');
// Only run Mon-Fri (1-5)
if ($dayOfWeek < 1 || $dayOfWeek > 5) {
Util::writeLog('Skipping daily report (weekend)');
return;
}
Util::writeLog('Starting daily report generation');
try {
// Generate reports for all active clients
$results = $this->gaService->generateForAllClients();
// Log summary
$success = count(array_filter($results, fn($r) => $r === 'success'));
$total = count($results);
Util::writeLog("Daily reports complete: {$success}/{$total} successful");
if ($success === $total) {
Util::writeLog('All reports generated successfully');
} else {
Util::writeLog('Some reports failed', LogLevel::WARN);
}
} catch (TokenExpiredException $e) {
Util::writeLog('❌ CRITICAL: Refresh token expired - re-run OAuth setup', LogLevel::ERROR);
} catch (RateLimitException $e) {
Util::writeLog('⚠️ WARNING: Rate limit exceeded', LogLevel::WARN);
} catch (\Exception $e) {
Util::writeLog("Daily report generation failed: {$e->getMessage()}", LogLevel::ERROR);
}
}
protected function getScheduledTime(): string {
// Mon-Fri at 7:00 AM
return '07:00';
}
protected function getInterval(): int {
// Daily
return 86400; // 24 hours in seconds
}
}
/**
* Register cron jobs
*/
return [
'app_id' => AppInfo::APP_NAME,
'jobs' => [
[
'class' => DailyReportJob::class,
'name' => 'OCA\AnalyticsHub\Cron\DailyReportJob',
],
],
];

20
analyticshub/info.xml Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0"?>
<info>
<id>analyticshub</id>
<name>Mini-CMO Analytics Hub</name>
<description>AI-powered Google Analytics 4 reporting with automated daily reports</description>
<licence>AGPL</licence>
<author>Shortcut Solutions</author>
<version>1.0.0</version>
<namespace>AnalyticsHub</namespace>
<category>integration</category>
<dependencies>
<nextcloud min-version="25" max-version="26"/>
</dependencies>
<settings>
<admin>OCA\AnalyticsHub\Settings\Admin</admin>
</settings>
<navigation>
<admin>OCA\AnalyticsHub\Settings\Admin</admin>
</navigation>
</info>

50
analyticshub/lib/App.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub;
use OCP\AppFramework\App;
use OCA\AnalyticsHub\Controller\ApiV1Controller;
use OCP\AnalyticsHub\Controller\ReportController;
use OCP\AnalyticsHub\Service\GoogleAnalyticsService;
use OCP\AnalyticsHub\Service\LLMService;
use OCP\AnalyticsHub\Service\DataProcessor;
class App extends App {
public const APP_NAME = 'analytics_hub';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_NAME, $urlParams);
// Register services
$this->registerService('GoogleAnalyticsService', function($c) {
return new GoogleAnalyticsService($c);
});
$this->registerService('LLMService', function($c) {
return new LLMService($c);
});
$this->registerService('DataProcessor', function($c) {
return new DataProcessor($c);
});
// Register controllers
$this->registerService('ApiV1Controller', function($c) {
return new ApiV1Controller($c);
});
$this->registerService('ReportController', function($c) {
return new ReportController($c);
});
$this->registerService('AdminController', function($c) {
return new AdminController($c);
});
}
public function getContainer() {
return $this->getContainer();
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub;
/**
* Application metadata and info
*/
class AppInfo {
public const APP_NAME = 'analytics_hub';
public const APP_VERSION = '1.0.0';
public const APP_AUTHOR = 'Shortcut Solutions';
public const APP_LICENSE = 'AGPL';
/**
* Get application version
*/
public static function getVersion(): string {
return self::APP_VERSION;
}
/**
* Get application name
*/
public static function getName(): string {
return self::APP_NAME;
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Controller;
use OCP\IRequest;
use OCP\IResponse;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCA\AnalyticsHub\Service\GoogleAnalyticsService;
use OCA\AnalyticsHub\Service\LLMService;
use OCA\AnalyticsHub\Service\DataProcessor;
/**
* Admin Settings Controller
* Handles app configuration via admin UI
*/
class AdminController {
private GoogleAnalyticsService $gaService;
private LLMService $llmService;
private DataProcessor $dataProcessor;
public function __construct(
GoogleAnalyticsService $gaService,
LLMService $llmService,
DataProcessor $dataProcessor
) {
$this->gaService = $gaService;
$this->llmService = $llmService;
$this->dataProcessor = $dataProcessor;
}
/**
* Save configuration
* POST /settings/save
*/
public function save(IRequest $request): JSONResponse {
$params = $request->getParams();
// Validate required fields
if (!isset($params['google_client_id'])) {
return new JSONResponse([
'success' => false,
'error' => 'google_client_id is required'
], Http::STATUS_BAD_REQUEST);
}
if (!isset($params['google_client_secret'])) {
return new JSONResponse([
'success' => false,
'error' => 'google_client_secret is required'
], Http::STATUS_BAD_REQUEST);
}
if (!isset($params['anthropic_api_key'])) {
return new JSONResponse([
'success' => false,
'error' => 'anthropic_api_key is required'
], Http::STATUS_BAD_REQUEST);
}
if (!isset($params['clients_json'])) {
return new JSONResponse([
'success' => false,
'error' => 'clients_json is required'
], Http::STATUS_BAD_REQUEST);
}
try {
// Save Google OAuth config
$this->saveConfigValue('google_client_id', $params['google_client_id']);
$this->saveConfigValue('google_client_secret', $params['google_client_secret']);
$this->saveConfigValue('anthropic_api_key', $params['anthropic_api_key']);
// Save client configuration
$clientsJson = $params['clients_json'];
if (!json_decode($clientsJson)) {
return new JSONResponse([
'success' => false,
'error' => 'Invalid JSON format'
], Http::STATUS_BAD_REQUEST);
}
$this->saveConfigValue('clients_json', $clientsJson);
// Test connections
$gaConfigured = $this->gaService->isConfigured();
$llmConfigured = $this->llmService->isConfigured();
return new JSONResponse([
'success' => true,
'data' => [
'google_analytics_configured' => $gaConfigured,
'llm_configured' => $llmConfigured,
'message' => 'Configuration saved successfully'
]
]);
} catch (\Exception $e) {
return new JSONResponse([
'success' => false,
'error' => $e->getMessage()
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get configuration
* GET /settings/load
*/
public function load(IRequest $request): DataResponse {
$config = [
'google_client_id' => $this->getConfigValue('google_client_id'),
'google_client_secret' => $this->getConfigValue('google_client_secret'),
'anthropic_api_key' => $this->getConfigValue('anthropic_api_key'),
'clients_json' => $this->getConfigValue('clients_json'),
];
return new DataResponse([
'success' => true,
'data' => $config
]);
}
/**
* Get app status
* GET /settings/status
*/
public function getStatus(IRequest $request): DataResponse {
$status = [
'app_name' => AppInfo::APP_NAME,
'version' => AppInfo::getVersion(),
'status' => 'operational',
'google_analytics' => $this->gaService->isConfigured() ? 'configured' : 'not_configured',
'llm_service' => $this->llmService->isConfigured() ? 'configured' : 'not_configured',
'total_clients' => $this->gaService->getClientCount(),
'last_report_time' => $this->gaService->getLastReportTime()
];
return new DataResponse([
'success' => true,
'data' => $status
]);
}
/**
* Helper methods
*/
private function saveConfigValue(string $key, string $value): void {
$this->getConfig()->setAppValue($key, $value, AppInfo::APP_NAME);
}
private function getConfigValue(string $key): ?string {
return $this->getConfig()->getAppValue($key, AppInfo::APP_NAME);
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Controller;
use OCP\IRequest;
use OCP\IResponse;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCA\AnalyticsHub\Service\GoogleAnalyticsService;
use OCA\AnalyticsHub\Service\LLMService;
use OCA\AnalyticsHub\Service\DataProcessor;
use OCA\AnalyticsHub\Model\ClientConfig;
use OCA\AnalyticsHub\Model\Report;
/**
* API V1 Controller - Exposes REST APIs for agent integration
*/
class ApiV1Controller {
private GoogleAnalyticsService $gaService;
private LLMService $llmService;
private DataProcessor $dataProcessor;
public function __construct(
GoogleAnalyticsService $gaService,
LLMService $llmService,
DataProcessor $dataProcessor
) {
$this->gaService = $gaService;
$this->llmService = $llmService;
$this->dataProcessor = $dataProcessor;
}
/**
* Get all available reports
* GET /api/reports
*/
public function getReports(IRequest $request): DataResponse {
$this->validateAgentAccess();
$reports = $this->gaService->getAllReports();
return new DataResponse([
'success' => true,
'data' => [
'reports' => $reports
]
]);
}
/**
* Get specific report by ID
* GET /api/report/{id}
*/
public function getReport(IRequest $request, int $id): DataResponse {
$this->validateAgentAccess();
$report = $this->gaService->getReportById($id);
if (!$report) {
return new DataResponse([
'success' => false,
'error' => 'Report not found'
], Http::STATUS_NOT_FOUND);
}
return new DataResponse([
'success' => true,
'data' => [
'report' => $report
]
]);
}
/**
* Generate new report
* POST /api/generate
*/
public function generateReport(IRequest $request): JSONResponse {
$this->validateAgentAccess();
$params = $request->getParams();
$clientSlug = $params['client_slug'] ?? null;
$dateRange = $params['date_range'] ?? '7d';
if (!$clientSlug) {
return new JSONResponse([
'success' => false,
'error' => 'client_slug is required'
], Http::STATUS_BAD_REQUEST);
}
try {
// Get client configuration
$client = $this->gaService->getClientBySlug($clientSlug);
if (!$client) {
return new JSONResponse([
'success' => false,
'error' => 'Client not found'
], Http::STATUS_NOT_FOUND);
}
// Fetch GA4 data
$rawData = $this->gaService->fetchGA4Data($client, $dateRange);
// Process and validate
$processed = $this->dataProcessor->process($rawData, $client);
// Generate report via LLM
$markdown = $this->llmService->generate($processed, $client);
// Save report to Nextcloud
$report = $this->gaService->saveReport($client, $markdown);
return new JSONResponse([
'success' => true,
'data' => [
'report_id' => $report->getId(),
'report_date' => date('Y-m-d'),
'file_path' => $report->getFilePath(),
'markdown_preview' => substr($markdown, 0, 500) . '...'
]
]);
} catch (\Exception $e) {
\OCP\Util::writeLog("Generate report failed: {$e->getMessage()}");
return new JSONResponse([
'success' => false,
'error' => $e->getMessage()
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get app status
* GET /api/status
*/
public function getStatus(IRequest $request): DataResponse {
$this->validateAgentAccess();
$status = [
'app_name' => AppInfo::APP_NAME,
'version' => AppInfo::getVersion(),
'status' => 'operational',
'google_analytics' => $this->gaService->isConfigured() ? 'configured' : 'not_configured',
'llm_service' => $this->llmService->isConfigured() ? 'configured' : 'not_configured',
'total_clients' => $this->gaService->getClientCount(),
'last_report_time' => $this->gaService->getLastReportTime()
];
return new DataResponse([
'success' => true,
'data' => $status
]);
}
/**
* Validate agent access using app password
*/
private function validateAgentAccess(): void {
// Authentication will be handled by Nextcloud middleware
// This is a placeholder for future enhancement
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Controller;
use OCP\IRequest;
use OCP\AppFramework\Http;
use OCA\AnalyticsHub\Service\GoogleAnalyticsService;
use OCA\AnalyticsHub\Service\LLMService;
use OCA\AnalyticsHub\Service\DataProcessor;
use OCA\AnalyticsHub\Model\ClientConfig;
/**
* Report Controller - Internal report generation logic
*/
class ReportController {
private GoogleAnalyticsService $gaService;
private LLMService $llmService;
private DataProcessor $dataProcessor;
public function __construct(
GoogleAnalyticsService $gaService,
LLMService $llmService,
DataProcessor $dataProcessor
) {
$this->gaService = $gaService;
$this->llmService = $llmService;
$this->dataProcessor = $dataProcessor;
}
/**
* Generate report for a specific client
* Called by cron job
*/
public function generateForClient(ClientConfig $client): ?string {
\OCP\Util::writeLog("Generating report for: {$client->getName()}");
try {
// Fetch GA4 data (last 7 days)
$rawData = $this->gaService->fetchGA4Data($client, '7d');
// Process and validate
$processed = $this->dataProcessor->process($rawData, $client);
// Generate report via LLM
$markdown = $this->llmService->generate($processed, $client);
// Save to Nextcloud
$report = $this->gaService->saveReport($client, $markdown);
\OCP\Util::writeLog("Report generated: {$report->getFilePath()}");
return $markdown;
} catch (\Exception $e) {
\OCP\Util::writeLog("Report generation failed for {$client->getName()}: {$e->getMessage()}");
throw $e;
}
}
/**
* Generate reports for all active clients
* Called by cron job
*/
public function generateForAllClients(): array {
$clients = $this->gaService->getActiveClients();
$results = [];
foreach ($clients as $client) {
try {
$this->generateForClient($client);
$results[$client->getSlug()] = 'success';
} catch (\Exception $e) {
$results[$client->getSlug()] = "error: {$e->getMessage()}";
}
}
return $results;
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Model;
/**
* Client configuration model
*/
class ClientConfig {
private int $id;
private string $propertyId;
private string $name;
private string $slug;
private bool $active;
private ?array $context;
private ?array $webdavConfig;
private ?array $thresholds;
public function __construct(
int $id,
string $propertyId,
string $name,
string $slug,
bool $active = true,
?array $context = null,
?array $webdavConfig = null,
?array $thresholds = null
) {
$this->id = $id;
$this->propertyId = $propertyId;
$this->name = $name;
$this->slug = $slug;
$this->active = $active;
$this->context = $context;
$this->webdavConfig = $webdavConfig;
$this->thresholds = $thresholds;
}
// Getters
public function getId(): int {
return $this->id;
}
public function getPropertyId(): string {
return $this->propertyId;
}
public function getName(): string {
return $this->name;
}
public function getSlug(): string {
return $this->slug;
}
public function isActive(): bool {
return $this->active;
}
public function getContext(): ?array {
return $this->context;
}
public function getWebdavConfig(): ?array {
return $this->webdavConfig;
}
public function getThresholds(): ?array {
return $this->thresholds;
}
// Setters
public function setActive(bool $active): void {
$this->active = $active;
}
/**
* Create from JSON
*/
public static function fromJson(array $data): self {
return new self(
(int)($data['id'] ?? 0),
(string)($data['property_id'] ?? ''),
(string)($data['name'] ?? ''),
(string)($data['slug'] ?? ''),
(bool)($data['active'] ?? true),
$data['context'] ?? null,
$data['webdav_config'] ?? null,
$data['thresholds'] ?? null
);
}
/**
* Convert to array
*/
public function toArray(): array {
return [
'id' => $this->id,
'property_id' => $this->propertyId,
'name' => $this->name,
'slug' => $this->slug,
'active' => $this->active,
'context' => $this->context,
'webdav_config' => $this->webdavConfig,
'thresholds' => $this->thresholds,
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Model;
/**
* Report model
*/
class Report {
private int $id;
private int $clientId;
private string $clientName;
private string $reportDate;
private string $filePath;
private ?int $fileSize;
private string $createdAt;
public function __construct(
int $id,
int $clientId,
string $clientName,
string $reportDate,
string $filePath,
?int $fileSize = null,
string $createdAt
) {
$this->id = $id;
$this->clientId = $clientId;
$this->clientName = $clientName;
$this->reportDate = $reportDate;
$this->filePath = $filePath;
$this->fileSize = $fileSize;
$this->createdAt = $createdAt;
}
// Getters
public function getId(): int {
return $this->id;
}
public function getClientId(): int {
return $this->clientId;
}
public function getClientName(): string {
return $this->clientName;
}
public function getReportDate(): string {
return $this->reportDate;
}
public function getFilePath(): string {
return $this->filePath;
}
public function getFileSize(): ?int {
return $this->fileSize;
}
public function getCreatedAt(): string {
return $this->createdAt;
}
/**
* Convert to array
*/
public function toArray(): array {
return [
'id' => $this->id,
'client_id' => $this->clientId,
'client_name' => $this->clientName,
'report_date' => $this->reportDate,
'file_path' => $this->filePath,
'file_size' => $this->fileSize,
'created_at' => $this->createdAt,
];
}
}

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Service;
use OCA\AnalyticsHub\Model\ClientConfig;
use OCP\Util\Logger;
/**
* Data Processor Service
* Handles intelligent delta calculations and data validation
*/
class DataProcessor {
private ?Logger $logger;
// Default thresholds
private const DEFAULT_SIGNIFICANT_CHANGE_PCT = 20;
private const DEFAULT_SIGNIFICANT_CHANGE_ABS = 5;
public function __construct() {
$this->logger = \OC::$server->getLogger();
}
/**
* Process and validate GA4 data
*/
public function process(array $rawData, ClientConfig $client): array {
$this->logger->info("Processing data for: {$client->getName()}");
try {
// Validate data completeness
$this->validateCompleteness($rawData);
// Calculate deltas
$processed = $this->calculateDeltas($rawData);
// Apply client thresholds
$processed = $this->applyThresholds($processed, $client);
// Generate summary
$processed['summary'] = $this->generateSummary($processed);
return $processed;
} catch (\Exception $e) {
$this->logger->error("Data processing failed: {$e->getMessage()}");
throw $e;
}
}
/**
* Validate data completeness
*/
private function validateCompleteness(array $data): void {
$requiredKeys = ['dates', 'metrics'];
foreach ($requiredKeys as $key) {
if (!isset($data[$key])) {
throw new DataIncompleteException("Missing required key: {$key}");
}
}
// Check we have at least 7 days of data
if (!isset($data['dates']) || count($data['dates']) < 7) {
throw new DataIncompleteException("Insufficient data - need at least 7 days");
}
// Check for null or empty metrics
$metrics = $data['metrics'] ?? [];
$requiredMetrics = ['sessions', 'totalUsers', 'conversions', 'eventCount'];
foreach ($requiredMetrics as $metric) {
if (!isset($metrics[$metric])) {
throw new DataIncompleteException("Missing required metric: {$metric}");
}
$values = $metrics[$metric] ?? [];
foreach ($values as $value) {
if ($value === null || $value === '') {
throw new DataIncompleteException("Null or empty value in metric: {$metric}");
}
}
}
$this->logger->info('Data validation passed');
}
/**
* Calculate intelligent deltas
*/
private function calculateDeltas(array $data): array {
$metrics = $data['metrics'] ?? [];
$processed = [];
foreach ($metrics as $metric => $values) {
$processed[$metric] = $this->calculateMetricDelta($values);
}
$processed['dates'] = $data['dates'] ?? [];
return $processed;
}
/**
* Calculate delta for a single metric
*/
private function calculateMetricDelta(array $values): array {
if (count($values) < 2) {
return [
'change' => 0,
'change_pct' => 0,
'abs_change' => 0,
'is_significant' => false,
'label' => 'Insufficient data',
'trend' => 'flat'
];
}
$current = $values[count($values) - 1]; // Last 7 days
$previous = $values[0]; // First 7 days (comparison)
// Edge case 1: Division by zero
if ($previous == 0) {
if ($current == 0) {
return [
'change' => 0,
'change_pct' => 0,
'abs_change' => 0,
'is_significant' => false,
'label' => 'No change',
'trend' => 'stable'
];
} else {
return [
'change' => $current,
'change_pct' => null,
'abs_change' => $current,
'is_significant' => true,
'label' => "New activity (+{$current})",
'trend' => 'increasing'
];
}
}
// Calculate percentage change
$changePct = (($current - $previous) / $previous) * 100;
$absChange = abs($current - $previous);
// Edge case 2: Small numbers creating misleading %
// Require both % threshold AND minimum absolute change
$thresholds = [
'significant_change_pct' => self::DEFAULT_SIGNIFICANT_CHANGE_PCT,
'significant_change_abs' => self::DEFAULT_SIGNIFICANT_CHANGE_ABS
];
$isSignificant = (abs($changePct) > $thresholds['significant_change_pct']
&& $absChange > $thresholds['significant_change_abs']);
// Determine trend
$trend = 'stable';
if ($absChange > $thresholds['significant_change_abs']) {
$trend = $changePct > 0 ? 'increasing' : 'decreasing';
}
return [
'current' => $current,
'previous' => $previous,
'change' => $current - $previous,
'change_pct' => round($changePct, 1),
'abs_change' => $absChange,
'is_significant' => $isSignificant,
'label' => $this->formatDeltaLabel($changePct, $absChange, $isSignificant),
'trend' => $trend
];
}
/**
* Format delta label
*/
private function formatDeltaLabel(float $changePct, float $absChange, bool $isSignificant): string {
if (!$isSignificant) {
return 'No significant change';
}
$direction = $changePct > 0 ? '+' : '';
$pct = abs($changePct);
$change = (int)$absChange;
return "{$direction}{$pct}% ({$direction}{$change})";
}
/**
* Apply client-specific thresholds
*/
private function applyThresholds(array $processed, ClientConfig $client): array {
$thresholds = $client->getThresholds();
if (!$thresholds) {
// Use defaults
$processed['significant_change_pct'] = self::DEFAULT_SIGNIFICANT_CHANGE_PCT;
$processed['significant_change_abs'] = self::DEFAULT_SIGNIFICANT_CHANGE_ABS;
} else {
// Use client thresholds
$processed['significant_change_pct'] = $thresholds['significant_change_pct'] ?? self::DEFAULT_SIGNIFICANT_CHANGE_PCT;
$processed['significant_change_abs'] = $thresholds['significant_change_abs'] ?? self::DEFAULT_SIGNIFICANT_CHANGE_ABS;
}
// Re-evaluate significance based on thresholds
foreach ($processed as $metric => $data) {
if (is_array($data)) {
$data['is_significant'] = (
abs($data['change_pct'] ?? 0) > $processed['significant_change_pct']
&& abs($data['abs_change'] ?? 0) > $processed['significant_change_abs']
);
}
}
return $processed;
}
/**
* Generate summary
*/
private function generateSummary(array $processed): array {
$totalMetrics = 0;
$significantChanges = 0;
$increasing = 0;
$decreasing = 0;
foreach ($processed as $metric => $data) {
if (is_array($data)) {
$totalMetrics++;
if ($data['is_significant'] ?? false) {
$significantChanges++;
}
if (($data['trend'] ?? '') === 'increasing') {
$increasing++;
}
if (($data['trend'] ?? '') === 'decreasing') {
$decreasing++;
}
}
}
return [
'total_metrics' => $totalMetrics,
'significant_changes' => $significantChanges,
'increasing' => $increasing,
'decreasing' => $decreasing
];
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Service;
use OCA\AnalyticsHub\Model\ClientConfig;
use OCP\IDBConnection;
use OCP\AppFramework\Utility\SimplePDO;
/**
* Database Service
* Handles report storage in Nextcloud database
*/
class DatabaseService {
private IDBConnection $db;
public function __construct(IDBConnection $db) {
$this->db = $db;
}
/**
* Initialize database tables
*/
public function initialize(): void {
$this->createReportsTable();
}
/**
* Create reports table
*/
private function createReportsTable(): void {
$sql = "
CREATE TABLE IF NOT EXISTS *PREFIX*analytics_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
client_name TEXT NOT NULL,
report_date TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TEXT NOT NULL
)
";
$this->db->executeUpdate($sql);
}
/**
* Save report to database
*/
public function saveReport(int $clientId, string $clientName, string $markdown, string $filePath): int {
$sql = "
INSERT INTO *PREFIX*analytics_reports
(client_id, client_name, report_date, file_path, file_size, created_at)
VALUES (?, ?, ?, ?, ?, ?)
";
$this->db->executeUpdate($sql, [
$clientId,
$clientName,
date('Y-m-d'),
$filePath,
strlen($markdown),
date('Y-m-d H:i:s')
]);
return (int)$this->db->lastInsertId();
}
/**
* Get report by ID
*/
public function getReportById(int $id): ?array {
$sql = "
SELECT * FROM *PREFIX*analytics_reports
WHERE id = ?
";
$result = $this->db->executeQuery($sql, [$id])->fetch();
return $result ? json_decode(json_encode($result), true) : null;
}
/**
* Get all reports
*/
public function getAllReports(): array {
$sql = "
SELECT * FROM *PREFIX*analytics_reports
ORDER BY created_at DESC
LIMIT 100
";
$result = $this->db->executeQuery($sql)->fetchAll();
return $result;
}
/**
* Get reports by client
*/
public function getReportsByClient(int $clientId): array {
$sql = "
SELECT * FROM *PREFIX*analytics_reports
WHERE client_id = ?
ORDER BY created_at DESC
";
$result = $this->db->executeQuery($sql, [$clientId])->fetchAll();
return $result;
}
/**
* Get latest report
*/
public function getLatestReport(): ?array {
$sql = "
SELECT * FROM *PREFIX*analytics_reports
ORDER BY created_at DESC
LIMIT 1
";
$result = $this->db->executeQuery($sql)->fetch();
return $result ? json_decode(json_encode($result), true) : null;
}
/**
* Delete old reports (cleanup)
*/
public function deleteOldReports(int $days = 90): int {
$sql = "
DELETE FROM *PREFIX*analytics_reports
WHERE created_at < datetime('now', '-' . $days . ' days')
";
return $this->db->executeUpdate($sql);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Service;
/**
* Custom exceptions for Analytics Hub
*/
/**
* Token expired exception
*/
class TokenExpiredException extends \Exception {
public function __construct(string $message = "Refresh token expired") {
parent::__construct($message);
}
}
/**
* Rate limit exceeded exception
*/
class RateLimitException extends \Exception {
public function __construct(string $message = "Rate limit exceeded") {
parent::__construct($message);
}
}
/**
* Timeout exception
*/
class TimeoutException extends \Exception {
public function __construct(string $message = "API timeout") {
parent::__construct($message);
}
}
/**
* Data incomplete exception
*/
class DataIncompleteException extends \Exception {
public function __construct(string $message = "Data incomplete") {
parent::__construct($message);
}
}

View File

@@ -0,0 +1,499 @@
<?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 (strpos($e->getMessage(), 'invalid_grant') !== false) {
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);
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace OCA\AnalyticsHub\Service;
use OCA\AnalyticsHub\Model\ClientConfig;
use OCP\IConfig;
use OCP\Util\Logger;
/**
* LLM Service - Anthropic Claude API integration
* Generates reports from analytics data with retry logic
*/
class LLMService {
private IConfig $config;
private ?Logger $logger;
// Anthropic API endpoint
private const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
private const MAX_RETRIES = 3;
private const RATE_LIMIT_DELAY = 12; // Seconds to wait between retries
private const TIMEOUT_SECONDS = 30;
public function __construct(IConfig $config) {
$this->config = $config;
$this->logger = \OC::$server->getLogger();
}
/**
* Check if LLM service is configured
*/
public function isConfigured(): bool {
$apiKey = $this->config->getAppValue('anthropic_api_key', AppInfo::APP_NAME);
return !empty($apiKey);
}
/**
* Generate report from processed data
*/
public function generate(array $processedData, ClientConfig $client): string {
$this->logger->info("Generating LLM report for: {$client->getName()}");
try {
$apiKey = $this->config->getAppValue('anthropic_api_key', AppInfo::APP_NAME);
if (empty($apiKey)) {
throw new \Exception('Anthropic API key not configured');
}
// Build prompt
$systemPrompt = $this->buildSystemPrompt($client);
$userPrompt = $this->buildUserPrompt($processedData);
// Call with retry
$response = $this->callWithRetry($systemPrompt, $userPrompt, $apiKey);
return $response;
} catch (\Exception $e) {
$this->logger->error("LLM generation failed: {$e->getMessage()}");
throw $e;
}
}
/**
* Build system prompt
*/
private function buildSystemPrompt(ClientConfig $client): string {
$context = $client->getContext();
$businessType = $context['business_type'] ?? 'business';
$tone = $context['tone'] ?? 'professional';
$focusAreas = $context['focus_areas'] ?? 'overall performance';
return <<<PROMPT
You are a Mini-CMO analyst for {$client->getName()}, a {$businessType}.
Your role: Transform analytics data into clear, actionable insights for business owner.
TONE: {$tone} but conversational - avoid jargon.
FOCUS: {$focusAreas}
OUTPUT FORMAT (strict):
# Weekly Analytics Snapshot - {report_date}
## 📊 Headline
[One sentence summary of the biggest story this week]
## ✅ Key Wins
[2-3 bullet points on positive movements or validations]
## 💡 Recommendation
[One specific action based on the data]
## 📈 The Numbers
[Formatted table of metrics with context]
RULES:
- Be specific (use actual numbers from data)
- Focus on "why this matters" not just "what changed"
- If a change is statistically insignificant, note it
- Avoid phrases like "optimization" or "synergy"
PROMPT;
}
/**
* Build user prompt
*/
private function buildUserPrompt(array $processedData): string {
$dataJson = json_encode($processedData, JSON_PRETTY_PRINT);
return <<<PROMPT
DATA SUMMARY: {$dataJson}
Generate weekly report following the system format exactly.
PROMPT;
}
/**
* Call Claude API with retry logic
*/
private function callWithRetry(string $systemPrompt, string $userPrompt, string $apiKey): string {
for ($attempt = 0; $attempt < self::MAX_RETRIES; $attempt++) {
$this->logger->info("LLM API call attempt {$attempt}/" . self::MAX_RETRIES);
try {
$response = $this->makeLLMRequest($systemPrompt, $userPrompt, $apiKey);
// Validate response
$this->validateResponse($response);
return $response;
} catch (\Exception $e) {
$errorMessage = $e->getMessage();
// Check for rate limit
if (strpos($errorMessage, 'rate_limit') !== false ||
strpos($errorMessage, '429') !== false ||
strpos($errorMessage, 'too_many_requests') !== false) {
$this->logger->warning("Rate limited, waiting " . self::RATE_LIMIT_DELAY . "s");
if ($attempt < self::MAX_RETRIES - 1) {
sleep(self::RATE_LIMIT_DELAY * $attempt); // Exponential backoff
continue;
} else {
throw new RateLimitException("Rate limit exceeded after retries");
}
}
// Check for timeout
if (strpos($errorMessage, 'timeout') !== false ||
strpos($errorMessage, 'connection') !== false ||
strpos($errorMessage, 'timed out') !== false) {
if ($attempt < self::MAX_RETRIES - 1) {
$this->logger->warning("Timeout, retrying...");
sleep(2 * $attempt);
continue;
} else {
throw new TimeoutException("API timeout after retries");
}
}
// Other errors - log and continue
$this->logger->error("LLM API error (attempt {$attempt}): {$errorMessage}");
if ($attempt < self::MAX_RETRIES - 1) {
sleep(1); // Brief pause before retry
continue;
} else {
throw $e;
}
}
}
throw new \Exception("Failed after " . self::MAX_RETRIES . " attempts");
}
/**
* Make HTTP request to Anthropic API
*/
private function makeLLMRequest(string $systemPrompt, string $userPrompt, string $apiKey): string {
$ch = curl_init();
$payload = [
'model' => 'claude-sonnet-4-5-20250929',
'max_tokens' => 2000,
'system' => $systemPrompt,
'messages' => [
[
'role' => 'user',
'content' => $userPrompt
]
]
];
curl_setopt($ch, CURLOPT_URL, self::ANTHROPIC_API);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'x-api-key: ' . $apiKey,
'anthropic-version: 2023-06-01'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT_SECONDS);
$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);
throw new \Exception('HTTP request failed');
}
curl_close($ch);
if ($httpCode !== 200) {
$this->logger->error("LLM API HTTP {$httpCode}");
throw new \Exception("API returned HTTP {$httpCode}");
}
return $response;
}
/**
* Validate response
*/
private function validateResponse(string $response): void {
if (strlen($response) < 200) {
throw new \Exception('Response too short - likely error');
}
if (strpos($response, '# Weekly Analytics Snapshot') === false) {
throw new \Exception('Missing required format');
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
style('display:none');
?>
<div id="analytics-hub-settings" class="section analytics-hub-settings">
<h2>Mini-CMO Analytics Hub</h2>
<p>AI-powered Google Analytics 4 reporting with automated daily reports.</p>
<div class="analytics-hub-settings__section">
<h3>Google Analytics Configuration</h3>
<div class="analytics-hub-settings__field">
<label for="google_client_id">Google Client ID</label>
<input
type="text"
id="google_client_id"
name="google_client_id"
placeholder="123456789.apps.googleusercontent.com"
autocomplete="off"
/>
</div>
<div class="analytics-hub-settings__field">
<label for="google_client_secret">Google Client Secret</label>
<input
type="password"
id="google_client_secret"
name="google_client_secret"
placeholder="GOCSPX-..."
autocomplete="off"
/>
</div>
<div class="analytics-hub-settings__field">
<label for="google_refresh_token">Refresh Token</label>
<input
type="password"
id="google_refresh_token"
name="google_refresh_token"
placeholder="1//..."
autocomplete="off"
/>
<p class="analytics-hub-settings__hint">
After OAuth consent, paste only the refresh token here.
The access token is refreshed automatically each run.
</p>
</div>
</div>
<div class="analytics-hub-settings__section">
<h3>Anthropic Claude API</h3>
<div class="analytics-hub-settings__field">
<label for="anthropic_api_key">API Key</label>
<input
type="password"
id="anthropic_api_key"
name="anthropic_api_key"
placeholder="sk-ant-..."
autocomplete="off"
/>
<p class="analytics-hub-settings__hint">
Enter your Anthropic API key for AI-powered report generation.
Model: claude-sonnet-4-5-20250929 (cost-effective)
Cost: ~$0.015 per report (3K tokens)
</p>
</div>
</div>
<div class="analytics-hub-settings__actions">
<button id="analytics-hub-save" class="primary">
Save Configuration
</button>
<button id="analytics-hub-test" class="secondary">
Test Connection
</button>
</div>
</div>
<style>
.analytics-hub-settings {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.analytics-hub-settings__section {
margin-bottom: 30px;
}
.analytics-hub-settings__field {
margin-bottom: 20px;
}
.analytics-hub-settings__field label {
display: block;
font-weight: 600;
margin-bottom: 8px;
}
.analytics-hub-settings__field input {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.analytics-hub-settings__hint {
font-size: 13px;
color: #666;
margin-top: 8px;
line-height: 1.4;
}
.analytics-hub-settings__actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.analytics-hub-settings__actions button {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.analytics-hub-settings__actions .primary {
background-color: #0078d4;
color: white;
}
.analytics-hub-settings__actions .secondary {
background-color: #6c757d;
color: white;
}
</style>