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));
+ }
+}