diff --git a/openapi.yaml b/openapi.yaml index 0ff795a8..027a8583 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7196,6 +7196,45 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /collected-invoices/economic/pdf: + get: + tags: + - Invoices + summary: Download a collected invoice e-conomic PDF + description: | + Resolves the requested collected invoice context and returns a presigned URL for the + draft or booked e-conomic invoice PDF. + operationId: downloadCollectedInvoiceEconomicPdf + parameters: + - name: collected_invoice_id + in: query + required: true + description: The internal collected invoice ID + schema: + type: integer + minimum: 1 + - name: type + in: query + required: true + description: Which e-conomic invoice PDF to download + schema: + type: string + enum: + - draft + - booked + responses: + '200': + description: PDF URL resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicPdfResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/v2/details: get: tags: @@ -17750,6 +17789,31 @@ components: - collected_invoice_id - internal_total + CollectedInvoiceEconomicPdfResponse: + type: object + description: Presigned PDF URL for a draft or booked e-conomic invoice attached to a collected invoice + properties: + collected_invoice_id: + type: integer + example: 123 + type: + type: string + enum: + - draft + - booked + example: booked + economic_invoice_id: + type: integer + example: 28368 + url: + type: string + format: uri + required: + - collected_invoice_id + - type + - economic_invoice_id + - url + CollectedInvoiceEconomicV2DetailsResponse: type: object properties: diff --git a/services/nginx/app/classes/fxratesapi.php b/services/nginx/app/classes/fxratesapi.php index f59ef902..36c3ba96 100644 --- a/services/nginx/app/classes/fxratesapi.php +++ b/services/nginx/app/classes/fxratesapi.php @@ -11,6 +11,7 @@ use fxratesapi\actions\convert_rate_a; use fxratesapi\fxratesapi_c; use interfaces\fxratesapi_i; use objects\fxratesapi_conversion_rates_o; +use Throwable; class fxratesapi implements fxratesapi_i { @@ -117,10 +118,10 @@ class fxratesapi implements fxratesapi_i // Validate the base and target currencies self::requireValidCurrency($base); self::requireValidCurrency($target); - // Validate the daily limit - self::requireDailyLimitNotExceeded(); // Validate the secret key self::requireValidSecretKey(); + // Reserve quota for the outbound provider call. Cached conversion reads return before this point. + $this->reserveRateFetchQuota($base, $target, $endpoint, $method); // Send the request $response = match ($method) { 'GET' => self::sendGetRequest($base, $target, $endpoint, $data), @@ -167,7 +168,7 @@ class fxratesapi implements fxratesapi_i function requireDailyLimitNotExceeded(): void { // Check if the daily limit is exceeded - if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) { + if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) { throw new Exception('Daily limit exceeded'); } } @@ -177,12 +178,30 @@ class fxratesapi implements fxratesapi_i */ function getDailyRequestCounter(): int { + try { + return (new module_usage_service())->currentUsedQuantity('fxratesapi', 'rate_fetch_calls'); + } catch (Throwable) { + } + // Count the rows from the fxratesapi request log that was made today $fxratesapi_lookups = new fxratesapi_conversion_rates_o(); $fxratesapi_lookups->getTodayCount(); return $fxratesapi_lookups->getTodayCount(); } + /** + * @throws Exception + */ + private function reserveRateFetchQuota(string $base, string $target, string $endpoint, string $method): void + { + (new module_usage_service())->reserveOrFail('fxratesapi', 'rate_fetch_calls', 1, [ + 'base' => $base, + 'target' => $target, + 'endpoint' => $endpoint, + 'method' => strtoupper($method), + ]); + } + /** * @inheritDoc */ @@ -515,4 +534,4 @@ class fxratesapi implements fxratesapi_i throw new Exception('Failed to add request to log'); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/invoice_store.php b/services/nginx/app/classes/invoice_store.php index 876ef17f..23367fa8 100644 --- a/services/nginx/app/classes/invoice_store.php +++ b/services/nginx/app/classes/invoice_store.php @@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i return count($objects['Contents'] ?? []) > 0; } - public function getInvoiceDownloadUrl(int $id): string + public function getInvoiceDownloadUrl(int|string $id): string { return self::getPresignedUrl('invoice_' . $id . '.pdf'); } @@ -51,4 +51,4 @@ class invoice_store implements minio_invoices_i { return self::getS3Client()->doesObjectExist(self::getBucket(), $file); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/module_usage_registry.php b/services/nginx/app/classes/module_usage_registry.php new file mode 100644 index 00000000..80ee7e8a --- /dev/null +++ b/services/nginx/app/classes/module_usage_registry.php @@ -0,0 +1,169 @@ + $this->withDefaults($descriptor), + [ + [ + 'module_key' => 'motorapi', + 'module_label' => 'MotorAPI', + 'metric_key' => 'lookup_calls', + 'metric_label' => 'License plate lookups', + 'unit' => 'calls', + 'period' => 'day', + 'source' => 'internal_counter', + 'default_enforce_mode' => 'block', + 'config_module' => 'motorapi', + 'config_variable' => 'daily_limit', + 'config_type' => 'int', + 'legacy_count_table' => 'motorapi_lookups', + 'primary' => true, + 'writable_limit' => true, + ], + [ + 'module_key' => 'motorapi', + 'module_label' => 'MotorAPI', + 'metric_key' => 'provider_usage', + 'metric_label' => 'Provider usage', + 'unit' => 'calls', + 'period' => 'provider', + 'source' => 'provider_snapshot', + ], + [ + 'module_key' => 'fxratesapi', + 'module_label' => 'FXRatesAPI', + 'metric_key' => 'rate_fetch_calls', + 'metric_label' => 'Currency rate fetches', + 'unit' => 'calls', + 'period' => 'day', + 'source' => 'internal_counter', + 'default_enforce_mode' => 'block', + 'config_module' => 'fxratesapi', + 'config_variable' => 'daily_limit', + 'config_type' => 'int', + 'legacy_count_table' => 'fxratesapi_conversion_rates', + 'primary' => true, + 'writable_limit' => true, + ], + [ + 'module_key' => 'virkdata', + 'module_label' => 'VirkData', + 'metric_key' => 'company_search_calls', + 'metric_label' => 'Company searches', + 'unit' => 'calls', + 'period' => 'month', + 'source' => 'internal_counter', + 'default_enforce_mode' => 'observe', + 'config_module' => 'virkdata', + 'config_variable' => 'monthly_limit', + 'config_type' => 'int', + 'legacy_log_module' => 'VIRKDATA', + 'legacy_log_action' => 'VIRKDATA_SEARCH', + 'primary' => true, + 'writable_limit' => true, + ], + [ + 'module_key' => 'licenseplaterecognizer', + 'module_label' => 'License Plate Recognizer', + 'metric_key' => 'plate_recognition_calls', + 'metric_label' => 'Plate recognition calls', + 'unit' => 'calls', + 'period' => 'provider', + 'source' => 'provider_snapshot', + 'default_enforce_mode' => 'observe', + 'primary' => true, + ], + [ + 'module_key' => 'email', + 'module_label' => 'Email', + 'metric_key' => 'mailersend_messages', + 'metric_label' => 'MailerSend messages', + 'unit' => 'messages', + 'period' => 'provider', + 'source' => 'provider_snapshot', + ], + ['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'api_calls', 'metric_label' => 'API calls', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'total_tokens', 'metric_label' => 'Total tokens', 'unit' => 'tokens', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'weatherapi', 'module_label' => 'WeatherAPI', 'metric_key' => 'requests', 'metric_label' => 'Weather requests', 'unit' => 'calls', 'period' => 'day', 'source' => 'internal_counter'], + ['module_key' => 'gatewayapi', 'module_label' => 'GatewayAPI', 'metric_key' => 'sms_messages', 'metric_label' => 'SMS messages', 'unit' => 'messages', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'bird', 'module_label' => 'Bird', 'metric_key' => 'messages_and_calls', 'metric_label' => 'Messages and calls', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'ocrspace', 'module_label' => 'OCRSpace', 'metric_key' => 'ocr_requests', 'metric_label' => 'OCR requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'limble', 'module_label' => 'Limble', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'workfeed', 'module_label' => 'Workfeed', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'stripe', 'module_label' => 'Stripe', 'metric_key' => 'payment_events', 'metric_label' => 'Payment events', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'coolify', 'module_label' => 'Coolify', 'metric_key' => 'operations', 'metric_label' => 'Operations', 'unit' => 'events', 'period' => 'month', 'source' => 'derived'], + ['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'backup_jobs', 'metric_label' => 'Backup jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'derived'], + ['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'stored_bytes', 'metric_label' => 'Stored backup data', 'unit' => 'bytes', 'period' => 'all_time', 'source' => 'derived'], + ['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'wash_sessions', 'metric_label' => 'Wash sessions', 'unit' => 'sessions', 'period' => 'month', 'source' => 'derived'], + ['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'lane_commands', 'metric_label' => 'Lane commands', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SELFSERVE'], + ['module_key' => 'xlvask', 'module_label' => 'XLVask', 'metric_key' => 'usage_rows', 'metric_label' => 'Usage rows', 'unit' => 'rows', 'period' => 'month', 'source' => 'derived'], + ['module_key' => 'attachments', 'module_label' => 'Attachments', 'metric_key' => 'stored_files', 'metric_label' => 'Stored files', 'unit' => 'files', 'period' => 'all_time', 'source' => 'derived'], + ['module_key' => 'dynamicimages', 'module_label' => 'Dynamic Images', 'metric_key' => 'renders', 'metric_label' => 'Image renders', 'unit' => 'renders', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'html2pdf', 'module_label' => 'HTML2PDF', 'metric_key' => 'pdf_jobs', 'metric_label' => 'PDF jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'internal_counter'], + ['module_key' => 'forms', 'module_label' => 'Forms', 'metric_key' => 'submissions', 'metric_label' => 'Submissions', 'unit' => 'submissions', 'period' => 'month', 'source' => 'derived'], + ['module_key' => 'notifications', 'module_label' => 'Notifications', 'metric_key' => 'notification_sends', 'metric_label' => 'Notification sends', 'unit' => 'notifications', 'period' => 'month', 'source' => 'derived'], + ['module_key' => 'shelly', 'module_label' => 'Shelly', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SHELLY'], + ['module_key' => 'edgegateway', 'module_label' => 'Edge Gateway', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'derived'], + ['module_key' => 'system', 'module_label' => 'System', 'metric_key' => 'cron_runs', 'metric_label' => 'Cron runs', 'unit' => 'runs', 'period' => 'day', 'source' => 'derived'], + ] + ); + } + + public function find(string $moduleKey, string $metricKey): ?array + { + $moduleKey = $this->normalizeModuleKey($moduleKey); + $metricKey = $this->normalizeMetricKey($metricKey); + + foreach ($this->all() as $descriptor) { + if ($descriptor['module_key'] === $moduleKey && $descriptor['metric_key'] === $metricKey) { + return $descriptor; + } + } + + return null; + } + + public function forModule(string $moduleKey): array + { + $moduleKey = $this->normalizeModuleKey($moduleKey); + return array_values(array_filter( + $this->all(), + static fn(array $descriptor): bool => $descriptor['module_key'] === $moduleKey + )); + } + + public function normalizeModuleKey(string $moduleKey): string + { + return strtolower(trim($moduleKey)); + } + + public function normalizeMetricKey(string $metricKey): string + { + return strtolower(trim($metricKey)); + } + + private function withDefaults(array $descriptor): array + { + $descriptor['module_key'] = $this->normalizeModuleKey((string)$descriptor['module_key']); + $descriptor['metric_key'] = $this->normalizeMetricKey((string)$descriptor['metric_key']); + $descriptor['module_label'] = (string)($descriptor['module_label'] ?? $descriptor['module_key']); + $descriptor['metric_label'] = (string)($descriptor['metric_label'] ?? $descriptor['metric_key']); + $descriptor['unit'] = (string)($descriptor['unit'] ?? 'count'); + $descriptor['period'] = (string)($descriptor['period'] ?? 'all_time'); + $descriptor['scope_type'] = (string)($descriptor['scope_type'] ?? 'global'); + $descriptor['scope_id'] = (string)($descriptor['scope_id'] ?? ''); + $descriptor['source'] = (string)($descriptor['source'] ?? 'internal_counter'); + $descriptor['default_enforce_mode'] = (string)($descriptor['default_enforce_mode'] ?? 'observe'); + $descriptor['soft_limit_percent'] = (float)($descriptor['soft_limit_percent'] ?? self::DEFAULT_SOFT_LIMIT_PERCENT); + $descriptor['writable_limit'] = (bool)($descriptor['writable_limit'] ?? false); + $descriptor['primary'] = (bool)($descriptor['primary'] ?? false); + return $descriptor; + } +} diff --git a/services/nginx/app/classes/module_usage_schema_bootstrap.php b/services/nginx/app/classes/module_usage_schema_bootstrap.php new file mode 100644 index 00000000..e6ad94dd --- /dev/null +++ b/services/nginx/app/classes/module_usage_schema_bootstrap.php @@ -0,0 +1,119 @@ +query( + "CREATE TABLE IF NOT EXISTS module_usage_counters ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + module_key VARCHAR(64) NOT NULL, + metric_key VARCHAR(128) NOT NULL, + scope_type VARCHAR(32) NOT NULL DEFAULT 'global', + scope_id VARCHAR(191) NOT NULL DEFAULT '', + period_key VARCHAR(32) NOT NULL DEFAULT 'all_time', + period_start DATETIME NOT NULL, + period_end DATETIME NULL, + unit VARCHAR(32) NOT NULL DEFAULT 'count', + used_quantity DECIMAL(20,4) NOT NULL DEFAULT 0, + limit_quantity DECIMAL(20,4) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ok', + metadata_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_module_usage_counter (module_key, metric_key, scope_type, scope_id, period_key, period_start), + KEY idx_module_usage_counters_module_period (module_key, period_key, period_start), + KEY idx_module_usage_counters_status (status, updated_at), + KEY idx_module_usage_counters_scope (scope_type, scope_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS module_usage_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + module_key VARCHAR(64) NOT NULL, + metric_key VARCHAR(128) NOT NULL, + source VARCHAR(32) NOT NULL DEFAULT 'provider', + period_key VARCHAR(32) NOT NULL DEFAULT 'provider', + period_start DATETIME NULL, + period_end DATETIME NULL, + unit VARCHAR(32) NOT NULL DEFAULT 'count', + used_quantity DECIMAL(20,4) NULL, + limit_quantity DECIMAL(20,4) NULL, + remaining_quantity DECIMAL(20,4) NULL, + usage_percent DECIMAL(8,4) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'unknown', + raw_payload_json LONGTEXT NULL, + checked_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_module_usage_snapshots_module_checked (module_key, metric_key, checked_at), + KEY idx_module_usage_snapshots_status (status, checked_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS module_quota_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + module_key VARCHAR(64) NOT NULL, + metric_key VARCHAR(128) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + enforce_mode VARCHAR(16) NOT NULL DEFAULT 'observe', + soft_limit_percent DECIMAL(6,2) NOT NULL DEFAULT 90.00, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_module_quota_setting (module_key, metric_key), + KEY idx_module_quota_settings_mode (enforce_mode, enabled) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS module_usage_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + module VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + status_code INT NOT NULL DEFAULT 0, + data LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_module_usage_logs_module_created (module, created_at), + KEY idx_module_usage_logs_action_created (action, created_at), + KEY idx_module_usage_logs_status_created (status_code, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + self::ensureColumn('module_quota_settings', 'enabled', "TINYINT(1) NOT NULL DEFAULT 1 AFTER metric_key"); + self::ensureColumn('module_quota_settings', 'soft_limit_percent', "DECIMAL(6,2) NOT NULL DEFAULT 90.00 AFTER enforce_mode"); + self::$initialized = true; + } + + private static function ensureColumn(string $table, string $column, string $definition): void + { + global $db; + + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column); + if ($table === '' || $column === '') { + return; + } + + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + if ($result && $result->num_rows > 0) { + return; + } + + $db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); + } +} diff --git a/services/nginx/app/classes/module_usage_service.php b/services/nginx/app/classes/module_usage_service.php new file mode 100644 index 00000000..3f923884 --- /dev/null +++ b/services/nginx/app/classes/module_usage_service.php @@ -0,0 +1,997 @@ +registry = $registry ?? new module_usage_registry(); + } + + public function summary(array $filters = []): array + { + $moduleFilter = isset($filters['module']) ? $this->registry->normalizeModuleKey((string)$filters['module']) : ''; + $periodFilter = isset($filters['period']) ? strtolower(trim((string)$filters['period'])) : ''; + $statusFilter = isset($filters['status']) ? strtolower(trim((string)$filters['status'])) : ''; + $date = isset($filters['date']) ? (string)$filters['date'] : null; + + $metrics = []; + foreach ($this->registry->all() as $descriptor) { + if ($moduleFilter !== '' && $descriptor['module_key'] !== $moduleFilter) { + continue; + } + if ($periodFilter !== '' && $periodFilter !== 'all' && $descriptor['period'] !== $periodFilter) { + continue; + } + + $metric = $this->currentMetric($descriptor, $date); + if ($statusFilter !== '' && $metric['status'] !== $statusFilter) { + continue; + } + $metrics[] = $metric; + } + + $modules = []; + foreach ($metrics as $metric) { + $moduleKey = $metric['module_key']; + if (!isset($modules[$moduleKey])) { + $modules[$moduleKey] = [ + 'key' => $moduleKey, + 'label' => $metric['module_label'], + 'status' => 'ok', + 'metrics' => [], + ]; + } + $modules[$moduleKey]['metrics'][] = $metric; + $modules[$moduleKey]['status'] = $this->worseStatus($modules[$moduleKey]['status'], $metric['status']); + } + + return [ + 'generated_at' => date('c'), + 'filters' => [ + 'module' => $moduleFilter !== '' ? $moduleFilter : null, + 'period' => $periodFilter !== '' ? $periodFilter : null, + 'status' => $statusFilter !== '' ? $statusFilter : null, + 'date' => $date, + ], + 'modules' => array_values($modules), + 'metrics' => $metrics, + ]; + } + + public function moduleDetail(string $moduleKey, array $filters = []): array + { + $moduleKey = $this->registry->normalizeModuleKey($moduleKey); + $descriptors = $this->registry->forModule($moduleKey); + $date = isset($filters['date']) ? (string)$filters['date'] : null; + $metrics = array_map(fn(array $descriptor): array => $this->currentMetric($descriptor, $date), $descriptors); + + return [ + 'generated_at' => date('c'), + 'module_key' => $moduleKey, + 'metrics' => $metrics, + 'history' => $this->historyForModule($moduleKey, $filters), + ]; + } + + public function metricsForModule(string $moduleKey): array + { + $moduleKey = $this->registry->normalizeModuleKey($moduleKey); + return array_map( + fn(array $descriptor): array => $this->currentMetric($descriptor), + $this->registry->forModule($moduleKey) + ); + } + + /** + * Atomically records usage and enforces blocking quotas when the metric is configured for block mode. + * + * @throws Exception + */ + public function reserveOrFail(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array + { + $descriptor = $this->requireDescriptor($moduleKey, $metricKey); + $quantity = max(0.0, $quantity); + if ($quantity <= 0.0) { + return $this->currentMetric($descriptor); + } + + $setting = $this->settingFor($descriptor); + if (($setting['enabled'] ?? true) !== true || ($setting['enforce_mode'] ?? 'observe') !== 'block') { + return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata); + } + + $limit = $this->resolveLimitQuantity($descriptor); + if ($limit === null) { + return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata); + } + + $period = $this->periodWindow((string)$descriptor['period']); + $this->insertCounterIfMissing($descriptor, $period, $limit); + + global $db; + $where = $this->counterWhereSql($descriptor, $period); + $quantitySql = $this->numberSql($quantity); + $limitSql = $this->numberSql($limit); + $metadataSql = $this->jsonSql($metadata); + + $db->query( + "UPDATE module_usage_counters + SET used_quantity = used_quantity + {$quantitySql}, + limit_quantity = {$limitSql}, + metadata_json = {$metadataSql}, + status = CASE + WHEN {$limitSql} <= 0 OR ((used_quantity + {$quantitySql}) >= {$limitSql}) THEN 'exhausted' + WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= " . module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT . " THEN 'near_limit' + ELSE 'ok' + END + WHERE {$where} AND (used_quantity + {$quantitySql}) <= {$limitSql}" + ); + + if ((int)$db->conn()->affected_rows <= 0) { + throw new Exception($this->quotaExceededMessage($descriptor)); + } + + return $this->currentMetric($descriptor); + } + + public function recordUsage(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array + { + $descriptor = $this->requireDescriptor($moduleKey, $metricKey); + $quantity = max(0.0, $quantity); + if ($quantity <= 0.0 || !$this->databaseReady()) { + return $this->currentMetric($descriptor); + } + + $limit = $this->resolveLimitQuantity($descriptor); + $setting = $this->settingFor($descriptor); + $period = $this->periodWindow((string)$descriptor['period']); + $this->insertCounterIfMissing($descriptor, $period, $limit); + + global $db; + $where = $this->counterWhereSql($descriptor, $period); + $quantitySql = $this->numberSql($quantity); + $limitSql = $this->nullableNumberSql($limit); + $metadataSql = $this->jsonSql($metadata); + $softLimitSql = $this->numberSql((float)$setting['soft_limit_percent']); + $statusSql = (($setting['enabled'] ?? true) !== true) + ? $this->sqlString('disabled') + : "CASE + WHEN {$limitSql} IS NULL THEN 'unlimited' + WHEN {$limitSql} <= 0 AND (used_quantity + {$quantitySql}) > 0 THEN 'exhausted' + WHEN {$limitSql} <= 0 THEN 'ok' + WHEN (used_quantity + {$quantitySql}) >= {$limitSql} THEN 'exhausted' + WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= {$softLimitSql} THEN 'near_limit' + ELSE 'ok' + END"; + + $db->query( + "UPDATE module_usage_counters + SET used_quantity = used_quantity + {$quantitySql}, + limit_quantity = {$limitSql}, + metadata_json = {$metadataSql}, + status = {$statusSql} + WHERE {$where}" + ); + + return $this->currentMetric($descriptor); + } + + public function currentUsedQuantity(string $moduleKey, string $metricKey): int + { + $descriptor = $this->requireDescriptor($moduleKey, $metricKey); + $metric = $this->currentMetric($descriptor); + return (int)floor((float)($metric['used'] ?? 0)); + } + + public function updateQuotaSetting(string $moduleKey, string $metricKey, array $payload): array + { + $descriptor = $this->requireDescriptor($moduleKey, $metricKey); + $setting = $this->settingFor($descriptor); + + $enabled = array_key_exists('enabled', $payload) ? $this->toBool($payload['enabled']) : (bool)$setting['enabled']; + $enforceMode = array_key_exists('enforce_mode', $payload) ? strtolower(trim((string)$payload['enforce_mode'])) : (string)$setting['enforce_mode']; + if (!in_array($enforceMode, ['observe', 'block'], true)) { + throw new Exception('Invalid enforce mode.'); + } + + $softLimitPercent = array_key_exists('soft_limit_percent', $payload) + ? (float)$payload['soft_limit_percent'] + : (float)$setting['soft_limit_percent']; + if ($softLimitPercent < 1.0 || $softLimitPercent > 100.0) { + throw new Exception('Soft limit percent must be between 1 and 100.'); + } + + if (array_key_exists('limit', $payload) || array_key_exists('hard_limit', $payload) || array_key_exists('hard_limit_quantity', $payload)) { + if (empty($descriptor['writable_limit']) || empty($descriptor['config_module']) || empty($descriptor['config_variable'])) { + throw new Exception('quota_not_writable'); + } + $limitValue = $payload['limit'] ?? $payload['hard_limit'] ?? $payload['hard_limit_quantity']; + if (!is_numeric($limitValue) || (int)$limitValue < 0) { + throw new Exception('Limit must be a non-negative integer.'); + } + $this->writeConfigLimit($descriptor, (int)$limitValue); + } + + if ($this->databaseReady()) { + global $db; + $moduleKeySql = $this->sqlString((string)$descriptor['module_key']); + $metricKeySql = $this->sqlString((string)$descriptor['metric_key']); + $enabledSql = $enabled ? '1' : '0'; + $modeSql = $this->sqlString($enforceMode); + $softLimitSql = $this->numberSql($softLimitPercent); + $db->query( + "INSERT INTO module_quota_settings (module_key, metric_key, enabled, enforce_mode, soft_limit_percent) + VALUES ({$moduleKeySql}, {$metricKeySql}, {$enabledSql}, {$modeSql}, {$softLimitSql}) + ON DUPLICATE KEY UPDATE + enabled = VALUES(enabled), + enforce_mode = VALUES(enforce_mode), + soft_limit_percent = VALUES(soft_limit_percent)" + ); + } + + return $this->currentMetric($descriptor); + } + + public function recordProviderSnapshotFromLegacyUsage(string $moduleKey, array $usage, array $rawPayload = []): ?array + { + $moduleKey = $this->registry->normalizeModuleKey($moduleKey); + $descriptor = null; + foreach ($this->registry->forModule($moduleKey) as $candidate) { + if (($candidate['source'] ?? '') === 'provider_snapshot') { + $descriptor = $candidate; + break; + } + } + + if ($descriptor === null) { + return null; + } + + $used = $this->firstNumeric($usage, ['used', 'calls_used', 'messages_used']); + $limit = $this->firstNumeric($usage, ['limit', 'quota', 'quota_calls', 'total_calls']); + $remaining = $this->firstNumeric($usage, ['remaining', 'calls_remaining', 'messages_remaining']); + $percent = $this->firstNumeric($usage, ['usage_percent', 'percent']); + $usageAvailable = !array_key_exists('usage_available', $usage) || $this->toBool($usage['usage_available']); + $unavailableReason = trim((string)($usage['unavailable_reason'] ?? '')); + + if ($used === null && $limit === null && $usageAvailable && $unavailableReason === '') { + return null; + } + + if ($remaining === null && $used !== null && $limit !== null) { + $remaining = max(0.0, $limit - $used); + } + if ($percent === null && $used !== null && $limit !== null && $limit > 0) { + $percent = round(($used / $limit) * 100, 4); + } + + $status = (!$usageAvailable || $unavailableReason !== '') + ? 'unknown' + : $this->statusForUsage($used, $limit, $this->settingFor($descriptor)); + $payload = array_merge($rawPayload, ['usage' => $this->redactPayload($usage)]); + + if ($this->databaseReady()) { + try { + global $db; + $db->query( + "INSERT INTO module_usage_snapshots + (module_key, metric_key, source, period_key, unit, used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at) + VALUES ( + " . $this->sqlString((string)$descriptor['module_key']) . ", + " . $this->sqlString((string)$descriptor['metric_key']) . ", + 'provider', + 'provider', + " . $this->sqlString((string)$descriptor['unit']) . ", + " . $this->nullableNumberSql($used) . ", + " . $this->nullableNumberSql($limit) . ", + " . $this->nullableNumberSql($remaining) . ", + " . $this->nullableNumberSql($percent) . ", + " . $this->sqlString($status) . ", + " . $this->jsonSql($payload) . ", + " . $this->sqlString(date('Y-m-d H:i:s')) . " + )" + ); + } catch (Throwable) { + // Provider snapshots are observability data. They must never break probes. + } + } + + return $this->providerMetricFromValues($descriptor, $used, $limit, $remaining, $percent, $status, date('c'), $usage); + } + + public function primarySystemUsage(array $metrics): ?array + { + $metrics = array_values(array_filter( + $metrics, + static fn(array $metric): bool => ($metric['limit'] ?? null) !== null || ($metric['used'] ?? null) !== null + )); + if ($metrics === []) { + return null; + } + + usort($metrics, function (array $left, array $right): int { + if (($left['primary'] ?? false) !== ($right['primary'] ?? false)) { + return ($right['primary'] ?? false) <=> ($left['primary'] ?? false); + } + $leftRank = $this->statusRank((string)($left['status'] ?? 'unknown')); + $rightRank = $this->statusRank((string)($right['status'] ?? 'unknown')); + return $rightRank <=> $leftRank; + }); + + $metric = $metrics[0]; + return [ + 'provider' => $metric['module_key'], + 'metric_key' => $metric['metric_key'], + 'unit' => $metric['unit'], + 'period' => $metric['period'], + 'calls_used' => $metric['used'], + 'quota_calls' => $metric['limit'], + 'calls_remaining' => $metric['remaining'], + 'usage_percent' => $metric['usage_percent'], + 'status' => $metric['status'], + 'source' => $metric['source'], + 'enforce_mode' => $metric['enforce_mode'], + ]; + } + + public function currentMetric(array $descriptor, ?string $date = null): array + { + $descriptor = $this->normalizeDescriptor($descriptor); + $setting = $this->settingFor($descriptor); + $window = $this->periodWindow((string)$descriptor['period'], $date); + $limit = $this->resolveLimitQuantity($descriptor); + $used = null; + $updatedAt = null; + $historyAvailable = false; + $snapshotExtra = []; + + if (($descriptor['source'] ?? '') === 'provider_snapshot') { + $snapshot = $this->latestProviderSnapshot($descriptor); + if ($snapshot !== null) { + $used = $snapshot['used_quantity']; + $limit = $snapshot['limit_quantity']; + $updatedAt = $snapshot['checked_at']; + $snapshotExtra = $snapshot['extra']; + $historyAvailable = true; + } + } else { + $counter = $this->counterFor($descriptor, $window); + if ($counter !== null) { + $used = $counter['used_quantity']; + $limit = $counter['limit_quantity'] ?? $limit; + $updatedAt = $counter['updated_at'] ?? $counter['created_at'] ?? null; + $historyAvailable = true; + } else { + $derived = $this->derivedOrLegacyUsage($descriptor, $window); + if ($derived !== null) { + $used = $derived; + $historyAvailable = true; + } elseif (($descriptor['source'] ?? '') === 'internal_counter') { + $used = 0.0; + $historyAvailable = true; + } + } + } + + $status = $this->statusForUsage($used, $limit, $setting); + $remaining = ($used !== null && $limit !== null) ? max(0.0, $limit - $used) : null; + $usagePercent = ($used !== null && $limit !== null && $limit > 0) ? round(($used / $limit) * 100, 2) : null; + + return array_merge([ + 'module_key' => $descriptor['module_key'], + 'module_label' => $descriptor['module_label'], + 'metric_key' => $descriptor['metric_key'], + 'metric_label' => $descriptor['metric_label'], + 'unit' => $descriptor['unit'], + 'period' => $descriptor['period'], + 'scope_type' => $descriptor['scope_type'], + 'scope_id' => $descriptor['scope_id'], + 'source' => $descriptor['source'], + 'primary' => (bool)$descriptor['primary'], + 'writable_limit' => (bool)$descriptor['writable_limit'], + 'limit_source' => isset($descriptor['config_variable']) ? 'module_config' : (($descriptor['source'] ?? '') === 'provider_snapshot' ? 'provider' : null), + 'config_module' => $descriptor['config_module'] ?? null, + 'config_variable' => $descriptor['config_variable'] ?? null, + 'used' => $used, + 'limit' => $limit, + 'remaining' => $remaining, + 'usage_percent' => $usagePercent, + 'status' => $status, + 'enabled' => (bool)$setting['enabled'], + 'enforce_mode' => $setting['enforce_mode'], + 'soft_limit_percent' => (float)$setting['soft_limit_percent'], + 'window' => [ + 'start' => $window['start_c'], + 'end' => $window['end_c'], + 'timezone' => date_default_timezone_get(), + ], + 'updated_at' => $updatedAt, + 'history_available' => $historyAvailable, + ], $snapshotExtra); + } + + private function requireDescriptor(string $moduleKey, string $metricKey): array + { + $descriptor = $this->registry->find($moduleKey, $metricKey); + if ($descriptor === null) { + throw new Exception('Unknown module usage metric.'); + } + return $descriptor; + } + + private function normalizeDescriptor(array $descriptor): array + { + $found = $this->registry->find((string)$descriptor['module_key'], (string)$descriptor['metric_key']); + return $found ?? $descriptor; + } + + private function settingFor(array $descriptor): array + { + $default = [ + 'enabled' => true, + 'enforce_mode' => (string)($descriptor['default_enforce_mode'] ?? 'observe'), + 'soft_limit_percent' => (float)($descriptor['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT), + ]; + + if (!$this->databaseReady()) { + return $default; + } + + try { + global $db; + $result = $db->query( + "SELECT enabled, enforce_mode, soft_limit_percent + FROM module_quota_settings + WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . " + AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . " + LIMIT 1" + ); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + if (!is_array($row)) { + return $default; + } + return [ + 'enabled' => (bool)((int)($row['enabled'] ?? 1)), + 'enforce_mode' => in_array((string)($row['enforce_mode'] ?? ''), ['observe', 'block'], true) + ? (string)$row['enforce_mode'] + : $default['enforce_mode'], + 'soft_limit_percent' => is_numeric($row['soft_limit_percent'] ?? null) + ? (float)$row['soft_limit_percent'] + : $default['soft_limit_percent'], + ]; + } catch (Throwable) { + return $default; + } + } + + private function resolveLimitQuantity(array $descriptor): ?float + { + if (empty($descriptor['config_module']) || empty($descriptor['config_variable']) || !$this->databaseReady()) { + return null; + } + + try { + global $db; + $result = $db->query( + "SELECT value + FROM module_config + WHERE module = " . $this->sqlString((string)$descriptor['config_module']) . " + AND variable = " . $this->sqlString((string)$descriptor['config_variable']) . " + LIMIT 1" + ); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + if (!is_array($row) || !is_numeric($row['value'] ?? null)) { + return null; + } + return max(0.0, (float)$row['value']); + } catch (Throwable) { + return null; + } + } + + private function writeConfigLimit(array $descriptor, int $limit): void + { + if (!$this->databaseReady()) { + return; + } + + global $db; + $module = (string)$descriptor['config_module']; + $variable = (string)$descriptor['config_variable']; + $type = (string)($descriptor['config_type'] ?? 'int'); + + $existing = $db->query( + "SELECT id + FROM module_config + WHERE module = " . $this->sqlString($module) . " + AND variable = " . $this->sqlString($variable) . " + LIMIT 1" + ); + if ($existing instanceof mysqli_result && $existing->num_rows > 0) { + $db->query( + "UPDATE module_config + SET value = " . $this->sqlString((string)$limit) . ", type = " . $this->sqlString($type) . " + WHERE module = " . $this->sqlString($module) . " + AND variable = " . $this->sqlString($variable) + ); + } else { + $db->query( + "INSERT INTO module_config (module, variable, value, type) + VALUES (" . $this->sqlString($module) . ", " . $this->sqlString($variable) . ", " . $this->sqlString((string)$limit) . ", " . $this->sqlString($type) . ")" + ); + } + system_search_cache::markDirtyTable('module_config'); + } + + private function insertCounterIfMissing(array $descriptor, array $period, ?float $limit): void + { + if (!$this->databaseReady()) { + return; + } + + global $db; + $baseline = $this->derivedOrLegacyUsage($descriptor, $period); + $baseline = $baseline === null ? 0.0 : max(0.0, (float)$baseline); + $metadata = [ + 'created_from' => $baseline > 0 ? 'legacy_or_derived_baseline' : 'counter', + ]; + + $db->query( + "INSERT IGNORE INTO module_usage_counters + (module_key, metric_key, scope_type, scope_id, period_key, period_start, period_end, unit, used_quantity, limit_quantity, status, metadata_json) + VALUES ( + " . $this->sqlString((string)$descriptor['module_key']) . ", + " . $this->sqlString((string)$descriptor['metric_key']) . ", + " . $this->sqlString((string)$descriptor['scope_type']) . ", + " . $this->sqlString((string)$descriptor['scope_id']) . ", + " . $this->sqlString($period['key']) . ", + " . $this->sqlString($period['start_sql']) . ", + " . ($period['end_sql'] === null ? 'NULL' : $this->sqlString($period['end_sql'])) . ", + " . $this->sqlString((string)$descriptor['unit']) . ", + " . $this->numberSql($baseline) . ", + " . $this->nullableNumberSql($limit) . ", + " . $this->sqlString($this->statusForUsage($baseline, $limit, $this->settingFor($descriptor))) . ", + " . $this->jsonSql($metadata) . " + )" + ); + } + + private function counterFor(array $descriptor, array $period): ?array + { + if (!$this->databaseReady()) { + return null; + } + + try { + global $db; + $result = $db->query( + "SELECT used_quantity, limit_quantity, status, created_at, updated_at + FROM module_usage_counters + WHERE " . $this->counterWhereSql($descriptor, $period) . " + LIMIT 1" + ); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + if (!is_array($row)) { + return null; + } + return [ + 'used_quantity' => (float)$row['used_quantity'], + 'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'], + 'status' => (string)$row['status'], + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ]; + } catch (Throwable) { + return null; + } + } + + private function latestProviderSnapshot(array $descriptor): ?array + { + if (!$this->databaseReady()) { + return null; + } + + try { + global $db; + $result = $db->query( + "SELECT used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at + FROM module_usage_snapshots + WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . " + AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . " + ORDER BY checked_at DESC, id DESC + LIMIT 1" + ); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + if (!is_array($row)) { + return null; + } + $raw = json_decode((string)($row['raw_payload_json'] ?? ''), true); + $usage = is_array($raw) && isset($raw['usage']) && is_array($raw['usage']) ? $raw['usage'] : []; + $extra = []; + if (isset($usage['version'])) { + $extra['version'] = (string)$usage['version']; + } + if (array_key_exists('usage_available', $usage)) { + $extra['usage_available'] = $this->toBool($usage['usage_available']); + } + if (isset($usage['unavailable_reason'])) { + $extra['unavailable_reason'] = (string)$usage['unavailable_reason']; + } + if (isset($usage['detected_keys']) && is_array($usage['detected_keys'])) { + $extra['detected_keys'] = array_values(array_map('strval', $usage['detected_keys'])); + } + + return [ + 'used_quantity' => $row['used_quantity'] === null ? null : (float)$row['used_quantity'], + 'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'], + 'remaining_quantity' => $row['remaining_quantity'] === null ? null : (float)$row['remaining_quantity'], + 'usage_percent' => $row['usage_percent'] === null ? null : (float)$row['usage_percent'], + 'status' => (string)$row['status'], + 'checked_at' => $row['checked_at'] ? date('c', strtotime((string)$row['checked_at'])) : null, + 'extra' => $extra, + ]; + } catch (Throwable) { + return null; + } + } + + private function providerMetricFromValues(array $descriptor, ?float $used, ?float $limit, ?float $remaining, ?float $percent, string $status, string $checkedAt, array $usage): array + { + return [ + 'module_key' => $descriptor['module_key'], + 'module_label' => $descriptor['module_label'], + 'metric_key' => $descriptor['metric_key'], + 'metric_label' => $descriptor['metric_label'], + 'unit' => $descriptor['unit'], + 'period' => $descriptor['period'], + 'scope_type' => $descriptor['scope_type'], + 'scope_id' => $descriptor['scope_id'], + 'source' => $descriptor['source'], + 'primary' => (bool)$descriptor['primary'], + 'writable_limit' => false, + 'limit_source' => 'provider', + 'used' => $used, + 'limit' => $limit, + 'remaining' => $remaining, + 'usage_percent' => $percent, + 'status' => $status, + 'enabled' => true, + 'enforce_mode' => 'observe', + 'soft_limit_percent' => module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT, + 'window' => ['start' => null, 'end' => null, 'timezone' => date_default_timezone_get()], + 'updated_at' => $checkedAt, + 'history_available' => true, + 'version' => isset($usage['version']) ? (string)$usage['version'] : null, + 'usage_available' => array_key_exists('usage_available', $usage) ? $this->toBool($usage['usage_available']) : true, + 'unavailable_reason' => isset($usage['unavailable_reason']) ? (string)$usage['unavailable_reason'] : null, + 'detected_keys' => isset($usage['detected_keys']) && is_array($usage['detected_keys']) + ? array_values(array_map('strval', $usage['detected_keys'])) + : [], + ]; + } + + private function derivedOrLegacyUsage(array $descriptor, array $period): ?float + { + try { + if (isset($descriptor['legacy_count_table'])) { + return $this->countRowsInPeriod((string)$descriptor['legacy_count_table'], 'created_at', $period); + } + + if (isset($descriptor['legacy_log_module'])) { + return $this->countActionLogs( + (string)$descriptor['legacy_log_module'], + isset($descriptor['legacy_log_action']) ? (string)$descriptor['legacy_log_action'] : null, + $period + ); + } + + return match ($descriptor['module_key'] . '.' . $descriptor['metric_key']) { + 'backups.backup_jobs' => $this->countRowsInPeriod('backup_jobs', 'created_at', $period), + 'backups.stored_bytes' => $this->sumColumn('backup_records', 'total_bytes'), + 'coolify.operations' => $this->countRowsInPeriod('coolify_operations', 'created_at', $period), + 'selfserve.wash_sessions' => $this->countRowsInPeriod('selfserve_wash_sessions', 'created_at', $period), + 'xlvask.usage_rows' => $this->countRowsInPeriod('xlvask_usage_logs', 'StartTime', $period), + 'attachments.stored_files' => $this->countRowsInPeriod('object_attachments', null, $period), + 'forms.submissions' => $this->countRowsInPeriod('form_submissions', 'created_at', $period), + 'notifications.notification_sends' => $this->countRowsInPeriod('notifications', 'created_at', $period), + 'edgegateway.relay_commands' => $this->countRowsInPeriod('edge_gateway_operations', 'created_at', $period), + 'system.cron_runs' => $this->countRowsInPeriod('cron_task_runs', 'created_at', $period), + default => null, + }; + } catch (Throwable) { + return null; + } + } + + private function countActionLogs(string $module, ?string $action, array $period): ?float + { + if (!$this->tableExists('module_usage_logs')) { + return null; + } + + global $db; + $where = "UPPER(module) = " . $this->sqlString(strtoupper($module)); + if ($action !== null && $action !== '') { + $where .= " AND UPPER(action) = " . $this->sqlString(strtoupper($action)); + } + $where .= $this->periodWhereSql('created_at', $period); + + $result = $db->query("SELECT COUNT(*) AS usage_count FROM module_usage_logs WHERE {$where}"); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + return is_array($row) ? (float)$row['usage_count'] : null; + } + + private function countRowsInPeriod(string $table, ?string $dateColumn, array $period): ?float + { + if (!$this->tableExists($table)) { + return null; + } + if ($dateColumn !== null && !$this->columnExists($table, $dateColumn)) { + return null; + } + + global $db; + $where = '1=1'; + if ($dateColumn !== null) { + $where .= $this->periodWhereSql($dateColumn, $period); + } elseif ($period['key'] !== 'all_time') { + return null; + } + + $result = $db->query("SELECT COUNT(*) AS usage_count FROM `{$table}` WHERE {$where}"); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + return is_array($row) ? (float)$row['usage_count'] : null; + } + + private function sumColumn(string $table, string $column): ?float + { + if (!$this->tableExists($table) || !$this->columnExists($table, $column)) { + return null; + } + + global $db; + $result = $db->query("SELECT COALESCE(SUM(`{$column}`), 0) AS usage_sum FROM `{$table}`"); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + return is_array($row) ? (float)$row['usage_sum'] : null; + } + + private function historyForModule(string $moduleKey, array $filters): array + { + if (!$this->databaseReady()) { + return []; + } + + $limit = isset($filters['limit']) && is_numeric($filters['limit']) ? max(1, min(200, (int)$filters['limit'])) : 100; + $rows = []; + + try { + global $db; + $result = $db->query( + "SELECT module_key, metric_key, period_key, period_start, period_end, used_quantity, limit_quantity, status, updated_at, created_at + FROM module_usage_counters + WHERE module_key = " . $this->sqlString($moduleKey) . " + ORDER BY period_start DESC, id DESC + LIMIT {$limit}" + ); + if ($result instanceof mysqli_result) { + while ($row = $result->fetch_assoc()) { + $rows[] = $row; + } + } + } catch (Throwable) { + return []; + } + + return $rows; + } + + private function statusForUsage(?float $used, ?float $limit, array $setting): string + { + if (($setting['enabled'] ?? true) !== true) { + return 'disabled'; + } + if ($used === null) { + return 'unknown'; + } + if ($limit === null) { + return 'unlimited'; + } + if ($limit <= 0.0) { + return $used > 0.0 ? 'exhausted' : 'ok'; + } + + $percent = ($used / $limit) * 100; + if ($used >= $limit || $percent >= 100.0) { + return 'exhausted'; + } + if ($percent >= (float)($setting['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT)) { + return 'near_limit'; + } + return 'ok'; + } + + private function worseStatus(string $current, string $candidate): string + { + return $this->statusRank($candidate) > $this->statusRank($current) ? $candidate : $current; + } + + private function statusRank(string $status): int + { + return match ($status) { + 'exhausted' => 5, + 'near_limit' => 4, + 'unknown' => 3, + 'disabled' => 2, + 'unlimited' => 1, + 'ok' => 0, + default => 0, + }; + } + + private function quotaExceededMessage(array $descriptor): string + { + return match ((string)$descriptor['period']) { + 'day' => 'Daily limit exceeded', + 'month' => 'Monthly limit exceeded', + default => 'Quota limit exceeded', + }; + } + + private function periodWindow(string $period, ?string $date = null): array + { + $timestamp = $date ? strtotime($date) : time(); + if ($timestamp === false) { + $timestamp = time(); + } + + return match ($period) { + 'day' => $this->periodFromTimestamps('day', strtotime(date('Y-m-d 00:00:00', $timestamp)), strtotime(date('Y-m-d 00:00:00', $timestamp) . ' +1 day')), + 'month' => $this->periodFromTimestamps('month', strtotime(date('Y-m-01 00:00:00', $timestamp)), strtotime(date('Y-m-01 00:00:00', $timestamp) . ' +1 month')), + 'provider' => ['key' => 'provider', 'start_sql' => date('Y-m-d 00:00:00', $timestamp), 'end_sql' => null, 'start_c' => null, 'end_c' => null], + default => ['key' => 'all_time', 'start_sql' => '1970-01-01 00:00:00', 'end_sql' => null, 'start_c' => null, 'end_c' => null], + }; + } + + private function periodFromTimestamps(string $key, int $start, int $end): array + { + return [ + 'key' => $key, + 'start_sql' => date('Y-m-d H:i:s', $start), + 'end_sql' => date('Y-m-d H:i:s', $end), + 'start_c' => date('c', $start), + 'end_c' => date('c', $end), + ]; + } + + private function periodWhereSql(string $dateColumn, array $period): string + { + if ($period['key'] === 'all_time' || $period['key'] === 'provider') { + return ''; + } + + $dateColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $dateColumn); + if ($dateColumn === '') { + return ''; + } + + return " AND `{$dateColumn}` >= " . $this->sqlString($period['start_sql']) . " AND `{$dateColumn}` < " . $this->sqlString((string)$period['end_sql']); + } + + private function counterWhereSql(array $descriptor, array $period): string + { + return "module_key = " . $this->sqlString((string)$descriptor['module_key']) + . " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) + . " AND scope_type = " . $this->sqlString((string)$descriptor['scope_type']) + . " AND scope_id = " . $this->sqlString((string)$descriptor['scope_id']) + . " AND period_key = " . $this->sqlString($period['key']) + . " AND period_start = " . $this->sqlString($period['start_sql']); + } + + private function databaseReady(): bool + { + global $db; + return isset($db) && is_object($db) && method_exists($db, 'query') && method_exists($db, 'conn'); + } + + private function tableExists(string $table): bool + { + if (!$this->databaseReady()) { + return false; + } + + try { + global $db; + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + if ($table === '') { + return false; + } + $result = $db->query("SHOW TABLES LIKE " . $this->sqlString($table)); + return $result instanceof mysqli_result && $result->num_rows > 0; + } catch (Throwable) { + return false; + } + } + + private function columnExists(string $table, string $column): bool + { + if (!$this->databaseReady()) { + return false; + } + + try { + global $db; + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column); + if ($table === '' || $column === '') { + return false; + } + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE " . $this->sqlString($column)); + return $result instanceof mysqli_result && $result->num_rows > 0; + } catch (Throwable) { + return false; + } + } + + private function firstNumeric(array $payload, array $keys): ?float + { + foreach ($keys as $key) { + if (isset($payload[$key]) && is_numeric($payload[$key])) { + return (float)$payload[$key]; + } + } + return null; + } + + private function redactPayload(array $payload): array + { + $redacted = []; + foreach ($payload as $key => $value) { + $normalized = strtolower((string)$key); + if (str_contains($normalized, 'key') || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) { + $redacted[$key] = '[redacted]'; + continue; + } + $redacted[$key] = is_array($value) ? $this->redactPayload($value) : $value; + } + return $redacted; + } + + private function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + private function sqlString(string $value): string + { + global $db; + return "'" . $db->escape_string($value) . "'"; + } + + private function numberSql(float $value): string + { + return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.') ?: '0'; + } + + private function nullableNumberSql(?float $value): string + { + return $value === null ? 'NULL' : $this->numberSql($value); + } + + private function jsonSql(array $value): string + { + return $this->sqlString(json_encode($this->redactPayload($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/services/nginx/app/classes/motorapi.php b/services/nginx/app/classes/motorapi.php index 6c96f7bf..47cdf079 100644 --- a/services/nginx/app/classes/motorapi.php +++ b/services/nginx/app/classes/motorapi.php @@ -13,6 +13,7 @@ use motorapi\actions\license_plate_lookup_a; use motorapi\helpers\motorapi_vehicle_types; use motorapi\motorapi_c; use objects\motorapi_lookups_o; +use Throwable; class motorapi implements motorapi_i { @@ -182,10 +183,10 @@ class motorapi implements motorapi_i self::requireModuleEnabled(); // Validate the license plate self::requireValidLicensePlate($licensePlate); - // Validate the daily limit - self::requireDailyLimitNotExceeded(); // Validate the secret key self::requireValidSecretKey(); + // Reserve quota for the outbound provider call. Cache hits return before this point. + $this->reserveLookupQuota($licensePlate, $endpoint, $method); // Send the request $response = match ($method) { 'GET' => self::sendGetRequest($licensePlate, $endpoint, $data), @@ -217,7 +218,7 @@ class motorapi implements motorapi_i function requireDailyLimitNotExceeded(): void { // Check if the daily limit is exceeded - if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) { + if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) { throw new Exception('Daily limit exceeded'); } } @@ -227,12 +228,29 @@ class motorapi implements motorapi_i */ function getDailyRequestCounter(): int { + try { + return (new module_usage_service())->currentUsedQuantity('motorapi', 'lookup_calls'); + } catch (Throwable) { + } + // Count the rows from the motorapi request log that was made today $motorapi_lookups = new motorapi_lookups_o(); $motorapi_lookups->getTodayCount(); return $motorapi_lookups->getTodayCount(); } + /** + * @throws Exception + */ + private function reserveLookupQuota(string $licensePlate, string $endpoint, string $method): void + { + (new module_usage_service())->reserveOrFail('motorapi', 'lookup_calls', 1, [ + 'license_plate' => $licensePlate, + 'endpoint' => $endpoint, + 'method' => strtoupper($method), + ]); + } + /** * @inheritDoc */ diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php index 206348e9..37352d6a 100644 --- a/services/nginx/app/classes/release_manager.php +++ b/services/nginx/app/classes/release_manager.php @@ -37,6 +37,8 @@ class release_manager private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api'; private const CRON_WORKER_APP = 'cron'; private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'; + private const CRON_WORKER_DESIRED_COUNT = 1; + private const CRON_WORKER_HEARTBEAT_GRACE_SECONDS = 180; private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io']; private const RELEASE_GATE_MAX_PATHS = 10; private const RELEASE_GATE_MAX_ASSETS = 50; @@ -4413,16 +4415,65 @@ class release_manager { $this->ensureSchema(); $channel = $this->channelFromInputOrDefault($input); - $target = $this->cronWorkerTargetForChannel((int)$channel['id']); + $channelId = (int)$channel['id']; + $apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api'); + $target = $this->cronWorkerTargetForChannel($channelId); + $publicTarget = $target !== null ? $this->publicDeploymentTarget($target) : null; + $publicApiTarget = $apiTarget !== null ? $this->publicDeploymentTarget($apiTarget) : null; + $workerRows = (new cron_worker())->listWorkers(); + $workers = $this->cronWorkersForTarget( + is_array($workerRows['workers'] ?? null) ? $workerRows['workers'] : [], + $channelId, + $target + ); + $summary = $this->cronWorkerSummary($workers); + $recentDeployments = $this->cronWorkerDeployments($channelId); + $latestDeployment = $recentDeployments[0] ?? null; + $providerStatus = $this->cronWorkerProviderStatus($target, $input); + $health = $this->cronWorkerHealth($apiTarget, $target, $workers, $summary, $latestDeployment); + $readiness = $this->cronWorkerDeploymentReadiness($apiTarget, $target, $providerStatus); + $issues = $this->cronWorkerMergeIssues( + is_array($health['issues'] ?? null) ? $health['issues'] : [], + is_array($readiness['issues'] ?? null) ? $readiness['issues'] : [] + ); + $channelPayload = [ + 'id' => $channelId, + 'slug' => (string)($channel['slug'] ?? ''), + 'name' => (string)($channel['name'] ?? ''), + ]; + $deploymentPayload = [ + 'ok' => true, + 'state' => $health['state'], + 'desired_workers' => self::CRON_WORKER_DESIRED_COUNT, + 'channel' => $channelPayload, + 'api_target' => $publicApiTarget, + 'target' => $publicTarget, + 'latest_deployment' => $latestDeployment, + 'provider' => $providerStatus, + 'action' => $readiness['action'], + 'can_deploy' => $readiness['can_deploy'], + 'issues' => $issues, + ]; return [ 'ok' => true, - 'channel' => [ - 'id' => (int)$channel['id'], - 'slug' => (string)($channel['slug'] ?? ''), - 'name' => (string)($channel['name'] ?? ''), + 'state' => $health['state'], + 'desired_workers' => self::CRON_WORKER_DESIRED_COUNT, + 'channel' => $channelPayload, + 'channels' => $this->cronWorkerChannels(), + 'api_target' => $publicApiTarget, + 'cron_target' => $publicTarget, + 'target' => $publicTarget, + 'workers' => $workers, + 'summary' => $summary + [ + 'desired' => self::CRON_WORKER_DESIRED_COUNT, + 'state' => $health['state'], ], - 'target' => $target !== null ? $this->publicDeploymentTarget($target) : null, + 'latest_deployment' => $latestDeployment, + 'recent_deployments' => $recentDeployments, + 'provider' => $providerStatus, + 'issues' => $issues, + 'deployment' => $deploymentPayload, ]; } @@ -4431,6 +4482,8 @@ class release_manager $this->ensureSchema(); $dryRun = $this->toBool($input['dry_run'] ?? false); $apiTarget = null; + $channel = null; + $existingCronTarget = null; $targetId = $this->nullablePositiveInt($input['api_target_id'] ?? $input['target_id'] ?? null); if ($targetId !== null) { @@ -4440,7 +4493,12 @@ class release_manager } } else { $channel = $this->channelFromInputOrDefault($input); - $apiTarget = $this->deploymentTargetFromInput(['channel_id' => (int)$channel['id']], (int)$channel['id'], 'api'); + $channelId = (int)$channel['id']; + $existingCronTarget = $this->cronWorkerTargetForChannel($channelId); + $apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api'); + if ($apiTarget === null && $this->cronWorkerTargetCanDeployWithoutApiTarget($existingCronTarget)) { + $apiTarget = $this->cronWorkerSourceFromCronTarget($existingCronTarget, $channel); + } } if ($apiTarget === null) { @@ -4451,6 +4509,16 @@ class release_manager return $this->deployCronWorkerForApiTarget($apiTarget, $commitSha !== '' ? $commitSha : null, $actorUserId, $dryRun); } + private function cronWorkerSourceFromCronTarget(array $target, ?array $channel = null): array + { + return array_replace($target, [ + 'app' => self::CRON_WORKER_APP, + 'channel_id' => (int)($target['channel_id'] ?? $channel['id'] ?? 0), + 'channel_slug' => (string)($target['channel_slug'] ?? $channel['slug'] ?? ''), + 'channel_name' => (string)($target['channel_name'] ?? $channel['name'] ?? ''), + ]); + } + private function deployCronWorkerForApiTarget( array $apiTarget, ?string $commitSha = null, @@ -4479,19 +4547,41 @@ class release_manager $repository = trim((string)($apiTarget['repository'] ?? '')); } $branch = trim((string)($apiTarget['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; - $serviceUuid = trim((string)($existing['coolify_service_uuid'] ?? '')) ?: null; + $serviceUuid = trim((string)($existing['coolify_service_uuid'] ?? '')); + $providerStatus = $existing !== null + ? $this->cronWorkerProviderStatus($existing, ['include_provider' => true]) + : ['configured' => false, 'missing' => false]; + $repairMissingResource = $existing !== null + && $serviceUuid !== '' + && $this->toBool($providerStatus['missing'] ?? false); + $sourceApp = (string)($apiTarget['app'] ?? 'api'); + $sourceTargetId = (int)($apiTarget['id'] ?? 0); + if ($repairMissingResource) { + $context['cron_worker_orphaned_coolify_service_uuid'] = $serviceUuid; + $context['cron_worker_orphaned_at'] = date('Y-m-d H:i:s'); + $context['cron_worker_repair_reason'] = 'coolify_resource_missing'; + $serviceUuid = ''; + } + $planAction = $targetId > 0 + ? ($repairMissingResource ? 'repair' : ($serviceUuid === '' ? 'create' : 'update')) + : 'create'; $plan = [ 'type' => 'deploy_cron_worker', 'channel_id' => $channelId, 'channel_slug' => (string)($channel['slug'] ?? ''), - 'api_target_id' => (int)($apiTarget['id'] ?? 0), + 'api_target_id' => $sourceApp === 'api' && $sourceTargetId > 0 ? $sourceTargetId : null, + 'source_target_id' => $sourceTargetId > 0 ? $sourceTargetId : null, + 'source_app' => $sourceApp, 'target_id' => $targetId > 0 ? $targetId : null, - 'action' => $targetId > 0 ? 'update' : 'create', + 'action' => $planAction, 'repository' => $repository, 'branch' => $branch, 'commit_sha' => $commitSha, 'coolify_instance_id' => (int)$apiTarget['coolify_instance_id'], - 'coolify_service_uuid' => $serviceUuid, + 'coolify_service_uuid' => $serviceUuid !== '' ? $serviceUuid : null, + 'orphaned_coolify_service_uuid' => $repairMissingResource + ? (string)($existing['coolify_service_uuid'] ?? '') + : null, 'start_command' => self::CRON_WORKER_START_COMMAND, 'dry_run' => $dryRun, ]; @@ -4503,79 +4593,137 @@ class release_manager 'mutated' => false, 'planned' => [$plan], 'target' => $existing !== null ? $this->publicDeploymentTarget($existing) : null, + 'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]), ]; } - if ($targetId > 0) { - $this->execute( - "UPDATE release_deployment_targets - SET coolify_instance_id = ?, repository = ?, branch = ?, auto_deploy = 0, - health_url = NULL, deploy_context_json = ? - WHERE id = ? AND deleted_at IS NULL", - 'isssi', - [ - (int)$apiTarget['coolify_instance_id'], - $repository, - $branch, - self::jsonEncode($context), - $targetId, - ] - ); - $action = 'cron_worker_target_updated'; - } else { - $this->execute( - "INSERT INTO release_deployment_targets ( - channel_id, app, coolify_instance_id, coolify_service_uuid, - repository, branch, auto_deploy, health_url, deploy_context_json - ) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)", - 'isissss', - [ - $channelId, - self::CRON_WORKER_APP, - (int)$apiTarget['coolify_instance_id'], - $serviceUuid, - $repository, - $branch, - self::jsonEncode($context), - ] - ); - $targetId = $this->insertId(); - $context = $this->cronWorkerDeployContext($apiTarget, null, $commitSha, $targetId); - $this->execute( - 'UPDATE release_deployment_targets SET deploy_context_json = ? WHERE id = ?', - 'si', - [self::jsonEncode($context), $targetId] - ); - $action = 'cron_worker_target_created'; - } + $cronDeploymentId = $this->createCronWorkerDeploymentRecord( + $channelId, + $targetId > 0 ? $targetId : null, + $repository, + $branch, + $commitSha, + $actorUserId, + ['plan' => $plan, 'parent_deployment_id' => $parentDeploymentId] + ); - $target = $this->getDeploymentTarget($targetId); - $deployTarget = array_replace($target, [ - 'repository' => $repository, - 'branch' => $branch, - 'commit_sha' => $commitSha ?? '', - ]); - $deployment = $this->deployCoolifyReleaseTarget($deployTarget); + try { + if ($targetId > 0) { + $this->execute( + "UPDATE release_deployment_targets + SET coolify_instance_id = ?, coolify_service_uuid = ?, repository = ?, branch = ?, auto_deploy = 0, + health_url = NULL, deploy_context_json = ? + WHERE id = ? AND deleted_at IS NULL", + 'issssi', + [ + (int)$apiTarget['coolify_instance_id'], + $serviceUuid !== '' ? $serviceUuid : null, + $repository, + $branch, + self::jsonEncode($context), + $targetId, + ] + ); + $action = $repairMissingResource ? 'cron_worker_target_repaired' : 'cron_worker_target_updated'; + } else { + $this->execute( + "INSERT INTO release_deployment_targets ( + channel_id, app, coolify_instance_id, coolify_service_uuid, + repository, branch, auto_deploy, health_url, deploy_context_json + ) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)", + 'isissss', + [ + $channelId, + self::CRON_WORKER_APP, + (int)$apiTarget['coolify_instance_id'], + $serviceUuid !== '' ? $serviceUuid : null, + $repository, + $branch, + self::jsonEncode($context), + ] + ); + $targetId = $this->insertId(); + $context = $this->cronWorkerDeployContext($apiTarget, null, $commitSha, $targetId); + $this->execute( + 'UPDATE release_deployment_targets SET deploy_context_json = ? WHERE id = ?', + 'si', + [self::jsonEncode($context), $targetId] + ); + $this->execute('UPDATE release_deployments SET target_id = ? WHERE id = ?', 'ii', [$targetId, $cronDeploymentId]); + $action = 'cron_worker_target_created'; + } - $this->audit($channelId, $parentDeploymentId, $action, $actorUserId, 'info', [ - 'api_target_id' => (int)($apiTarget['id'] ?? 0), - 'cron_target_id' => $targetId, - 'commit_sha' => $commitSha, - 'deployment' => $deployment, - ]); + $target = $this->getDeploymentTarget($targetId); + $deployTarget = array_replace($target, [ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha ?? '', + ]); + $deployment = $this->deployCoolifyReleaseTarget($deployTarget); + $providerOperationId = $this->coolifyDeploymentOperationId($deployment); + $this->execute( + "UPDATE release_deployments + SET status = 'deployed', provider_operation_id = ?, result_json = ?, completed_at = NOW() + WHERE id = ?", + 'ssi', + [$providerOperationId, self::jsonEncode(self::redactPayload($deployment)), $cronDeploymentId] + ); - return [ - 'ok' => true, - 'dry_run' => false, - 'mutated' => true, - 'planned' => [$plan], - 'applied' => [[ - 'target_id' => $targetId, - 'action' => $action, + $this->audit($channelId, $cronDeploymentId, $action, $actorUserId, 'info', [ + 'api_target_id' => (int)($apiTarget['id'] ?? 0), + 'cron_target_id' => $targetId, + 'commit_sha' => $commitSha, + 'parent_deployment_id' => $parentDeploymentId, 'deployment' => $deployment, - ]], - 'target' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)), - ]; + ]); + + return [ + 'ok' => true, + 'dry_run' => false, + 'mutated' => true, + 'planned' => [$plan], + 'applied' => [[ + 'target_id' => $targetId, + 'deployment_id' => $cronDeploymentId, + 'action' => $action, + 'deployment' => $deployment, + ]], + 'deployment' => $this->publicDeployment($this->getDeployment($cronDeploymentId)), + 'target' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)), + 'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]), + ]; + } catch (Throwable $throwable) { + $this->execute( + "UPDATE release_deployments + SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW() + WHERE id = ?", + 'ssi', + [ + self::jsonEncode([ + 'message' => 'Cron worker deployment failed before a worker heartbeat was observed.', + 'failure_summary' => self::deploymentFailureSummary($throwable, [ + 'app' => self::CRON_WORKER_APP, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'target_id' => $targetId > 0 ? $targetId : null, + 'coolify_instance_id' => (int)($apiTarget['coolify_instance_id'] ?? 0), + 'coolify_service_uuid' => $serviceUuid, + ]), + ]), + $throwable->getMessage(), + $cronDeploymentId, + ] + ); + $this->audit($channelId, $cronDeploymentId, 'cron_worker_deploy_failed', $actorUserId, 'warning', [ + 'api_target_id' => (int)($apiTarget['id'] ?? 0), + 'cron_target_id' => $targetId > 0 ? $targetId : null, + 'commit_sha' => $commitSha, + 'parent_deployment_id' => $parentDeploymentId, + 'error' => $throwable->getMessage(), + ]); + throw $throwable; + } } private function cronWorkerTargetForChannel(int $channelId): ?array @@ -4593,6 +4741,430 @@ class release_manager ); } + private function createCronWorkerDeploymentRecord( + int $channelId, + ?int $targetId, + string $repository, + string $branch, + ?string $commitSha, + ?int $actorUserId, + array $requestedPayload + ): int { + $this->execute( + "INSERT INTO release_deployments ( + channel_id, target_id, deployment_kind, app, provider, repository, branch, + commit_sha, status, actor_user_id, requested_payload_json, started_at + ) VALUES (?, ?, 'cron_worker', ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())", + 'iissssis', + [ + $channelId, + $targetId, + self::CRON_WORKER_APP, + $repository, + $branch, + $commitSha, + $actorUserId, + self::jsonEncode(self::redactPayload($requestedPayload)), + ] + ); + + return $this->insertId(); + } + + private function cronWorkerDeployments(int $channelId, int $limit = 5): array + { + $limit = max(1, min(25, $limit)); + return array_map( + fn(array $deployment): array => $this->publicDeployment($deployment), + $this->selectRows( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE d.channel_id = ? AND d.app = ? AND d.deployment_kind = 'cron_worker' + ORDER BY d.id DESC + LIMIT $limit", + 'is', + [$channelId, self::CRON_WORKER_APP] + ) + ); + } + + private function cronWorkerChannels(): array + { + return array_map( + static fn(array $channel): array => [ + 'id' => (int)($channel['id'] ?? 0), + 'slug' => (string)($channel['slug'] ?? ''), + 'name' => (string)($channel['name'] ?? ''), + 'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)), + ], + $this->selectRows( + "SELECT id, slug, name, default_channel + FROM release_channels + WHERE deleted_at IS NULL AND enabled = 1 + ORDER BY default_channel DESC, slug" + ) + ); + } + + private function cronWorkersForTarget(array $workers, int $channelId, ?array $target): array + { + $targetId = $target !== null ? (int)($target['id'] ?? 0) : 0; + $resourceUuid = $target !== null ? trim((string)($target['coolify_service_uuid'] ?? '')) : ''; + $matched = []; + foreach ($workers as $worker) { + if (!is_array($worker)) { + continue; + } + + $workerChannelId = (int)($worker['release_channel_id'] ?? 0); + $workerTargetId = (int)($worker['release_target_id'] ?? 0); + $workerResourceUuid = trim((string)($worker['coolify_resource_uuid'] ?? '')); + if ( + ($workerChannelId > 0 && $workerChannelId === $channelId) + || ($targetId > 0 && $workerTargetId === $targetId) + || ($resourceUuid !== '' && $workerResourceUuid === $resourceUuid) + ) { + $matched[] = $worker; + } + } + + return $matched; + } + + private function cronWorkerSummary(array $workers): array + { + $running = 0; + $stale = 0; + $failed = 0; + foreach ($workers as $worker) { + $status = (string)($worker['status'] ?? ''); + $isStale = (bool)($worker['stale'] ?? false); + if ($status === 'running' && !$isStale) { + $running++; + } + if ($isStale) { + $stale++; + } + if ($status === 'failed') { + $failed++; + } + } + + return [ + 'total' => count($workers), + 'running' => $running, + 'stale' => $stale, + 'failed' => $failed, + ]; + } + + private function cronWorkerHealth( + ?array $apiTarget, + ?array $target, + array $workers, + array $summary, + ?array $latestDeployment + ): array { + $issues = []; + if ($apiTarget === null) { + $issues[] = $this->cronWorkerIssue('missing_api_target', 'danger', 'No API deployment target is configured for this release channel.'); + return ['state' => 'needs_deploy', 'issues' => $issues]; + } + + if ($target === null) { + $issues[] = $this->cronWorkerIssue('missing_cron_target', 'warning', 'No cron worker Coolify target exists for this release channel.'); + return ['state' => 'needs_deploy', 'issues' => $issues]; + } + + $deploymentStatus = (string)($latestDeployment['status'] ?? ''); + if ($deploymentStatus === 'failed') { + $issues[] = $this->cronWorkerIssue( + 'latest_deployment_failed', + 'danger', + (string)($latestDeployment['error_message'] ?? 'Latest cron worker deployment failed.') + ); + return ['state' => 'failed', 'issues' => $issues]; + } + if (in_array($deploymentStatus, ['queued', 'deploying'], true)) { + $issues[] = $this->cronWorkerIssue('deployment_in_progress', 'info', 'Cron worker deployment is in progress.'); + return ['state' => 'deploying', 'issues' => $issues]; + } + + $running = (int)($summary['running'] ?? 0); + $stale = (int)($summary['stale'] ?? 0); + $failed = (int)($summary['failed'] ?? 0); + if ($running >= self::CRON_WORKER_DESIRED_COUNT && $stale === 0 && $failed === 0) { + return ['state' => 'healthy', 'issues' => []]; + } + + if ($running > 0) { + if ($stale > 0) { + $issues[] = $this->cronWorkerIssue('stale_workers_present', 'warning', 'At least one cron worker heartbeat is stale.'); + } + if ($failed > 0) { + $issues[] = $this->cronWorkerIssue('failed_workers_present', 'warning', 'At least one cron worker reported a failed loop.'); + } + if ($running < self::CRON_WORKER_DESIRED_COUNT) { + $issues[] = $this->cronWorkerIssue('below_desired_worker_count', 'warning', 'Fewer cron workers are running than desired.'); + } + return ['state' => 'degraded', 'issues' => $issues]; + } + + if ($workers === []) { + $age = $this->cronWorkerDeploymentAgeSeconds($latestDeployment); + if ($deploymentStatus === 'deployed' && ($age === null || $age <= self::CRON_WORKER_HEARTBEAT_GRACE_SECONDS)) { + $issues[] = $this->cronWorkerIssue('waiting_for_first_heartbeat', 'info', 'Coolify accepted the deployment; waiting for the worker to write its first heartbeat.'); + return ['state' => 'waiting_for_heartbeat', 'issues' => $issues]; + } + + $issues[] = $this->cronWorkerIssue('no_worker_heartbeat', 'danger', 'Cron worker target exists, but no worker heartbeat has been recorded.'); + return ['state' => $deploymentStatus === 'deployed' ? 'failed' : 'degraded', 'issues' => $issues]; + } + + if ($stale > 0 || $failed > 0) { + $issues[] = $this->cronWorkerIssue('no_fresh_running_worker', 'danger', 'Cron workers exist, but none have a fresh running heartbeat.'); + return ['state' => 'failed', 'issues' => $issues]; + } + + $issues[] = $this->cronWorkerIssue('worker_not_running', 'warning', 'Cron worker is not currently running.'); + return ['state' => 'degraded', 'issues' => $issues]; + } + + private function cronWorkerDeploymentReadiness(?array $apiTarget, ?array $target, array $providerStatus): array + { + $issues = []; + if ($target === null) { + if ($apiTarget === null) { + $issues[] = $this->cronWorkerIssue( + 'missing_api_target', + 'danger', + 'No API deployment target is configured for this release channel.' + ); + return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues]; + } + + return ['action' => 'create', 'can_deploy' => true, 'issues' => []]; + } + + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $canUseCronTargetContext = $this->cronWorkerTargetCanDeployWithoutApiTarget($target); + + if ($resourceUuid === '') { + if ($apiTarget !== null || $canUseCronTargetContext) { + if ($apiTarget === null) { + $issues[] = $this->cronWorkerIssue( + 'repairable_cron_target', + 'info', + 'No API target is configured, but the cron target has enough deployment context to deploy a worker.' + ); + } + return ['action' => 'create', 'can_deploy' => true, 'issues' => $issues]; + } + + $issues[] = $this->cronWorkerIssue( + 'missing_api_target', + 'danger', + 'No API deployment target is configured for this release channel.' + ); + return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues]; + } + + if ($this->toBool($providerStatus['missing'] ?? false)) { + $issues[] = $this->cronWorkerIssue( + 'missing_coolify_worker_resource', + 'danger', + 'The stored Coolify cron worker resource was not found and must be recreated.' + ); + + if ($apiTarget !== null || $canUseCronTargetContext) { + if ($apiTarget === null) { + $issues[] = $this->cronWorkerIssue( + 'repairable_cron_target', + 'info', + 'No API target is configured, but the cron target has enough deployment context to repair itself.' + ); + } + return ['action' => 'repair', 'can_deploy' => true, 'issues' => $issues]; + } + + $issues[] = $this->cronWorkerIssue( + 'missing_api_target', + 'danger', + 'No API deployment target is configured for this release channel.' + ); + return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues]; + } + + if ($apiTarget === null && !$canUseCronTargetContext) { + $issues[] = $this->cronWorkerIssue( + 'missing_api_target', + 'danger', + 'No API deployment target is configured for this release channel.' + ); + return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues]; + } + + return ['action' => 'update', 'can_deploy' => true, 'issues' => $issues]; + } + + private function cronWorkerTargetCanDeployWithoutApiTarget(?array $target): bool + { + if ($target === null) { + return false; + } + + $repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? '')); + if ($repository === '') { + $repository = trim((string)($target['repository'] ?? '')); + } + + return $this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null + && $repository !== ''; + } + + private function cronWorkerMergeIssues(array ...$issueGroups): array + { + $merged = []; + $seen = []; + foreach ($issueGroups as $issues) { + foreach ($issues as $issue) { + if (!is_array($issue)) { + continue; + } + $key = trim((string)($issue['code'] ?? '')); + if ($key === '') { + $key = trim((string)($issue['message'] ?? '')); + } + if ($key !== '' && isset($seen[$key])) { + continue; + } + if ($key !== '') { + $seen[$key] = true; + } + $merged[] = $issue; + } + } + + return $merged; + } + + private function cronWorkerIssue(string $code, string $severity, string $message): array + { + return [ + 'code' => $code, + 'severity' => $severity, + 'message' => $message, + ]; + } + + private function cronWorkerDeploymentAgeSeconds(?array $deployment): ?int + { + if ($deployment === null) { + return null; + } + + foreach (['completed_at', 'started_at', 'created_at'] as $key) { + $value = trim((string)($deployment[$key] ?? '')); + if ($value === '') { + continue; + } + $timestamp = strtotime($value); + if ($timestamp !== false) { + return max(0, time() - $timestamp); + } + } + + return null; + } + + private function cronWorkerProviderStatus(?array $target, array $input): array + { + if ($target === null) { + return [ + 'configured' => false, + 'resource' => null, + ]; + } + + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $resourceType = trim((string)($context['coolify_resource_type'] ?? 'application')) ?: 'application'; + $status = [ + 'configured' => $resourceUuid !== '', + 'instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null, + 'instance_label' => $target['coolify_instance_label'] ?? null, + 'resource_uuid' => $resourceUuid !== '' ? $resourceUuid : null, + 'resource_type' => $resourceType, + 'resource' => null, + 'checked' => false, + 'missing' => false, + ]; + + if (!$this->toBool($input['include_provider'] ?? $input['include_provider_status'] ?? false) || $resourceUuid === '') { + return $status; + } + + try { + $status['checked'] = true; + $instance = $this->selectOne( + 'SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', + 'i', + [(int)($target['coolify_instance_id'] ?? 0)] + ); + if ($instance === null) { + throw new RuntimeException('Coolify instance for cron worker target was not found.'); + } + $client = $this->coolifyClientForInstance($instance, 3); + $resource = $resourceType === 'service' + ? $client->getService($resourceUuid) + : $client->getApplication($resourceUuid); + $status['resource'] = [ + 'ok' => true, + 'uuid' => $resource['uuid'] ?? $resourceUuid, + 'name' => $resource['name'] ?? null, + 'status' => $resource['status'] ?? $resource['state'] ?? null, + 'fqdn' => $resource['fqdn'] ?? $resource['domains'] ?? null, + ]; + } catch (Throwable $throwable) { + $status['checked'] = true; + $status['missing'] = self::coolifyResourceMissing($throwable); + $status['resource'] = [ + 'ok' => false, + 'error' => $throwable->getMessage(), + 'missing' => $status['missing'], + ]; + } + + return $status; + } + + private static function coolifyResourceMissing(Throwable $throwable): bool + { + $message = strtolower($throwable->getMessage()); + return str_contains($message, 'http 404') + || str_contains($message, 'not found') + || preg_match('/\b404\b/', $message) === 1; + } + + private function coolifyDeploymentOperationId(array $deployment): ?string + { + foreach ([$deployment, $deployment['deployment'] ?? null, $deployment['data'] ?? null] as $candidate) { + if (!is_array($candidate)) { + continue; + } + foreach (['deployment_uuid', 'operation_id', 'deployment_id', 'uuid', 'id'] as $key) { + $value = trim((string)($candidate[$key] ?? '')); + if ($value !== '') { + return substr($value, 0, 128); + } + } + } + + return null; + } + private function cronWorkerDeployContext(array $apiTarget, ?array $existing, ?string $commitSha, int $targetId): array { $apiContext = self::jsonDecode($apiTarget['deploy_context_json'] ?? null); @@ -4632,7 +5204,13 @@ class release_manager $context['coolify_is_auto_deploy_enabled'] = false; $context['coolify_service_name'] = $workerName; $context['cron_worker_autoprovision'] = true; - $context['cron_worker_source_api_target_id'] = (int)($apiTarget['id'] ?? 0); + if ((string)($apiTarget['app'] ?? 'api') === 'api') { + $context['cron_worker_source_api_target_id'] = (int)($apiTarget['id'] ?? 0); + unset($context['cron_worker_source_cron_target_id']); + } else { + $context['cron_worker_source_cron_target_id'] = (int)($apiTarget['id'] ?? 0); + unset($context['cron_worker_source_api_target_id']); + } unset( $context['coolify_domain'], $context['coolify_public_url'], @@ -7415,6 +7993,7 @@ class release_manager throw new RuntimeException('Coolify did not return a resource UUID for the release target.'); } $context['coolify_resource_type'] = $resourceType; + $target['coolify_service_uuid'] = $serviceUuid; $this->execute( 'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?', 'ssi', @@ -7636,6 +8215,12 @@ class release_manager $env = array_replace($env, $contextEnv); $env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true'; $env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? '')); + if ($app === self::CRON_WORKER_APP) { + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + if ($resourceUuid !== '') { + $env['CRON_WORKER_COOLIFY_RESOURCE_UUID'] = $resourceUuid; + } + } $this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha); return $this->normalizeCoolifyRuntimeEnv($env); } @@ -11577,6 +12162,7 @@ class release_manager 'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null, 'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '', 'provider' => (string)($deployment['provider'] ?? 'coolify'), + 'provider_operation_id' => $deployment['provider_operation_id'] ?? null, 'repository' => $deployment['repository'] ?? null, 'branch' => $deployment['branch'] ?? null, 'commit_sha' => $deployment['commit_sha'] ?? null, diff --git a/services/nginx/app/classes/superuser_system_status_service.php b/services/nginx/app/classes/superuser_system_status_service.php index 4e284439..3a7e40d0 100644 --- a/services/nginx/app/classes/superuser_system_status_service.php +++ b/services/nginx/app/classes/superuser_system_status_service.php @@ -537,6 +537,7 @@ class superuser_system_status_service $result['configured'] = false; $result['status'] = 'disabled'; $result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.')); + $result = $this->attachModuleUsageMetrics($result); $results[] = $result; continue; } @@ -548,6 +549,7 @@ class superuser_system_status_service $result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params']) ? $configuration['reason_params'] : ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired]; + $result = $this->attachModuleUsageMetrics($result); $results[] = $result; continue; } @@ -563,6 +565,7 @@ class superuser_system_status_service 'Configuration is present, but no safe read-only probe is available.' ) ); + $result = $this->attachModuleUsageMetrics($result); $results[] = $result; continue; } @@ -578,6 +581,7 @@ class superuser_system_status_service if (isset($probeResult['usage']) && is_array($probeResult['usage'])) { $result['usage'] = $probeResult['usage']; } + $result = $this->attachModuleUsageMetrics($result, isset($probeResult['usage']) && is_array($probeResult['usage']) ? $probeResult['usage'] : null); $results[] = $result; } @@ -596,6 +600,60 @@ class superuser_system_status_service return $results; } + protected function attachModuleUsageMetrics(array $moduleResult, ?array $probeUsage = null): array + { + try { + $service = new module_usage_service(); + $metrics = $service->metricsForModule((string)($moduleResult['key'] ?? '')); + + if ($probeUsage !== null) { + $probeMetric = $service->recordProviderSnapshotFromLegacyUsage( + (string)($moduleResult['key'] ?? ''), + $probeUsage, + ['source' => 'system_status_probe'] + ); + if ($probeMetric !== null) { + $metrics = $this->replaceModuleUsageMetric($metrics, $probeMetric); + } + } + + if ($metrics !== []) { + $moduleResult['usage_metrics'] = $metrics; + if (!isset($moduleResult['usage'])) { + $primaryUsage = $service->primarySystemUsage($metrics); + if ($primaryUsage !== null) { + $moduleResult['usage'] = $primaryUsage; + } + } + } + } catch (Throwable $throwable) { + $moduleResult['usage_metrics_error'] = $throwable->getMessage(); + } + + return $moduleResult; + } + + protected function replaceModuleUsageMetric(array $metrics, array $replacement): array + { + $replaced = false; + foreach ($metrics as $index => $metric) { + if ( + ($metric['module_key'] ?? null) === ($replacement['module_key'] ?? null) + && ($metric['metric_key'] ?? null) === ($replacement['metric_key'] ?? null) + ) { + $metrics[$index] = $replacement; + $replaced = true; + break; + } + } + + if (!$replaced) { + $metrics[] = $replacement; + } + + return $metrics; + } + protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array { $cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key']; @@ -956,7 +1014,11 @@ class superuser_system_status_service 'Authorization: Bearer ' . $apiKey, 'Accept: application/json', ], - 'MailerSend API' + 'MailerSend API', + null, + 'GET', + null, + fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'email') ); } @@ -1033,7 +1095,11 @@ class superuser_system_status_service return $this->performHttpProbe( $this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'), ['X-AUTH-TOKEN: ' . $secretKey], - 'MotorAPI' + 'MotorAPI', + null, + 'GET', + null, + fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'motorapi') ); } @@ -1502,30 +1568,31 @@ class superuser_system_status_service $decoded = json_decode((string)($httpResponse['body'] ?? ''), true); if (!is_array($decoded)) { - return [ - 'status' => 'degraded', - 'status_reason' => $label . ' returned an unreadable usage payload.', - 'status_reason_key' => 'licenseplaterecognizer_usage_unreadable', - 'status_reason_params' => ['label' => $label], - 'checked_at' => $httpResponse['checked_at'] ?? date('c'), - 'latency_ms' => $httpResponse['latency_ms'] ?? null, - 'http_status' => $httpResponse['http_status'] ?? null, - ]; + return $this->providerQuotaUnavailableProbeResult( + $httpResponse, + $label, + 'licenseplaterecognizer', + 'unreadable_payload', + [] + ); } $usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : []; $callsUsedRaw = $usage['calls'] ?? null; $quotaCallsRaw = $decoded['total_calls'] ?? null; if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) { - return [ - 'status' => 'degraded', - 'status_reason' => $label . ' did not return usable quota values.', - 'status_reason_key' => 'licenseplaterecognizer_usage_missing', - 'status_reason_params' => ['label' => $label], - 'checked_at' => $httpResponse['checked_at'] ?? date('c'), - 'latency_ms' => $httpResponse['latency_ms'] ?? null, - 'http_status' => $httpResponse['http_status'] ?? null, - ]; + $reason = !is_numeric($quotaCallsRaw) ? 'missing_limit' : 'missing_usage'; + if (is_numeric($quotaCallsRaw) && (int)$quotaCallsRaw <= 0) { + $reason = 'invalid_limit'; + } + + return $this->providerQuotaUnavailableProbeResult( + $httpResponse, + $label, + 'licenseplaterecognizer', + $reason, + $this->payloadKeyPaths($decoded) + ); } $callsUsed = max(0, (int)$callsUsedRaw); @@ -1586,6 +1653,176 @@ class superuser_system_status_service ]; } + protected function evaluateProviderQuotaProbeResponse(array $httpResponse, string $label, string $moduleKey): array + { + $classified = $this->classifyHttpProbeResult($httpResponse, $label); + if (($classified['status'] ?? 'down') !== 'ok') { + return $classified; + } + + $decoded = json_decode((string)($httpResponse['body'] ?? ''), true); + if (!is_array($decoded)) { + return $this->providerQuotaUnavailableProbeResult( + $httpResponse, + $label, + $moduleKey, + 'unreadable_payload', + [] + ); + } + + $used = $this->findFirstNumericPayloadValue($decoded, ['used', 'usage', 'calls', 'messages_used', 'used_messages', 'sent', 'total_used']); + $limit = $this->findFirstNumericPayloadValue($decoded, ['limit', 'quota', 'total', 'total_calls', 'messages_limit', 'max', 'allowed']); + $remaining = $this->findFirstNumericPayloadValue($decoded, ['remaining', 'left', 'available', 'calls_remaining', 'messages_remaining']); + + if ($used === null && $limit !== null && $remaining !== null) { + $used = max(0.0, $limit - $remaining); + } + if ($remaining === null && $used !== null && $limit !== null) { + $remaining = max(0.0, $limit - $used); + } + + if ($limit === null) { + return $this->providerQuotaUnavailableProbeResult( + $httpResponse, + $label, + $moduleKey, + 'missing_limit', + $this->payloadKeyPaths($decoded) + ); + } + + if ($limit <= 0) { + return $this->providerQuotaUnavailableProbeResult( + $httpResponse, + $label, + $moduleKey, + 'invalid_limit', + $this->payloadKeyPaths($decoded) + ); + } + + if ($used === null) { + return $this->providerQuotaUnavailableProbeResult( + $httpResponse, + $label, + $moduleKey, + 'missing_usage', + $this->payloadKeyPaths($decoded) + ); + } + + $usagePercent = round(($used / $limit) * 100, 2); + $usagePayload = [ + 'provider' => $moduleKey, + 'calls_used' => $used, + 'quota_calls' => $limit, + 'calls_remaining' => $remaining, + 'usage_percent' => $usagePercent, + ]; + + if (($remaining !== null && $remaining <= 0) || $usagePercent >= 100.0) { + return [ + 'status' => 'down', + 'status_reason' => $label . ' quota is exhausted.', + 'status_reason_key' => 'provider_quota_exhausted', + 'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')], + 'checked_at' => $httpResponse['checked_at'] ?? date('c'), + 'latency_ms' => $httpResponse['latency_ms'] ?? null, + 'http_status' => $httpResponse['http_status'] ?? null, + 'usage' => $usagePayload, + ]; + } + + if ($usagePercent >= 90.0) { + return [ + 'status' => 'degraded', + 'status_reason' => $label . ' quota usage is near the limit.', + 'status_reason_key' => 'provider_quota_near_limit', + 'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')], + 'checked_at' => $httpResponse['checked_at'] ?? date('c'), + 'latency_ms' => $httpResponse['latency_ms'] ?? null, + 'http_status' => $httpResponse['http_status'] ?? null, + 'usage' => $usagePayload, + ]; + } + + return [ + 'status' => 'ok', + 'status_reason' => $label . ' quota usage is available.', + 'status_reason_key' => 'provider_quota_available', + 'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')], + 'checked_at' => $httpResponse['checked_at'] ?? date('c'), + 'latency_ms' => $httpResponse['latency_ms'] ?? null, + 'http_status' => $httpResponse['http_status'] ?? null, + 'usage' => $usagePayload, + ]; + } + + protected function providerQuotaUnavailableProbeResult(array $httpResponse, string $label, string $moduleKey, string $reason, array $detectedKeys = []): array + { + $usagePayload = [ + 'provider' => $moduleKey, + 'usage_available' => false, + 'status' => 'unknown', + 'calls_used' => null, + 'quota_calls' => null, + 'calls_remaining' => null, + 'usage_percent' => null, + 'unavailable_reason' => $reason, + 'detected_keys' => $detectedKeys, + ]; + + return [ + 'status' => 'degraded', + 'status_reason' => $label . ' responded, but quota usage could not be read.', + 'status_reason_key' => 'provider_quota_unavailable', + 'status_reason_params' => [ + 'label' => $label, + 'reason' => $reason, + 'detected_keys' => implode(', ', array_slice($detectedKeys, 0, 12)), + ], + 'checked_at' => $httpResponse['checked_at'] ?? date('c'), + 'latency_ms' => $httpResponse['latency_ms'] ?? null, + 'http_status' => $httpResponse['http_status'] ?? null, + 'usage' => $usagePayload, + ]; + } + + protected function findFirstNumericPayloadValue(array $payload, array $keys): ?float + { + foreach ($payload as $key => $value) { + $normalizedKey = strtolower((string)$key); + if (in_array($normalizedKey, $keys, true) && is_numeric($value)) { + return (float)$value; + } + + if (is_array($value)) { + $nested = $this->findFirstNumericPayloadValue($value, $keys); + if ($nested !== null) { + return $nested; + } + } + } + + return null; + } + + protected function payloadKeyPaths(array $payload, string $prefix = ''): array + { + $paths = []; + foreach ($payload as $key => $value) { + $path = $prefix === '' ? (string)$key : $prefix . '.' . (string)$key; + $paths[] = $path; + + if (is_array($value)) { + array_push($paths, ...$this->payloadKeyPaths($value, $path)); + } + } + + return array_values(array_unique($paths)); + } + protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array { $classified = $this->classifyHttpProbeResult($httpResponse, $label); diff --git a/services/nginx/app/classes/virkdata.php b/services/nginx/app/classes/virkdata.php index 6e429e93..a200e756 100644 --- a/services/nginx/app/classes/virkdata.php +++ b/services/nginx/app/classes/virkdata.php @@ -72,6 +72,8 @@ class virkdata implements virkdata_i self::requireModuleEnabled(); // Validate the secret key self::requireValidSecretKey(); + // VirkData has a monthly limit config, but defaults to observe mode until explicitly switched to block. + $this->reserveCompanySearchQuota($search, $endpoint, $method); // Send the request $response = match ($method) { 'GET' => self::sendGetRequest($search, $endpoint, $data), @@ -128,6 +130,18 @@ class virkdata implements virkdata_i } } + /** + * @throws Exception + */ + private function reserveCompanySearchQuota(string $search, string $endpoint, string $method): void + { + (new module_usage_service())->reserveOrFail('virkdata', 'company_search_calls', 1, [ + 'search' => $search, + 'endpoint' => $endpoint, + 'method' => strtoupper($method), + ]); + } + /** * @throws Exception */ @@ -235,4 +249,4 @@ class virkdata implements virkdata_i { return $this->sendRequest($query, 'search', [], 'GET'); } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_pdf_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_pdf_endpoint.php index 7eb201fd..02c73c83 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_pdf_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_pdf_endpoint.php @@ -9,21 +9,49 @@ class economic_invoices_pdf_endpoint use economic_endpoint_t; /** - * Get a PDF of a booked invoice + * Get a PDF of a booked invoice. + * + * Kept as the legacy alias used by existing invoice download flows. * @param int $id * @return string */ public function get(int $id): string + { + return $this->getBooked($id); + } + + /** + * Get a PDF of a booked invoice. + * @param int $id + * @return string + */ + public function getBooked(int $id): string + { + return $this->downloadPdf('/invoices/booked/' . $id . '/pdf', 'booked_invoice_'); + } + + /** + * Get a PDF of a draft invoice. + * @param int $id + * @return string + */ + public function getDraft(int $id): string + { + return $this->downloadPdf('/invoices/drafts/' . $id . '/pdf', 'draft_invoice_'); + } + + private function downloadPdf(string $path, string $prefix): string { $unique_id = uniqid(); + $file_path = '/tmp/' . $prefix . $unique_id . '.pdf'; $this->send_file_download_request( - '/invoices/booked/' . $id . '/pdf', + $path, 'GET', '', false, - '/tmp/invoice_' . $unique_id . '.pdf' + $file_path ); - return '/tmp/invoice_' . $unique_id . '.pdf'; + return $file_path; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/module_action_logs_o.php b/services/nginx/app/objects/module_action_logs_o.php index 5492a97f..9e56b35e 100644 --- a/services/nginx/app/objects/module_action_logs_o.php +++ b/services/nginx/app/objects/module_action_logs_o.php @@ -3,6 +3,7 @@ namespace objects; use classes\db; +use classes\module_usage_schema_bootstrap; use classes\object_property; use Exception; use traits\db_object_t; @@ -40,6 +41,7 @@ class module_action_logs_o extends db public function structure(): void { + module_usage_schema_bootstrap::ensureTables(); $this->setTable('module_usage_logs'); } @@ -131,4 +133,4 @@ class module_action_logs_o extends db return is_array($decoded) ? $decoded : []; } -} \ No newline at end of file +} diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 54eb278c..35b31e9e 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -7624,6 +7624,45 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /collected-invoices/economic/pdf: + get: + tags: + - Invoices + summary: Download a collected invoice e-conomic PDF + description: | + Resolves the requested collected invoice context and returns a presigned URL for the + draft or booked e-conomic invoice PDF. + operationId: downloadCollectedInvoiceEconomicPdf + parameters: + - name: collected_invoice_id + in: query + required: true + description: The internal collected invoice ID + schema: + type: integer + minimum: 1 + - name: type + in: query + required: true + description: Which e-conomic invoice PDF to download + schema: + type: string + enum: + - draft + - booked + responses: + '200': + description: PDF URL resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicPdfResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/v2/details: get: tags: @@ -10044,6 +10083,118 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /modules/usage/summary: + get: + tags: + - Modules + summary: Module usage and quota summary + description: Returns registered module usage metrics, quota status, provider snapshots, and derived operational statistics. + operationId: getModuleUsageSummary + parameters: + - in: query + name: module + required: false + schema: + type: string + - in: query + name: period + required: false + schema: + type: string + enum: [all, day, month, provider, all_time] + - in: query + name: status + required: false + schema: + type: string + enum: [ok, near_limit, exhausted, unlimited, unknown, disabled] + - in: query + name: date + required: false + schema: + type: string + format: date + responses: + '200': + description: Module usage summary returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleUsageSummary' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/usage/{moduleKey}: + get: + tags: + - Modules + summary: Module usage details + operationId: getModuleUsageDetail + parameters: + - in: path + name: moduleKey + required: true + schema: + type: string + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + - in: query + name: date + required: false + schema: + type: string + format: date + responses: + '200': + description: Module usage detail returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleUsageDetail' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/quotas/{moduleKey}/{metricKey}: + patch: + tags: + - Modules + summary: Update module quota setting + description: Updates quota enforcement settings and writable module-config hard limits for a registered metric. + operationId: updateModuleQuotaSetting + parameters: + - in: path + name: moduleKey + required: true + schema: + type: string + - in: path + name: metricKey + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleQuotaUpdateRequest' + responses: + '200': + description: Module quota setting updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleUsageMetric' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '409': { $ref: '#/components/responses/Conflict' } + '422': { $ref: '#/components/responses/BadRequest' } + # Module - Self-Serve Endpoints /modules/self-serve/lane/status: get: @@ -16507,34 +16658,242 @@ components: type: string enum: [disabled, not_configured, configured, ok, degraded, down] + ModuleUsageStatusEnum: + type: string + enum: [ok, near_limit, exhausted, unlimited, unknown, disabled] + + ModuleUsageMetric: + type: object + properties: + module_key: + type: string + module_label: + type: string + metric_key: + type: string + metric_label: + type: string + unit: + type: string + period: + type: string + enum: [day, month, provider, all_time] + scope_type: + type: string + scope_id: + type: string + source: + type: string + enum: [internal_counter, provider_snapshot, derived] + primary: + type: boolean + writable_limit: + type: boolean + limit_source: + type: string + nullable: true + enum: [module_config, provider] + config_module: + type: string + nullable: true + config_variable: + type: string + nullable: true + used: + type: number + format: float + nullable: true + limit: + type: number + format: float + nullable: true + remaining: + type: number + format: float + nullable: true + usage_percent: + type: number + format: float + nullable: true + status: + $ref: '#/components/schemas/ModuleUsageStatusEnum' + enabled: + type: boolean + enforce_mode: + type: string + enum: [observe, block] + soft_limit_percent: + type: number + format: float + window: + type: object + properties: + start: + type: string + format: date-time + nullable: true + end: + type: string + format: date-time + nullable: true + timezone: + type: string + updated_at: + type: string + format: date-time + nullable: true + history_available: + type: boolean + version: + type: string + nullable: true + usage_available: + type: boolean + description: False when a provider responded but quota fields could not be read. + unavailable_reason: + type: string + nullable: true + enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit] + detected_keys: + type: array + items: + type: string + required: + - module_key + - metric_key + - unit + - period + - source + - status + - enforce_mode + + ModuleUsageSummary: + type: object + properties: + generated_at: + type: string + format: date-time + filters: + type: object + additionalProperties: true + modules: + type: array + items: + type: object + properties: + key: + type: string + label: + type: string + status: + $ref: '#/components/schemas/ModuleUsageStatusEnum' + metrics: + type: array + items: + $ref: '#/components/schemas/ModuleUsageMetric' + metrics: + type: array + items: + $ref: '#/components/schemas/ModuleUsageMetric' + required: + - generated_at + - modules + - metrics + + ModuleUsageDetail: + type: object + properties: + generated_at: + type: string + format: date-time + module_key: + type: string + metrics: + type: array + items: + $ref: '#/components/schemas/ModuleUsageMetric' + history: + type: array + items: + type: object + additionalProperties: true + + ModuleQuotaUpdateRequest: + type: object + properties: + enabled: + type: boolean + enforce_mode: + type: string + enum: [observe, block] + soft_limit_percent: + type: number + format: float + minimum: 1 + maximum: 100 + limit: + type: integer + minimum: 0 + SuperuserModuleUsage: type: object properties: provider: type: string - enum: [licenseplaterecognizer] + metric_key: + type: string + nullable: true + unit: + type: string + nullable: true + period: + type: string + nullable: true calls_used: - type: integer + type: number + format: float minimum: 0 + nullable: true quota_calls: - type: integer - minimum: 1 - calls_remaining: - type: integer + type: number + format: float minimum: 0 + nullable: true + calls_remaining: + type: number + format: float + minimum: 0 + nullable: true usage_percent: type: number format: float minimum: 0 + nullable: true + status: + type: string + nullable: true + source: + type: string + nullable: true + enforce_mode: + type: string + nullable: true version: type: string nullable: true + usage_available: + type: boolean + nullable: true + unavailable_reason: + type: string + nullable: true + enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit] + detected_keys: + type: array + items: + type: string required: - provider - - calls_used - - quota_calls - - calls_remaining - - usage_percent SuperuserRuntimeMetric: type: object @@ -16653,6 +17012,10 @@ components: additionalProperties: true usage: $ref: '#/components/schemas/SuperuserModuleUsage' + usage_metrics: + type: array + items: + $ref: '#/components/schemas/ModuleUsageMetric' checked_at: type: string format: date-time @@ -19324,6 +19687,31 @@ components: - collected_invoice_id - internal_total + CollectedInvoiceEconomicPdfResponse: + type: object + description: Presigned PDF URL for a draft or booked e-conomic invoice attached to a collected invoice + properties: + collected_invoice_id: + type: integer + example: 123 + type: + type: string + enum: + - draft + - booked + example: booked + economic_invoice_id: + type: integer + example: 28368 + url: + type: string + format: uri + required: + - collected_invoice_id + - type + - economic_invoice_id + - url + CollectedInvoiceEconomicV2DetailsResponse: type: object properties: diff --git a/services/nginx/app/routes/cronRoute.php b/services/nginx/app/routes/cronRoute.php index 92573e64..a6f0f036 100644 --- a/services/nginx/app/routes/cronRoute.php +++ b/services/nginx/app/routes/cronRoute.php @@ -43,16 +43,22 @@ class cronRoute $this->requireClassicSuperuserPermission('superuser_cron_view'); $parameters = $this->getParametersAsArray(); - $workers = (new cron_worker())->listWorkers(); try { - $workers['deployment'] = (new release_manager())->cronWorkerStatus($parameters); + $response->success((new release_manager())->cronWorkerStatus($parameters)); } catch (Throwable $throwable) { + $workers = (new cron_worker())->listWorkers(); $workers['deployment'] = [ 'ok' => false, 'error' => $throwable->getMessage(), ]; + $workers['state'] = 'failed'; + $workers['issues'] = [[ + 'code' => 'status_load_failed', + 'severity' => 'danger', + 'message' => $throwable->getMessage(), + ]]; + $response->success($workers); } - $response->success($workers); }, [ 'superuser_cron_view' => 'View cron worker deployment and heartbeat state', ]); @@ -68,7 +74,7 @@ class cronRoute $this->actorUserId() ); (new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_WORKER_DEPLOY', 'Deployed cron worker'); - $response->success($result); + $response->success($result, 202); } catch (Throwable $throwable) { $response->error(['message' => $throwable->getMessage()], 409); } diff --git a/services/nginx/app/routes/moduleUsageRoute.php b/services/nginx/app/routes/moduleUsageRoute.php new file mode 100644 index 00000000..4302bcbe --- /dev/null +++ b/services/nginx/app/routes/moduleUsageRoute.php @@ -0,0 +1,70 @@ +get('/modules/usage/summary', function () { + global $response; + + $this->requirePermission('modules_usage_view'); + $response->success((new module_usage_service())->summary([ + 'module' => $this->getParameter('module'), + 'period' => $this->getParameter('period'), + 'status' => $this->getParameter('status'), + 'date' => $this->getParameter('date'), + ])); + }, [ + 'modules_usage_view' => 'View module usage, quota, and statistics summaries', + ]); + + $this->get('/modules/usage/{moduleKey}', function () { + global $response; + + $this->requirePermission('modules_usage_view'); + $moduleKey = (string)($this->fromRoute('moduleKey') ?? ''); + $response->success((new module_usage_service())->moduleDetail($moduleKey, [ + 'period' => $this->getParameter('period'), + 'date' => $this->getParameter('date'), + 'limit' => $this->getParameter('limit'), + ])); + }, [ + 'modules_usage_view' => 'View detailed module usage, quota, and statistics history', + ]); + + $this->patch('/modules/quotas/{moduleKey}/{metricKey}', function () { + global $response; + + $this->requirePermission('modules_quotas_manage'); + $moduleKey = (string)($this->fromRoute('moduleKey') ?? ''); + $metricKey = (string)($this->fromRoute('metricKey') ?? ''); + + try { + $response->success((new module_usage_service())->updateQuotaSetting( + $moduleKey, + $metricKey, + $response->getAllRequestParameters() + )); + } catch (Exception $exception) { + if ($exception->getMessage() === 'quota_not_writable') { + $response->error([ + 'message' => 'Quota limit is not writable for this provider or derived metric.', + 'code' => 'quota_not_writable', + ], 409); + } + + $response->error($exception->getMessage(), 422); + } + }, [ + 'modules_quotas_manage' => 'Manage module quota enforcement and writable hard limits', + ]); + } +} diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 884aa8b8..9bc6e3b4 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary; use classes\economic_v2_compare_engine; use classes\economic_v2_line_normalizer; use classes\economic_v2_revenue_statistics_service; +use classes\invoice_store; use classes\invoice_collection_bulk_action_service; use classes\invoicing_period_utils; use classes\response; @@ -249,6 +250,26 @@ class orderInvoicesRoute ] ); + /** Collected order invoices > E-conomic PDF > GET */ + $this->get('/collected-invoices/economic/pdf', function () { + global $response; + self::requirePermission('download_collected_invoice_economic_pdf'); + $collected_invoice_id = $this->requireCollectedInvoiceId(); + self::requireParameters(['type']); + + $type = strtolower((string)self::getParameter('type')); + if (!in_array($type, ['draft', 'booked'], true)) { + $response->error('type must be either draft or booked', 400); + } + + $payload = $this->downloadCollectedEconomicInvoicePdf($collected_invoice_id, $type); + $response->success($payload); + }, + [ + 'download_collected_invoice_economic_pdf' => 'Download draft/booked e-conomic PDF for a collected order invoice.', + ] + ); + /** Collected order invoices > E-conomic V2 compare > GET */ $this->get('/collected-invoices/economic/v2/compare', function () { global $response; @@ -2105,6 +2126,61 @@ class orderInvoicesRoute ]; } + private function downloadCollectedEconomicInvoicePdf(int $collected_invoice_id, string $type): array + { + global $response; + + $invoice = (new collected_order_invoices_o())->select($collected_invoice_id); + $invoice->requireSelected(); + $this->requireCollectedInvoiceContextAccess($invoice); + + $economic_invoice_id = null; + try { + $economic_invoice_id = $type === 'draft' + ? $invoice->getInvoiceDraftId() + : $invoice->getInvoiceBookedId(); + } catch (Exception $e) { + $response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); + } + + $economic_invoice_id = (int)$economic_invoice_id; + if ($economic_invoice_id <= 0) { + $response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); + } + + $economic = new economic(); + $invoice_path_file = $type === 'draft' + ? $economic->invoices->pdf->getDraft($economic_invoice_id) + : $economic->invoices->pdf->getBooked($economic_invoice_id); + + $store_key_id = 'economic_' . $type . '_' . $economic_invoice_id; + $invoice_store = new invoice_store(); + try { + $invoice_store->uploadFile('invoice_' . $store_key_id . '.pdf', $invoice_path_file); + } finally { + if (is_string($invoice_path_file) && file_exists($invoice_path_file)) { + unlink($invoice_path_file); + } + } + + $user = (new authentication())->get_user(); + (new logs_o())->add( + 'orderInvoices', + 'global', + 1, + $user ? (int)$user->id : 0, + 'DOWNLOAD_COLLECTED_ECONOMIC_INVOICE_PDF', + 'Downloaded ' . $type . ' e-conomic invoice PDF for collected invoice ' . $collected_invoice_id + ); + + return [ + 'collected_invoice_id' => $collected_invoice_id, + 'type' => $type, + 'economic_invoice_id' => $economic_invoice_id, + 'url' => $invoice_store->getInvoiceDownloadUrl($store_key_id), + ]; + } + private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void { global $response; diff --git a/services/nginx/app/tests/Unit/Cron/CronWorkerWiringTest.php b/services/nginx/app/tests/Unit/Cron/CronWorkerWiringTest.php index e7b679ab..1cced959 100644 --- a/services/nginx/app/tests/Unit/Cron/CronWorkerWiringTest.php +++ b/services/nginx/app/tests/Unit/Cron/CronWorkerWiringTest.php @@ -33,6 +33,7 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu expect($route)->toContain('/superuser/cron/workers'); expect($route)->toContain('/superuser/cron/workers/deploy'); + expect($route)->toContain('$response->success($result, 202)'); expect($route)->toContain('queueTaskRun('); expect($route)->toContain('$response->success($run, 202)'); expect($route)->toContain('superuser_cron_view'); @@ -42,6 +43,9 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'"); expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'"); expect($manager)->toContain('deployCronWorkerAfterApiDeployment'); + expect($manager)->toContain('deployment_kind = \'cron_worker\''); + expect($manager)->toContain('createCronWorkerDeploymentRecord'); + expect($manager)->toContain('waiting_for_heartbeat'); expect($manager)->toContain('cronWorkerAutoprovisionRequired'); expect($manager)->toContain('cron_worker_autoprovision_disabled'); expect($manager)->toContain('cron_worker_deploy_failed'); diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicPdfRouteTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicPdfRouteTest.php new file mode 100644 index 00000000..7e3ad851 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicPdfRouteTest.php @@ -0,0 +1,25 @@ +not->toBeFalse(); + expect($content)->toContain('/collected-invoices/economic/pdf'); + expect($content)->toContain("requirePermission('download_collected_invoice_economic_pdf')"); + expect($content)->toContain("self::requireParameters(['type'])"); + expect($content)->toContain('downloadCollectedEconomicInvoicePdf'); + expect($content)->toContain('getInvoiceDraftId()'); + expect($content)->toContain('getInvoiceBookedId()'); +}); + +it('supports draft and booked pdf downloads in the e-conomic pdf endpoint wrapper', function (): void { + $endpointFile = app_path('modules/economic/endpoints/invoices/economic_invoices_pdf_endpoint.php'); + $content = file_get_contents($endpointFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('function getBooked(int $id): string'); + expect($content)->toContain('function getDraft(int $id): string'); + expect($content)->toContain("'/invoices/booked/' . \$id . '/pdf'"); + expect($content)->toContain("'/invoices/drafts/' . \$id . '/pdf'"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php index 1af658a0..8b603f45 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php @@ -31,6 +31,7 @@ it('documents economic v2 invoice paths in openapi', function (): void { $content = economic_v2_openapi_content_or_skip(); expect($content)->not->toBeFalse(); + expect($content)->toContain('/collected-invoices/economic/pdf:'); expect($content)->toContain('/collected-invoices/economic/v2/details:'); expect($content)->toContain('/collected-invoices/economic/v2/compare:'); expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk:'); @@ -69,6 +70,7 @@ it('defines new reusable v2 schemas for normalization comparison versioning and expect($content)->not->toBeFalse(); expect($content)->toContain('EconomicV2NormalizedLineItem:'); + expect($content)->toContain('CollectedInvoiceEconomicPdfResponse:'); expect($content)->toContain('EconomicV2Comparison:'); expect($content)->toContain('CollectedInvoiceEconomicV2CustomerSummary:'); expect($content)->toContain('CollectedInvoiceEconomicV2RevenueStatisticsResponse:'); diff --git a/services/nginx/app/tests/Unit/Modules/ModuleUsageOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Modules/ModuleUsageOpenApiSpecTest.php new file mode 100644 index 00000000..f307c0fd --- /dev/null +++ b/services/nginx/app/tests/Unit/Modules/ModuleUsageOpenApiSpecTest.php @@ -0,0 +1,25 @@ +toContain('/modules/usage/summary:') + ->and($content)->toContain('operationId: getModuleUsageSummary') + ->and($content)->toContain('/modules/usage/{moduleKey}:') + ->and($content)->toContain('operationId: getModuleUsageDetail') + ->and($content)->toContain('/modules/quotas/{moduleKey}/{metricKey}:') + ->and($content)->toContain('operationId: updateModuleQuotaSetting') + ->and($content)->toContain('ModuleUsageSummary:') + ->and($content)->toContain('ModuleUsageDetail:') + ->and($content)->toContain('ModuleUsageMetric:') + ->and($content)->toContain('ModuleQuotaUpdateRequest:'); +}); + +it('documents unavailable provider quota fields in openapi', function (): void { + $content = file_get_contents(app_path('openapi.yaml')); + + expect($content)->toContain('usage_available:') + ->and($content)->toContain('unavailable_reason:') + ->and($content)->toContain('enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit]') + ->and($content)->toContain('detected_keys:'); +}); diff --git a/services/nginx/app/tests/Unit/Modules/ModuleUsageServiceTest.php b/services/nginx/app/tests/Unit/Modules/ModuleUsageServiceTest.php new file mode 100644 index 00000000..3ba43eab --- /dev/null +++ b/services/nginx/app/tests/Unit/Modules/ModuleUsageServiceTest.php @@ -0,0 +1,89 @@ +find('motorapi', 'lookup_calls'); + $fxRates = $registry->find('fxratesapi', 'rate_fetch_calls'); + $virkData = $registry->find('virkdata', 'company_search_calls'); + + expect($motorApi)->not->toBeNull() + ->and($motorApi['config_module'])->toBe('motorapi') + ->and($motorApi['config_variable'])->toBe('daily_limit') + ->and($motorApi['default_enforce_mode'])->toBe('block') + ->and($motorApi['writable_limit'])->toBeTrue() + ->and($fxRates)->not->toBeNull() + ->and($fxRates['config_variable'])->toBe('daily_limit') + ->and($fxRates['default_enforce_mode'])->toBe('block') + ->and($virkData)->not->toBeNull() + ->and($virkData['config_variable'])->toBe('monthly_limit') + ->and($virkData['default_enforce_mode'])->toBe('observe'); +}); + +it('normalizes provider usage payloads into module usage metrics without requiring a database', function (): void { + $previousDb = $GLOBALS['db'] ?? null; + unset($GLOBALS['db']); + + try { + $metric = (new module_usage_service())->recordProviderSnapshotFromLegacyUsage('licenseplaterecognizer', [ + 'calls_used' => 2250, + 'quota_calls' => 2500, + 'calls_remaining' => 250, + 'usage_percent' => 90.0, + 'version' => '1.54.0', + ]); + } finally { + if ($previousDb !== null) { + $GLOBALS['db'] = $previousDb; + } + } + + expect($metric)->not->toBeNull() + ->and($metric['module_key'])->toBe('licenseplaterecognizer') + ->and($metric['metric_key'])->toBe('plate_recognition_calls') + ->and($metric['used'])->toBe(2250.0) + ->and($metric['limit'])->toBe(2500.0) + ->and($metric['remaining'])->toBe(250.0) + ->and($metric['usage_percent'])->toBe(90.0) + ->and($metric['status'])->toBe('near_limit') + ->and($metric['version'])->toBe('1.54.0'); +}); + +it('normalizes unavailable provider quota payloads into placeholder metrics without requiring a database', function (): void { + $previousDb = $GLOBALS['db'] ?? null; + unset($GLOBALS['db']); + + try { + $metric = (new module_usage_service())->recordProviderSnapshotFromLegacyUsage('email', [ + 'usage_available' => false, + 'status' => 'unknown', + 'calls_used' => null, + 'quota_calls' => null, + 'calls_remaining' => null, + 'usage_percent' => null, + 'unavailable_reason' => 'missing_limit', + 'detected_keys' => ['plan', 'usage.calls'], + ]); + } finally { + if ($previousDb !== null) { + $GLOBALS['db'] = $previousDb; + } + } + + expect($metric)->not->toBeNull() + ->and($metric['module_key'])->toBe('email') + ->and($metric['metric_key'])->toBe('mailersend_messages') + ->and($metric['used'])->toBeNull() + ->and($metric['limit'])->toBeNull() + ->and($metric['usage_percent'])->toBeNull() + ->and($metric['status'])->toBe('unknown') + ->and($metric['usage_available'])->toBeFalse() + ->and($metric['unavailable_reason'])->toBe('missing_limit') + ->and($metric['detected_keys'])->toBe(['plan', 'usage.calls']); +}); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php index 4a7944f9..003eca2b 100644 --- a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -468,8 +468,129 @@ it('requires Coolify cron worker autoprovisioning for API deployments by default expect($enabledMethod->invoke($manager, $disabledTarget))->toBeFalse(); expect($requiredMethod->invoke($manager, $disabledTarget))->toBeFalse(); - $managerSource = file_get_contents(app_path('classes/release_manager.php')); - expect($managerSource)->toContain('Cron worker deployment is required for API deployments'); + $managerSource = file_get_contents(app_path('classes/release_manager.php')); + expect($managerSource)->toContain('Cron worker deployment is required for API deployments'); + }); + +it('classifies cron worker deployment and heartbeat lifecycle states', function (): void { + $manager = new release_manager(); + $healthMethod = new ReflectionMethod(release_manager::class, 'cronWorkerHealth'); + $healthMethod->setAccessible(true); + + expect($healthMethod->invoke($manager, null, null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state']) + ->toBe('needs_deploy'); + expect($healthMethod->invoke($manager, ['id' => 4], null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state']) + ->toBe('needs_deploy'); + expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [ + 'status' => 'deploying', + 'created_at' => date('Y-m-d H:i:s'), + ])['state'])->toBe('deploying'); + expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [ + 'status' => 'deployed', + 'completed_at' => date('Y-m-d H:i:s'), + ])['state'])->toBe('waiting_for_heartbeat'); + expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [ + ['status' => 'running', 'stale' => false], + ], ['running' => 1, 'stale' => 0, 'failed' => 0], [ + 'status' => 'deployed', + ])['state'])->toBe('healthy'); + expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [ + 'status' => 'deployed', + 'completed_at' => '2020-01-01 00:00:00', + ])['state'])->toBe('failed'); +}); + +it('extracts Coolify cron deployment operation identifiers from provider payloads', function (): void { + $manager = new release_manager(); + $operationMethod = new ReflectionMethod(release_manager::class, 'coolifyDeploymentOperationId'); + $operationMethod->setAccessible(true); + + expect($operationMethod->invoke($manager, ['deployment' => ['deployment_uuid' => 'deployment-123']])) + ->toBe('deployment-123'); + expect($operationMethod->invoke($manager, ['data' => ['uuid' => 'operation-456']])) + ->toBe('operation-456'); + expect($operationMethod->invoke($manager, ['message' => 'queued'])) + ->toBeNull(); +}); + +it('detects missing Coolify cron worker resources from provider errors', function (): void { + $method = new ReflectionMethod(release_manager::class, 'coolifyResourceMissing'); + $method->setAccessible(true); + + expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 404')))->toBeTrue(); + expect($method->invoke(null, new RuntimeException('Application not found')))->toBeTrue(); + expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 401')))->toBeFalse(); +}); + +it('classifies missing Coolify cron worker resources as repairable', function (): void { + $manager = new release_manager(); + $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness'); + $readiness->setAccessible(true); + + $result = $readiness->invoke($manager, [ + 'id' => 17, + 'app' => 'api', + 'coolify_instance_id' => 3, + 'repository' => 'copenhagentruckwash/api', + ], [ + 'id' => 71, + 'app' => 'cron', + 'coolify_instance_id' => 3, + 'coolify_service_uuid' => 'missing-cron-worker', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'configured' => true, + 'missing' => true, + ]); + + expect($result['action'])->toBe('repair'); + expect($result['can_deploy'])->toBeTrue(); + expect(array_column($result['issues'], 'code'))->toContain('missing_coolify_worker_resource'); +}); + +it('repairs from an existing cron target when the API target is absent', function (): void { + $manager = new release_manager(); + $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness'); + $readiness->setAccessible(true); + + $result = $readiness->invoke($manager, null, [ + 'id' => 71, + 'app' => 'cron', + 'coolify_instance_id' => 3, + 'coolify_service_uuid' => 'missing-cron-worker', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'configured' => true, + 'missing' => true, + ]); + + expect($result['action'])->toBe('repair'); + expect($result['can_deploy'])->toBeTrue(); + expect(array_column($result['issues'], 'code'))->toContain('repairable_cron_target'); +}); + +it('blocks cron worker deployment without an API target or deployable cron context', function (): void { + $manager = new release_manager(); + $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness'); + $readiness->setAccessible(true); + + $result = $readiness->invoke($manager, null, [ + 'id' => 71, + 'app' => 'cron', + 'coolify_instance_id' => null, + 'coolify_service_uuid' => 'missing-cron-worker', + 'repository' => '', + 'branch' => 'master', + ], [ + 'configured' => true, + 'missing' => true, + ]); + + expect($result['action'])->toBe('blocked'); + expect($result['can_deploy'])->toBeFalse(); + expect(array_column($result['issues'], 'code'))->toContain('missing_api_target'); }); it('builds explicit Coolify application route labels for release API targets', function (): void { @@ -778,6 +899,7 @@ it('builds cron worker runtime environment from API runtime keys and cron contex $env = $runtimeEnv->invoke($manager, [ 'app' => 'cron', 'commit_sha' => $selectedCommit, + 'coolify_service_uuid' => 'cron-worker-resource-uuid', ], [ 'coolify_env' => [ 'CRON_WORKER_NAME' => 'release-internal-cron-worker', @@ -789,6 +911,7 @@ it('builds cron worker runtime environment from API runtime keys and cron contex expect($env['CONFIG_DB_HOST'])->toBe('db.example.test'); expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker'); expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker'); + expect($env['CRON_WORKER_COOLIFY_RESOURCE_UUID'])->toBe('cron-worker-resource-uuid'); expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit); expect($env['API_COMMIT_SHA'])->toBe($selectedCommit); expect($env['COMMIT_SHA'])->toBe($selectedCommit); diff --git a/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php index ab299864..73b89bc8 100644 --- a/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php +++ b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php @@ -62,6 +62,11 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se return $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label); } + public function evaluateProviderQuotaProbeResponsePublic(array $httpResponse, string $label, string $moduleKey): array + { + return $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, $moduleKey); + } + public function normalizeWarningEntriesPublic(array $warnings): array { return $this->normalizeWarningEntries($warnings); @@ -393,7 +398,7 @@ it('builds the expected http probe requests for newly supported modules', functi null, 'GET', null, - false, + true, ], 'motorapi' => [ 'probeMotorApiModulePublic', @@ -403,7 +408,7 @@ it('builds the expected http probe requests for newly supported modules', functi null, 'GET', null, - false, + true, ], 'fxratesapi' => [ 'probeFxRatesApiModulePublic', @@ -581,8 +586,17 @@ it('evaluates license plate recognizer usage and quota health from the info payl 'unreadable payload' => [ 'not-json', 'degraded', - 'licenseplaterecognizer_usage_unreadable', - null, + 'provider_quota_unavailable', + [ + 'provider' => 'licenseplaterecognizer', + 'usage_available' => false, + 'status' => 'unknown', + 'calls_used' => null, + 'quota_calls' => null, + 'calls_remaining' => null, + 'usage_percent' => null, + 'unavailable_reason' => 'unreadable_payload', + ], ], 'missing quota' => [ json_encode([ @@ -590,11 +604,53 @@ it('evaluates license plate recognizer usage and quota health from the info payl 'usage' => ['calls' => 10], ]), 'degraded', - 'licenseplaterecognizer_usage_missing', - null, + 'provider_quota_unavailable', + [ + 'provider' => 'licenseplaterecognizer', + 'usage_available' => false, + 'status' => 'unknown', + 'calls_used' => null, + 'quota_calls' => null, + 'calls_remaining' => null, + 'usage_percent' => null, + 'unavailable_reason' => 'missing_limit', + ], ], ]); +it('degrades provider quota probes when the provider response does not expose readable quota fields', function ( + string $body, + string $expectedReason +): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $result = $service->evaluateProviderQuotaProbeResponsePublic([ + 'checked_at' => '2026-04-08T17:00:00+00:00', + 'latency_ms' => 7.5, + 'http_status' => 200, + 'body' => $body, + 'error' => '', + ], 'MailerSend', 'email'); + + expect($result['status'])->toBe('degraded') + ->and($result['status_reason_key'])->toBe('provider_quota_unavailable') + ->and($result['usage'])->toMatchArray([ + 'provider' => 'email', + 'usage_available' => false, + 'status' => 'unknown', + 'calls_used' => null, + 'quota_calls' => null, + 'calls_remaining' => null, + 'usage_percent' => null, + 'unavailable_reason' => $expectedReason, + ]); +})->with([ + 'invalid json' => ['not-json', 'unreadable_payload'], + 'missing limit' => [json_encode(['usage' => ['calls' => 12], 'plan' => 'pro']), 'missing_limit'], + 'missing usage' => [json_encode(['quota' => 100, 'plan' => 'pro']), 'missing_usage'], + 'invalid limit' => [json_encode(['used' => 12, 'quota' => 0]), 'invalid_limit'], +]); + it('carries license plate recognizer usage from probe results into module status rows', function (): void { $service = new SuperuserSystemStatusServiceProbeDouble(); $service->nextHttpProbeResult = [