diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2ddc416f3..ac16c80264 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -427,6 +427,7 @@ jobs: FunctionsSchedule, GraphQL, Health, + Advisor, Locale, Projects, Realtime, diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 748211f222..7496b7a9a7 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -1956,6 +1956,440 @@ $platformCollections = [ 'attributes' => [], 'indexes' => [] ], + + 'reports' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('reports'), + 'name' => 'Reports', + 'attributes' => [ + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('projectId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('appInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('appId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('type'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('title'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('summary'), + 'type' => Database::VAR_TEXT, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + // Resource type the report is about. Plural noun, e.g. databases, sites, urls. + '$id' => ID::custom('targetType'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + // Free-form target identifier (URL for lighthouse, resource ID for db). + // Indexed by `_key_project_target` with an explicit prefix length. + '$id' => ID::custom('target'), + 'type' => Database::VAR_TEXT, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + // Category strings, e.g. 'performance', 'accessibility'. Native array + // column — we never query on individual entries (MySQL JSON-array + // indexes are weak), this is read+rewrite only. + '$id' => ID::custom('categories'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => true, + 'filters' => [], + ], + [ + // Virtual attribute — insights live in the `insights` collection + // back-referenced by `reportInternalId`. The subQuery filter joins + // them at read time. + '$id' => ID::custom('insights'), + 'type' => Database::VAR_TEXT, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['subQueryReportInsights'], + ], + [ + '$id' => ID::custom('analyzedAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_project_app_type'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'appInternalId', 'type'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_target'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'appInternalId', 'targetType', 'target'], + 'lengths' => [null, null, null, 700], + 'orders' => [], + ], + ], + ], + + 'insights' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('insights'), + 'name' => 'Insights', + 'attributes' => [ + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('projectId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('reportInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('reportId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('type'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('severity'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('status'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16, + 'signed' => true, + 'required' => true, + 'default' => 'active', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('resourceType'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('resourceId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('resourceInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('parentResourceType'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('parentResourceId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('parentResourceInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('title'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('summary'), + 'type' => Database::VAR_TEXT, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('ctas'), + 'type' => Database::VAR_TEXT, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('analyzedAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => ID::custom('dismissedAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => ID::custom('dismissedBy'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_project_report'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'reportInternalId'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_resource'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'resourceType', 'resourceId'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_parent_resource'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'parentResourceType', 'parentResourceId'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_type'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'type'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_severity'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'severity'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_status'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'status'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_dismissedAt'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'dismissedAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC, Database::ORDER_DESC], + ], + ], + ], + ]; // Organization API keys subquery diff --git a/app/config/errors.php b/app/config/errors.php index e7b6839a20..42ce9ac91b 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1453,4 +1453,28 @@ return [ 'description' => 'The maximum number of mock phones for this project has been reached.', 'code' => 400, ], + + /** Advisor */ + Exception::INSIGHT_NOT_FOUND => [ + 'name' => Exception::INSIGHT_NOT_FOUND, + 'description' => 'Insight with the requested ID could not be found.', + 'code' => 404, + ], + Exception::INSIGHT_ALREADY_EXISTS => [ + 'name' => Exception::INSIGHT_ALREADY_EXISTS, + 'description' => 'Insight with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', + 'code' => 409, + ], + + /** Reports */ + Exception::REPORT_NOT_FOUND => [ + 'name' => Exception::REPORT_NOT_FOUND, + 'description' => 'Report with the requested ID could not be found.', + 'code' => 404, + ], + Exception::REPORT_ALREADY_EXISTS => [ + 'name' => Exception::REPORT_ALREADY_EXISTS, + 'description' => 'Report with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', + 'code' => 409, + ], ]; diff --git a/app/config/events.php b/app/config/events.php index 11dc2e0e4a..2825562ab7 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -426,5 +426,33 @@ return [ 'update' => [ '$description' => 'This event triggers when a proxy rule is updated.', ] - ] + ], + 'reports' => [ + '$model' => Response::MODEL_REPORT, + '$resource' => true, + '$description' => 'This event triggers on any report event.', + 'create' => [ + '$description' => 'This event triggers when a report is created.', + ], + 'update' => [ + '$description' => 'This event triggers when a report is updated.', + ], + 'delete' => [ + '$description' => 'This event triggers when a report is deleted.', + ], + 'insights' => [ + '$model' => Response::MODEL_INSIGHT, + '$resource' => true, + '$description' => 'This event triggers on any insight event.', + 'create' => [ + '$description' => 'This event triggers when an insight is created.', + ], + 'update' => [ + '$description' => 'This event triggers when an insight is updated.', + ], + 'delete' => [ + '$description' => 'This event triggers when an insight is deleted.', + ], + ], + ], ]; diff --git a/app/config/roles.php b/app/config/roles.php index 04175ac1d5..cb4b178a29 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -103,6 +103,10 @@ $admins = [ 'tokens.write', 'schedules.read', 'schedules.write', + 'insights.read', + 'insights.write', + 'reports.read', + 'reports.write', ]; return [ diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7281f4b9..3d8998fb2f 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -361,4 +361,22 @@ return [ 'description' => 'Access to create, update, and delete resources under VCS service.', 'category' => 'Other', ], + + // Advisor + 'insights.read' => [ + 'description' => 'Access to read insights under Advisor service.', + 'category' => 'Advisor', + ], + 'insights.write' => [ + 'description' => 'Reserved for Advisor insight ingestion outside CE.', + 'category' => 'Advisor', + ], + 'reports.read' => [ + 'description' => 'Access to read reports under Advisor service.', + 'category' => 'Advisor', + ], + 'reports.write' => [ + 'description' => 'Access to delete reports under Advisor service.', + 'category' => 'Advisor', + ], ]; diff --git a/app/config/services.php b/app/config/services.php index cf2714f8c5..f829937623 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -308,5 +308,19 @@ return [ 'optional' => true, 'icon' => '/images/services/messaging.png', 'platforms' => ['client', 'server', 'console'], - ] + ], + 'advisor' => [ + 'key' => 'advisor', + 'name' => 'Advisor', + 'subtitle' => 'The Advisor service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the console.', + 'description' => '/docs/services/advisor.md', + 'controller' => '', // Uses modules + 'sdk' => true, + 'docs' => true, + 'docsUrl' => 'https://appwrite.io/docs/server/advisor', + 'tests' => true, + 'optional' => true, + 'icon' => '/images/services/insights.png', + 'platforms' => ['server', 'console'], + ], ]; diff --git a/app/init/constants.php b/app/init/constants.php index 17afc35ae9..966035b956 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -1,5 +1,11 @@ value, // legacy databases.createIndex + InsightType::TABLES_DB_INDEX->value, // tablesDB.createIndex + InsightType::DOCUMENTS_DB_INDEX->value, // documentsDB.createIndex + InsightType::VECTORS_DB_INDEX->value, // vectorsDB.createIndex + InsightType::DATABASE_PERFORMANCE->value, + InsightType::SITE_PERFORMANCE->value, + InsightType::SITE_ACCESSIBILITY->value, + InsightType::SITE_SEO->value, + InsightType::FUNCTION_PERFORMANCE->value, +]; + +// Public API services (SDK namespaces) that an insight CTA's `service` can reference. +// Analyzers must pick the one matching the engine the resource lives in. +const ADVISOR_CTA_SERVICES = [ + InsightCTAService::DATABASES->value, // legacy + InsightCTAService::TABLES_DB->value, + InsightCTAService::DOCUMENTS_DB->value, + InsightCTAService::VECTORS_DB->value, +]; + +// Public API method names that an insight CTA's `method` can reference for index suggestions. +const ADVISOR_CTA_METHODS = [ + InsightCTAMethod::CREATE_INDEX->value, +]; + +// Insight severities +const ADVISOR_SEVERITIES = [ + InsightSeverity::INFO->value, + InsightSeverity::WARNING->value, + InsightSeverity::CRITICAL->value, +]; + +// Insight statuses +const ADVISOR_STATUSES = [ + InsightStatus::ACTIVE->value, + InsightStatus::DISMISSED->value, +]; + +// Report types +const ADVISOR_REPORT_TYPES = [ + ReportType::LIGHTHOUSE->value, + ReportType::AUDIT->value, + ReportType::DATABASE_ANALYZER->value, +]; // Resource types for Tokens const TOKENS_RESOURCE_TYPE_FILES = 'files'; diff --git a/app/init/database/filters.php b/app/init/database/filters.php index 5a65479424..e171805c47 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -475,3 +475,17 @@ Database::addFilter( ])); } ); + +Database::addFilter( + 'subQueryReportInsights', + function (mixed $value) { + return; + }, + function (mixed $value, Document $document, Database $database) { + return $database->getAuthorization()->skip(fn () => $database->find('insights', [ + Query::equal('projectInternalId', [$document->getAttribute('projectInternalId')]), + Query::equal('reportInternalId', [$document->getSequence()]), + Query::limit(APP_LIMIT_SUBQUERY), + ])); + } +); diff --git a/app/init/models.php b/app/init/models.php index f983c43f0a..521a3b77cd 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -92,6 +92,8 @@ use Appwrite\Utopia\Response\Model\HealthTime; use Appwrite\Utopia\Response\Model\HealthVersion; use Appwrite\Utopia\Response\Model\Identity; use Appwrite\Utopia\Response\Model\Index; +use Appwrite\Utopia\Response\Model\Insight; +use Appwrite\Utopia\Response\Model\InsightCTA; use Appwrite\Utopia\Response\Model\Installation; use Appwrite\Utopia\Response\Model\JWT; use Appwrite\Utopia\Response\Model\Key; @@ -182,6 +184,7 @@ use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; +use Appwrite\Utopia\Response\Model\Report; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -291,6 +294,8 @@ Response::setModel(new BaseList('Specifications List', Response::MODEL_SPECIFICA Response::setModel(new BaseList('VCS Content List', Response::MODEL_VCS_CONTENT_LIST, 'contents', Response::MODEL_VCS_CONTENT)); Response::setModel(new BaseList('VectorsDB Collections List', Response::MODEL_VECTORSDB_COLLECTION_LIST, 'collections', Response::MODEL_VECTORSDB_COLLECTION)); Response::setModel(new BaseList('Embedding list', Response::MODEL_EMBEDDING_LIST, 'embeddings', Response::MODEL_EMBEDDING)); +Response::setModel(new BaseList('Insights List', Response::MODEL_INSIGHT_LIST, 'insights', Response::MODEL_INSIGHT)); +Response::setModel(new BaseList('Reports List', Response::MODEL_REPORT_LIST, 'reports', Response::MODEL_REPORT)); // Entities Response::setModel(new Database()); @@ -515,6 +520,9 @@ Response::setModel(new Target()); Response::setModel(new Migration()); Response::setModel(new MigrationReport()); Response::setModel(new MigrationFirebaseProject()); +Response::setModel(new Insight()); +Response::setModel(new InsightCTA()); +Response::setModel(new Report()); // Tests (keep last) Response::setModel(new Mock()); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 2649216453..68a5a3edf5 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1248,7 +1248,7 @@ return function (Container $context): void { $context->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) { return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database { - $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', '')); + $databaseDSN = $database->getAttribute('database') ?: $project->getAttribute('database', ''); $databaseType = $database->getAttribute('type', ''); try { diff --git a/docs/references/advisor/delete-report.md b/docs/references/advisor/delete-report.md new file mode 100644 index 0000000000..b32ba845e2 --- /dev/null +++ b/docs/references/advisor/delete-report.md @@ -0,0 +1 @@ +Delete an analyzer report by its unique ID. Nested insights and CTA metadata are removed asynchronously by the deletes worker. diff --git a/docs/references/advisor/get-insight.md b/docs/references/advisor/get-insight.md new file mode 100644 index 0000000000..7e1e795c22 --- /dev/null +++ b/docs/references/advisor/get-insight.md @@ -0,0 +1 @@ +Get an insight by its unique ID, scoped to its parent report. diff --git a/docs/references/advisor/get-report.md b/docs/references/advisor/get-report.md new file mode 100644 index 0000000000..731c10dc8a --- /dev/null +++ b/docs/references/advisor/get-report.md @@ -0,0 +1 @@ +Get an analyzer report by its unique ID. The response includes the report's metadata and the nested insights it produced. diff --git a/docs/references/advisor/list-insights.md b/docs/references/advisor/list-insights.md new file mode 100644 index 0000000000..56d6a2fca0 --- /dev/null +++ b/docs/references/advisor/list-insights.md @@ -0,0 +1 @@ +List the insights produced under a single analyzer report. You can use the query params to filter your results further. diff --git a/docs/references/advisor/list-reports.md b/docs/references/advisor/list-reports.md new file mode 100644 index 0000000000..04b91c541a --- /dev/null +++ b/docs/references/advisor/list-reports.md @@ -0,0 +1 @@ +Get a list of all the project's analyzer reports. You can use the query params to filter your results. diff --git a/docs/services/advisor.md b/docs/services/advisor.md new file mode 100644 index 0000000000..2fa3943829 --- /dev/null +++ b/docs/services/advisor.md @@ -0,0 +1,3 @@ +The Advisor service provides read access to analyzer reports and their nested insights for a project. + +Use the reports endpoints to list and fetch analyzer runs, then use the insights endpoints to inspect individual findings attached to a report. diff --git a/phpunit.xml b/phpunit.xml index 9748c5a5c8..32e865fe35 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -38,6 +38,7 @@ ./tests/e2e/Services/Messaging ./tests/e2e/Services/Migrations ./tests/e2e/Services/Project + ./tests/e2e/Services/Advisor ./tests/e2e/Services/Functions/FunctionsBase.php ./tests/e2e/Services/Functions/FunctionsCustomServerTest.php ./tests/e2e/Services/Functions/FunctionsCustomClientTest.php diff --git a/src/Appwrite/Advisor/Validator/CTAs.php b/src/Appwrite/Advisor/Validator/CTAs.php new file mode 100644 index 0000000000..14f7d788e7 --- /dev/null +++ b/src/Appwrite/Advisor/Validator/CTAs.php @@ -0,0 +1,83 @@ +allowedServices = $allowedServices ?? ADVISOR_CTA_SERVICES; + $this->allowedMethods = $allowedMethods ?? ADVISOR_CTA_METHODS; + } + + public function getDescription(): string + { + return $this->message; + } + + public function isArray(): bool + { + return true; + } + + public function getType(): string + { + return self::TYPE_ARRAY; + } + + public function isValid($value): bool + { + if (!\is_array($value)) { + return false; + } + + if (\count($value) > $this->maxCount) { + $this->message = "A maximum of {$this->maxCount} CTAs are allowed per insight."; + return false; + } + + foreach ($value as $entry) { + if (!\is_array($entry)) { + return false; + } + + $maxLengths = ['label' => 256, 'service' => 64, 'method' => 64]; + foreach ($maxLengths as $required => $maxLength) { + if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') { + return false; + } + if (\strlen($entry[$required]) > $maxLength) { + $this->message = "CTA `{$required}` must not exceed {$maxLength} characters."; + return false; + } + } + + if (!empty($this->allowedServices) && !\in_array($entry['service'], $this->allowedServices, true)) { + $this->message = "CTA `service` must be one of: " . \implode(', ', $this->allowedServices) . '.'; + return false; + } + + if (!empty($this->allowedMethods) && !\in_array($entry['method'], $this->allowedMethods, true)) { + $this->message = "CTA `method` must be one of: " . \implode(', ', $this->allowedMethods) . '.'; + return false; + } + + if (isset($entry['params']) && !\is_array($entry['params']) && !\is_object($entry['params'])) { + return false; + } + } + + return true; + } +} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 42dcacaf34..a0553d00b8 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -406,6 +406,14 @@ class Exception extends \Exception public const string TOKEN_EXPIRED = 'token_expired'; public const string TOKEN_RESOURCE_TYPE_INVALID = 'token_resource_type_invalid'; + /** Advisor */ + public const string INSIGHT_NOT_FOUND = 'insight_not_found'; + public const string INSIGHT_ALREADY_EXISTS = 'insight_already_exists'; + + /** Reports */ + public const string REPORT_NOT_FOUND = 'report_not_found'; + public const string REPORT_ALREADY_EXISTS = 'report_already_exists'; + protected string $type = ''; protected array $errors = []; protected bool $publish; diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 5a9c02a2bd..c4cd2c08d5 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -774,6 +774,21 @@ class Realtime extends MessagingAdapter $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } break; + case 'reports': + // Plain report event: `reports.{reportId}.{action}` + $channels[] = 'reports'; + if (isset($parts[1])) { + $channels[] = 'reports.' . $parts[1]; + } + // Nested insight event: `reports.{reportId}.insights.{insightId}.{action}` + if (isset($parts[2]) && $parts[2] === 'insights') { + $channels[] = 'reports.' . $parts[1] . '.insights'; + if (isset($parts[3])) { + $channels[] = 'reports.' . $parts[1] . '.insights.' . $parts[3]; + } + } + $roles = [Role::team($project->getAttribute('teamId'))->toString()]; + break; } // Action is the last segment for plain CRUD events (e.g. `documents.X.create`), diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 88788b73fc..a9cd1a8e2f 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform; use Appwrite\Platform\Modules\Account; +use Appwrite\Platform\Modules\Advisor; use Appwrite\Platform\Modules\Avatars; use Appwrite\Platform\Modules\Console; use Appwrite\Platform\Modules\Core; @@ -42,5 +43,6 @@ class Appwrite extends Platform $this->addModule(new Webhooks\Module()); $this->addModule(new Migrations\Module()); $this->addModule(new Project\Module()); + $this->addModule(new Advisor\Module()); } } diff --git a/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php b/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php new file mode 100644 index 0000000000..31d578a991 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php @@ -0,0 +1,8 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/reports/:reportId/insights/:insightId') + ->desc('Get insight') + ->groups(['api', 'advisor']) + ->label('scope', 'insights.read') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('sdk', new Method( + namespace: 'advisor', + group: 'insights', + name: 'getInsight', + description: '/docs/references/advisor/get-insight.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_INSIGHT, + ), + ] + )) + ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform']) + ->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + string $reportId, + string $insightId, + Response $response, + Document $project, + Database $dbForPlatform + ) { + // Skip the insights subquery — we only need ownership metadata. + $report = $dbForPlatform->skipFilters( + fn () => $dbForPlatform->getDocument('reports', $reportId), + ['subQueryReportInsights'], + ); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + + $insight = $dbForPlatform->getDocument('insights', $insightId); + + if ( + $insight->isEmpty() + || $insight->getAttribute('projectInternalId') !== $project->getSequence() + || $insight->getAttribute('reportInternalId') !== $report->getSequence() + ) { + throw new Exception(Exception::INSIGHT_NOT_FOUND); + } + + $response->dynamic($insight, Response::MODEL_INSIGHT); + } +} diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php new file mode 100644 index 0000000000..64d3676c08 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php @@ -0,0 +1,126 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/reports/:reportId/insights') + ->desc('List insights') + ->groups(['api', 'advisor']) + ->label('scope', 'insights.read') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('sdk', new Method( + namespace: 'advisor', + group: 'insights', + name: 'listInsights', + description: '/docs/references/advisor/list-insights.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_INSIGHT_LIST, + ), + ] + )) + ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform']) + ->param('queries', [], new Insights(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Insights::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + string $reportId, + array $queries, + bool $includeTotal, + Response $response, + Document $project, + Database $dbForPlatform + ) { + // Skip the insights subquery — we're about to fetch a filtered, paginated slice ourselves. + $report = $dbForPlatform->skipFilters( + fn () => $dbForPlatform->getDocument('reports', $reportId), + ['subQueryReportInsights'], + ); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + $queries[] = Query::equal('reportInternalId', [$report->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $insightId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('insights', $insightId); + + if ( + $cursorDocument->isEmpty() + || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence() + || $cursorDocument->getAttribute('reportInternalId') !== $report->getSequence() + ) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Insight '{$insightId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $insights = $dbForPlatform->find('insights', $queries); + $total = $includeTotal ? $dbForPlatform->count('insights', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'insights' => $insights, + 'total' => $total, + ]), Response::MODEL_INSIGHT_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php new file mode 100644 index 0000000000..6b1dfba31b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php @@ -0,0 +1,97 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/reports/:reportId') + ->desc('Delete report') + ->groups(['api', 'advisor']) + ->label('scope', 'reports.write') + ->label('event', 'reports.[reportId].delete') + ->label('resourceType', RESOURCE_TYPE_REPORTS) + ->label('audits.event', 'report.delete') + ->label('audits.resource', 'report/{request.reportId}') + ->label('abuse-key', 'projectId:{projectId},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'advisor', + group: 'reports', + name: 'deleteReport', + description: '/docs/references/advisor/delete-report.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ), + ], + contentType: ContentType::NONE + )) + ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $reportId, + Response $response, + Document $project, + Database $dbForPlatform, + DeleteEvent $queueForDeletes, + Event $queueForEvents + ): void { + $report = $dbForPlatform->skipFilters( + fn () => $dbForPlatform->getDocument('reports', $reportId), + ['subQueryReportInsights'], + ); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + + if (!$dbForPlatform->deleteDocument('reports', $report->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove report from DB'); + } + + $queueForDeletes + ->setType(DELETE_TYPE_REPORT) + ->setDocument($report); + + $queueForEvents + ->setParam('reportId', $report->getId()) + ->setPayload($response->output($report, Response::MODEL_REPORT)); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php new file mode 100644 index 0000000000..78885a7c5d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/reports/:reportId') + ->desc('Get report') + ->groups(['api', 'advisor']) + ->label('scope', 'reports.read') + ->label('resourceType', RESOURCE_TYPE_REPORTS) + ->label('sdk', new Method( + namespace: 'advisor', + group: 'reports', + name: 'getReport', + description: '/docs/references/advisor/get-report.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_REPORT, + ), + ] + )) + ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + string $reportId, + Response $response, + Document $project, + Database $dbForPlatform + ) { + $report = $dbForPlatform->skipFilters( + fn () => $dbForPlatform->getDocument('reports', $reportId), + ['subQueryReportInsights'], + ); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + + $insights = $dbForPlatform->find('insights', [ + Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('reportInternalId', [$report->getSequence()]), + Query::limit(APP_LIMIT_SUBQUERY), + ]); + + $report->setAttribute('insights', $insights); + + $response->dynamic($report, Response::MODEL_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php new file mode 100644 index 0000000000..c5debb7f68 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php @@ -0,0 +1,133 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/reports') + ->desc('List reports') + ->groups(['api', 'advisor']) + ->label('scope', 'reports.read') + ->label('resourceType', RESOURCE_TYPE_REPORTS) + ->label('sdk', new Method( + namespace: 'advisor', + group: 'reports', + name: 'listReports', + description: '/docs/references/advisor/list-reports.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_REPORT_LIST, + ), + ] + )) + ->param('queries', [], new Reports(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Reports::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + array $queries, + bool $includeTotal, + Response $response, + Document $project, + Database $dbForPlatform + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $reportId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->skipFilters( + fn () => $dbForPlatform->getDocument('reports', $reportId), + ['subQueryReportInsights'], + ); + + if ($cursorDocument->isEmpty() || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Report '{$reportId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $reports = $dbForPlatform->skipFilters( + fn () => $dbForPlatform->find('reports', $queries), + ['subQueryReportInsights'], + ); + $total = $includeTotal ? $dbForPlatform->count('reports', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + if (!empty($reports)) { + $reportSequences = \array_map(fn (Document $r) => $r->getSequence(), $reports); + + $insights = $dbForPlatform->find('insights', [ + Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('reportInternalId', $reportSequences), + Query::limit(APP_LIMIT_SUBQUERY), + ]); + + $insightsByReport = []; + foreach ($insights as $insight) { + $insightsByReport[$insight->getAttribute('reportInternalId')][] = $insight; + } + + foreach ($reports as $report) { + $report->setAttribute('insights', $insightsByReport[$report->getSequence()] ?? []); + } + } + + $response->dynamic(new Document([ + 'reports' => $reports, + 'total' => $total, + ]), Response::MODEL_REPORT_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Advisor/Module.php b/src/Appwrite/Platform/Modules/Advisor/Module.php new file mode 100644 index 0000000000..b28a2421c2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Advisor/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Advisor/Services/Http.php b/src/Appwrite/Platform/Modules/Advisor/Services/Http.php new file mode 100644 index 0000000000..2558b00247 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Advisor/Services/Http.php @@ -0,0 +1,25 @@ +type = Service::TYPE_HTTP; + + $this->addAction(GetReport::getName(), new GetReport()); + $this->addAction(ListReports::getName(), new ListReports()); + $this->addAction(DeleteReport::getName(), new DeleteReport()); + + $this->addAction(GetInsight::getName(), new GetInsight()); + $this->addAction(ListInsights::getName(), new ListInsights()); + } +} diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index a5fe352b07..3a865bf6a9 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -216,11 +216,25 @@ class Deletes extends Action $this->deleteExpiredTransactions($project, $getProjectDB); $this->deleteOldDeployments($queueForDeletes, $project, $getProjectDB); break; + case DELETE_TYPE_REPORT: + $this->deleteReport($dbForPlatform, $project, $document); + break; default: throw new \Exception('No delete operation for type: ' . \strval($type)); } } + private function deleteReport(Database $dbForPlatform, Document $project, Document $report): void + { + $projectInternalId = $project->getSequence(); + $reportInternalId = $report->getSequence(); + + $this->deleteByGroup('insights', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::equal('reportInternalId', [$reportInternalId]), + ], $dbForPlatform); + } + private function cleanDatabase( Document $databaseDoc, callable $executionActionPerDatabase, @@ -716,6 +730,26 @@ class Deletes extends Action Console::error('Failed to delete schedules: ' . $th->getMessage()); } + // Delete Advisor insights + try { + $this->deleteByGroup('insights', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete insights: ' . $th->getMessage()); + } + + // Delete Advisor reports + try { + $this->deleteByGroup('reports', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete reports: ' . $th->getMessage()); + } + /** * @var Database $dbForProject */ diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php new file mode 100644 index 0000000000..18badf8722 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php @@ -0,0 +1,24 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Insight ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Insight creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Insight update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('reportId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Parent report ID. Insights always belong to a report.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Insight type. One of databaseIndex (legacy), tablesDBIndex, documentsDBIndex, vectorsDBIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance. The index types are engine-specific so each CTA can pair the right service+method (databases.createIndex, tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex).', + 'default' => '', + 'example' => 'tablesDBIndex', + ]) + ->addRule('severity', [ + 'type' => self::TYPE_STRING, + 'description' => 'Insight severity. One of info, warning, critical.', + 'default' => 'info', + 'example' => 'warning', + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Insight status. One of active, dismissed.', + 'default' => 'active', + 'example' => 'active', + ]) + ->addRule('resourceType', [ + 'type' => self::TYPE_STRING, + 'description' => 'Type of the resource the insight is about. Plural noun, e.g. databases, sites, functions.', + 'default' => '', + 'example' => 'databases', + ]) + ->addRule('resourceId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the resource the insight is about.', + 'default' => '', + 'example' => 'main', + ]) + ->addRule('parentResourceType', [ + 'type' => self::TYPE_STRING, + 'description' => 'Plural noun for the parent resource that contains the insight\'s resource, e.g. an insight about a column index on a table → resourceType=indexes, parentResourceType=tables. Empty when the resource has no parent.', + 'default' => '', + 'example' => 'tables', + ]) + ->addRule('parentResourceId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the parent resource. Empty when the resource has no parent.', + 'default' => '', + 'example' => 'orders', + ]) + ->addRule('title', [ + 'type' => self::TYPE_STRING, + 'description' => 'Insight title.', + 'default' => '', + 'example' => 'Missing index on collection orders', + ]) + ->addRule('summary', [ + 'type' => self::TYPE_STRING, + 'description' => 'Short markdown summary describing the insight.', + 'default' => '', + 'example' => 'Queries against `orders.status` are scanning the full collection.', + ]) + ->addRule('ctas', [ + 'type' => Response::MODEL_INSIGHT_CTA, + 'description' => 'List of call-to-action buttons attached to this insight.', + 'default' => [], + 'example' => [], + 'array' => true, + ]) + ->addRule('analyzedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Time the insight was analyzed in ISO 8601 format.', + 'default' => null, + 'example' => self::TYPE_DATETIME_EXAMPLE, + 'required' => false, + ]) + ->addRule('dismissedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Time the insight was dismissed in ISO 8601 format. Empty when not dismissed.', + 'default' => null, + 'example' => self::TYPE_DATETIME_EXAMPLE, + 'required' => false, + ]) + ->addRule('dismissedBy', [ + 'type' => self::TYPE_STRING, + 'description' => 'User ID that dismissed the insight. Empty when not dismissed.', + 'default' => '', + 'example' => '5e5ea5c16897e', + 'required' => false, + ]); + } + + public function getName(): string + { + return 'Insight'; + } + + public function getType(): string + { + return Response::MODEL_INSIGHT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTA.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php new file mode 100644 index 0000000000..3ebd8b5796 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php @@ -0,0 +1,48 @@ +addRule('label', [ + 'type' => self::TYPE_STRING, + 'description' => 'Human-readable label for the CTA, used in UI.', + 'default' => '', + 'example' => 'Create missing index', + ]) + ->addRule('service', [ + 'type' => self::TYPE_STRING, + 'description' => 'Public API service (SDK namespace) the client should invoke. Must match the engine that owns the resource — for index suggestions: databases (legacy), tablesDB, documentsDB, or vectorsDB.', + 'default' => '', + 'example' => 'tablesDB', + ]) + ->addRule('method', [ + 'type' => self::TYPE_STRING, + 'description' => 'Public API method on the chosen service the client should invoke when this CTA is triggered.', + 'default' => '', + 'example' => 'createIndex', + ]) + ->addRule('params', [ + 'type' => self::TYPE_JSON, + 'description' => 'Parameter map the client should pass to the service method when this CTA is triggered. Keys match the target API\'s parameter names (e.g. databaseId/tableId/columns for tablesDB, databaseId/collectionId/attributes for the legacy Databases API).', + 'default' => new \stdClass(), + 'example' => ['databaseId' => 'main', 'tableId' => 'orders', 'key' => '_idx_status', 'type' => 'key', 'columns' => ['status']], + ]); + } + + public function getName(): string + { + return 'InsightCTA'; + } + + public function getType(): string + { + return Response::MODEL_INSIGHT_CTA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Report.php b/src/Appwrite/Utopia/Response/Model/Report.php new file mode 100644 index 0000000000..0c5baf9cdd --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Report.php @@ -0,0 +1,99 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Report ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Report creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Report update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('appId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the third-party app that submitted the report.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Analyzer that produced this report. e.g. lighthouse, audit, databaseAnalyzer.', + 'default' => '', + 'example' => 'lighthouse', + ]) + ->addRule('title', [ + 'type' => self::TYPE_STRING, + 'description' => 'Short, human-readable title for the report.', + 'default' => '', + 'example' => 'Lighthouse audit for https://appwrite.io/', + ]) + ->addRule('summary', [ + 'type' => self::TYPE_STRING, + 'description' => 'Markdown summary describing the report.', + 'default' => '', + 'example' => 'Performance score 78. 4 opportunities found.', + ]) + ->addRule('targetType', [ + 'type' => self::TYPE_STRING, + 'description' => 'Plural noun describing what the report analyzes, e.g. databases, sites, urls.', + 'default' => '', + 'example' => 'urls', + ]) + ->addRule('target', [ + 'type' => self::TYPE_STRING, + 'description' => 'Free-form target identifier (URL for lighthouse, resource ID for db).', + 'default' => '', + 'example' => 'https://appwrite.io/', + ]) + ->addRule('categories', [ + 'type' => self::TYPE_STRING, + 'description' => 'Categories covered by the report, e.g. performance, accessibility.', + 'default' => [], + 'example' => ['performance', 'accessibility'], + 'array' => true, + ]) + ->addRule('insights', [ + 'type' => Response::MODEL_INSIGHT, + 'description' => 'Insights nested under this report.', + 'default' => [], + 'example' => [], + 'array' => true, + ]) + ->addRule('analyzedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Time the report was analyzed in ISO 8601 format.', + 'default' => null, + 'example' => self::TYPE_DATETIME_EXAMPLE, + 'required' => false, + ]); + } + + public function getName(): string + { + return 'Report'; + } + + public function getType(): string + { + return Response::MODEL_REPORT; + } +} diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 99219ebf99..c34a6527f9 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -177,6 +177,10 @@ trait ProjectCustom 'project.policies.write', 'templates.read', 'templates.write', + 'insights.read', + 'insights.write', + 'reports.read', + 'reports.write', ], ]); diff --git a/tests/e2e/Services/Advisor/AdvisorBase.php b/tests/e2e/Services/Advisor/AdvisorBase.php new file mode 100644 index 0000000000..f228cf5591 --- /dev/null +++ b/tests/e2e/Services/Advisor/AdvisorBase.php @@ -0,0 +1,122 @@ + 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + } + + protected function getReport(string $reportId, ?array $headers = null): array + { + return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId, $headers ?? $this->serverHeaders()); + } + + protected function listReports(array $params = [], ?array $headers = null): array + { + return $this->client->call(Client::METHOD_GET, '/reports', $headers ?? $this->serverHeaders(), $params); + } + + protected function getInsight(string $reportId, string $insightId, ?array $headers = null): array + { + return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders()); + } + + protected function listInsights(string $reportId, array $params = [], ?array $headers = null): array + { + return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights', $headers ?? $this->serverHeaders(), $params); + } + + public function testListReports(): void + { + $list = $this->listReports(); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertArrayHasKey('reports', $list['body']); + $this->assertArrayHasKey('total', $list['body']); + $this->assertIsArray($list['body']['reports']); + } + + public function testGetReportMissing(): void + { + $missing = $this->getReport(ID::unique()); + + $this->assertSame(404, $missing['headers']['status-code']); + $this->assertSame('report_not_found', $missing['body']['type']); + } + + public function testListInsightsMissingReport(): void + { + $missing = $this->listInsights(ID::unique()); + + $this->assertSame(404, $missing['headers']['status-code']); + $this->assertSame('report_not_found', $missing['body']['type']); + } + + public function testGetInsightMissingReport(): void + { + $missing = $this->getInsight(ID::unique(), ID::unique()); + + $this->assertSame(404, $missing['headers']['status-code']); + $this->assertSame('report_not_found', $missing['body']['type']); + } + + public function testReportsCreateAndUpdateNotExposed(): void + { + $create = $this->client->call(Client::METHOD_POST, '/reports', $this->serverHeaders(), [ + 'reportId' => ID::unique(), + 'type' => 'audit', + 'title' => 'Read-only check', + 'targetType' => 'sites', + 'target' => 'home', + ]); + $this->assertSame(404, $create['headers']['status-code']); + + $update = $this->client->call(Client::METHOD_PATCH, '/reports/' . ID::unique(), $this->serverHeaders(), [ + 'title' => 'Read-only check', + ]); + $this->assertSame(404, $update['headers']['status-code']); + } + + public function testDeleteReportMissing(): void + { + $delete = $this->client->call(Client::METHOD_DELETE, '/reports/' . ID::unique(), $this->serverHeaders()); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('report_not_found', $delete['body']['type']); + } + + public function testInsightsCreateUpdateDeleteNotExposed(): void + { + $create = $this->client->call( + Client::METHOD_POST, + '/reports/' . ID::unique() . '/insights', + $this->serverHeaders(), + [] + ); + $this->assertSame(404, $create['headers']['status-code']); + + $update = $this->client->call( + Client::METHOD_PATCH, + '/reports/' . ID::unique() . '/insights/' . ID::unique(), + $this->serverHeaders(), + ['status' => 'dismissed'] + ); + $this->assertSame(404, $update['headers']['status-code']); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/reports/' . ID::unique() . '/insights/' . ID::unique(), + $this->serverHeaders() + ); + $this->assertSame(404, $delete['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php b/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php new file mode 100644 index 0000000000..d91f95035e --- /dev/null +++ b/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php @@ -0,0 +1,58 @@ +getProject()['$id']; + + $userKey = $this->getNewKey([ + // Advisor read APIs are protected by the underlying report/insight resource scopes. + 'insights.read', + 'reports.read', + ]); + + $listed = $this->client->call( + Client::METHOD_GET, + '/reports', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $userKey, + ] + ); + + $this->assertSame(200, $listed['headers']['status-code']); + + $create = $this->client->call( + Client::METHOD_POST, + '/reports', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $userKey, + ], + [ + 'reportId' => ID::unique(), + 'type' => 'audit', + 'title' => 'Read-only check', + 'targetType' => 'sites', + 'target' => 'home', + ] + ); + + $this->assertSame(404, $create['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Proxy/ProxyBase.php b/tests/e2e/Services/Proxy/ProxyBase.php index 48de610365..4811fc3737 100644 --- a/tests/e2e/Services/Proxy/ProxyBase.php +++ b/tests/e2e/Services/Proxy/ProxyBase.php @@ -171,8 +171,8 @@ trait ProxyBase $siteId = $this->setupSite()['siteId']; - $ruleId = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 301, 'site', $siteId); - $this->assertNotEmpty($ruleId); + $ruleId301 = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 301, 'site', $siteId); + $this->assertNotEmpty($ruleId301); $response = $proxyClient->call(Client::METHOD_GET, '/todos/1'); $this->assertEquals(200, $response['headers']['status-code']); @@ -187,8 +187,8 @@ trait ProxyBase $this->assertEquals('https://jsonplaceholder.typicode.com/todos/1', $response['headers']['location']); $domain = \uniqid() . '-redirect-307.custom.localhost'; - $ruleId = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 307, 'site', $siteId); - $this->assertNotEmpty($ruleId); + $ruleId307 = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 307, 'site', $siteId); + $this->assertNotEmpty($ruleId307); $proxyClient = new Client(); $proxyClient->setEndpoint('http://appwrite.test'); @@ -209,7 +209,8 @@ trait ProxyBase $this->assertEquals(200, $rules['headers']['status-code']); $this->assertEquals(2, $rules['body']['total']); - $this->cleanupRule($ruleId); + $this->cleanupRule($ruleId301); + $this->cleanupRule($ruleId307); $this->cleanupSite($siteId); } diff --git a/tests/unit/Advisor/Validator/CTAsTest.php b/tests/unit/Advisor/Validator/CTAsTest.php new file mode 100644 index 0000000000..5511910072 --- /dev/null +++ b/tests/unit/Advisor/Validator/CTAsTest.php @@ -0,0 +1,241 @@ +assertFalse($validator->isValid('not-an-array')); + $this->assertFalse($validator->isValid(42)); + $this->assertFalse($validator->isValid(null)); + } + + public function testAcceptsEmptyArray(): void + { + $validator = new CTAs(); + + $this->assertTrue($validator->isValid([])); + } + + public function testAcceptsCompleteEntry(): void + { + $validator = new CTAs(); + + $this->assertTrue($validator->isValid([[ + 'label' => 'Create missing index', + 'service' => 'tablesDB', + 'method' => 'createIndex', + 'params' => [ + 'databaseId' => 'main', + 'tableId' => 'orders', + ], + ]])); + } + + public function testAcceptsEntryWithoutParams(): void + { + $validator = new CTAs(); + + $this->assertTrue($validator->isValid([[ + 'label' => 'Create missing index', + 'service' => 'tablesDB', + 'method' => 'createIndex', + ]])); + } + + public function testRejectsEntryMissingRequiredKeys(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([['label' => 'x']])); + $this->assertFalse($validator->isValid([['label' => 'x', 'service' => 'tablesDB']])); + $this->assertFalse($validator->isValid([['label' => 'x', 'method' => 'createIndex']])); + } + + public function testRejectsEntryWithEmptyStrings(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'label' => '', + 'service' => 'tablesDB', + 'method' => 'createIndex', + ]])); + } + + public function testRejectsEntryWithNonStringFields(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'label' => 123, + 'service' => 'tablesDB', + 'method' => 'createIndex', + ]])); + } + + public function testRejectsEntryWithScalarParams(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'label' => 'Create missing index', + 'service' => 'tablesDB', + 'method' => 'createIndex', + 'params' => 'not-a-map', + ]])); + } + + public function testReportsArrayType(): void + { + $validator = new CTAs(); + + $this->assertTrue($validator->isArray()); + $this->assertSame($validator::TYPE_ARRAY, $validator->getType()); + } + + public function testRejectsMoreThanMaxCount(): void + { + $validator = new CTAs(maxCount: 3); + + $entries = []; + for ($i = 0; $i < 4; $i++) { + $entries[] = [ + 'label' => 'Label ' . $i, + 'service' => 'tablesDB', + 'method' => 'createIndex', + ]; + } + + $this->assertFalse($validator->isValid($entries)); + $this->assertStringContainsString('maximum of 3', $validator->getDescription()); + } + + public function testAcceptsExactlyMaxCount(): void + { + $validator = new CTAs(maxCount: 3); + + $entries = []; + for ($i = 0; $i < 3; $i++) { + $entries[] = [ + 'label' => 'Label ' . $i, + 'service' => 'tablesDB', + 'method' => 'createIndex', + ]; + } + + $this->assertTrue($validator->isValid($entries)); + } + + public function testAcceptsObjectParams(): void + { + $validator = new CTAs(); + + $entry = [ + 'label' => 'Create missing index', + 'service' => 'tablesDB', + 'method' => 'createIndex', + 'params' => new \stdClass(), + ]; + + $this->assertTrue($validator->isValid([$entry])); + } + + public function testRejectsEntryWithEmptyService(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'label' => 'Create missing index', + 'service' => '', + 'method' => 'createIndex', + ]])); + } + + public function testRejectsEntryWithEmptyMethod(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'label' => 'Create missing index', + 'service' => 'tablesDB', + 'method' => '', + ]])); + } + + public function testRejectsUnknownService(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'label' => 'Create missing index', + 'service' => 'nonExistentService', + 'method' => 'createIndex', + ]])); + $this->assertStringContainsString('service', $validator->getDescription()); + } + + public function testRejectsUnknownMethod(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'label' => 'Create missing index', + 'service' => 'tablesDB', + 'method' => 'nonExistentMethod', + ]])); + $this->assertStringContainsString('method', $validator->getDescription()); + } + + public function testAcceptsCustomAllowedLists(): void + { + $validator = new CTAs( + allowedServices: ['custom'], + allowedMethods: ['doThing'], + ); + + $this->assertTrue($validator->isValid([[ + 'label' => 'Custom action', + 'service' => 'custom', + 'method' => 'doThing', + ]])); + + $this->assertFalse($validator->isValid([[ + 'label' => 'Custom action', + 'service' => 'tablesDB', + 'method' => 'doThing', + ]])); + } + + public function testDefaultMaxCountIsSixteen(): void + { + $validator = new CTAs(); + + $this->assertSame(CTAs::MAX_COUNT_DEFAULT, 16); + + $entries = []; + for ($i = 0; $i < 16; $i++) { + $entries[] = [ + 'label' => 'Label ' . $i, + 'service' => 'tablesDB', + 'method' => 'createIndex', + ]; + } + + $this->assertTrue($validator->isValid($entries)); + + $entries[] = [ + 'label' => 'Label 16', + 'service' => 'tablesDB', + 'method' => 'createIndex', + ]; + + $this->assertFalse($validator->isValid($entries)); + } +}