From 1889ccdd12c1d6b2c880478042af1c5994fb4ff1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:40:16 +1200 Subject: [PATCH 01/70] feat(insights): add CTA framework Introduces the call-to-action framework: a server-side registry plus an Action interface that lets analyzers offer one-click remediations alongside each insight. Ships a stub `databases.createIndex` action whose execute() defers to a future cloud-side implementation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Appwrite/Insights/Cta/Action.php | 43 ++++++++++++++ .../Cta/Action/DatabasesCreateIndex.php | 57 +++++++++++++++++++ src/Appwrite/Insights/Cta/Registry.php | 45 +++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 src/Appwrite/Insights/Cta/Action.php create mode 100644 src/Appwrite/Insights/Cta/Action/DatabasesCreateIndex.php create mode 100644 src/Appwrite/Insights/Cta/Registry.php diff --git a/src/Appwrite/Insights/Cta/Action.php b/src/Appwrite/Insights/Cta/Action.php new file mode 100644 index 0000000000..27ffcd7779 --- /dev/null +++ b/src/Appwrite/Insights/Cta/Action.php @@ -0,0 +1,43 @@ + $params + */ + public function validate(array $params): void; + + /** + * Execute the action on behalf of the authenticated caller. + * + * Returns a `Document` describing the result. The document is rendered using + * `Response::MODEL_INSIGHT_CTA_RESULT` and its keys must match that model's rules. + * + * @param array $params + */ + public function execute(array $params, Document $insight, Document $project, Database $dbForProject): Document; +} diff --git a/src/Appwrite/Insights/Cta/Action/DatabasesCreateIndex.php b/src/Appwrite/Insights/Cta/Action/DatabasesCreateIndex.php new file mode 100644 index 0000000000..194d71119e --- /dev/null +++ b/src/Appwrite/Insights/Cta/Action/DatabasesCreateIndex.php @@ -0,0 +1,57 @@ + $params + */ + public function validate(array $params): void + { + foreach (['databaseId', 'collectionId', 'key', 'type', 'attributes'] as $required) { + if (!isset($params[$required])) { + throw new Exception( + Exception::INSIGHT_CTA_VALIDATION_FAILED, + 'Missing required param "' . $required . '" for action "' . $this->getName() . '".' + ); + } + } + + if (!\is_array($params['attributes']) || $params['attributes'] === []) { + throw new Exception( + Exception::INSIGHT_CTA_VALIDATION_FAILED, + 'Param "attributes" must be a non-empty array of attribute keys.' + ); + } + } + + /** + * @param array $params + */ + public function execute(array $params, Document $insight, Document $project, Database $dbForProject): Document + { + // Placeholder. Cloud's dedicated-database adapter plugs in the real implementation + // when the bespoke `dedicatedDatabaseIndexSuggestions` collection is migrated to + // the generic `insights` collection. + throw new Exception( + Exception::GENERAL_NOT_IMPLEMENTED, + 'CTA action "' . $this->getName() . '" is not implemented in this build.' + ); + } +} diff --git a/src/Appwrite/Insights/Cta/Registry.php b/src/Appwrite/Insights/Cta/Registry.php new file mode 100644 index 0000000000..4a2da111ff --- /dev/null +++ b/src/Appwrite/Insights/Cta/Registry.php @@ -0,0 +1,45 @@ + + */ + private array $actions = []; + + public function register(Action $action): void + { + $this->actions[$action->getName()] = $action; + } + + public function has(string $name): bool + { + return isset($this->actions[$name]); + } + + /** + * Resolve an action by name. + * + * @throws Exception When the action is not registered. + */ + public function get(string $name): Action + { + if (!isset($this->actions[$name])) { + throw new Exception(Exception::INSIGHT_CTA_ACTION_NOT_REGISTERED, 'CTA action "' . $name . '" is not registered.'); + } + + return $this->actions[$name]; + } + + /** + * @return array + */ + public function all(): array + { + return $this->actions; + } +} From 1c8cc6fc92d2948439b0ef3a494c61ce48a12aa1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:40:22 +1200 Subject: [PATCH 02/70] feat(insights): add response models Adds the Insight, InsightCta, and InsightCtaResult response models and registers their model identifiers on the Response class so endpoints can serialise insights consistently across the SDK surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Appwrite/Utopia/Response.php | 6 + .../Utopia/Response/Model/Insight.php | 125 ++++++++++++++++++ .../Utopia/Response/Model/InsightCta.php | 48 +++++++ .../Response/Model/InsightCtaResult.php | 54 ++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/Appwrite/Utopia/Response/Model/Insight.php create mode 100644 src/Appwrite/Utopia/Response/Model/InsightCta.php create mode 100644 src/Appwrite/Utopia/Response/Model/InsightCtaResult.php diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 899cdc086a..dc2c54d4a5 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -330,6 +330,12 @@ class Response extends SwooleResponse public const MODEL_HEALTH_CERTIFICATE = 'healthCertificate'; public const MODEL_HEALTH_STATUS_LIST = 'healthStatusList'; + // Insights + public const MODEL_INSIGHT = 'insight'; + public const MODEL_INSIGHT_LIST = 'insightList'; + public const MODEL_INSIGHT_CTA = 'insightCta'; + public const MODEL_INSIGHT_CTA_RESULT = 'insightCtaResult'; + // Console public const MODEL_CONSOLE_VARIABLES = 'consoleVariables'; public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter'; diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php new file mode 100644 index 0000000000..1c567f8c72 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Insight.php @@ -0,0 +1,125 @@ +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('$permissions', [ + 'type' => self::TYPE_STRING, + 'description' => 'Insight permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', + 'default' => [], + 'example' => ['read("any")'], + 'array' => true, + ]) + ->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Insight type. One of databaseIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance.', + 'default' => '', + 'example' => 'databaseIndex', + ]) + ->addRule('severity', [ + 'type' => self::TYPE_STRING, + 'description' => 'Insight severity. One of info, warning, critical.', + 'default' => 'info', + 'example' => 'warning', + ]) + ->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('resourceInternalId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Internal ID of the resource the insight is about.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->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('payload', [ + 'type' => self::TYPE_JSON, + 'description' => 'Type-specific structured payload for the insight.', + 'default' => new \stdClass(), + 'example' => ['databaseId' => 'main', 'collectionId' => 'orders'], + ]) + ->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..ac35363043 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/InsightCta.php @@ -0,0 +1,48 @@ +addRule('id', [ + 'type' => self::TYPE_STRING, + 'description' => 'CTA identifier, unique within the parent insight.', + 'default' => '', + 'example' => 'createIndex', + ]) + ->addRule('label', [ + 'type' => self::TYPE_STRING, + 'description' => 'Human-readable label for the CTA, used in UI.', + 'default' => '', + 'example' => 'Create missing index', + ]) + ->addRule('action', [ + 'type' => self::TYPE_STRING, + 'description' => 'Registered server-side action name to execute when this CTA is triggered.', + 'default' => '', + 'example' => 'databases.createIndex', + ]) + ->addRule('params', [ + 'type' => self::TYPE_JSON, + 'description' => 'Parameter map passed to the action when this CTA is triggered.', + 'default' => new \stdClass(), + 'example' => ['databaseId' => 'main', 'collectionId' => 'orders', 'key' => '_idx_status'], + ]); + } + + public function getName(): string + { + return 'InsightCta'; + } + + public function getType(): string + { + return Response::MODEL_INSIGHT_CTA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/InsightCtaResult.php b/src/Appwrite/Utopia/Response/Model/InsightCtaResult.php new file mode 100644 index 0000000000..a6fe9addca --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/InsightCtaResult.php @@ -0,0 +1,54 @@ +addRule('insightId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the insight the CTA was triggered against.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('ctaId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the CTA that was triggered.', + 'default' => '', + 'example' => 'createIndex', + ]) + ->addRule('action', [ + 'type' => self::TYPE_STRING, + 'description' => 'Registered server-side action that was executed.', + 'default' => '', + 'example' => 'databases.createIndex', + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Outcome of the CTA execution. One of succeeded, failed.', + 'default' => 'succeeded', + 'example' => 'succeeded', + ]) + ->addRule('result', [ + 'type' => self::TYPE_JSON, + 'description' => 'Action-specific result data. May reference the resource that was created or updated.', + 'default' => new \stdClass(), + 'example' => ['indexId' => '_idx_status'], + ]); + } + + public function getName(): string + { + return 'InsightCtaResult'; + } + + public function getType(): string + { + return Response::MODEL_INSIGHT_CTA_RESULT; + } +} From e1ddcd051c00e3854fe89a3d34c6ee46d0382945 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:40:28 +1200 Subject: [PATCH 03/70] feat(insights): add schema, scopes, events, errors, constants Wires the platform glue for insights: the `insights` collection on the project database, the `insights.read` / `insights.write` scopes, the `insights.[insightId]` event tree (including the nested `ctas.[ctaId].trigger` event), the typed exceptions, and the runtime CTA registry resource. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/projects.php | 158 ++++++++++++++++++++++++++++ app/config/errors.php | 27 +++++ app/config/events.php | 27 ++++- app/config/roles.php | 2 + app/config/scopes/project.php | 10 ++ app/config/services.php | 16 ++- app/init/constants.php | 32 ++++++ app/init/models.php | 7 ++ app/init/resources.php | 8 ++ src/Appwrite/Extend/Exception.php | 7 ++ 10 files changed, 292 insertions(+), 2 deletions(-) diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 9568c59369..96c7fa5c5b 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2754,4 +2754,162 @@ return [ ], ], ], + + 'insights' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('insights'), + 'name' => 'Insights', + 'attributes' => [ + [ + '$id' => ID::custom('type'), + 'type' => Database::VAR_STRING, + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('severity'), + 'type' => Database::VAR_STRING, + 'size' => 16, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('resourceType'), + 'type' => Database::VAR_STRING, + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('resourceId'), + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('resourceInternalId'), + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('title'), + 'type' => Database::VAR_STRING, + 'size' => 256, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('summary'), + 'type' => Database::VAR_STRING, + 'size' => 4096, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('payload'), + 'type' => Database::VAR_STRING, + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('ctas'), + 'type' => Database::VAR_STRING, + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('analyzedAt'), + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => ID::custom('dismissedAt'), + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => ID::custom('dismissedBy'), + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_resource'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['resourceType', 'resourceId', '$createdAt'], + 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, 0], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_DESC], + ], + [ + '$id' => ID::custom('_key_type'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['type'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_severity'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['severity'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_dismissedAt'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['dismissedAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_DESC], + ], + ], + ], ]; diff --git a/app/config/errors.php b/app/config/errors.php index fa112bcb6f..62a4f444d1 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1423,4 +1423,31 @@ return [ 'description' => 'The maximum number of mock phones for this project has been reached.', 'code' => 400, ], + + /** Insights */ + 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, + ], + Exception::INSIGHT_CTA_NOT_FOUND => [ + 'name' => Exception::INSIGHT_CTA_NOT_FOUND, + 'description' => 'CTA with the requested ID could not be found on the insight.', + 'code' => 404, + ], + Exception::INSIGHT_CTA_ACTION_NOT_REGISTERED => [ + 'name' => Exception::INSIGHT_CTA_ACTION_NOT_REGISTERED, + 'description' => 'The CTA action requested is not registered on the server.', + 'code' => 501, + ], + Exception::INSIGHT_CTA_VALIDATION_FAILED => [ + 'name' => Exception::INSIGHT_CTA_VALIDATION_FAILED, + 'description' => 'CTA parameter validation failed. Please ensure all required parameters are provided and well formed.', + 'code' => 400, + ], ]; diff --git a/app/config/events.php b/app/config/events.php index 11dc2e0e4a..aeaf48081f 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -426,5 +426,30 @@ return [ 'update' => [ '$description' => 'This event triggers when a proxy rule is updated.', ] - ] + ], + '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.', + ], + 'dismiss' => [ + '$description' => 'This event triggers when an insight is dismissed.', + ], + 'ctas' => [ + '$model' => Response::MODEL_INSIGHT_CTA, + '$resource' => true, + '$description' => 'This event triggers on any insight CTA event.', + 'trigger' => [ + '$description' => 'This event triggers when an insight CTA is executed.', + ], + ], + ], ]; diff --git a/app/config/roles.php b/app/config/roles.php index 8fba27e503..fa92d16e4e 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -103,6 +103,8 @@ $admins = [ 'tokens.write', 'schedules.read', 'schedules.write', + 'insights.read', + 'insights.write', ]; return [ diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 63b946f74f..3fbdc0fc17 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -337,4 +337,14 @@ return [ 'description' => 'Access to create, update, and delete proxy rules.', 'category' => 'Other', ], + + // Insights + 'insights.read' => [ + 'description' => 'Access to read insights and their CTAs.', + 'category' => 'Other', + ], + 'insights.write' => [ + 'description' => 'Access to create, update, dismiss, delete insights, and trigger their CTAs.', + 'category' => 'Other', + ], ]; diff --git a/app/config/services.php b/app/config/services.php index cf2714f8c5..ea2f29cc52 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'], - ] + ], + 'insights' => [ + 'key' => 'insights', + 'name' => 'Insights', + 'subtitle' => 'The Insights service surfaces actionable reports about your project resources, with CTAs for one-click remediation.', + 'description' => '/docs/services/insights.md', + 'controller' => '', // Uses modules + 'sdk' => true, + 'docs' => true, + 'docsUrl' => 'https://appwrite.io/docs/server/insights', + '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 f27d0c7c70..44b51bd6d9 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -423,6 +423,38 @@ const RESOURCE_TYPE_MESSAGES = 'messages'; const RESOURCE_TYPE_EXECUTIONS = 'executions'; const RESOURCE_TYPE_VCS = 'vcs'; const RESOURCE_TYPE_EMBEDDINGS_TEXT = 'embeddingsText'; +const RESOURCE_TYPE_INSIGHTS = 'insights'; + +// Insight types +const INSIGHT_TYPE_DATABASE_INDEX = 'databaseIndex'; +const INSIGHT_TYPE_DATABASE_PERFORMANCE = 'databasePerformance'; +const INSIGHT_TYPE_SITE_PERFORMANCE = 'sitePerformance'; +const INSIGHT_TYPE_SITE_ACCESSIBILITY = 'siteAccessibility'; +const INSIGHT_TYPE_SITE_SEO = 'siteSeo'; +const INSIGHT_TYPE_FUNCTION_PERFORMANCE = 'functionPerformance'; + +const INSIGHT_TYPES = [ + INSIGHT_TYPE_DATABASE_INDEX, + INSIGHT_TYPE_DATABASE_PERFORMANCE, + INSIGHT_TYPE_SITE_PERFORMANCE, + INSIGHT_TYPE_SITE_ACCESSIBILITY, + INSIGHT_TYPE_SITE_SEO, + INSIGHT_TYPE_FUNCTION_PERFORMANCE, +]; + +// Insight severities +const INSIGHT_SEVERITY_INFO = 'info'; +const INSIGHT_SEVERITY_WARNING = 'warning'; +const INSIGHT_SEVERITY_CRITICAL = 'critical'; + +const INSIGHT_SEVERITIES = [ + INSIGHT_SEVERITY_INFO, + INSIGHT_SEVERITY_WARNING, + INSIGHT_SEVERITY_CRITICAL, +]; + +// Insight CTA actions +const INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX = 'databases.createIndex'; // Resource types for Tokens const TOKENS_RESOURCE_TYPE_FILES = 'files'; diff --git a/app/init/models.php b/app/init/models.php index 9530b4b98b..e75cb89142 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -90,6 +90,9 @@ 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\InsightCtaResult; use Appwrite\Utopia\Response\Model\Installation; use Appwrite\Utopia\Response\Model\JWT; use Appwrite\Utopia\Response\Model\Key; @@ -286,6 +289,7 @@ 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)); // Entities Response::setModel(new Database()); @@ -505,6 +509,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 InsightCtaResult()); // Tests (keep last) Response::setModel(new Mock()); diff --git a/app/init/resources.php b/app/init/resources.php index 96457294de..28d9814edd 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -8,6 +8,8 @@ use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; +use Appwrite\Insights\Cta\Action\DatabasesCreateIndex; +use Appwrite\Insights\Cta\Registry as InsightCtaRegistry; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; @@ -128,6 +130,12 @@ $container->set('authorization', function () { return new Authorization(); }, []); +$container->set('insightCtaRegistry', function () { + $registry = new InsightCtaRegistry(); + $registry->register(new DatabasesCreateIndex()); + return $registry; +}, []); + $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 6fc3e88635..82891f123e 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -400,6 +400,13 @@ class Exception extends \Exception public const string TOKEN_EXPIRED = 'token_expired'; public const string TOKEN_RESOURCE_TYPE_INVALID = 'token_resource_type_invalid'; + /** Insights */ + public const string INSIGHT_NOT_FOUND = 'insight_not_found'; + public const string INSIGHT_ALREADY_EXISTS = 'insight_already_exists'; + public const string INSIGHT_CTA_NOT_FOUND = 'insight_cta_not_found'; + public const string INSIGHT_CTA_ACTION_NOT_REGISTERED = 'insight_cta_action_not_registered'; + public const string INSIGHT_CTA_VALIDATION_FAILED = 'insight_cta_validation_failed'; + protected string $type = ''; protected array $errors = []; protected bool $publish; From 7f7be465470969e950dde5647b058e5b293e3e5c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:40:34 +1200 Subject: [PATCH 04/70] feat(insights): add module skeleton and registration Adds the platform module so the http service is discoverable, registers it on the Appwrite platform alongside the other modules, exposes the `insights` and `insights.[insightId]` realtime channels via the messaging adapter, and ships the queries validator covering the indexed attributes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Appwrite/Messaging/Adapter/Realtime.php | 5 ++++ src/Appwrite/Platform/Appwrite.php | 2 ++ .../Platform/Modules/Insights/Module.php | 14 +++++++++ .../Modules/Insights/Services/Http.php | 29 +++++++++++++++++++ .../Database/Validator/Queries/Insights.php | 21 ++++++++++++++ 5 files changed, 71 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Insights/Module.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Services/Http.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/Insights.php diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 5a9c02a2bd..b2e12cf2e6 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -774,6 +774,11 @@ class Realtime extends MessagingAdapter $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } break; + case 'insights': + $channels[] = 'insights'; + $channels[] = 'insights.' . $parts[1]; + $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..eda0e4cc3e 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; +use Appwrite\Platform\Modules\Insights; use Appwrite\Platform\Modules\Migrations; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; @@ -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 Insights\Module()); } } diff --git a/src/Appwrite/Platform/Modules/Insights/Module.php b/src/Appwrite/Platform/Modules/Insights/Module.php new file mode 100644 index 0000000000..3435d8993c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Services/Http.php b/src/Appwrite/Platform/Modules/Insights/Services/Http.php new file mode 100644 index 0000000000..7533b41023 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Services/Http.php @@ -0,0 +1,29 @@ +type = Service::TYPE_HTTP; + + $this->addAction(CreateInsight::getName(), new CreateInsight()); + $this->addAction(GetInsight::getName(), new GetInsight()); + $this->addAction(ListInsights::getName(), new ListInsights()); + $this->addAction(UpdateInsight::getName(), new UpdateInsight()); + $this->addAction(DismissInsight::getName(), new DismissInsight()); + $this->addAction(DeleteInsight::getName(), new DeleteInsight()); + + $this->addAction(TriggerCta::getName(), new TriggerCta()); + } +} 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..607c2b915e --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php @@ -0,0 +1,21 @@ + Date: Fri, 1 May 2026 12:40:47 +1200 Subject: [PATCH 05/70] feat(insights): add CRUD endpoints Adds the create, get, list, update, and delete endpoints under the `insights` SDK namespace. Mutating endpoints are admin/key-only because insights are produced by analyzers; reads are open to sessions and JWTs so console UIs can surface them. Updates use sparse documents so unset fields keep their existing value. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Modules/Insights/Http/Insights/Create.php | 138 ++++++++++++++++++ .../Modules/Insights/Http/Insights/Delete.php | 86 +++++++++++ .../Modules/Insights/Http/Insights/Get.php | 67 +++++++++ .../Modules/Insights/Http/Insights/Update.php | 134 +++++++++++++++++ .../Modules/Insights/Http/Insights/XList.php | 106 ++++++++++++++ 5 files changed, 531 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php new file mode 100644 index 0000000000..001c339e88 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php @@ -0,0 +1,138 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/insights') + ->desc('Create insight') + ->groups(['api', 'insights']) + ->label('scope', 'insights.write') + ->label('event', 'insights.[insightId].create') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('audits.event', 'insight.create') + ->label('audits.resource', 'insight/{response.$id}') + ->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: 'insights', + group: 'insights', + name: 'create', + description: <<param('insightId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('type', '', new WhiteList(INSIGHT_TYPES, true), 'Insight type. Determines the analyzer that owns this insight and the shape of `payload`.') + ->param('severity', INSIGHT_SEVERITY_INFO, new WhiteList(INSIGHT_SEVERITIES, true), 'Insight severity. One of `info`, `warning`, `critical`.', true) + ->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.') + ->param('resourceId', '', new Text(36), 'ID of the resource the insight is about.') + ->param('resourceInternalId', '', new Text(36), 'Internal ID of the resource the insight is about.', true) + ->param('title', '', new Text(256), 'Short, human-readable title.') + ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) + ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) + ->param('ctas', [], new ArrayList(new JSON(), 16), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true) + ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $insightId, + string $type, + string $severity, + string $resourceType, + string $resourceId, + string $resourceInternalId, + string $title, + string $summary, + ?array $payload, + array $ctas, + ?string $analyzedAt, + Response $response, + Database $dbForProject, + Event $queueForEvents + ) { + $insightId = ($insightId === 'unique()') ? ID::unique() : $insightId; + + $normalizedCtas = []; + foreach ($ctas as $cta) { + if (!isset($cta['id'], $cta['label'], $cta['action'])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Each CTA must define `id`, `label`, and `action`.'); + } + $normalizedCtas[] = [ + 'id' => (string) $cta['id'], + 'label' => (string) $cta['label'], + 'action' => (string) $cta['action'], + 'params' => $cta['params'] ?? new \stdClass(), + ]; + } + + try { + $insight = $dbForProject->createDocument('insights', new Document([ + '$id' => $insightId, + 'type' => $type, + 'severity' => $severity, + 'resourceType' => $resourceType, + 'resourceId' => $resourceId, + 'resourceInternalId' => $resourceInternalId, + 'title' => $title, + 'summary' => $summary, + 'payload' => $payload, + 'ctas' => $normalizedCtas, + 'analyzedAt' => $analyzedAt, + 'dismissedAt' => null, + 'dismissedBy' => '', + ])); + } catch (DuplicateException) { + throw new Exception(Exception::INSIGHT_ALREADY_EXISTS); + } + + $queueForEvents->setParam('insightId', $insight->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($insight, Response::MODEL_INSIGHT); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php new file mode 100644 index 0000000000..ad2cd01818 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/insights/:insightId') + ->desc('Delete insight') + ->groups(['api', 'insights']) + ->label('scope', 'insights.write') + ->label('event', 'insights.[insightId].delete') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('audits.event', 'insight.delete') + ->label('audits.resource', 'insight/{request.insightId}') + ->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: 'insights', + group: 'insights', + name: 'delete', + description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $insightId, + Response $response, + Database $dbForProject, + Event $queueForEvents + ) { + $insight = $dbForProject->getDocument('insights', $insightId); + + if ($insight->isEmpty()) { + throw new Exception(Exception::INSIGHT_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('insights', $insight->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove insight from DB'); + } + + $queueForEvents + ->setParam('insightId', $insight->getId()) + ->setPayload($response->output($insight, Response::MODEL_INSIGHT)); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php new file mode 100644 index 0000000000..bc4d33f241 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php @@ -0,0 +1,67 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/insights/:insightId') + ->desc('Get insight') + ->groups(['api', 'insights']) + ->label('scope', 'insights.read') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('sdk', new Method( + namespace: 'insights', + group: 'insights', + name: 'get', + description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $insightId, + Response $response, + Database $dbForProject + ) { + $insight = $dbForProject->getDocument('insights', $insightId); + + if ($insight->isEmpty()) { + throw new Exception(Exception::INSIGHT_NOT_FOUND); + } + + $response->dynamic($insight, Response::MODEL_INSIGHT); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php new file mode 100644 index 0000000000..47480eb980 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php @@ -0,0 +1,134 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/insights/:insightId') + ->desc('Update insight') + ->groups(['api', 'insights']) + ->label('scope', 'insights.write') + ->label('event', 'insights.[insightId].update') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('audits.event', 'insight.update') + ->label('audits.resource', 'insight/{response.$id}') + ->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: 'insights', + group: 'insights', + name: 'update', + description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->param('severity', null, new Nullable(new WhiteList(INSIGHT_SEVERITIES, true)), 'Insight severity. One of `info`, `warning`, `critical`.', true) + ->param('title', null, new Nullable(new Text(256)), 'Short, human-readable title.', true) + ->param('summary', null, new Nullable(new Text(4096, 0)), 'Markdown summary describing the insight.', true) + ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) + ->param('ctas', null, new Nullable(new ArrayList(new JSON(), 16)), 'Array of call-to-action descriptors.', true) + ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $insightId, + ?string $severity, + ?string $title, + ?string $summary, + ?array $payload, + ?array $ctas, + ?string $analyzedAt, + Response $response, + Database $dbForProject, + Event $queueForEvents + ) { + $insight = $dbForProject->getDocument('insights', $insightId); + + if ($insight->isEmpty()) { + throw new Exception(Exception::INSIGHT_NOT_FOUND); + } + + $changes = []; + + if ($severity !== null) { + $changes['severity'] = $severity; + } + if ($title !== null) { + $changes['title'] = $title; + } + if ($summary !== null) { + $changes['summary'] = $summary; + } + if ($payload !== null) { + $changes['payload'] = $payload; + } + if ($ctas !== null) { + $normalized = []; + foreach ($ctas as $cta) { + if (!isset($cta['id'], $cta['label'], $cta['action'])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Each CTA must define `id`, `label`, and `action`.'); + } + $normalized[] = [ + 'id' => (string) $cta['id'], + 'label' => (string) $cta['label'], + 'action' => (string) $cta['action'], + 'params' => $cta['params'] ?? new \stdClass(), + ]; + } + $changes['ctas'] = $normalized; + } + if ($analyzedAt !== null) { + $changes['analyzedAt'] = $analyzedAt; + } + + if ($changes !== []) { + $insight = $dbForProject->updateDocument('insights', $insight->getId(), new Document($changes)); + } + + $queueForEvents->setParam('insightId', $insight->getId()); + + $response->dynamic($insight, Response::MODEL_INSIGHT); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php new file mode 100644 index 0000000000..9ab6dfffc8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php @@ -0,0 +1,106 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/insights') + ->desc('List insights') + ->groups(['api', 'insights']) + ->label('scope', 'insights.read') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('sdk', new Method( + namespace: 'insights', + group: 'insights', + name: 'list', + description: <<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('dbForProject') + ->callback($this->action(...)); + } + + public function action( + array $queries, + bool $includeTotal, + Response $response, + Database $dbForProject + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $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 = $dbForProject->getDocument('insights', $insightId); + + if ($cursorDocument->isEmpty()) { + 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 = $dbForProject->find('insights', $queries); + $total = $includeTotal ? $dbForProject->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); + } +} From 236e59419505d3c5fb8f0ad597bb204b4c3fdc27 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:40:53 +1200 Subject: [PATCH 06/70] feat(insights): add dismiss and CTA trigger endpoints Dismiss is a convenience that stamps `dismissedAt` and `dismissedBy` so analyzers can see an insight has been acknowledged without losing the record. CTA trigger looks up the action in the runtime registry, validates the params blob, executes the action, and returns the result as an `InsightCtaResult`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Modules/Insights/Http/Cta/Trigger.php | 130 ++++++++++++++++++ .../Insights/Http/Insights/Dismiss.php | 87 ++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php b/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php new file mode 100644 index 0000000000..160eb2eae2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php @@ -0,0 +1,130 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/insights/:insightId/ctas/:ctaId/trigger') + ->desc('Trigger insight CTA') + ->groups(['api', 'insights']) + ->label('scope', 'insights.write') + ->label('event', 'insights.[insightId].ctas.[ctaId].trigger') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('audits.event', 'insight.cta.trigger') + ->label('audits.resource', 'insight/{request.insightId}') + ->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: 'insights', + group: 'insights', + name: 'triggerCta', + description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->param('ctaId', '', new Text(64), 'CTA ID, unique within the parent insight.') + ->inject('response') + ->inject('project') + ->inject('dbForProject') + ->inject('insightCtaRegistry') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $insightId, + string $ctaId, + Response $response, + Document $project, + Database $dbForProject, + InsightCtaRegistry $insightCtaRegistry, + Event $queueForEvents + ) { + $insight = $dbForProject->getDocument('insights', $insightId); + + if ($insight->isEmpty()) { + throw new Exception(Exception::INSIGHT_NOT_FOUND); + } + + $cta = null; + foreach ($insight->getAttribute('ctas', []) as $candidate) { + if (($candidate['id'] ?? null) === $ctaId) { + $cta = $candidate; + break; + } + } + + if ($cta === null) { + throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); + } + + $actionName = (string) ($cta['action'] ?? ''); + $params = $cta['params'] ?? []; + if (!\is_array($params)) { + $params = []; + } + + $action = $insightCtaRegistry->get($actionName); + $action->validate($params); + + $status = 'succeeded'; + $resultPayload = new \stdClass(); + + try { + $result = $action->execute($params, $insight, $project, $dbForProject); + $resultPayload = $result->getArrayCopy(); + } catch (Exception $e) { + if ($e->getType() === Exception::GENERAL_NOT_IMPLEMENTED) { + throw $e; + } + $status = 'failed'; + $resultPayload = ['error' => $e->getMessage()]; + } + + $queueForEvents + ->setParam('insightId', $insight->getId()) + ->setParam('ctaId', $ctaId); + + $response->dynamic(new Document([ + 'insightId' => $insight->getId(), + 'ctaId' => $ctaId, + 'action' => $actionName, + 'status' => $status, + 'result' => $resultPayload, + ]), Response::MODEL_INSIGHT_CTA_RESULT); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php new file mode 100644 index 0000000000..ab2ef38682 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php @@ -0,0 +1,87 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/insights/:insightId/dismiss') + ->desc('Dismiss insight') + ->groups(['api', 'insights']) + ->label('scope', 'insights.write') + ->label('event', 'insights.[insightId].dismiss') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('audits.event', 'insight.dismiss') + ->label('audits.resource', 'insight/{response.$id}') + ->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: 'insights', + group: 'insights', + name: 'dismiss', + description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $insightId, + Response $response, + Document $user, + Database $dbForProject, + Event $queueForEvents + ) { + $insight = $dbForProject->getDocument('insights', $insightId); + + if ($insight->isEmpty()) { + throw new Exception(Exception::INSIGHT_NOT_FOUND); + } + + $insight = $dbForProject->updateDocument('insights', $insight->getId(), new Document([ + 'dismissedAt' => DateTime::now(), + 'dismissedBy' => $user->getId(), + ])); + + $queueForEvents->setParam('insightId', $insight->getId()); + + $response->dynamic($insight, Response::MODEL_INSIGHT); + } +} From 68dc97427163378606948a4903e8df8dd3b1ba8d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:40:59 +1200 Subject: [PATCH 07/70] test(insights): unit and e2e tests Unit tests cover the CTA registry register/resolve/has/all behaviour and the DatabasesCreateIndex action's name, scope, validation surface, and not-implemented execute path. The e2e suite runs the full CRUD lifecycle, dismiss, and CTA trigger paths against a real cloud project, including authentication boundaries. Co-Authored-By: Claude Opus 4.7 (1M context) --- phpunit.xml | 1 + .../Insights/InsightsCustomServerTest.php | 202 ++++++++++++++++++ tests/unit/Insights/ActionTest.php | 105 +++++++++ tests/unit/Insights/CtaRegistryTest.php | 88 ++++++++ 4 files changed, 396 insertions(+) create mode 100644 tests/e2e/Services/Insights/InsightsCustomServerTest.php create mode 100644 tests/unit/Insights/ActionTest.php create mode 100644 tests/unit/Insights/CtaRegistryTest.php diff --git a/phpunit.xml b/phpunit.xml index 9748c5a5c8..1202cbebbe 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/Insights ./tests/e2e/Services/Functions/FunctionsBase.php ./tests/e2e/Services/Functions/FunctionsCustomServerTest.php ./tests/e2e/Services/Functions/FunctionsCustomClientTest.php diff --git a/tests/e2e/Services/Insights/InsightsCustomServerTest.php b/tests/e2e/Services/Insights/InsightsCustomServerTest.php new file mode 100644 index 0000000000..c51cd4b164 --- /dev/null +++ b/tests/e2e/Services/Insights/InsightsCustomServerTest.php @@ -0,0 +1,202 @@ + 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + } + + protected function clientHeaders(): array + { + return array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + } + + public function testCreate(): array + { + $insightId = ID::unique(); + + $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + 'insightId' => $insightId, + 'type' => 'databaseIndex', + 'severity' => 'warning', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Missing index on collection orders', + 'summary' => 'Queries against `orders.status` are scanning the full collection.', + 'payload' => ['databaseId' => 'main', 'collectionId' => 'orders'], + 'ctas' => [[ + 'id' => 'createIndex', + 'label' => 'Create missing index', + 'action' => 'databases.createIndex', + 'params' => [ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => ['status'], + ], + ]], + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($insightId, $response['body']['$id']); + $this->assertSame('databaseIndex', $response['body']['type']); + $this->assertSame('warning', $response['body']['severity']); + $this->assertSame('databases', $response['body']['resourceType']); + $this->assertSame('main', $response['body']['resourceId']); + $this->assertSame('Missing index on collection orders', $response['body']['title']); + $this->assertCount(1, $response['body']['ctas']); + $this->assertSame('createIndex', $response['body']['ctas'][0]['id']); + + return ['insightId' => $insightId]; + } + + /** + * @depends testCreate + */ + public function testGet(array $data): array + { + $insightId = $data['insightId']; + + $response = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($insightId, $response['body']['$id']); + + $missing = $this->client->call(Client::METHOD_GET, '/insights/missing', $this->serverHeaders()); + $this->assertSame(404, $missing['headers']['status-code']); + + return $data; + } + + /** + * @depends testGet + */ + public function testList(array $data): array + { + $response = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders()); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + $this->assertNotEmpty($response['body']['insights']); + + $filtered = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders(), [ + 'queries' => [ + 'equal("resourceType", "databases")', + ], + ]); + $this->assertSame(200, $filtered['headers']['status-code']); + foreach ($filtered['body']['insights'] as $insight) { + $this->assertSame('databases', $insight['resourceType']); + } + + return $data; + } + + /** + * @depends testList + */ + public function testUpdate(array $data): array + { + $insightId = $data['insightId']; + + $response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ + 'severity' => 'critical', + 'summary' => 'Updated summary.', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('critical', $response['body']['severity']); + $this->assertSame('Updated summary.', $response['body']['summary']); + $this->assertSame('Missing index on collection orders', $response['body']['title']); + + return $data; + } + + /** + * @depends testUpdate + */ + public function testDismiss(array $data): array + { + $insightId = $data['insightId']; + + $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/dismiss', $this->serverHeaders()); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['dismissedAt']); + + return $data; + } + + /** + * @depends testDismiss + */ + public function testTriggerCta(array $data): void + { + $insightId = $data['insightId']; + + $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/createIndex/trigger', $this->serverHeaders()); + + $this->assertSame(501, $response['headers']['status-code']); + $this->assertSame('general_not_implemented', $response['body']['type']); + + $missingCta = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/missing/trigger', $this->serverHeaders()); + $this->assertSame(404, $missingCta['headers']['status-code']); + $this->assertSame('insight_cta_not_found', $missingCta['body']['type']); + } + + public function testCreateRequiresServerKey(): void + { + $response = $this->client->call(Client::METHOD_POST, '/insights', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'insightId' => ID::unique(), + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + ]); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testDelete(): void + { + $insightId = ID::unique(); + + $create = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + 'insightId' => $insightId, + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Insight to be deleted', + ]); + $this->assertSame(201, $create['headers']['status-code']); + + $delete = $this->client->call(Client::METHOD_DELETE, '/insights/' . $insightId, $this->serverHeaders()); + $this->assertSame(204, $delete['headers']['status-code']); + + $missing = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); + $this->assertSame(404, $missing['headers']['status-code']); + } +} diff --git a/tests/unit/Insights/ActionTest.php b/tests/unit/Insights/ActionTest.php new file mode 100644 index 0000000000..a13076bf1c --- /dev/null +++ b/tests/unit/Insights/ActionTest.php @@ -0,0 +1,105 @@ +assertSame(INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX, $action->getName()); + $this->assertSame('databases.write', $action->getRequiredScope()); + } + + public function testValidateAcceptsCompleteParams(): void + { + $action = new DatabasesCreateIndex(); + + $action->validate([ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => ['status'], + ]); + + $this->expectNotToPerformAssertions(); + } + + #[DataProvider('missingParamProvider')] + public function testValidateFailsForMissingParams(string $missing, array $params): void + { + $action = new DatabasesCreateIndex(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Missing required param "' . $missing . '"'); + + $action->validate($params); + } + + public static function missingParamProvider(): array + { + $base = [ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => ['status'], + ]; + + $cases = []; + foreach (\array_keys($base) as $key) { + $partial = $base; + unset($partial[$key]); + $cases[$key] = [$key, $partial]; + } + + return $cases; + } + + public function testValidateRejectsEmptyAttributes(): void + { + $action = new DatabasesCreateIndex(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Param "attributes" must be a non-empty array'); + + $action->validate([ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => [], + ]); + } + + public function testExecuteThrowsNotImplemented(): void + { + $action = new DatabasesCreateIndex(); + + $insight = new Document(['$id' => 'insight1']); + $project = new Document(['$id' => 'project1']); + $database = $this->createMock(Database::class); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('is not implemented in this build'); + + $action->execute([ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => ['status'], + ], $insight, $project, $database); + } +} diff --git a/tests/unit/Insights/CtaRegistryTest.php b/tests/unit/Insights/CtaRegistryTest.php new file mode 100644 index 0000000000..e1b91826ba --- /dev/null +++ b/tests/unit/Insights/CtaRegistryTest.php @@ -0,0 +1,88 @@ +assertFalse($registry->has($action->getName())); + + $registry->register($action); + + $this->assertTrue($registry->has($action->getName())); + $this->assertSame($action, $registry->get($action->getName())); + } + + public function testGetUnknownActionThrows(): void + { + $registry = new Registry(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('CTA action "missing.action" is not registered.'); + + $registry->get('missing.action'); + } + + public function testHasReturnsFalseForUnknownAction(): void + { + $registry = new Registry(); + + $this->assertFalse($registry->has('missing.action')); + } + + public function testAllReturnsRegisteredActions(): void + { + $registry = new Registry(); + $action = new DatabasesCreateIndex(); + $registry->register($action); + + $all = $registry->all(); + + $this->assertCount(1, $all); + $this->assertArrayHasKey($action->getName(), $all); + $this->assertSame($action, $all[$action->getName()]); + } + + public function testRegisterReplacesExistingAction(): void + { + $registry = new Registry(); + $first = new DatabasesCreateIndex(); + $second = new class () implements Action { + public function getName(): string + { + return INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX; + } + + public function getRequiredScope(): string + { + return 'databases.write'; + } + + public function validate(array $params): void + { + } + + public function execute(array $params, Document $insight, Document $project, Database $dbForProject): Document + { + return new Document(['ok' => true]); + } + }; + + $registry->register($first); + $registry->register($second); + + $this->assertSame($second, $registry->get($first->getName())); + } +} From 8beae59454ac865cd412e21c611da32922f5a503 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 13:30:23 +1200 Subject: [PATCH 08/70] fix(insights): schema corrections from review - Add inline comments listing possible values for enum-bounded attributes - Use VAR_ID for resourceInternalId - Drop trailing 0 from composite index lengths and use $sequence over $createdAt - Drop redundant ORDER_ASC values (default direction) --- app/config/collections/projects.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 96c7fa5c5b..ddb6717f7e 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2761,6 +2761,7 @@ return [ 'name' => 'Insights', 'attributes' => [ [ + // Possible values: databaseIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance '$id' => ID::custom('type'), 'type' => Database::VAR_STRING, 'size' => 64, @@ -2771,6 +2772,7 @@ return [ 'filters' => [], ], [ + // Possible values: info, warning, critical '$id' => ID::custom('severity'), 'type' => Database::VAR_STRING, 'size' => 16, @@ -2781,6 +2783,7 @@ return [ 'filters' => [], ], [ + // Possible values: databases, collections, sites, functions '$id' => ID::custom('resourceType'), 'type' => Database::VAR_STRING, 'size' => 64, @@ -2802,8 +2805,8 @@ return [ ], [ '$id' => ID::custom('resourceInternalId'), - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, + 'type' => Database::VAR_ID, + 'size' => 0, 'signed' => true, 'required' => false, 'default' => '', @@ -2885,23 +2888,23 @@ return [ [ '$id' => ID::custom('_key_resource'), 'type' => Database::INDEX_KEY, - 'attributes' => ['resourceType', 'resourceId', '$createdAt'], - 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, 0], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_DESC], + 'attributes' => ['resourceType', 'resourceId', '$sequence'], + 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], + 'orders' => [], ], [ '$id' => ID::custom('_key_type'), 'type' => Database::INDEX_KEY, 'attributes' => ['type'], 'lengths' => [], - 'orders' => [Database::ORDER_ASC], + 'orders' => [], ], [ '$id' => ID::custom('_key_severity'), 'type' => Database::INDEX_KEY, 'attributes' => ['severity'], 'lengths' => [], - 'orders' => [Database::ORDER_ASC], + 'orders' => [], ], [ '$id' => ID::custom('_key_dismissedAt'), From f5d6f6e27c7a789519acd60c6ace53cc5e1e92f8 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 13:40:27 +1200 Subject: [PATCH 09/70] refactor(insights): use Utopia Platform Action and Registry Replace the bespoke CtaAction/Registry with Utopia\Platform\Action and Utopia\Registry\Registry. Implement DatabasesCreateIndex with the full createDocument('indexes') + queue path used by the existing indexes endpoint, validated via a dedicated Utopia validator. Drop the obsolete unit tests (custom-Action contract) in favor of validator-focused tests. --- app/init/resources.php | 6 +- src/Appwrite/Insights/Cta/Action.php | 46 ++-- .../Cta/Action/DatabasesCreateIndex.php | 205 +++++++++++++++--- src/Appwrite/Insights/Cta/Registry.php | 45 ---- .../CtaParams/DatabasesCreateIndex.php | 51 +++++ src/Appwrite/Insights/Validator/Ctas.php | 50 +++++ .../Modules/Insights/Http/Cta/Trigger.php | 56 ++++- tests/unit/Insights/ActionTest.php | 105 --------- tests/unit/Insights/CtaRegistryTest.php | 88 -------- .../CtaParams/DatabasesCreateIndexTest.php | 65 ++++++ tests/unit/Insights/Validator/CtasTest.php | 101 +++++++++ 11 files changed, 506 insertions(+), 312 deletions(-) delete mode 100644 src/Appwrite/Insights/Cta/Registry.php create mode 100644 src/Appwrite/Insights/Validator/CtaParams/DatabasesCreateIndex.php create mode 100644 src/Appwrite/Insights/Validator/Ctas.php delete mode 100644 tests/unit/Insights/ActionTest.php delete mode 100644 tests/unit/Insights/CtaRegistryTest.php create mode 100644 tests/unit/Insights/Validator/CtaParams/DatabasesCreateIndexTest.php create mode 100644 tests/unit/Insights/Validator/CtasTest.php diff --git a/app/init/resources.php b/app/init/resources.php index 28d9814edd..dfc3a4dd17 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -9,7 +9,6 @@ use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Insights\Cta\Action\DatabasesCreateIndex; -use Appwrite\Insights\Cta\Registry as InsightCtaRegistry; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; @@ -28,6 +27,7 @@ use Utopia\Pools\Group; use Utopia\Queue\Broker\Pool as BrokerPool; use Utopia\Queue\Publisher; use Utopia\Queue\Queue; +use Utopia\Registry\Registry as UtopiaRegistry; use Utopia\Storage\Device; use Utopia\Storage\Device\AWS; use Utopia\Storage\Device\Backblaze; @@ -131,8 +131,8 @@ $container->set('authorization', function () { }, []); $container->set('insightCtaRegistry', function () { - $registry = new InsightCtaRegistry(); - $registry->register(new DatabasesCreateIndex()); + $registry = new UtopiaRegistry(); + $registry->set(DatabasesCreateIndex::getName(), fn () => new DatabasesCreateIndex()); return $registry; }, []); diff --git a/src/Appwrite/Insights/Cta/Action.php b/src/Appwrite/Insights/Cta/Action.php index 27ffcd7779..f9c9fc6509 100644 --- a/src/Appwrite/Insights/Cta/Action.php +++ b/src/Appwrite/Insights/Cta/Action.php @@ -2,42 +2,22 @@ namespace Appwrite\Insights\Cta; -use Utopia\Database\Database; -use Utopia\Database\Document; +use Utopia\Platform\Action as PlatformAction; -interface Action +/** + * Base class for CTA actions registered in the insights CTA registry. + * + * A CTA action is a named, parameter-validated callable invoked when a user triggers + * a call-to-action attached to an insight. Subclasses declare their inputs via `param()` + * and dependencies via `inject()`, and provide their executable body via `callback()`. + * + * Convention for `getName()`: `domain.verb` in camelCase, e.g. `databases.createIndex`. + * The required project scope is declared via `label('scope', '...')`. + */ +abstract class Action extends PlatformAction { /** * Unique, registered name for this action. - * - * Convention: `domain.verb` in camelCase, e.g. `databases.createIndex`. */ - public function getName(): string; - - /** - * The project scope a caller must hold to trigger CTAs that map to this action. - * - * Returned exactly as it would appear in the role/scopes config (e.g. `databases.write`). - */ - public function getRequiredScope(): string; - - /** - * Validate the params blob attached to the CTA. - * - * Implementations MUST throw `Appwrite\Extend\Exception::INSIGHT_CTA_VALIDATION_FAILED` - * (or a more specific error) when params are missing or malformed. - * - * @param array $params - */ - public function validate(array $params): void; - - /** - * Execute the action on behalf of the authenticated caller. - * - * Returns a `Document` describing the result. The document is rendered using - * `Response::MODEL_INSIGHT_CTA_RESULT` and its keys must match that model's rules. - * - * @param array $params - */ - public function execute(array $params, Document $insight, Document $project, Database $dbForProject): Document; + abstract public static function getName(): string; } diff --git a/src/Appwrite/Insights/Cta/Action/DatabasesCreateIndex.php b/src/Appwrite/Insights/Cta/Action/DatabasesCreateIndex.php index 194d71119e..cd4cf58078 100644 --- a/src/Appwrite/Insights/Cta/Action/DatabasesCreateIndex.php +++ b/src/Appwrite/Insights/Cta/Action/DatabasesCreateIndex.php @@ -2,56 +2,199 @@ namespace Appwrite\Insights\Cta\Action; +use Appwrite\Event\Database as EventDatabase; +use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Insights\Cta\Action; +use Appwrite\Insights\Validator\CtaParams\DatabasesCreateIndex as DatabasesCreateIndexParams; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Helpers\ID; +use Utopia\Database\Query; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\Index as IndexValidator; -class DatabasesCreateIndex implements Action +class DatabasesCreateIndex extends Action { - public function getName(): string + public static function getName(): string { return INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX; } - public function getRequiredScope(): string + public function __construct() { - return 'databases.write'; + $this + ->desc('Create a database index from an insight CTA.') + ->label('scope', 'collections.write') + ->param('params', [], new DatabasesCreateIndexParams(), 'CTA params describing the index to create.') + ->param('insight', null, fn () => true, 'Parent insight document.', skipValidation: true) + ->param('project', null, fn () => true, 'Project document.', skipValidation: true) + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); } /** - * @param array $params + * @param array $params */ - public function validate(array $params): void - { - foreach (['databaseId', 'collectionId', 'key', 'type', 'attributes'] as $required) { - if (!isset($params[$required])) { - throw new Exception( - Exception::INSIGHT_CTA_VALIDATION_FAILED, - 'Missing required param "' . $required . '" for action "' . $this->getName() . '".' - ); + public function action( + array $params, + Document $insight, + Document $project, + Database $dbForProject, + callable $getDatabasesDB, + EventDatabase $queueForDatabase, + Event $queueForEvents, + Authorization $authorization + ): Document { + $databaseId = (string) $params['databaseId']; + $collectionId = (string) $params['collectionId']; + $key = (string) $params['key']; + $type = (string) $params['type']; + $attributes = $params['attributes']; + $orders = $params['orders'] ?? []; + $lengths = $params['lengths'] ?? []; + + $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($db->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); + } + + $collection = $dbForProject->getDocument('database_' . $db->getSequence(), $collectionId); + + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND, params: [$collectionId]); + } + + $count = $dbForProject->count('indexes', [ + Query::equal('collectionInternalId', [$collection->getSequence()]), + Query::equal('databaseInternalId', [$db->getSequence()]), + ], 61); + + $dbForDatabases = $getDatabasesDB($db); + + if ($count >= $dbForDatabases->getLimitForIndexes()) { + throw new Exception(Exception::INDEX_LIMIT_EXCEEDED, params: [$collectionId]); + } + + $oldAttributes = \array_map( + fn ($a) => $a->getArrayCopy(), + $collection->getAttribute('attributes') + ); + + foreach ([ + ['$id', Database::VAR_STRING, true, Database::LENGTH_KEY], + ['$createdAt', Database::VAR_DATETIME, false, 0], + ['$updatedAt', Database::VAR_DATETIME, false, 0], + ] as [$attributeKey, $attributeType, $required, $size]) { + $oldAttributes[] = [ + 'key' => $attributeKey, + 'type' => $attributeType, + 'status' => 'available', + 'required' => $required, + 'array' => false, + 'default' => null, + 'size' => $size, + 'signed' => $attributeType === Database::VAR_DATETIME ? false : true, + ]; + } + + if ($dbForDatabases->getAdapter()->getSupportForAttributes()) { + foreach ($attributes as $i => $attribute) { + $attributeIndex = \array_search($attribute, \array_column($oldAttributes, 'key')); + + if ($attributeIndex === false) { + throw new Exception(Exception::ATTRIBUTE_UNKNOWN, params: [$attribute]); + } + + $attributeStatus = $oldAttributes[$attributeIndex]['status']; + $attributeType = $oldAttributes[$attributeIndex]['type']; + $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; + + if ($attributeType === Database::VAR_RELATIONSHIP) { + throw new Exception(Exception::ATTRIBUTE_TYPE_INVALID, 'Cannot create an index for a relationship attribute: ' . $oldAttributes[$attributeIndex]['key']); + } + + if ($attributeStatus !== 'available') { + throw new Exception(Exception::ATTRIBUTE_NOT_AVAILABLE, params: [$oldAttributes[$attributeIndex]['key']]); + } + + if (empty($lengths[$i])) { + $lengths[$i] = null; + } + + if ($attributeArray === true) { + throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); + } } } - if (!\is_array($params['attributes']) || $params['attributes'] === []) { - throw new Exception( - Exception::INSIGHT_CTA_VALIDATION_FAILED, - 'Param "attributes" must be a non-empty array of attribute keys.' - ); - } - } + $index = new Document([ + '$id' => ID::custom($db->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'status' => 'processing', + 'databaseInternalId' => $db->getSequence(), + 'databaseId' => $databaseId, + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collectionId, + 'type' => $type, + 'attributes' => $attributes, + 'lengths' => $lengths, + 'orders' => $orders, + ]); - /** - * @param array $params - */ - public function execute(array $params, Document $insight, Document $project, Database $dbForProject): Document - { - // Placeholder. Cloud's dedicated-database adapter plugs in the real implementation - // when the bespoke `dedicatedDatabaseIndexSuggestions` collection is migrated to - // the generic `insights` collection. - throw new Exception( - Exception::GENERAL_NOT_IMPLEMENTED, - 'CTA action "' . $this->getName() . '" is not implemented in this build.' + $validator = new IndexValidator( + $collection->getAttribute('attributes'), + $collection->getAttribute('indexes'), + $dbForDatabases->getAdapter()->getMaxIndexLength(), + $dbForDatabases->getAdapter()->getInternalIndexesKeys(), + $dbForDatabases->getAdapter()->getSupportForIndexArray(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), + $dbForDatabases->getAdapter()->getSupportForVectors(), + $dbForDatabases->getAdapter()->getSupportForAttributes(), + $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), + $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), + $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), + $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForIndex(), + $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), + $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), + $dbForDatabases->getAdapter()->getSupportForObject() ); + + if (!$validator->isValid($index)) { + throw new Exception(Exception::INDEX_INVALID, $validator->getDescription()); + } + + try { + $index = $dbForProject->createDocument('indexes', $index); + } catch (DuplicateException) { + throw new Exception(Exception::INDEX_ALREADY_EXISTS, params: [$key]); + } + + $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); + + $queueForDatabase + ->setType(DATABASE_TYPE_CREATE_INDEX) + ->setDatabase($db) + ->setCollection($collection) + ->setDocument($index); + + $queueForEvents + ->setContext('database', $db) + ->setContext('collection', $collection) + ->setParam('databaseId', $databaseId) + ->setParam('collectionId', $collection->getId()) + ->setParam('indexId', $index->getId()); + + return $index; } } diff --git a/src/Appwrite/Insights/Cta/Registry.php b/src/Appwrite/Insights/Cta/Registry.php deleted file mode 100644 index 4a2da111ff..0000000000 --- a/src/Appwrite/Insights/Cta/Registry.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ - private array $actions = []; - - public function register(Action $action): void - { - $this->actions[$action->getName()] = $action; - } - - public function has(string $name): bool - { - return isset($this->actions[$name]); - } - - /** - * Resolve an action by name. - * - * @throws Exception When the action is not registered. - */ - public function get(string $name): Action - { - if (!isset($this->actions[$name])) { - throw new Exception(Exception::INSIGHT_CTA_ACTION_NOT_REGISTERED, 'CTA action "' . $name . '" is not registered.'); - } - - return $this->actions[$name]; - } - - /** - * @return array - */ - public function all(): array - { - return $this->actions; - } -} diff --git a/src/Appwrite/Insights/Validator/CtaParams/DatabasesCreateIndex.php b/src/Appwrite/Insights/Validator/CtaParams/DatabasesCreateIndex.php new file mode 100644 index 0000000000..f8649d5546 --- /dev/null +++ b/src/Appwrite/Insights/Validator/CtaParams/DatabasesCreateIndex.php @@ -0,0 +1,51 @@ + + */ + private const REQUIRED = ['databaseId', 'collectionId', 'key', 'type', 'attributes']; + + protected string $message = 'CTA params must define databaseId, collectionId, key, type, and a non-empty attributes array.'; + + 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; + } + + foreach (self::REQUIRED as $key) { + if (!isset($value[$key])) { + $this->message = 'Missing required param "' . $key . '".'; + return false; + } + } + + if (!\is_array($value['attributes']) || $value['attributes'] === []) { + $this->message = 'Param "attributes" must be a non-empty array of attribute keys.'; + return false; + } + + return true; + } +} diff --git a/src/Appwrite/Insights/Validator/Ctas.php b/src/Appwrite/Insights/Validator/Ctas.php new file mode 100644 index 0000000000..e15ccecdcd --- /dev/null +++ b/src/Appwrite/Insights/Validator/Ctas.php @@ -0,0 +1,50 @@ +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; + } + + foreach ($value as $entry) { + if (!\is_array($entry)) { + return false; + } + + foreach (['id', 'label', 'action'] as $required) { + if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') { + return false; + } + } + + if (isset($entry['params']) && !\is_array($entry['params']) && !\is_object($entry['params'])) { + return false; + } + } + + return true; + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php b/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php index 160eb2eae2..68d4dd73ec 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php @@ -2,18 +2,21 @@ namespace Appwrite\Platform\Modules\Insights\Http\Cta; +use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Insights\Cta\Registry as InsightCtaRegistry; +use Appwrite\Insights\Cta\Action as CtaAction; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Registry\Registry as UtopiaRegistry; use Utopia\Validator\Text; class Trigger extends Action @@ -60,8 +63,11 @@ class Trigger extends Action ->inject('response') ->inject('project') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('insightCtaRegistry') + ->inject('queueForDatabase') ->inject('queueForEvents') + ->inject('authorization') ->callback($this->action(...)); } @@ -71,8 +77,11 @@ class Trigger extends Action Response $response, Document $project, Database $dbForProject, - InsightCtaRegistry $insightCtaRegistry, - Event $queueForEvents + callable $getDatabasesDB, + UtopiaRegistry $insightCtaRegistry, + EventDatabase $queueForDatabase, + Event $queueForEvents, + Authorization $authorization ) { $insight = $dbForProject->getDocument('insights', $insightId); @@ -94,19 +103,52 @@ class Trigger extends Action $actionName = (string) ($cta['action'] ?? ''); $params = $cta['params'] ?? []; + + if (\is_object($params)) { + $params = (array) $params; + } + if (!\is_array($params)) { $params = []; } - $action = $insightCtaRegistry->get($actionName); - $action->validate($params); + try { + $action = $insightCtaRegistry->get($actionName); + } catch (\Throwable) { + throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); + } + + if (!$action instanceof CtaAction) { + throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); + } + + $paramsValidator = $action->getParams()['params']['validator'] ?? null; + + if ($paramsValidator !== null && !$paramsValidator->isValid($params)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $paramsValidator->getDescription()); + } $status = 'succeeded'; $resultPayload = new \stdClass(); + $callback = $action->getCallback(); + + if (!\is_callable($callback)) { + throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); + } + try { - $result = $action->execute($params, $insight, $project, $dbForProject); - $resultPayload = $result->getArrayCopy(); + $result = $callback( + $params, + $insight, + $project, + $dbForProject, + $getDatabasesDB, + $queueForDatabase, + $queueForEvents, + $authorization + ); + $resultPayload = $result instanceof Document ? $result->getArrayCopy() : (array) $result; } catch (Exception $e) { if ($e->getType() === Exception::GENERAL_NOT_IMPLEMENTED) { throw $e; diff --git a/tests/unit/Insights/ActionTest.php b/tests/unit/Insights/ActionTest.php deleted file mode 100644 index a13076bf1c..0000000000 --- a/tests/unit/Insights/ActionTest.php +++ /dev/null @@ -1,105 +0,0 @@ -assertSame(INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX, $action->getName()); - $this->assertSame('databases.write', $action->getRequiredScope()); - } - - public function testValidateAcceptsCompleteParams(): void - { - $action = new DatabasesCreateIndex(); - - $action->validate([ - 'databaseId' => 'main', - 'collectionId' => 'orders', - 'key' => '_idx_status', - 'type' => 'key', - 'attributes' => ['status'], - ]); - - $this->expectNotToPerformAssertions(); - } - - #[DataProvider('missingParamProvider')] - public function testValidateFailsForMissingParams(string $missing, array $params): void - { - $action = new DatabasesCreateIndex(); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Missing required param "' . $missing . '"'); - - $action->validate($params); - } - - public static function missingParamProvider(): array - { - $base = [ - 'databaseId' => 'main', - 'collectionId' => 'orders', - 'key' => '_idx_status', - 'type' => 'key', - 'attributes' => ['status'], - ]; - - $cases = []; - foreach (\array_keys($base) as $key) { - $partial = $base; - unset($partial[$key]); - $cases[$key] = [$key, $partial]; - } - - return $cases; - } - - public function testValidateRejectsEmptyAttributes(): void - { - $action = new DatabasesCreateIndex(); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Param "attributes" must be a non-empty array'); - - $action->validate([ - 'databaseId' => 'main', - 'collectionId' => 'orders', - 'key' => '_idx_status', - 'type' => 'key', - 'attributes' => [], - ]); - } - - public function testExecuteThrowsNotImplemented(): void - { - $action = new DatabasesCreateIndex(); - - $insight = new Document(['$id' => 'insight1']); - $project = new Document(['$id' => 'project1']); - $database = $this->createMock(Database::class); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('is not implemented in this build'); - - $action->execute([ - 'databaseId' => 'main', - 'collectionId' => 'orders', - 'key' => '_idx_status', - 'type' => 'key', - 'attributes' => ['status'], - ], $insight, $project, $database); - } -} diff --git a/tests/unit/Insights/CtaRegistryTest.php b/tests/unit/Insights/CtaRegistryTest.php deleted file mode 100644 index e1b91826ba..0000000000 --- a/tests/unit/Insights/CtaRegistryTest.php +++ /dev/null @@ -1,88 +0,0 @@ -assertFalse($registry->has($action->getName())); - - $registry->register($action); - - $this->assertTrue($registry->has($action->getName())); - $this->assertSame($action, $registry->get($action->getName())); - } - - public function testGetUnknownActionThrows(): void - { - $registry = new Registry(); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('CTA action "missing.action" is not registered.'); - - $registry->get('missing.action'); - } - - public function testHasReturnsFalseForUnknownAction(): void - { - $registry = new Registry(); - - $this->assertFalse($registry->has('missing.action')); - } - - public function testAllReturnsRegisteredActions(): void - { - $registry = new Registry(); - $action = new DatabasesCreateIndex(); - $registry->register($action); - - $all = $registry->all(); - - $this->assertCount(1, $all); - $this->assertArrayHasKey($action->getName(), $all); - $this->assertSame($action, $all[$action->getName()]); - } - - public function testRegisterReplacesExistingAction(): void - { - $registry = new Registry(); - $first = new DatabasesCreateIndex(); - $second = new class () implements Action { - public function getName(): string - { - return INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX; - } - - public function getRequiredScope(): string - { - return 'databases.write'; - } - - public function validate(array $params): void - { - } - - public function execute(array $params, Document $insight, Document $project, Database $dbForProject): Document - { - return new Document(['ok' => true]); - } - }; - - $registry->register($first); - $registry->register($second); - - $this->assertSame($second, $registry->get($first->getName())); - } -} diff --git a/tests/unit/Insights/Validator/CtaParams/DatabasesCreateIndexTest.php b/tests/unit/Insights/Validator/CtaParams/DatabasesCreateIndexTest.php new file mode 100644 index 0000000000..79e151a518 --- /dev/null +++ b/tests/unit/Insights/Validator/CtaParams/DatabasesCreateIndexTest.php @@ -0,0 +1,65 @@ +assertTrue($validator->isValid([ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => ['status'], + ])); + } + + public function testRejectsNonArray(): void + { + $validator = new DatabasesCreateIndex(); + + $this->assertFalse($validator->isValid('not-an-array')); + $this->assertFalse($validator->isValid(null)); + } + + public function testRejectsMissingRequiredParam(): void + { + $validator = new DatabasesCreateIndex(); + + $this->assertFalse($validator->isValid([ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + ])); + $this->assertStringContainsString('attributes', $validator->getDescription()); + } + + public function testRejectsEmptyAttributes(): void + { + $validator = new DatabasesCreateIndex(); + + $this->assertFalse($validator->isValid([ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => [], + ])); + $this->assertStringContainsString('non-empty', $validator->getDescription()); + } + + public function testReportsArrayType(): void + { + $validator = new DatabasesCreateIndex(); + + $this->assertTrue($validator->isArray()); + $this->assertSame($validator::TYPE_ARRAY, $validator->getType()); + } +} diff --git a/tests/unit/Insights/Validator/CtasTest.php b/tests/unit/Insights/Validator/CtasTest.php new file mode 100644 index 0000000000..1ab6f897af --- /dev/null +++ b/tests/unit/Insights/Validator/CtasTest.php @@ -0,0 +1,101 @@ +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([[ + 'id' => 'createIndex', + 'label' => 'Create missing index', + 'action' => 'databases.createIndex', + 'params' => [ + 'databaseId' => 'main', + 'collectionId' => 'orders', + ], + ]])); + } + + public function testAcceptsEntryWithoutParams(): void + { + $validator = new Ctas(); + + $this->assertTrue($validator->isValid([[ + 'id' => 'createIndex', + 'label' => 'Create missing index', + 'action' => 'databases.createIndex', + ]])); + } + + public function testRejectsEntryMissingRequiredKeys(): void + { + $validator = new Ctas(); + + $this->assertFalse($validator->isValid([['id' => 'x']])); + $this->assertFalse($validator->isValid([['id' => 'x', 'label' => 'y']])); + } + + public function testRejectsEntryWithEmptyStrings(): void + { + $validator = new Ctas(); + + $this->assertFalse($validator->isValid([[ + 'id' => '', + 'label' => 'Create missing index', + 'action' => 'databases.createIndex', + ]])); + } + + public function testRejectsEntryWithNonStringFields(): void + { + $validator = new Ctas(); + + $this->assertFalse($validator->isValid([[ + 'id' => 123, + 'label' => 'Create missing index', + 'action' => 'databases.createIndex', + ]])); + } + + public function testRejectsEntryWithScalarParams(): void + { + $validator = new Ctas(); + + $this->assertFalse($validator->isValid([[ + 'id' => 'createIndex', + 'label' => 'Create missing index', + 'action' => 'databases.createIndex', + 'params' => 'not-a-map', + ]])); + } + + public function testReportsArrayType(): void + { + $validator = new Ctas(); + + $this->assertTrue($validator->isArray()); + $this->assertSame($validator::TYPE_ARRAY, $validator->getType()); + } +} From d15be219235bf75b12d25af684b1ed646638f338 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 13:42:01 +1200 Subject: [PATCH 10/70] refactor(insights): use Ctas validator on create endpoint Replace the inline CTA shape check with a dedicated Utopia validator so the constraint surfaces as a normal 400 with a useful message rather than a generic argument-invalid exception. --- .../Platform/Modules/Insights/Http/Insights/Create.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php index 001c339e88..16c256dfec 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Insights\Http\Insights; use Appwrite\Event\Event; use Appwrite\Extend\Exception; +use Appwrite\Insights\Validator\Ctas as CtasValidator; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -16,7 +17,6 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\ArrayList; use Utopia\Validator\JSON; use Utopia\Validator\Nullable; use Utopia\Validator\Text; @@ -70,7 +70,7 @@ class Create extends Action ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) - ->param('ctas', [], new ArrayList(new JSON(), 16), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true) + ->param('ctas', [], new CtasValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) ->inject('response') ->inject('dbForProject') @@ -98,9 +98,6 @@ class Create extends Action $normalizedCtas = []; foreach ($ctas as $cta) { - if (!isset($cta['id'], $cta['label'], $cta['action'])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Each CTA must define `id`, `label`, and `action`.'); - } $normalizedCtas[] = [ 'id' => (string) $cta['id'], 'label' => (string) $cta['label'], From da5a137b987708e30ea618cffdebe3da41665d3d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 14:04:31 +1200 Subject: [PATCH 11/70] refactor(insights): nest CTA execution and dismissal as sub-resources Move POST /v1/insights/:id/dismiss to /v1/insights/:id/dismissals and POST /v1/insights/:id/ctas/:ctaId/trigger to /v1/insights/:id/ctas/:ctaId/executions, with the corresponding class moves into Http/Insights/Dismissal/Create.php and Http/CTA/Execution/Create.php. Rename the response model to InsightCtaExecution and update events.php to surface dismissal and execution as resource events with create verbs. The reshape matches the rest of the API where verbs hang off plural sub-resources. --- app/config/events.php | 18 +++++++++++---- app/init/models.php | 4 ++-- .../Trigger.php => CTA/Execution/Create.php} | 22 +++++++++---------- .../{Dismiss.php => Dismissal/Create.php} | 16 +++++++------- .../Modules/Insights/Services/Http.php | 9 ++++---- src/Appwrite/Utopia/Response.php | 2 +- ...tCtaResult.php => InsightCtaExecution.php} | 10 ++++----- 7 files changed, 45 insertions(+), 36 deletions(-) rename src/Appwrite/Platform/Modules/Insights/Http/{Cta/Trigger.php => CTA/Execution/Create.php} (89%) rename src/Appwrite/Platform/Modules/Insights/Http/Insights/{Dismiss.php => Dismissal/Create.php} (86%) rename src/Appwrite/Utopia/Response/Model/{InsightCtaResult.php => InsightCtaExecution.php} (87%) diff --git a/app/config/events.php b/app/config/events.php index aeaf48081f..576962fbe0 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -440,15 +440,25 @@ return [ 'delete' => [ '$description' => 'This event triggers when an insight is deleted.', ], - 'dismiss' => [ - '$description' => 'This event triggers when an insight is dismissed.', + 'dismissals' => [ + '$model' => Response::MODEL_INSIGHT, + '$resource' => true, + '$description' => 'This event triggers on any insight dismissal event.', + 'create' => [ + '$description' => 'This event triggers when an insight is dismissed.', + ], ], 'ctas' => [ '$model' => Response::MODEL_INSIGHT_CTA, '$resource' => true, '$description' => 'This event triggers on any insight CTA event.', - 'trigger' => [ - '$description' => 'This event triggers when an insight CTA is executed.', + 'executions' => [ + '$model' => Response::MODEL_INSIGHT_CTA_EXECUTION, + '$resource' => true, + '$description' => 'This event triggers on any insight CTA execution event.', + 'create' => [ + '$description' => 'This event triggers when an insight CTA is executed.', + ], ], ], ], diff --git a/app/init/models.php b/app/init/models.php index e75cb89142..e1d7c81ed6 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -92,7 +92,7 @@ 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\InsightCtaResult; +use Appwrite\Utopia\Response\Model\InsightCtaExecution; use Appwrite\Utopia\Response\Model\Installation; use Appwrite\Utopia\Response\Model\JWT; use Appwrite\Utopia\Response\Model\Key; @@ -511,7 +511,7 @@ Response::setModel(new MigrationReport()); Response::setModel(new MigrationFirebaseProject()); Response::setModel(new Insight()); Response::setModel(new InsightCta()); -Response::setModel(new InsightCtaResult()); +Response::setModel(new InsightCtaExecution()); // Tests (keep last) Response::setModel(new Mock()); diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php b/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php similarity index 89% rename from src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php rename to src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php index 68d4dd73ec..523c742f2d 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/insights/:insightId/ctas/:ctaId/trigger') - ->desc('Trigger insight CTA') + ->setHttpPath('/v1/insights/:insightId/ctas/:ctaId/executions') + ->desc('Create insight CTA execution') ->groups(['api', 'insights']) ->label('scope', 'insights.write') - ->label('event', 'insights.[insightId].ctas.[ctaId].trigger') + ->label('event', 'insights.[insightId].ctas.[ctaId].executions.create') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) - ->label('audits.event', 'insight.cta.trigger') + ->label('audits.event', 'insight.cta.execution.create') ->label('audits.resource', 'insight/{request.insightId}') ->label('abuse-key', 'projectId:{projectId},userId:{userId}') ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) @@ -46,15 +46,15 @@ class Trigger extends Action ->label('sdk', new Method( namespace: 'insights', group: 'insights', - name: 'triggerCta', + name: 'createCtaExecution', description: << $actionName, 'status' => $status, 'result' => $resultPayload, - ]), Response::MODEL_INSIGHT_CTA_RESULT); + ]), Response::MODEL_INSIGHT_CTA_EXECUTION); } } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismissal/Create.php similarity index 86% rename from src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php rename to src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismissal/Create.php index ab2ef38682..6430e35746 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismissal/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/insights/:insightId/dismiss') - ->desc('Dismiss insight') + ->setHttpPath('/v1/insights/:insightId/dismissals') + ->desc('Create insight dismissal') ->groups(['api', 'insights']) ->label('scope', 'insights.write') - ->label('event', 'insights.[insightId].dismiss') + ->label('event', 'insights.[insightId].dismissals.create') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) - ->label('audits.event', 'insight.dismiss') + ->label('audits.event', 'insight.dismissal.create') ->label('audits.resource', 'insight/{response.$id}') ->label('abuse-key', 'projectId:{projectId},userId:{userId}') ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) @@ -42,7 +42,7 @@ class Dismiss extends Action ->label('sdk', new Method( namespace: 'insights', group: 'insights', - name: 'dismiss', + name: 'createDismissal', description: <<addAction(GetInsight::getName(), new GetInsight()); $this->addAction(ListInsights::getName(), new ListInsights()); $this->addAction(UpdateInsight::getName(), new UpdateInsight()); - $this->addAction(DismissInsight::getName(), new DismissInsight()); $this->addAction(DeleteInsight::getName(), new DeleteInsight()); - - $this->addAction(TriggerCta::getName(), new TriggerCta()); + $this->addAction(CreateInsightDismissal::getName(), new CreateInsightDismissal()); + $this->addAction(CreateInsightCtaExecution::getName(), new CreateInsightCtaExecution()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index dc2c54d4a5..e2c04c0178 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -334,7 +334,7 @@ class Response extends SwooleResponse public const MODEL_INSIGHT = 'insight'; public const MODEL_INSIGHT_LIST = 'insightList'; public const MODEL_INSIGHT_CTA = 'insightCta'; - public const MODEL_INSIGHT_CTA_RESULT = 'insightCtaResult'; + public const MODEL_INSIGHT_CTA_EXECUTION = 'insightCtaExecution'; // Console public const MODEL_CONSOLE_VARIABLES = 'consoleVariables'; diff --git a/src/Appwrite/Utopia/Response/Model/InsightCtaResult.php b/src/Appwrite/Utopia/Response/Model/InsightCtaExecution.php similarity index 87% rename from src/Appwrite/Utopia/Response/Model/InsightCtaResult.php rename to src/Appwrite/Utopia/Response/Model/InsightCtaExecution.php index a6fe9addca..522105e3ef 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCtaResult.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCtaExecution.php @@ -5,20 +5,20 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; -class InsightCtaResult extends Model +class InsightCtaExecution extends Model { public function __construct() { $this ->addRule('insightId', [ 'type' => self::TYPE_STRING, - 'description' => 'ID of the insight the CTA was triggered against.', + 'description' => 'ID of the insight the CTA was executed against.', 'default' => '', 'example' => '5e5ea5c16897e', ]) ->addRule('ctaId', [ 'type' => self::TYPE_STRING, - 'description' => 'ID of the CTA that was triggered.', + 'description' => 'ID of the CTA that was executed.', 'default' => '', 'example' => 'createIndex', ]) @@ -44,11 +44,11 @@ class InsightCtaResult extends Model public function getName(): string { - return 'InsightCtaResult'; + return 'InsightCtaExecution'; } public function getType(): string { - return Response::MODEL_INSIGHT_CTA_RESULT; + return Response::MODEL_INSIGHT_CTA_EXECUTION; } } From e0d5164af201e649b868fcdd7d82726e9f6e2254 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 14:04:36 +1200 Subject: [PATCH 12/70] test(insights): split e2e into base trait and server/console overlays Extract the existing test methods into InsightsBase trait and provide thin Custom{Server,Console}Test classes that compose the base with the appropriate scope/side traits. Updates the dismissal and CTA execution tests to the new sub-resource paths. --- tests/e2e/Services/Insights/InsightsBase.php | 199 ++++++++++++++++++ .../Insights/InsightsCustomConsoleTest.php | 14 ++ .../Insights/InsightsCustomServerTest.php | 190 +---------------- 3 files changed, 214 insertions(+), 189 deletions(-) create mode 100644 tests/e2e/Services/Insights/InsightsBase.php create mode 100644 tests/e2e/Services/Insights/InsightsCustomConsoleTest.php diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php new file mode 100644 index 0000000000..c8a2b809cf --- /dev/null +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -0,0 +1,199 @@ + 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + } + + protected function clientHeaders(): array + { + return array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + } + + public function testCreate(): array + { + $insightId = ID::unique(); + + $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + 'insightId' => $insightId, + 'type' => 'databaseIndex', + 'severity' => 'warning', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Missing index on collection orders', + 'summary' => 'Queries against `orders.status` are scanning the full collection.', + 'payload' => ['databaseId' => 'main', 'collectionId' => 'orders'], + 'ctas' => [[ + 'id' => 'createIndex', + 'label' => 'Create missing index', + 'action' => 'databases.createIndex', + 'params' => [ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => ['status'], + ], + ]], + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($insightId, $response['body']['$id']); + $this->assertSame('databaseIndex', $response['body']['type']); + $this->assertSame('warning', $response['body']['severity']); + $this->assertSame('databases', $response['body']['resourceType']); + $this->assertSame('main', $response['body']['resourceId']); + $this->assertSame('Missing index on collection orders', $response['body']['title']); + $this->assertCount(1, $response['body']['ctas']); + $this->assertSame('createIndex', $response['body']['ctas'][0]['id']); + + return ['insightId' => $insightId]; + } + + /** + * @depends testCreate + */ + public function testGet(array $data): array + { + $insightId = $data['insightId']; + + $response = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($insightId, $response['body']['$id']); + + $missing = $this->client->call(Client::METHOD_GET, '/insights/missing', $this->serverHeaders()); + $this->assertSame(404, $missing['headers']['status-code']); + + return $data; + } + + /** + * @depends testGet + */ + public function testList(array $data): array + { + $response = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders()); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + $this->assertNotEmpty($response['body']['insights']); + + $filtered = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders(), [ + 'queries' => [ + 'equal("resourceType", "databases")', + ], + ]); + $this->assertSame(200, $filtered['headers']['status-code']); + foreach ($filtered['body']['insights'] as $insight) { + $this->assertSame('databases', $insight['resourceType']); + } + + return $data; + } + + /** + * @depends testList + */ + public function testUpdate(array $data): array + { + $insightId = $data['insightId']; + + $response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ + 'severity' => 'critical', + 'summary' => 'Updated summary.', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('critical', $response['body']['severity']); + $this->assertSame('Updated summary.', $response['body']['summary']); + $this->assertSame('Missing index on collection orders', $response['body']['title']); + + return $data; + } + + /** + * @depends testUpdate + */ + public function testCreateDismissal(array $data): array + { + $insightId = $data['insightId']; + + $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/dismissals', $this->serverHeaders()); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['dismissedAt']); + + return $data; + } + + /** + * @depends testCreateDismissal + */ + public function testCreateCtaExecution(array $data): void + { + $insightId = $data['insightId']; + + $missingCta = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/missing/executions', $this->serverHeaders()); + $this->assertSame(404, $missingCta['headers']['status-code']); + $this->assertSame('insight_cta_not_found', $missingCta['body']['type']); + + $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/createIndex/executions', $this->serverHeaders()); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($insightId, $response['body']['insightId']); + $this->assertSame('createIndex', $response['body']['ctaId']); + $this->assertSame('databases.createIndex', $response['body']['action']); + $this->assertContains($response['body']['status'], ['succeeded', 'failed']); + } + + public function testCreateRequiresServerKey(): void + { + $response = $this->client->call(Client::METHOD_POST, '/insights', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'insightId' => ID::unique(), + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + ]); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testDelete(): void + { + $insightId = ID::unique(); + + $create = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + 'insightId' => $insightId, + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Insight to be deleted', + ]); + $this->assertSame(201, $create['headers']['status-code']); + + $delete = $this->client->call(Client::METHOD_DELETE, '/insights/' . $insightId, $this->serverHeaders()); + $this->assertSame(204, $delete['headers']['status-code']); + + $missing = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); + $this->assertSame(404, $missing['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Insights/InsightsCustomConsoleTest.php b/tests/e2e/Services/Insights/InsightsCustomConsoleTest.php new file mode 100644 index 0000000000..daf0ea819d --- /dev/null +++ b/tests/e2e/Services/Insights/InsightsCustomConsoleTest.php @@ -0,0 +1,14 @@ + 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]; - } - - protected function clientHeaders(): array - { - return array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()); - } - - public function testCreate(): array - { - $insightId = ID::unique(); - - $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ - 'insightId' => $insightId, - 'type' => 'databaseIndex', - 'severity' => 'warning', - 'resourceType' => 'databases', - 'resourceId' => 'main', - 'title' => 'Missing index on collection orders', - 'summary' => 'Queries against `orders.status` are scanning the full collection.', - 'payload' => ['databaseId' => 'main', 'collectionId' => 'orders'], - 'ctas' => [[ - 'id' => 'createIndex', - 'label' => 'Create missing index', - 'action' => 'databases.createIndex', - 'params' => [ - 'databaseId' => 'main', - 'collectionId' => 'orders', - 'key' => '_idx_status', - 'type' => 'key', - 'attributes' => ['status'], - ], - ]], - ]); - - $this->assertSame(201, $response['headers']['status-code']); - $this->assertSame($insightId, $response['body']['$id']); - $this->assertSame('databaseIndex', $response['body']['type']); - $this->assertSame('warning', $response['body']['severity']); - $this->assertSame('databases', $response['body']['resourceType']); - $this->assertSame('main', $response['body']['resourceId']); - $this->assertSame('Missing index on collection orders', $response['body']['title']); - $this->assertCount(1, $response['body']['ctas']); - $this->assertSame('createIndex', $response['body']['ctas'][0]['id']); - - return ['insightId' => $insightId]; - } - - /** - * @depends testCreate - */ - public function testGet(array $data): array - { - $insightId = $data['insightId']; - - $response = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); - - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($insightId, $response['body']['$id']); - - $missing = $this->client->call(Client::METHOD_GET, '/insights/missing', $this->serverHeaders()); - $this->assertSame(404, $missing['headers']['status-code']); - - return $data; - } - - /** - * @depends testGet - */ - public function testList(array $data): array - { - $response = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders()); - - $this->assertSame(200, $response['headers']['status-code']); - $this->assertGreaterThanOrEqual(1, $response['body']['total']); - $this->assertNotEmpty($response['body']['insights']); - - $filtered = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders(), [ - 'queries' => [ - 'equal("resourceType", "databases")', - ], - ]); - $this->assertSame(200, $filtered['headers']['status-code']); - foreach ($filtered['body']['insights'] as $insight) { - $this->assertSame('databases', $insight['resourceType']); - } - - return $data; - } - - /** - * @depends testList - */ - public function testUpdate(array $data): array - { - $insightId = $data['insightId']; - - $response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ - 'severity' => 'critical', - 'summary' => 'Updated summary.', - ]); - - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('critical', $response['body']['severity']); - $this->assertSame('Updated summary.', $response['body']['summary']); - $this->assertSame('Missing index on collection orders', $response['body']['title']); - - return $data; - } - - /** - * @depends testUpdate - */ - public function testDismiss(array $data): array - { - $insightId = $data['insightId']; - - $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/dismiss', $this->serverHeaders()); - - $this->assertSame(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['dismissedAt']); - - return $data; - } - - /** - * @depends testDismiss - */ - public function testTriggerCta(array $data): void - { - $insightId = $data['insightId']; - - $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/createIndex/trigger', $this->serverHeaders()); - - $this->assertSame(501, $response['headers']['status-code']); - $this->assertSame('general_not_implemented', $response['body']['type']); - - $missingCta = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/missing/trigger', $this->serverHeaders()); - $this->assertSame(404, $missingCta['headers']['status-code']); - $this->assertSame('insight_cta_not_found', $missingCta['body']['type']); - } - - public function testCreateRequiresServerKey(): void - { - $response = $this->client->call(Client::METHOD_POST, '/insights', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], [ - 'insightId' => ID::unique(), - 'type' => 'databaseIndex', - 'resourceType' => 'databases', - 'resourceId' => 'main', - 'title' => 'Should not be created', - ]); - - $this->assertSame(401, $response['headers']['status-code']); - } - - public function testDelete(): void - { - $insightId = ID::unique(); - - $create = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ - 'insightId' => $insightId, - 'type' => 'databaseIndex', - 'resourceType' => 'databases', - 'resourceId' => 'main', - 'title' => 'Insight to be deleted', - ]); - $this->assertSame(201, $create['headers']['status-code']); - - $delete = $this->client->call(Client::METHOD_DELETE, '/insights/' . $insightId, $this->serverHeaders()); - $this->assertSame(204, $delete['headers']['status-code']); - - $missing = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); - $this->assertSame(404, $missing['headers']['status-code']); - } } From 3c4aceea4820d5c393e6b88b57b2842f720ce5c6 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 14:04:49 +1200 Subject: [PATCH 13/70] ci(insights): add Insights to e2e test matrix --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc3b3e113..b57cbc209a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -427,6 +427,7 @@ jobs: FunctionsSchedule, GraphQL, Health, + Insights, Locale, Projects, Realtime, From d188bd7a2ed14d76c093025fcc3bc0b2ad3950c2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 14:22:10 +1200 Subject: [PATCH 14/70] refactor(insights): register CTA registry on the global $register Persist the insight CTA registry across requests by attaching it to the boot-time global $register, mirroring the pattern used for `geodb`, `passwordsDictionary`, `hooks`, etc. The container resource now looks the registry up from `$register` instead of rebuilding it on every request. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/registers.php | 6 ++++++ app/init/resources.php | 10 +++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/init/registers.php b/app/init/registers.php index 54c0053a33..8ccd9c10c2 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -3,6 +3,7 @@ use Appwrite\Extend\Exception; use Appwrite\GraphQL\Promises\Adapter\Swoole; use Appwrite\Hooks\Hooks; +use Appwrite\Insights\Cta\Action\DatabasesCreateIndex; use Appwrite\PubSub\Adapter\Redis as PubSub; use Appwrite\URL\URL as AppwriteURL; use MaxMind\Db\Reader; @@ -451,6 +452,11 @@ $register->set('promiseAdapter', function () { $register->set('hooks', function () { return new Hooks(); }); +$register->set('insightCtaRegistry', function () { + $registry = new Registry(); + $registry->set(DatabasesCreateIndex::getName(), fn () => new DatabasesCreateIndex()); + return $registry; +}); $listeners = require __DIR__ . '/../listeners.php'; $register->set('bus', function () use ($listeners) { $bus = new \Utopia\Bus\Bus(); diff --git a/app/init/resources.php b/app/init/resources.php index dfc3a4dd17..1cff9248e5 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -8,7 +8,6 @@ use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; -use Appwrite\Insights\Cta\Action\DatabasesCreateIndex; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; @@ -27,7 +26,6 @@ use Utopia\Pools\Group; use Utopia\Queue\Broker\Pool as BrokerPool; use Utopia\Queue\Publisher; use Utopia\Queue\Queue; -use Utopia\Registry\Registry as UtopiaRegistry; use Utopia\Storage\Device; use Utopia\Storage\Device\AWS; use Utopia\Storage\Device\Backblaze; @@ -130,11 +128,9 @@ $container->set('authorization', function () { return new Authorization(); }, []); -$container->set('insightCtaRegistry', function () { - $registry = new UtopiaRegistry(); - $registry->set(DatabasesCreateIndex::getName(), fn () => new DatabasesCreateIndex()); - return $registry; -}, []); +$container->set('insightCtaRegistry', function ($register) { + return $register->get('insightCtaRegistry'); +}, ['register']); $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { From cd539d972a6ba204a0a58b9ec608dc06765d0fc9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 14:27:45 +1200 Subject: [PATCH 15/70] refactor(insights): capitalise CTA acronym in identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project-specific override of the default camelCase-acronyms convention: namespaces, class names, file paths, and SDK method names use `CTA` in all caps. Touches all insights surfaces — directories, response models, validators, container resource keys, and SDK method names like `createInsightCTAExecution`. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/models.php | 8 +++---- app/init/registers.php | 4 ++-- app/init/resources.php | 4 ++-- src/Appwrite/Insights/{Cta => CTA}/Action.php | 2 +- .../Action/DatabasesCreateIndex.php | 6 ++--- .../DatabasesCreateIndex.php | 2 +- .../Insights/Validator/{Ctas.php => CTAs.php} | 2 +- .../Insights/Http/CTA/Execution/Create.php | 14 ++++++------ .../Modules/Insights/Http/Insights/Create.php | 10 ++++----- .../Modules/Insights/Services/Http.php | 4 ++-- src/Appwrite/Utopia/Response.php | 4 ++-- .../Model/{InsightCta.php => InsightCTA.php} | 4 ++-- ...aExecution.php => InsightCTAExecution.php} | 4 ++-- tests/e2e/Services/Insights/InsightsBase.php | 8 +++---- .../DatabasesCreateIndexTest.php | 4 ++-- .../Validator/{CtasTest.php => CTAsTest.php} | 22 +++++++++---------- 16 files changed, 51 insertions(+), 51 deletions(-) rename src/Appwrite/Insights/{Cta => CTA}/Action.php (95%) rename src/Appwrite/Insights/{Cta => CTA}/Action/DatabasesCreateIndex.php (98%) rename src/Appwrite/Insights/Validator/{CtaParams => CTAParams}/DatabasesCreateIndex.php (95%) rename src/Appwrite/Insights/Validator/{Ctas.php => CTAs.php} (97%) rename src/Appwrite/Utopia/Response/Model/{InsightCta.php => InsightCTA.php} (96%) rename src/Appwrite/Utopia/Response/Model/{InsightCtaExecution.php => InsightCTAExecution.php} (95%) rename tests/unit/Insights/Validator/{CtaParams => CTAParams}/DatabasesCreateIndexTest.php (93%) rename tests/unit/Insights/Validator/{CtasTest.php => CTAsTest.php} (86%) diff --git a/app/init/models.php b/app/init/models.php index e1d7c81ed6..f1342ce27f 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -91,8 +91,8 @@ 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\InsightCtaExecution; +use Appwrite\Utopia\Response\Model\InsightCTA; +use Appwrite\Utopia\Response\Model\InsightCTAExecution; use Appwrite\Utopia\Response\Model\Installation; use Appwrite\Utopia\Response\Model\JWT; use Appwrite\Utopia\Response\Model\Key; @@ -510,8 +510,8 @@ Response::setModel(new Migration()); Response::setModel(new MigrationReport()); Response::setModel(new MigrationFirebaseProject()); Response::setModel(new Insight()); -Response::setModel(new InsightCta()); -Response::setModel(new InsightCtaExecution()); +Response::setModel(new InsightCTA()); +Response::setModel(new InsightCTAExecution()); // Tests (keep last) Response::setModel(new Mock()); diff --git a/app/init/registers.php b/app/init/registers.php index 8ccd9c10c2..05aa27b44a 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -3,7 +3,7 @@ use Appwrite\Extend\Exception; use Appwrite\GraphQL\Promises\Adapter\Swoole; use Appwrite\Hooks\Hooks; -use Appwrite\Insights\Cta\Action\DatabasesCreateIndex; +use Appwrite\Insights\CTA\Action\DatabasesCreateIndex; use Appwrite\PubSub\Adapter\Redis as PubSub; use Appwrite\URL\URL as AppwriteURL; use MaxMind\Db\Reader; @@ -452,7 +452,7 @@ $register->set('promiseAdapter', function () { $register->set('hooks', function () { return new Hooks(); }); -$register->set('insightCtaRegistry', function () { +$register->set('insightCTARegistry', function () { $registry = new Registry(); $registry->set(DatabasesCreateIndex::getName(), fn () => new DatabasesCreateIndex()); return $registry; diff --git a/app/init/resources.php b/app/init/resources.php index 1cff9248e5..73d080c7a6 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -128,8 +128,8 @@ $container->set('authorization', function () { return new Authorization(); }, []); -$container->set('insightCtaRegistry', function ($register) { - return $register->get('insightCtaRegistry'); +$container->set('insightCTARegistry', function ($register) { + return $register->get('insightCTARegistry'); }, ['register']); $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { diff --git a/src/Appwrite/Insights/Cta/Action.php b/src/Appwrite/Insights/CTA/Action.php similarity index 95% rename from src/Appwrite/Insights/Cta/Action.php rename to src/Appwrite/Insights/CTA/Action.php index f9c9fc6509..a2a5e2f0bd 100644 --- a/src/Appwrite/Insights/Cta/Action.php +++ b/src/Appwrite/Insights/CTA/Action.php @@ -1,6 +1,6 @@ label('sdk', new Method( namespace: 'insights', group: 'insights', - name: 'createCtaExecution', + name: 'createCTAExecution', description: <<inject('project') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('insightCtaRegistry') + ->inject('insightCTARegistry') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') @@ -78,7 +78,7 @@ class Create extends Action Document $project, Database $dbForProject, callable $getDatabasesDB, - UtopiaRegistry $insightCtaRegistry, + UtopiaRegistry $insightCTARegistry, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization @@ -113,12 +113,12 @@ class Create extends Action } try { - $action = $insightCtaRegistry->get($actionName); + $action = $insightCTARegistry->get($actionName); } catch (\Throwable) { throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); } - if (!$action instanceof CtaAction) { + if (!$action instanceof CTAAction) { throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php index 16c256dfec..cddcfd6738 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php @@ -4,7 +4,7 @@ namespace Appwrite\Platform\Modules\Insights\Http\Insights; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Insights\Validator\Ctas as CtasValidator; +use Appwrite\Insights\Validator\CTAs as CTAsValidator; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -70,7 +70,7 @@ class Create extends Action ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) - ->param('ctas', [], new CtasValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true) + ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) ->inject('response') ->inject('dbForProject') @@ -96,9 +96,9 @@ class Create extends Action ) { $insightId = ($insightId === 'unique()') ? ID::unique() : $insightId; - $normalizedCtas = []; + $normalizedCTAs = []; foreach ($ctas as $cta) { - $normalizedCtas[] = [ + $normalizedCTAs[] = [ 'id' => (string) $cta['id'], 'label' => (string) $cta['label'], 'action' => (string) $cta['action'], @@ -117,7 +117,7 @@ class Create extends Action 'title' => $title, 'summary' => $summary, 'payload' => $payload, - 'ctas' => $normalizedCtas, + 'ctas' => $normalizedCTAs, 'analyzedAt' => $analyzedAt, 'dismissedAt' => null, 'dismissedBy' => '', diff --git a/src/Appwrite/Platform/Modules/Insights/Services/Http.php b/src/Appwrite/Platform/Modules/Insights/Services/Http.php index 6b1fe5fa67..48f52ca7e9 100644 --- a/src/Appwrite/Platform/Modules/Insights/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Insights/Services/Http.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Insights\Services; -use Appwrite\Platform\Modules\Insights\Http\CTA\Execution\Create as CreateInsightCtaExecution; +use Appwrite\Platform\Modules\Insights\Http\CTA\Execution\Create as CreateInsightCTAExecution; use Appwrite\Platform\Modules\Insights\Http\Insights\Create as CreateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Delete as DeleteInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Dismissal\Create as CreateInsightDismissal; @@ -23,6 +23,6 @@ class Http extends Service $this->addAction(UpdateInsight::getName(), new UpdateInsight()); $this->addAction(DeleteInsight::getName(), new DeleteInsight()); $this->addAction(CreateInsightDismissal::getName(), new CreateInsightDismissal()); - $this->addAction(CreateInsightCtaExecution::getName(), new CreateInsightCtaExecution()); + $this->addAction(CreateInsightCTAExecution::getName(), new CreateInsightCTAExecution()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index e2c04c0178..497272d331 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -333,8 +333,8 @@ class Response extends SwooleResponse // Insights public const MODEL_INSIGHT = 'insight'; public const MODEL_INSIGHT_LIST = 'insightList'; - public const MODEL_INSIGHT_CTA = 'insightCta'; - public const MODEL_INSIGHT_CTA_EXECUTION = 'insightCtaExecution'; + public const MODEL_INSIGHT_CTA = 'insightCTA'; + public const MODEL_INSIGHT_CTA_EXECUTION = 'insightCTAExecution'; // Console public const MODEL_CONSOLE_VARIABLES = 'consoleVariables'; diff --git a/src/Appwrite/Utopia/Response/Model/InsightCta.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php similarity index 96% rename from src/Appwrite/Utopia/Response/Model/InsightCta.php rename to src/Appwrite/Utopia/Response/Model/InsightCTA.php index ac35363043..4c06b49571 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCta.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php @@ -5,7 +5,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; -class InsightCta extends Model +class InsightCTA extends Model { public function __construct() { @@ -38,7 +38,7 @@ class InsightCta extends Model public function getName(): string { - return 'InsightCta'; + return 'InsightCTA'; } public function getType(): string diff --git a/src/Appwrite/Utopia/Response/Model/InsightCtaExecution.php b/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php similarity index 95% rename from src/Appwrite/Utopia/Response/Model/InsightCtaExecution.php rename to src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php index 522105e3ef..a935d75b34 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCtaExecution.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php @@ -5,7 +5,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; -class InsightCtaExecution extends Model +class InsightCTAExecution extends Model { public function __construct() { @@ -44,7 +44,7 @@ class InsightCtaExecution extends Model public function getName(): string { - return 'InsightCtaExecution'; + return 'InsightCTAExecution'; } public function getType(): string diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index c8a2b809cf..84bcd97c2d 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -144,13 +144,13 @@ trait InsightsBase /** * @depends testCreateDismissal */ - public function testCreateCtaExecution(array $data): void + public function testCreateCTAExecution(array $data): void { $insightId = $data['insightId']; - $missingCta = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/missing/executions', $this->serverHeaders()); - $this->assertSame(404, $missingCta['headers']['status-code']); - $this->assertSame('insight_cta_not_found', $missingCta['body']['type']); + $missingCTA = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/missing/executions', $this->serverHeaders()); + $this->assertSame(404, $missingCTA['headers']['status-code']); + $this->assertSame('insight_cta_not_found', $missingCTA['body']['type']); $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/createIndex/executions', $this->serverHeaders()); diff --git a/tests/unit/Insights/Validator/CtaParams/DatabasesCreateIndexTest.php b/tests/unit/Insights/Validator/CTAParams/DatabasesCreateIndexTest.php similarity index 93% rename from tests/unit/Insights/Validator/CtaParams/DatabasesCreateIndexTest.php rename to tests/unit/Insights/Validator/CTAParams/DatabasesCreateIndexTest.php index 79e151a518..7907374418 100644 --- a/tests/unit/Insights/Validator/CtaParams/DatabasesCreateIndexTest.php +++ b/tests/unit/Insights/Validator/CTAParams/DatabasesCreateIndexTest.php @@ -1,8 +1,8 @@ assertFalse($validator->isValid('not-an-array')); $this->assertFalse($validator->isValid(42)); @@ -18,14 +18,14 @@ class CtasTest extends TestCase public function testAcceptsEmptyArray(): void { - $validator = new Ctas(); + $validator = new CTAs(); $this->assertTrue($validator->isValid([])); } public function testAcceptsCompleteEntry(): void { - $validator = new Ctas(); + $validator = new CTAs(); $this->assertTrue($validator->isValid([[ 'id' => 'createIndex', @@ -40,7 +40,7 @@ class CtasTest extends TestCase public function testAcceptsEntryWithoutParams(): void { - $validator = new Ctas(); + $validator = new CTAs(); $this->assertTrue($validator->isValid([[ 'id' => 'createIndex', @@ -51,7 +51,7 @@ class CtasTest extends TestCase public function testRejectsEntryMissingRequiredKeys(): void { - $validator = new Ctas(); + $validator = new CTAs(); $this->assertFalse($validator->isValid([['id' => 'x']])); $this->assertFalse($validator->isValid([['id' => 'x', 'label' => 'y']])); @@ -59,7 +59,7 @@ class CtasTest extends TestCase public function testRejectsEntryWithEmptyStrings(): void { - $validator = new Ctas(); + $validator = new CTAs(); $this->assertFalse($validator->isValid([[ 'id' => '', @@ -70,7 +70,7 @@ class CtasTest extends TestCase public function testRejectsEntryWithNonStringFields(): void { - $validator = new Ctas(); + $validator = new CTAs(); $this->assertFalse($validator->isValid([[ 'id' => 123, @@ -81,7 +81,7 @@ class CtasTest extends TestCase public function testRejectsEntryWithScalarParams(): void { - $validator = new Ctas(); + $validator = new CTAs(); $this->assertFalse($validator->isValid([[ 'id' => 'createIndex', @@ -93,7 +93,7 @@ class CtasTest extends TestCase public function testReportsArrayType(): void { - $validator = new Ctas(); + $validator = new CTAs(); $this->assertTrue($validator->isArray()); $this->assertSame($validator::TYPE_ARRAY, $validator->getType()); From f779c7aa3bd1cc9c2b8e91fa49072d3eee17026b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 14:36:31 +1200 Subject: [PATCH 16/70] refactor(insights): collapse dismissal into a status field on update Dismissal was a sub-resource (POST /v1/insights/:id/dismissals) but a dismissal is just a state transition, not a thing the client creates. Drop the dedicated endpoint and add a `status` enum (`active` | `dismissed`) to the insights schema, settable via the existing PATCH update route. The server still derives `dismissedAt` and `dismissedBy` on transition for audit/sorting, but the client-facing API is just a single status toggle. - Schema: add `status` attribute (default `active`) - Constants: add `INSIGHT_STATUSES` - Update endpoint: accept `status` param, derive dismissedAt/By on active <-> dismissed transitions - Response model: add `status` rule - Drop Insights/Dismissal/Create.php, the createInsightDismissal SDK method, the `insights.[id].dismissals.create` event, and the `insight.dismissal.create` audit - E2E: replace testCreateDismissal with testDismissViaUpdate covering both directions of the toggle Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/projects.php | 11 +++ app/config/events.php | 8 -- app/init/constants.php | 9 ++ .../Modules/Insights/Http/Insights/Create.php | 1 + .../Http/Insights/Dismissal/Create.php | 87 ------------------- .../Modules/Insights/Http/Insights/Update.php | 15 ++++ .../Modules/Insights/Services/Http.php | 2 - .../Utopia/Response/Model/Insight.php | 6 ++ tests/e2e/Services/Insights/InsightsBase.php | 17 +++- 9 files changed, 56 insertions(+), 100 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismissal/Create.php diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index ddb6717f7e..be44627167 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2782,6 +2782,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + // Possible values: active, dismissed + '$id' => ID::custom('status'), + 'type' => Database::VAR_STRING, + 'size' => 16, + 'signed' => true, + 'required' => true, + 'default' => 'active', + 'array' => false, + 'filters' => [], + ], [ // Possible values: databases, collections, sites, functions '$id' => ID::custom('resourceType'), diff --git a/app/config/events.php b/app/config/events.php index 576962fbe0..3b4d636471 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -440,14 +440,6 @@ return [ 'delete' => [ '$description' => 'This event triggers when an insight is deleted.', ], - 'dismissals' => [ - '$model' => Response::MODEL_INSIGHT, - '$resource' => true, - '$description' => 'This event triggers on any insight dismissal event.', - 'create' => [ - '$description' => 'This event triggers when an insight is dismissed.', - ], - ], 'ctas' => [ '$model' => Response::MODEL_INSIGHT_CTA, '$resource' => true, diff --git a/app/init/constants.php b/app/init/constants.php index 44b51bd6d9..5a4da73988 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -453,6 +453,15 @@ const INSIGHT_SEVERITIES = [ INSIGHT_SEVERITY_CRITICAL, ]; +// Insight statuses +const INSIGHT_STATUS_ACTIVE = 'active'; +const INSIGHT_STATUS_DISMISSED = 'dismissed'; + +const INSIGHT_STATUSES = [ + INSIGHT_STATUS_ACTIVE, + INSIGHT_STATUS_DISMISSED, +]; + // Insight CTA actions const INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX = 'databases.createIndex'; diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php index cddcfd6738..9b6c17ad75 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php @@ -111,6 +111,7 @@ class Create extends Action '$id' => $insightId, 'type' => $type, 'severity' => $severity, + 'status' => INSIGHT_STATUS_ACTIVE, 'resourceType' => $resourceType, 'resourceId' => $resourceId, 'resourceInternalId' => $resourceInternalId, diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismissal/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismissal/Create.php deleted file mode 100644 index 6430e35746..0000000000 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismissal/Create.php +++ /dev/null @@ -1,87 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/insights/:insightId/dismissals') - ->desc('Create insight dismissal') - ->groups(['api', 'insights']) - ->label('scope', 'insights.write') - ->label('event', 'insights.[insightId].dismissals.create') - ->label('resourceType', RESOURCE_TYPE_INSIGHTS) - ->label('audits.event', 'insight.dismissal.create') - ->label('audits.resource', 'insight/{response.$id}') - ->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: 'insights', - group: 'insights', - name: 'createDismissal', - description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) - ->inject('response') - ->inject('user') - ->inject('dbForProject') - ->inject('queueForEvents') - ->callback($this->action(...)); - } - - public function action( - string $insightId, - Response $response, - Document $user, - Database $dbForProject, - Event $queueForEvents - ) { - $insight = $dbForProject->getDocument('insights', $insightId); - - if ($insight->isEmpty()) { - throw new Exception(Exception::INSIGHT_NOT_FOUND); - } - - $insight = $dbForProject->updateDocument('insights', $insight->getId(), new Document([ - 'dismissedAt' => DateTime::now(), - 'dismissedBy' => $user->getId(), - ])); - - $queueForEvents->setParam('insightId', $insight->getId()); - - $response->dynamic($insight, Response::MODEL_INSIGHT); - } -} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php index 47480eb980..300fa19cf1 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\UID; @@ -61,12 +62,14 @@ class Update extends Action )) ->param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) ->param('severity', null, new Nullable(new WhiteList(INSIGHT_SEVERITIES, true)), 'Insight severity. One of `info`, `warning`, `critical`.', true) + ->param('status', null, new Nullable(new WhiteList(INSIGHT_STATUSES, true)), 'Insight status. Set to `dismissed` to dismiss the insight, `active` to undo a dismissal.', true) ->param('title', null, new Nullable(new Text(256)), 'Short, human-readable title.', true) ->param('summary', null, new Nullable(new Text(4096, 0)), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) ->param('ctas', null, new Nullable(new ArrayList(new JSON(), 16)), 'Array of call-to-action descriptors.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format.', true) ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') ->callback($this->action(...)); @@ -75,12 +78,14 @@ class Update extends Action public function action( string $insightId, ?string $severity, + ?string $status, ?string $title, ?string $summary, ?array $payload, ?array $ctas, ?string $analyzedAt, Response $response, + Document $user, Database $dbForProject, Event $queueForEvents ) { @@ -95,6 +100,16 @@ class Update extends Action if ($severity !== null) { $changes['severity'] = $severity; } + if ($status !== null && $status !== $insight->getAttribute('status')) { + $changes['status'] = $status; + if ($status === INSIGHT_STATUS_DISMISSED) { + $changes['dismissedAt'] = DateTime::now(); + $changes['dismissedBy'] = $user->getId(); + } else { + $changes['dismissedAt'] = null; + $changes['dismissedBy'] = ''; + } + } if ($title !== null) { $changes['title'] = $title; } diff --git a/src/Appwrite/Platform/Modules/Insights/Services/Http.php b/src/Appwrite/Platform/Modules/Insights/Services/Http.php index 48f52ca7e9..433df62865 100644 --- a/src/Appwrite/Platform/Modules/Insights/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Insights/Services/Http.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Insights\Services; use Appwrite\Platform\Modules\Insights\Http\CTA\Execution\Create as CreateInsightCTAExecution; use Appwrite\Platform\Modules\Insights\Http\Insights\Create as CreateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Delete as DeleteInsight; -use Appwrite\Platform\Modules\Insights\Http\Insights\Dismissal\Create as CreateInsightDismissal; use Appwrite\Platform\Modules\Insights\Http\Insights\Get as GetInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Update as UpdateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\XList as ListInsights; @@ -22,7 +21,6 @@ class Http extends Service $this->addAction(ListInsights::getName(), new ListInsights()); $this->addAction(UpdateInsight::getName(), new UpdateInsight()); $this->addAction(DeleteInsight::getName(), new DeleteInsight()); - $this->addAction(CreateInsightDismissal::getName(), new CreateInsightDismissal()); $this->addAction(CreateInsightCTAExecution::getName(), new CreateInsightCTAExecution()); } } diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php index 1c567f8c72..c1e437696c 100644 --- a/src/Appwrite/Utopia/Response/Model/Insight.php +++ b/src/Appwrite/Utopia/Response/Model/Insight.php @@ -47,6 +47,12 @@ class Insight extends Model '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.', diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 84bcd97c2d..178eaddc4a 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -129,20 +129,31 @@ trait InsightsBase /** * @depends testUpdate */ - public function testCreateDismissal(array $data): array + public function testDismissViaUpdate(array $data): array { $insightId = $data['insightId']; - $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/dismissals', $this->serverHeaders()); + $response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ + 'status' => 'dismissed', + ]); $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('dismissed', $response['body']['status']); $this->assertNotEmpty($response['body']['dismissedAt']); + $undismiss = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ + 'status' => 'active', + ]); + + $this->assertSame(200, $undismiss['headers']['status-code']); + $this->assertSame('active', $undismiss['body']['status']); + $this->assertEmpty($undismiss['body']['dismissedAt']); + return $data; } /** - * @depends testCreateDismissal + * @depends testDismissViaUpdate */ public function testCreateCTAExecution(array $data): void { From 242c7f75ada8cdaa840d66f8089796845e928107 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 14:57:28 +1200 Subject: [PATCH 17/70] refactor(insights): nest databases create-index CTA under resource path Move the action class from Action/DatabasesCreateIndex.php to Action/Databases/Indexes/Create.php so the directory mirrors the underlying resource hierarchy. Action name follows: databases.createIndex becomes databases.indexes.create, with the constant renamed to INSIGHT_CTA_ACTION_DATABASES_INDEXES_CREATE. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/constants.php | 2 +- app/init/registers.php | 3 +-- src/Appwrite/Insights/CTA/Action.php | 2 +- .../Indexes/Create.php} | 6 +++--- src/Appwrite/Utopia/Response/Model/InsightCTA.php | 2 +- .../Utopia/Response/Model/InsightCTAExecution.php | 2 +- tests/e2e/Services/Insights/InsightsBase.php | 4 ++-- tests/unit/Insights/Validator/CTAsTest.php | 10 +++++----- 8 files changed, 15 insertions(+), 16 deletions(-) rename src/Appwrite/Insights/CTA/Action/{DatabasesCreateIndex.php => Databases/Indexes/Create.php} (98%) diff --git a/app/init/constants.php b/app/init/constants.php index 5a4da73988..36ca8ebcc2 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -463,7 +463,7 @@ const INSIGHT_STATUSES = [ ]; // Insight CTA actions -const INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX = 'databases.createIndex'; +const INSIGHT_CTA_ACTION_DATABASES_INDEXES_CREATE = 'databases.indexes.create'; // Resource types for Tokens const TOKENS_RESOURCE_TYPE_FILES = 'files'; diff --git a/app/init/registers.php b/app/init/registers.php index 05aa27b44a..91870a6ce4 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -3,7 +3,6 @@ use Appwrite\Extend\Exception; use Appwrite\GraphQL\Promises\Adapter\Swoole; use Appwrite\Hooks\Hooks; -use Appwrite\Insights\CTA\Action\DatabasesCreateIndex; use Appwrite\PubSub\Adapter\Redis as PubSub; use Appwrite\URL\URL as AppwriteURL; use MaxMind\Db\Reader; @@ -454,7 +453,7 @@ $register->set('hooks', function () { }); $register->set('insightCTARegistry', function () { $registry = new Registry(); - $registry->set(DatabasesCreateIndex::getName(), fn () => new DatabasesCreateIndex()); + $registry->set(\Appwrite\Insights\CTA\Action\Databases\Indexes\Create::getName(), fn () => new \Appwrite\Insights\CTA\Action\Databases\Indexes\Create()); return $registry; }); $listeners = require __DIR__ . '/../listeners.php'; diff --git a/src/Appwrite/Insights/CTA/Action.php b/src/Appwrite/Insights/CTA/Action.php index a2a5e2f0bd..61b3983d72 100644 --- a/src/Appwrite/Insights/CTA/Action.php +++ b/src/Appwrite/Insights/CTA/Action.php @@ -11,7 +11,7 @@ use Utopia\Platform\Action as PlatformAction; * a call-to-action attached to an insight. Subclasses declare their inputs via `param()` * and dependencies via `inject()`, and provide their executable body via `callback()`. * - * Convention for `getName()`: `domain.verb` in camelCase, e.g. `databases.createIndex`. + * Convention for `getName()`: dot-separated `domain..verb` in camelCase, e.g. `databases.indexes.create`. * The required project scope is declared via `label('scope', '...')`. */ abstract class Action extends PlatformAction diff --git a/src/Appwrite/Insights/CTA/Action/DatabasesCreateIndex.php b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php similarity index 98% rename from src/Appwrite/Insights/CTA/Action/DatabasesCreateIndex.php rename to src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php index c611186a34..a3f2487516 100644 --- a/src/Appwrite/Insights/CTA/Action/DatabasesCreateIndex.php +++ b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php @@ -1,6 +1,6 @@ self::TYPE_STRING, 'description' => 'Registered server-side action name to execute when this CTA is triggered.', 'default' => '', - 'example' => 'databases.createIndex', + 'example' => 'databases.indexes.create', ]) ->addRule('params', [ 'type' => self::TYPE_JSON, diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php b/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php index a935d75b34..b52056c28c 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php @@ -26,7 +26,7 @@ class InsightCTAExecution extends Model 'type' => self::TYPE_STRING, 'description' => 'Registered server-side action that was executed.', 'default' => '', - 'example' => 'databases.createIndex', + 'example' => 'databases.indexes.create', ]) ->addRule('status', [ 'type' => self::TYPE_STRING, diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 178eaddc4a..44e37a2768 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -40,7 +40,7 @@ trait InsightsBase 'ctas' => [[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'action' => 'databases.indexes.create', 'params' => [ 'databaseId' => 'main', 'collectionId' => 'orders', @@ -168,7 +168,7 @@ trait InsightsBase $this->assertSame(200, $response['headers']['status-code']); $this->assertSame($insightId, $response['body']['insightId']); $this->assertSame('createIndex', $response['body']['ctaId']); - $this->assertSame('databases.createIndex', $response['body']['action']); + $this->assertSame('databases.indexes.create', $response['body']['action']); $this->assertContains($response['body']['status'], ['succeeded', 'failed']); } diff --git a/tests/unit/Insights/Validator/CTAsTest.php b/tests/unit/Insights/Validator/CTAsTest.php index 8a1fd33991..7d520d7a9a 100644 --- a/tests/unit/Insights/Validator/CTAsTest.php +++ b/tests/unit/Insights/Validator/CTAsTest.php @@ -30,7 +30,7 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'action' => 'databases.indexes.create', 'params' => [ 'databaseId' => 'main', 'collectionId' => 'orders', @@ -45,7 +45,7 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'action' => 'databases.indexes.create', ]])); } @@ -64,7 +64,7 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => '', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'action' => 'databases.indexes.create', ]])); } @@ -75,7 +75,7 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => 123, 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'action' => 'databases.indexes.create', ]])); } @@ -86,7 +86,7 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'action' => 'databases.indexes.create', 'params' => 'not-a-map', ]])); } From 69c637c72dece3c8c1fff52dc85c6a0ca9dacbcf Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 15:01:31 +1200 Subject: [PATCH 18/70] style(insights): use aliased import for DatabasesIndexesCreate registry entry Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/registers.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/init/registers.php b/app/init/registers.php index 91870a6ce4..1280049e2d 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -3,6 +3,7 @@ use Appwrite\Extend\Exception; use Appwrite\GraphQL\Promises\Adapter\Swoole; use Appwrite\Hooks\Hooks; +use Appwrite\Insights\CTA\Action\Databases\Indexes\Create as DatabasesIndexesCreate; use Appwrite\PubSub\Adapter\Redis as PubSub; use Appwrite\URL\URL as AppwriteURL; use MaxMind\Db\Reader; @@ -453,7 +454,7 @@ $register->set('hooks', function () { }); $register->set('insightCTARegistry', function () { $registry = new Registry(); - $registry->set(\Appwrite\Insights\CTA\Action\Databases\Indexes\Create::getName(), fn () => new \Appwrite\Insights\CTA\Action\Databases\Indexes\Create()); + $registry->set(DatabasesIndexesCreate::getName(), fn () => new DatabasesIndexesCreate()); return $registry; }); $listeners = require __DIR__ . '/../listeners.php'; From 4d560bdff2f901b25b34b37897490208ceef2c6b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 15:12:41 +1200 Subject: [PATCH 19/70] feat(insights): validate insight and project documents in CTA actions Replace the no-op `fn () => true` validators (gated by skipValidation) on the `insight` and `project` params with dedicated InsightDocument and ProjectDocument validators. They check that the injected value is a non-empty Document with the expected attributes, so a misconfigured dispatcher or unbound injection fails fast with a useful message. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CTA/Action/Databases/Indexes/Create.php | 6 +- .../Insights/Validator/InsightDocument.php | 48 +++++++++++++ .../Insights/Validator/ProjectDocument.php | 39 +++++++++++ .../Validator/InsightDocumentTest.php | 69 +++++++++++++++++++ .../Validator/ProjectDocumentTest.php | 53 ++++++++++++++ 5 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Insights/Validator/InsightDocument.php create mode 100644 src/Appwrite/Insights/Validator/ProjectDocument.php create mode 100644 tests/unit/Insights/Validator/InsightDocumentTest.php create mode 100644 tests/unit/Insights/Validator/ProjectDocumentTest.php diff --git a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php index a3f2487516..a72c21e284 100644 --- a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php +++ b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php @@ -7,6 +7,8 @@ use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Insights\CTA\Action; use Appwrite\Insights\Validator\CTAParams\DatabasesCreateIndex as DatabasesCreateIndexParams; +use Appwrite\Insights\Validator\InsightDocument as InsightDocumentValidator; +use Appwrite\Insights\Validator\ProjectDocument as ProjectDocumentValidator; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; @@ -28,8 +30,8 @@ class Create extends Action ->desc('Create a database index from an insight CTA.') ->label('scope', 'collections.write') ->param('params', [], new DatabasesCreateIndexParams(), 'CTA params describing the index to create.') - ->param('insight', null, fn () => true, 'Parent insight document.', skipValidation: true) - ->param('project', null, fn () => true, 'Project document.', skipValidation: true) + ->param('insight', null, new InsightDocumentValidator(), 'Parent insight document.') + ->param('project', null, new ProjectDocumentValidator(), 'Project document.') ->inject('dbForProject') ->inject('getDatabasesDB') ->inject('queueForDatabase') diff --git a/src/Appwrite/Insights/Validator/InsightDocument.php b/src/Appwrite/Insights/Validator/InsightDocument.php new file mode 100644 index 0000000000..6ad76d3fb4 --- /dev/null +++ b/src/Appwrite/Insights/Validator/InsightDocument.php @@ -0,0 +1,48 @@ +message; + } + + public function isArray(): bool + { + return false; + } + + public function getType(): string + { + return self::TYPE_OBJECT; + } + + public function isValid($value): bool + { + if (!$value instanceof Document) { + return false; + } + + if ($value->isEmpty()) { + return false; + } + + $type = $value->getAttribute('type'); + if (!\is_string($type) || $type === '') { + return false; + } + + if (!\is_array($value->getAttribute('ctas', []))) { + return false; + } + + return true; + } +} diff --git a/src/Appwrite/Insights/Validator/ProjectDocument.php b/src/Appwrite/Insights/Validator/ProjectDocument.php new file mode 100644 index 0000000000..89b819eacd --- /dev/null +++ b/src/Appwrite/Insights/Validator/ProjectDocument.php @@ -0,0 +1,39 @@ +message; + } + + public function isArray(): bool + { + return false; + } + + public function getType(): string + { + return self::TYPE_OBJECT; + } + + public function isValid($value): bool + { + if (!$value instanceof Document) { + return false; + } + + if ($value->isEmpty()) { + return false; + } + + return $value->getId() !== ''; + } +} diff --git a/tests/unit/Insights/Validator/InsightDocumentTest.php b/tests/unit/Insights/Validator/InsightDocumentTest.php new file mode 100644 index 0000000000..77ab865d48 --- /dev/null +++ b/tests/unit/Insights/Validator/InsightDocumentTest.php @@ -0,0 +1,69 @@ + 'insight-1', + 'type' => 'databaseIndex', + 'ctas' => [], + ]); + + $this->assertTrue($validator->isValid($insight)); + } + + public function testRejectsNonDocument(): void + { + $validator = new InsightDocument(); + + $this->assertFalse($validator->isValid('not a document')); + $this->assertFalse($validator->isValid(null)); + $this->assertFalse($validator->isValid(['type' => 'databaseIndex'])); + } + + public function testRejectsEmptyDocument(): void + { + $validator = new InsightDocument(); + + $this->assertFalse($validator->isValid(new Document())); + } + + public function testRejectsMissingType(): void + { + $validator = new InsightDocument(); + $insight = new Document([ + '$id' => 'insight-1', + 'ctas' => [], + ]); + + $this->assertFalse($validator->isValid($insight)); + } + + public function testRejectsNonArrayCtas(): void + { + $validator = new InsightDocument(); + $insight = new Document([ + '$id' => 'insight-1', + 'type' => 'databaseIndex', + 'ctas' => 'not-an-array', + ]); + + $this->assertFalse($validator->isValid($insight)); + } + + public function testReportsObjectType(): void + { + $validator = new InsightDocument(); + + $this->assertSame('object', $validator->getType()); + $this->assertFalse($validator->isArray()); + } +} diff --git a/tests/unit/Insights/Validator/ProjectDocumentTest.php b/tests/unit/Insights/Validator/ProjectDocumentTest.php new file mode 100644 index 0000000000..c053a02f4e --- /dev/null +++ b/tests/unit/Insights/Validator/ProjectDocumentTest.php @@ -0,0 +1,53 @@ + 'project-1', + 'name' => 'Test', + ]); + + $this->assertTrue($validator->isValid($project)); + } + + public function testRejectsNonDocument(): void + { + $validator = new ProjectDocument(); + + $this->assertFalse($validator->isValid('not a document')); + $this->assertFalse($validator->isValid(null)); + $this->assertFalse($validator->isValid(['$id' => 'project-1'])); + } + + public function testRejectsEmptyDocument(): void + { + $validator = new ProjectDocument(); + + $this->assertFalse($validator->isValid(new Document())); + } + + public function testRejectsMissingId(): void + { + $validator = new ProjectDocument(); + $project = new Document(['name' => 'Test']); + + $this->assertFalse($validator->isValid($project)); + } + + public function testReportsObjectType(): void + { + $validator = new ProjectDocument(); + + $this->assertSame('object', $validator->getType()); + $this->assertFalse($validator->isArray()); + } +} From 0fbf31bd9a989b6c0b44b26dcf884021c838d473 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 15:18:27 +1200 Subject: [PATCH 20/70] refactor(insights): drop Document suffix from Insight/Project validators Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CTA/Action/Databases/Indexes/Create.php | 8 ++++---- .../{InsightDocument.php => Insight.php} | 2 +- .../{ProjectDocument.php => Project.php} | 2 +- .../{InsightDocumentTest.php => InsightTest.php} | 16 ++++++++-------- .../{ProjectDocumentTest.php => ProjectTest.php} | 14 +++++++------- 5 files changed, 21 insertions(+), 21 deletions(-) rename src/Appwrite/Insights/Validator/{InsightDocument.php => Insight.php} (95%) rename src/Appwrite/Insights/Validator/{ProjectDocument.php => Project.php} (94%) rename tests/unit/Insights/Validator/{InsightDocumentTest.php => InsightTest.php} (80%) rename tests/unit/Insights/Validator/{ProjectDocumentTest.php => ProjectTest.php} (77%) diff --git a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php index a72c21e284..97c0666346 100644 --- a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php +++ b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php @@ -7,8 +7,8 @@ use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Insights\CTA\Action; use Appwrite\Insights\Validator\CTAParams\DatabasesCreateIndex as DatabasesCreateIndexParams; -use Appwrite\Insights\Validator\InsightDocument as InsightDocumentValidator; -use Appwrite\Insights\Validator\ProjectDocument as ProjectDocumentValidator; +use Appwrite\Insights\Validator\Insight as InsightValidator; +use Appwrite\Insights\Validator\Project as ProjectValidator; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; @@ -30,8 +30,8 @@ class Create extends Action ->desc('Create a database index from an insight CTA.') ->label('scope', 'collections.write') ->param('params', [], new DatabasesCreateIndexParams(), 'CTA params describing the index to create.') - ->param('insight', null, new InsightDocumentValidator(), 'Parent insight document.') - ->param('project', null, new ProjectDocumentValidator(), 'Project document.') + ->param('insight', null, new InsightValidator(), 'Parent insight document.') + ->param('project', null, new ProjectValidator(), 'Project document.') ->inject('dbForProject') ->inject('getDatabasesDB') ->inject('queueForDatabase') diff --git a/src/Appwrite/Insights/Validator/InsightDocument.php b/src/Appwrite/Insights/Validator/Insight.php similarity index 95% rename from src/Appwrite/Insights/Validator/InsightDocument.php rename to src/Appwrite/Insights/Validator/Insight.php index 6ad76d3fb4..1e43ca6a56 100644 --- a/src/Appwrite/Insights/Validator/InsightDocument.php +++ b/src/Appwrite/Insights/Validator/Insight.php @@ -5,7 +5,7 @@ namespace Appwrite\Insights\Validator; use Utopia\Database\Document; use Utopia\Validator; -class InsightDocument extends Validator +class Insight extends Validator { protected string $message = 'Value must be a non-empty insight Document with `type` and `ctas` attributes.'; diff --git a/src/Appwrite/Insights/Validator/ProjectDocument.php b/src/Appwrite/Insights/Validator/Project.php similarity index 94% rename from src/Appwrite/Insights/Validator/ProjectDocument.php rename to src/Appwrite/Insights/Validator/Project.php index 89b819eacd..5a34bb82b3 100644 --- a/src/Appwrite/Insights/Validator/ProjectDocument.php +++ b/src/Appwrite/Insights/Validator/Project.php @@ -5,7 +5,7 @@ namespace Appwrite\Insights\Validator; use Utopia\Database\Document; use Utopia\Validator; -class ProjectDocument extends Validator +class Project extends Validator { protected string $message = 'Value must be a non-empty project Document with an `$id`.'; diff --git a/tests/unit/Insights/Validator/InsightDocumentTest.php b/tests/unit/Insights/Validator/InsightTest.php similarity index 80% rename from tests/unit/Insights/Validator/InsightDocumentTest.php rename to tests/unit/Insights/Validator/InsightTest.php index 77ab865d48..fd1a5fb66e 100644 --- a/tests/unit/Insights/Validator/InsightDocumentTest.php +++ b/tests/unit/Insights/Validator/InsightTest.php @@ -2,15 +2,15 @@ namespace Tests\Unit\Insights\Validator; -use Appwrite\Insights\Validator\InsightDocument; +use Appwrite\Insights\Validator\Insight; use PHPUnit\Framework\TestCase; use Utopia\Database\Document; -class InsightDocumentTest extends TestCase +class InsightTest extends TestCase { public function testAcceptsValidInsight(): void { - $validator = new InsightDocument(); + $validator = new Insight(); $insight = new Document([ '$id' => 'insight-1', 'type' => 'databaseIndex', @@ -22,7 +22,7 @@ class InsightDocumentTest extends TestCase public function testRejectsNonDocument(): void { - $validator = new InsightDocument(); + $validator = new Insight(); $this->assertFalse($validator->isValid('not a document')); $this->assertFalse($validator->isValid(null)); @@ -31,14 +31,14 @@ class InsightDocumentTest extends TestCase public function testRejectsEmptyDocument(): void { - $validator = new InsightDocument(); + $validator = new Insight(); $this->assertFalse($validator->isValid(new Document())); } public function testRejectsMissingType(): void { - $validator = new InsightDocument(); + $validator = new Insight(); $insight = new Document([ '$id' => 'insight-1', 'ctas' => [], @@ -49,7 +49,7 @@ class InsightDocumentTest extends TestCase public function testRejectsNonArrayCtas(): void { - $validator = new InsightDocument(); + $validator = new Insight(); $insight = new Document([ '$id' => 'insight-1', 'type' => 'databaseIndex', @@ -61,7 +61,7 @@ class InsightDocumentTest extends TestCase public function testReportsObjectType(): void { - $validator = new InsightDocument(); + $validator = new Insight(); $this->assertSame('object', $validator->getType()); $this->assertFalse($validator->isArray()); diff --git a/tests/unit/Insights/Validator/ProjectDocumentTest.php b/tests/unit/Insights/Validator/ProjectTest.php similarity index 77% rename from tests/unit/Insights/Validator/ProjectDocumentTest.php rename to tests/unit/Insights/Validator/ProjectTest.php index c053a02f4e..897717e322 100644 --- a/tests/unit/Insights/Validator/ProjectDocumentTest.php +++ b/tests/unit/Insights/Validator/ProjectTest.php @@ -2,15 +2,15 @@ namespace Tests\Unit\Insights\Validator; -use Appwrite\Insights\Validator\ProjectDocument; +use Appwrite\Insights\Validator\Project; use PHPUnit\Framework\TestCase; use Utopia\Database\Document; -class ProjectDocumentTest extends TestCase +class ProjectTest extends TestCase { public function testAcceptsValidProject(): void { - $validator = new ProjectDocument(); + $validator = new Project(); $project = new Document([ '$id' => 'project-1', 'name' => 'Test', @@ -21,7 +21,7 @@ class ProjectDocumentTest extends TestCase public function testRejectsNonDocument(): void { - $validator = new ProjectDocument(); + $validator = new Project(); $this->assertFalse($validator->isValid('not a document')); $this->assertFalse($validator->isValid(null)); @@ -30,14 +30,14 @@ class ProjectDocumentTest extends TestCase public function testRejectsEmptyDocument(): void { - $validator = new ProjectDocument(); + $validator = new Project(); $this->assertFalse($validator->isValid(new Document())); } public function testRejectsMissingId(): void { - $validator = new ProjectDocument(); + $validator = new Project(); $project = new Document(['name' => 'Test']); $this->assertFalse($validator->isValid($project)); @@ -45,7 +45,7 @@ class ProjectDocumentTest extends TestCase public function testReportsObjectType(): void { - $validator = new ProjectDocument(); + $validator = new Project(); $this->assertSame('object', $validator->getType()); $this->assertFalse($validator->isArray()); From 1d215b2840bb3bc5a39c84f76c5a1590199f2ef1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 15:22:28 +1200 Subject: [PATCH 21/70] revert(insights): drop insight/project param declarations on CTA actions The CTA execution dispatcher invokes the action callback positionally with values that have already been validated (insight fetched and asserted non-empty in the endpoint, project pulled from the request context). Re-validating them through param declarations adds noise without catching anything. Drop the declarations and the validator classes/tests created for them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CTA/Action/Databases/Indexes/Create.php | 4 -- src/Appwrite/Insights/Validator/Insight.php | 48 ------------- src/Appwrite/Insights/Validator/Project.php | 39 ----------- tests/unit/Insights/Validator/InsightTest.php | 69 ------------------- tests/unit/Insights/Validator/ProjectTest.php | 53 -------------- 5 files changed, 213 deletions(-) delete mode 100644 src/Appwrite/Insights/Validator/Insight.php delete mode 100644 src/Appwrite/Insights/Validator/Project.php delete mode 100644 tests/unit/Insights/Validator/InsightTest.php delete mode 100644 tests/unit/Insights/Validator/ProjectTest.php diff --git a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php index 97c0666346..ea1f45010f 100644 --- a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php +++ b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php @@ -7,8 +7,6 @@ use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Insights\CTA\Action; use Appwrite\Insights\Validator\CTAParams\DatabasesCreateIndex as DatabasesCreateIndexParams; -use Appwrite\Insights\Validator\Insight as InsightValidator; -use Appwrite\Insights\Validator\Project as ProjectValidator; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; @@ -30,8 +28,6 @@ class Create extends Action ->desc('Create a database index from an insight CTA.') ->label('scope', 'collections.write') ->param('params', [], new DatabasesCreateIndexParams(), 'CTA params describing the index to create.') - ->param('insight', null, new InsightValidator(), 'Parent insight document.') - ->param('project', null, new ProjectValidator(), 'Project document.') ->inject('dbForProject') ->inject('getDatabasesDB') ->inject('queueForDatabase') diff --git a/src/Appwrite/Insights/Validator/Insight.php b/src/Appwrite/Insights/Validator/Insight.php deleted file mode 100644 index 1e43ca6a56..0000000000 --- a/src/Appwrite/Insights/Validator/Insight.php +++ /dev/null @@ -1,48 +0,0 @@ -message; - } - - public function isArray(): bool - { - return false; - } - - public function getType(): string - { - return self::TYPE_OBJECT; - } - - public function isValid($value): bool - { - if (!$value instanceof Document) { - return false; - } - - if ($value->isEmpty()) { - return false; - } - - $type = $value->getAttribute('type'); - if (!\is_string($type) || $type === '') { - return false; - } - - if (!\is_array($value->getAttribute('ctas', []))) { - return false; - } - - return true; - } -} diff --git a/src/Appwrite/Insights/Validator/Project.php b/src/Appwrite/Insights/Validator/Project.php deleted file mode 100644 index 5a34bb82b3..0000000000 --- a/src/Appwrite/Insights/Validator/Project.php +++ /dev/null @@ -1,39 +0,0 @@ -message; - } - - public function isArray(): bool - { - return false; - } - - public function getType(): string - { - return self::TYPE_OBJECT; - } - - public function isValid($value): bool - { - if (!$value instanceof Document) { - return false; - } - - if ($value->isEmpty()) { - return false; - } - - return $value->getId() !== ''; - } -} diff --git a/tests/unit/Insights/Validator/InsightTest.php b/tests/unit/Insights/Validator/InsightTest.php deleted file mode 100644 index fd1a5fb66e..0000000000 --- a/tests/unit/Insights/Validator/InsightTest.php +++ /dev/null @@ -1,69 +0,0 @@ - 'insight-1', - 'type' => 'databaseIndex', - 'ctas' => [], - ]); - - $this->assertTrue($validator->isValid($insight)); - } - - public function testRejectsNonDocument(): void - { - $validator = new Insight(); - - $this->assertFalse($validator->isValid('not a document')); - $this->assertFalse($validator->isValid(null)); - $this->assertFalse($validator->isValid(['type' => 'databaseIndex'])); - } - - public function testRejectsEmptyDocument(): void - { - $validator = new Insight(); - - $this->assertFalse($validator->isValid(new Document())); - } - - public function testRejectsMissingType(): void - { - $validator = new Insight(); - $insight = new Document([ - '$id' => 'insight-1', - 'ctas' => [], - ]); - - $this->assertFalse($validator->isValid($insight)); - } - - public function testRejectsNonArrayCtas(): void - { - $validator = new Insight(); - $insight = new Document([ - '$id' => 'insight-1', - 'type' => 'databaseIndex', - 'ctas' => 'not-an-array', - ]); - - $this->assertFalse($validator->isValid($insight)); - } - - public function testReportsObjectType(): void - { - $validator = new Insight(); - - $this->assertSame('object', $validator->getType()); - $this->assertFalse($validator->isArray()); - } -} diff --git a/tests/unit/Insights/Validator/ProjectTest.php b/tests/unit/Insights/Validator/ProjectTest.php deleted file mode 100644 index 897717e322..0000000000 --- a/tests/unit/Insights/Validator/ProjectTest.php +++ /dev/null @@ -1,53 +0,0 @@ - 'project-1', - 'name' => 'Test', - ]); - - $this->assertTrue($validator->isValid($project)); - } - - public function testRejectsNonDocument(): void - { - $validator = new Project(); - - $this->assertFalse($validator->isValid('not a document')); - $this->assertFalse($validator->isValid(null)); - $this->assertFalse($validator->isValid(['$id' => 'project-1'])); - } - - public function testRejectsEmptyDocument(): void - { - $validator = new Project(); - - $this->assertFalse($validator->isValid(new Document())); - } - - public function testRejectsMissingId(): void - { - $validator = new Project(); - $project = new Document(['name' => 'Test']); - - $this->assertFalse($validator->isValid($project)); - } - - public function testReportsObjectType(): void - { - $validator = new Project(); - - $this->assertSame('object', $validator->getType()); - $this->assertFalse($validator->isArray()); - } -} From 719b1885bf9a515926a46f39b72c39ed2e0436c0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 15:49:44 +1200 Subject: [PATCH 22/70] refactor(insights): share index-create body between CE action and CTA Move the body of the public databases create-index endpoint into a new final `createIndex(...)` helper on the abstract Indexes Action so both the HTTP-facing CE action and the insights CTA share one source of truth for index validation, persistence, and queue dispatch. The CTA's `Appwrite\Insights\CTA\Action` becomes a small interface (`getName()` + `execute()`); the dispatcher now calls `$action->execute(...)` directly instead of poking at Utopia Action internals via `getCallback()` and `getParams()`. The CTA's `Create` extends the CE Indexes `Create` so it inherits `createIndex()` for free, while keeping a no-op constructor to skip the HTTP route registration that runs in the parent. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Appwrite/Insights/CTA/Action.php | 37 +++- .../CTA/Action/Databases/Indexes/Create.php | 194 +++--------------- .../Databases/Collections/Indexes/Action.php | 194 ++++++++++++++++++ .../Databases/Collections/Indexes/Create.php | 181 ++-------------- .../Insights/Http/CTA/Execution/Create.php | 18 +- 5 files changed, 269 insertions(+), 355 deletions(-) diff --git a/src/Appwrite/Insights/CTA/Action.php b/src/Appwrite/Insights/CTA/Action.php index 61b3983d72..e99cafdae4 100644 --- a/src/Appwrite/Insights/CTA/Action.php +++ b/src/Appwrite/Insights/CTA/Action.php @@ -2,22 +2,43 @@ namespace Appwrite\Insights\CTA; -use Utopia\Platform\Action as PlatformAction; +use Appwrite\Event\Database as EventDatabase; +use Appwrite\Event\Event; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; /** - * Base class for CTA actions registered in the insights CTA registry. + * Contract for CTA actions registered in the insights CTA registry. * - * A CTA action is a named, parameter-validated callable invoked when a user triggers - * a call-to-action attached to an insight. Subclasses declare their inputs via `param()` - * and dependencies via `inject()`, and provide their executable body via `callback()`. + * A CTA action is a named handler invoked when a user triggers a call-to-action + * attached to an insight. Implementations validate `$params` themselves and return + * the document produced by the action (e.g. a freshly-created index). * * Convention for `getName()`: dot-separated `domain..verb` in camelCase, e.g. `databases.indexes.create`. - * The required project scope is declared via `label('scope', '...')`. */ -abstract class Action extends PlatformAction +interface Action { /** * Unique, registered name for this action. */ - abstract public static function getName(): string; + public static function getName(): string; + + /** + * Run the action. Implementations may throw any `Appwrite\Extend\Exception` to + * signal a failed execution; the returned Document is surfaced to the caller + * in the CTA execution response. + * + * @param array $params + */ + public function execute( + array $params, + Document $insight, + Document $project, + Database $dbForProject, + callable $getDatabasesDB, + EventDatabase $queueForDatabase, + Event $queueForEvents, + Authorization $authorization, + ): Document; } diff --git a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php index ea1f45010f..965a4b33bb 100644 --- a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php +++ b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php @@ -5,41 +5,33 @@ namespace Appwrite\Insights\CTA\Action\Databases\Indexes; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Insights\CTA\Action; +use Appwrite\Insights\CTA\Action as CTAAction; use Appwrite\Insights\Validator\CTAParams\DatabasesCreateIndex as DatabasesCreateIndexParams; +use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes\Create as IndexCreate; +use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Index as IndexValidator; -class Create extends Action +class Create extends IndexCreate implements CTAAction { public static function getName(): string { return INSIGHT_CTA_ACTION_DATABASES_INDEXES_CREATE; } - public function __construct() + protected function getResponseModel(): string { - $this - ->desc('Create a database index from an insight CTA.') - ->label('scope', 'collections.write') - ->param('params', [], new DatabasesCreateIndexParams(), 'CTA params describing the index to create.') - ->inject('dbForProject') - ->inject('getDatabasesDB') - ->inject('queueForDatabase') - ->inject('queueForEvents') - ->inject('authorization') - ->callback($this->action(...)); + return Response::MODEL_INDEX; } - /** - * @param array $params - */ - public function action( + public function __construct() + { + // Skip the parent HTTP route registration — this CTA handler is invoked + // directly through the insights CTA dispatcher, not via Utopia routing. + } + + public function execute( array $params, Document $insight, Document $project, @@ -47,152 +39,26 @@ class Create extends Action callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, - Authorization $authorization + Authorization $authorization, ): Document { - $databaseId = (string) $params['databaseId']; - $collectionId = (string) $params['collectionId']; - $key = (string) $params['key']; - $type = (string) $params['type']; - $attributes = $params['attributes']; - $orders = $params['orders'] ?? []; - $lengths = $params['lengths'] ?? []; - - $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($db->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); + $validator = new DatabasesCreateIndexParams(); + if (!$validator->isValid($params)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $validator->getDescription()); } - $collection = $dbForProject->getDocument('database_' . $db->getSequence(), $collectionId); - - if ($collection->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND, params: [$collectionId]); - } - - $count = $dbForProject->count('indexes', [ - Query::equal('collectionInternalId', [$collection->getSequence()]), - Query::equal('databaseInternalId', [$db->getSequence()]), - ], 61); - - $dbForDatabases = $getDatabasesDB($db); - - if ($count >= $dbForDatabases->getLimitForIndexes()) { - throw new Exception(Exception::INDEX_LIMIT_EXCEEDED, params: [$collectionId]); - } - - $oldAttributes = \array_map( - fn ($a) => $a->getArrayCopy(), - $collection->getAttribute('attributes') + return $this->createIndex( + (string) $params['databaseId'], + (string) $params['collectionId'], + (string) $params['key'], + (string) $params['type'], + $params['attributes'], + $params['orders'] ?? [], + $params['lengths'] ?? [], + $dbForProject, + $getDatabasesDB, + $queueForDatabase, + $queueForEvents, + $authorization, ); - - foreach ([ - ['$id', Database::VAR_STRING, true, Database::LENGTH_KEY], - ['$createdAt', Database::VAR_DATETIME, false, 0], - ['$updatedAt', Database::VAR_DATETIME, false, 0], - ] as [$attributeKey, $attributeType, $required, $size]) { - $oldAttributes[] = [ - 'key' => $attributeKey, - 'type' => $attributeType, - 'status' => 'available', - 'required' => $required, - 'array' => false, - 'default' => null, - 'size' => $size, - 'signed' => $attributeType === Database::VAR_DATETIME ? false : true, - ]; - } - - if ($dbForDatabases->getAdapter()->getSupportForAttributes()) { - foreach ($attributes as $i => $attribute) { - $attributeIndex = \array_search($attribute, \array_column($oldAttributes, 'key')); - - if ($attributeIndex === false) { - throw new Exception(Exception::ATTRIBUTE_UNKNOWN, params: [$attribute]); - } - - $attributeStatus = $oldAttributes[$attributeIndex]['status']; - $attributeType = $oldAttributes[$attributeIndex]['type']; - $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; - - if ($attributeType === Database::VAR_RELATIONSHIP) { - throw new Exception(Exception::ATTRIBUTE_TYPE_INVALID, 'Cannot create an index for a relationship attribute: ' . $oldAttributes[$attributeIndex]['key']); - } - - if ($attributeStatus !== 'available') { - throw new Exception(Exception::ATTRIBUTE_NOT_AVAILABLE, params: [$oldAttributes[$attributeIndex]['key']]); - } - - if (empty($lengths[$i])) { - $lengths[$i] = null; - } - - if ($attributeArray === true) { - throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); - } - } - } - - $index = new Document([ - '$id' => ID::custom($db->getSequence() . '_' . $collection->getSequence() . '_' . $key), - 'key' => $key, - 'status' => 'processing', - 'databaseInternalId' => $db->getSequence(), - 'databaseId' => $databaseId, - 'collectionInternalId' => $collection->getSequence(), - 'collectionId' => $collectionId, - 'type' => $type, - 'attributes' => $attributes, - 'lengths' => $lengths, - 'orders' => $orders, - ]); - - $validator = new IndexValidator( - $collection->getAttribute('attributes'), - $collection->getAttribute('indexes'), - $dbForDatabases->getAdapter()->getMaxIndexLength(), - $dbForDatabases->getAdapter()->getInternalIndexesKeys(), - $dbForDatabases->getAdapter()->getSupportForIndexArray(), - $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), - $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), - $dbForDatabases->getAdapter()->getSupportForVectors(), - $dbForDatabases->getAdapter()->getSupportForAttributes(), - $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), - $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), - $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), - $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), - $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), - $dbForDatabases->getAdapter()->getSupportForIndex(), - $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), - $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), - $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), - $dbForDatabases->getAdapter()->getSupportForObject() - ); - - if (!$validator->isValid($index)) { - throw new Exception(Exception::INDEX_INVALID, $validator->getDescription()); - } - - try { - $index = $dbForProject->createDocument('indexes', $index); - } catch (DuplicateException) { - throw new Exception(Exception::INDEX_ALREADY_EXISTS, params: [$key]); - } - - $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); - - $queueForDatabase - ->setType(DATABASE_TYPE_CREATE_INDEX) - ->setDatabase($db) - ->setCollection($collection) - ->setDocument($index); - - $queueForEvents - ->setContext('database', $db) - ->setContext('collection', $collection) - ->setParam('databaseId', $databaseId) - ->setParam('collectionId', $collection->getId()) - ->setParam('indexId', $index->getId()); - - return $index; } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php index 251e493cb6..71b9ccdcbb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php @@ -2,7 +2,16 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes; +use Appwrite\Event\Database as EventDatabase; +use Appwrite\Event\Event; use Appwrite\Extend\Exception; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Helpers\ID; +use Utopia\Database\Query; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\Index as IndexValidator; use Utopia\Platform\Action as UtopiaAction; abstract class Action extends UtopiaAction @@ -152,4 +161,189 @@ abstract class Action extends UtopiaAction { return $this->isCollectionsAPI() ? 'collection' : 'table'; } + + /** + * Build, validate, persist and queue a new index document for the current + * API context. Shared by the public HTTP create-index actions and by the + * insights CTA action that surfaces missing indexes to project members. + * + * @param array $attributes + * @param array $orders + * @param array $lengths + */ + final public function createIndex( + string $databaseId, + string $collectionId, + string $key, + string $type, + array $attributes, + array $orders, + array $lengths, + Database $dbForProject, + callable $getDatabasesDB, + EventDatabase $queueForDatabase, + Event $queueForEvents, + Authorization $authorization, + ): Document { + $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($db->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); + } + + $collection = $dbForProject->getDocument('database_' . $db->getSequence(), $collectionId); + + if ($collection->isEmpty()) { + throw new Exception($this->getGrandParentNotFoundException(), params: [$collectionId]); + } + + $count = $dbForProject->count('indexes', [ + Query::equal('collectionInternalId', [$collection->getSequence()]), + Query::equal('databaseInternalId', [$db->getSequence()]), + ], 61); + + $dbForDatabases = $getDatabasesDB($db); + + if ($count >= $dbForDatabases->getLimitForIndexes()) { + throw new Exception($this->getLimitException(), params: [$collectionId]); + } + + $oldAttributes = \array_map( + fn ($a) => $a->getArrayCopy(), + $collection->getAttribute('attributes') + ); + + $oldAttributes[] = [ + 'key' => '$id', + 'type' => Database::VAR_STRING, + 'status' => 'available', + 'required' => true, + 'array' => false, + 'default' => null, + 'size' => Database::LENGTH_KEY, + ]; + $oldAttributes[] = [ + 'key' => '$createdAt', + 'type' => Database::VAR_DATETIME, + 'status' => 'available', + 'signed' => false, + 'required' => false, + 'array' => false, + 'default' => null, + 'size' => 0, + ]; + $oldAttributes[] = [ + 'key' => '$updatedAt', + 'type' => Database::VAR_DATETIME, + 'status' => 'available', + 'signed' => false, + 'required' => false, + 'array' => false, + 'default' => null, + 'size' => 0, + ]; + + $contextType = $this->getParentContext(); + if ($dbForDatabases->getAdapter()->getSupportForAttributes()) { + foreach ($attributes as $i => $attribute) { + $attributeIndex = \array_search($attribute, \array_column($oldAttributes, 'key')); + + if ($attributeIndex === false) { + throw new Exception($this->getParentUnknownException(), params: [$attribute]); + } + + $attributeStatus = $oldAttributes[$attributeIndex]['status']; + $attributeType = $oldAttributes[$attributeIndex]['type']; + $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; + + if ($attributeType === Database::VAR_RELATIONSHIP) { + throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']); + } + + if ($attributeStatus !== 'available') { + throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]); + } + + if (empty($lengths[$i])) { + $lengths[$i] = null; + } + + if ($attributeArray === true) { + throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); + } + } + } + + $index = new Document([ + '$id' => ID::custom($db->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'status' => 'processing', + 'databaseInternalId' => $db->getSequence(), + 'databaseId' => $databaseId, + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collectionId, + 'type' => $type, + 'attributes' => $attributes, + 'lengths' => $lengths, + 'orders' => $orders, + ]); + + $validator = new IndexValidator( + $collection->getAttribute('attributes'), + $collection->getAttribute('indexes'), + $dbForDatabases->getAdapter()->getMaxIndexLength(), + $dbForDatabases->getAdapter()->getInternalIndexesKeys(), + $dbForDatabases->getAdapter()->getSupportForIndexArray(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), + $dbForDatabases->getAdapter()->getSupportForVectors(), + $dbForDatabases->getAdapter()->getSupportForAttributes(), + $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), + $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), + $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), + $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForIndex(), + $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), + $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), + $dbForDatabases->getAdapter()->getSupportForObject() + ); + + if (!$validator->isValid($index)) { + throw new Exception($this->getInvalidTypeException(), $validator->getDescription()); + } + + try { + $index = $dbForProject->createDocument('indexes', $index); + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException(), params: [$key]); + } + + $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); + + $queueForDatabase + ->setType(DATABASE_TYPE_CREATE_INDEX) + ->setDatabase($db); + + if ($this->isCollectionsAPI()) { + $queueForDatabase + ->setCollection($collection) + ->setDocument($index); + } else { + $queueForDatabase + ->setTable($collection) + ->setRow($index); + } + + $queueForEvents + ->setContext('database', $db) + ->setParam('databaseId', $databaseId) + ->setParam('indexId', $index->getId()) + ->setParam('collectionId', $collection->getId()) + ->setParam('tableId', $collection->getId()) + ->setContext($this->getCollectionsEventsContext(), $collection); + + return $index; + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php index 7e073c95d4..1c1668056b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; -use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; @@ -12,12 +11,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Index as IndexValidator; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; @@ -86,170 +80,21 @@ class Create extends Action public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void { - $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($db->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); - } - - $collection = $dbForProject->getDocument('database_' . $db->getSequence(), $collectionId); - - if ($collection->isEmpty()) { - // table or collection. - throw new Exception($this->getGrandParentNotFoundException(), params: [$collectionId]); - } - - $count = $dbForProject->count('indexes', [ - Query::equal('collectionInternalId', [$collection->getSequence()]), - Query::equal('databaseInternalId', [$db->getSequence()]) - ], 61); - - $dbForDatabases = $getDatabasesDB($db); - - $limit = $dbForDatabases->getLimitForIndexes(); - - if ($count >= $limit) { - throw new Exception($this->getLimitException(), params: [$collectionId]); - } - - $oldAttributes = \array_map( - fn ($a) => $a->getArrayCopy(), - $collection->getAttribute('attributes') + $index = $this->createIndex( + $databaseId, + $collectionId, + $key, + $type, + $attributes, + $orders, + $lengths, + $dbForProject, + $getDatabasesDB, + $queueForDatabase, + $queueForEvents, + $authorization, ); - $oldAttributes[] = [ - 'key' => '$id', - 'type' => Database::VAR_STRING, - 'status' => 'available', - 'required' => true, - 'array' => false, - 'default' => null, - 'size' => Database::LENGTH_KEY - ]; - $oldAttributes[] = [ - 'key' => '$createdAt', - 'type' => Database::VAR_DATETIME, - 'status' => 'available', - 'signed' => false, - 'required' => false, - 'array' => false, - 'default' => null, - 'size' => 0 - ]; - $oldAttributes[] = [ - 'key' => '$updatedAt', - 'type' => Database::VAR_DATETIME, - 'status' => 'available', - 'signed' => false, - 'required' => false, - 'array' => false, - 'default' => null, - 'size' => 0 - ]; - - $contextType = $this->getParentContext(); - if ($dbForDatabases->getAdapter()->getSupportForAttributes()) { - foreach ($attributes as $i => $attribute) { - // find attribute metadata in collection document - $attributeIndex = \array_search($attribute, array_column($oldAttributes, 'key')); - - if ($attributeIndex === false) { - throw new Exception($this->getParentUnknownException(), params: [$attribute]); - } - - $attributeStatus = $oldAttributes[$attributeIndex]['status']; - $attributeType = $oldAttributes[$attributeIndex]['type']; - $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; - - if ($attributeType === Database::VAR_RELATIONSHIP) { - throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']); - } - - if ($attributeStatus !== 'available') { - throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]); - } - - if (empty($lengths[$i])) { - $lengths[$i] = null; - } - - if ($attributeArray === true) { - // Because of a bug in MySQL, we cannot create indexes on array attributes for now, otherwise queries break. - throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); - } - } - } - - $index = new Document([ - '$id' => ID::custom($db->getSequence() . '_' . $collection->getSequence() . '_' . $key), - 'key' => $key, - 'status' => 'processing', // processing, available, failed, deleting, stuck - 'databaseInternalId' => $db->getSequence(), - 'databaseId' => $databaseId, - 'collectionInternalId' => $collection->getSequence(), - 'collectionId' => $collectionId, - 'type' => $type, - 'attributes' => $attributes, - 'lengths' => $lengths, - 'orders' => $orders, - ]); - - $validator = new IndexValidator( - $collection->getAttribute('attributes'), - $collection->getAttribute('indexes'), - $dbForDatabases->getAdapter()->getMaxIndexLength(), - $dbForDatabases->getAdapter()->getInternalIndexesKeys(), - $dbForDatabases->getAdapter()->getSupportForIndexArray(), - $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), - $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), - $dbForDatabases->getAdapter()->getSupportForVectors(), - $dbForDatabases->getAdapter()->getSupportForAttributes(), - $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), - $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), - $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), - $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), - $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), - $dbForDatabases->getAdapter()->getSupportForIndex(), - $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), - $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), - $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), - $dbForDatabases->getAdapter()->getSupportForObject() - ); - - if (!$validator->isValid($index)) { - throw new Exception($this->getInvalidTypeException(), $validator->getDescription()); - } - - try { - $index = $dbForProject->createDocument('indexes', $index); - } catch (DuplicateException) { - throw new Exception($this->getDuplicateException(), params: [$key]); - } - - $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); - - $queueForDatabase - ->setType(DATABASE_TYPE_CREATE_INDEX) - ->setDatabase($db); - - if ($this->isCollectionsAPI()) { - $queueForDatabase - ->setCollection($collection) - ->setDocument($index); - } else { - $queueForDatabase - ->setTable($collection) - ->setRow($index); - } - - $queueForEvents - ->setContext('database', $db) - ->setParam('databaseId', $databaseId) - ->setParam('indexId', $index->getId()) - ->setParam('collectionId', $collection->getId()) - ->setParam('tableId', $collection->getId()) - ->setContext($this->getCollectionsEventsContext(), $collection); - $response ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED) ->dynamic($index, $this->getResponseModel()); diff --git a/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php index d57ff0493b..30fef30001 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php @@ -122,23 +122,11 @@ class Create extends Action throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); } - $paramsValidator = $action->getParams()['params']['validator'] ?? null; - - if ($paramsValidator !== null && !$paramsValidator->isValid($params)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $paramsValidator->getDescription()); - } - $status = 'succeeded'; $resultPayload = new \stdClass(); - $callback = $action->getCallback(); - - if (!\is_callable($callback)) { - throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); - } - try { - $result = $callback( + $result = $action->execute( $params, $insight, $project, @@ -146,9 +134,9 @@ class Create extends Action $getDatabasesDB, $queueForDatabase, $queueForEvents, - $authorization + $authorization, ); - $resultPayload = $result instanceof Document ? $result->getArrayCopy() : (array) $result; + $resultPayload = $result->getArrayCopy(); } catch (Exception $e) { if ($e->getType() === Exception::GENERAL_NOT_IMPLEMENTED) { throw $e; From 56ef3b4cfa50bfd4682c90363f46cf0959047c5d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 16:11:30 +1200 Subject: [PATCH 23/70] refactor(insights): nest CTA params validator under resource path Moves Validator/CTAParams/DatabasesCreateIndex to Validator/CTA/Databases/Index/Create so the validator hierarchy mirrors the action hierarchy (Insights/CTA/Action/Databases/Indexes/Create). Also tightens the Update and CTA execute endpoint descriptions to call out the dismissal-via-status flow and what the execution result carries. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CTA/Action/Databases/Indexes/Create.php | 4 ++-- .../Databases/Index/Create.php} | 4 ++-- .../Insights/Http/CTA/Execution/Create.php | 2 +- .../Modules/Insights/Http/Insights/Update.php | 2 +- .../Databases/Index/CreateTest.php} | 16 ++++++++-------- 5 files changed, 14 insertions(+), 14 deletions(-) rename src/Appwrite/Insights/Validator/{CTAParams/DatabasesCreateIndex.php => CTA/Databases/Index/Create.php} (92%) rename tests/unit/Insights/Validator/{CTAParams/DatabasesCreateIndexTest.php => CTA/Databases/Index/CreateTest.php} (78%) diff --git a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php index 965a4b33bb..9894960299 100644 --- a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php +++ b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php @@ -6,7 +6,7 @@ use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Insights\CTA\Action as CTAAction; -use Appwrite\Insights\Validator\CTAParams\DatabasesCreateIndex as DatabasesCreateIndexParams; +use Appwrite\Insights\Validator\CTA\Databases\Index\Create as IndexCreateParams; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes\Create as IndexCreate; use Appwrite\Utopia\Response; use Utopia\Database\Database; @@ -41,7 +41,7 @@ class Create extends IndexCreate implements CTAAction Event $queueForEvents, Authorization $authorization, ): Document { - $validator = new DatabasesCreateIndexParams(); + $validator = new IndexCreateParams(); if (!$validator->isValid($params)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $validator->getDescription()); } diff --git a/src/Appwrite/Insights/Validator/CTAParams/DatabasesCreateIndex.php b/src/Appwrite/Insights/Validator/CTA/Databases/Index/Create.php similarity index 92% rename from src/Appwrite/Insights/Validator/CTAParams/DatabasesCreateIndex.php rename to src/Appwrite/Insights/Validator/CTA/Databases/Index/Create.php index 52ccc13312..e5f7110c2a 100644 --- a/src/Appwrite/Insights/Validator/CTAParams/DatabasesCreateIndex.php +++ b/src/Appwrite/Insights/Validator/CTA/Databases/Index/Create.php @@ -1,10 +1,10 @@ diff --git a/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php index 30fef30001..2875c1fe95 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php @@ -48,7 +48,7 @@ class Create extends Action group: 'insights', name: 'createCTAExecution', description: <<assertTrue($validator->isValid([ 'databaseId' => 'main', @@ -22,7 +22,7 @@ class DatabasesCreateIndexTest extends TestCase public function testRejectsNonArray(): void { - $validator = new DatabasesCreateIndex(); + $validator = new Create(); $this->assertFalse($validator->isValid('not-an-array')); $this->assertFalse($validator->isValid(null)); @@ -30,7 +30,7 @@ class DatabasesCreateIndexTest extends TestCase public function testRejectsMissingRequiredParam(): void { - $validator = new DatabasesCreateIndex(); + $validator = new Create(); $this->assertFalse($validator->isValid([ 'databaseId' => 'main', @@ -43,7 +43,7 @@ class DatabasesCreateIndexTest extends TestCase public function testRejectsEmptyAttributes(): void { - $validator = new DatabasesCreateIndex(); + $validator = new Create(); $this->assertFalse($validator->isValid([ 'databaseId' => 'main', @@ -57,7 +57,7 @@ class DatabasesCreateIndexTest extends TestCase public function testReportsArrayType(): void { - $validator = new DatabasesCreateIndex(); + $validator = new Create(); $this->assertTrue($validator->isArray()); $this->assertSame($validator::TYPE_ARRAY, $validator->getType()); From 00565ea471cca35b302ec7706bff8c3105f283df Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 13:46:07 +1200 Subject: [PATCH 24/70] refactor(insights): metadata-only CTAs, platform DB, reports parent Address review feedback on PR #12194: - Pivot CTAs to pure descriptors (id/label/action/params). Drop the server-side execution layer: Action interface, registry, the databases.indexes.create CTA action, the params validator, the /v1/insights/:id/ctas/:id/executions endpoint, the InsightCTAExecution model, the INSIGHT_CTA_* errors, and the corresponding events. The console invokes the existing public API directly with the descriptor's action + params. - Restore Databases\Indexes\Action.php to its pre-CTA shape and inline the index-create body back into Create.php (the createIndex helper was added solely for CTA reuse). - Move insights collection from project DB to platform DB and add a parent reports collection alongside it. Insights carry projectId / projectInternalId for tenant scoping and an optional reportId for grouping. List endpoints filter by projectInternalId; Get/Update/ Delete also enforce project ownership before touching the document. - New Reports module with full CRUD (Create/Get/XList/Update/Delete), Report response model, Reports query validator, REPORT_NOT_FOUND / REPORT_ALREADY_EXISTS errors, reports.read / reports.write scopes, and reports.* event tree. Delete cascades to child insights. - Update.php now mutates the loaded document via setAttribute (instead of passing a partial new Document), reuses CTAsValidator (instead of the looser ArrayList + isset check), and rejects duplicate CTA ids. - Create.php enforces unique CTA ids during normalization. - CTAsValidator gained a configurable maxCount (default 16) so the Create path matches the Update path and the DB column size, and oversized payloads return a clean 400. - Validator\Queries\Insights adds status and reportId to ALLOWED_ATTRIBUTES so dismissal / report workflows are filterable. - Realtime channel parser guards $parts[1] for both insights and reports event names. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/platform.php | 384 ++++++++++++++++++ app/config/collections/projects.php | 172 -------- app/config/errors.php | 21 +- app/config/events.php | 25 +- app/config/roles.php | 2 + app/config/scopes/project.php | 12 +- app/config/services.php | 2 +- app/init/constants.php | 13 +- app/init/models.php | 5 +- app/init/registers.php | 6 - app/init/resources.php | 4 - src/Appwrite/Extend/Exception.php | 7 +- src/Appwrite/Insights/CTA/Action.php | 44 -- .../CTA/Action/Databases/Indexes/Create.php | 64 --- .../Validator/CTA/Databases/Index/Create.php | 51 --- src/Appwrite/Insights/Validator/CTAs.php | 11 + src/Appwrite/Messaging/Adapter/Realtime.php | 7 +- .../Databases/Collections/Indexes/Action.php | 194 --------- .../Databases/Collections/Indexes/Create.php | 181 ++++++++- .../Insights/Http/CTA/Execution/Create.php | 160 -------- .../Modules/Insights/Http/Insights/Create.php | 39 +- .../Modules/Insights/Http/Insights/Delete.php | 15 +- .../Modules/Insights/Http/Insights/Get.php | 13 +- .../Modules/Insights/Http/Insights/Update.php | 31 +- .../Modules/Insights/Http/Insights/XList.php | 16 +- .../Modules/Insights/Http/Reports/Create.php | 117 ++++++ .../Modules/Insights/Http/Reports/Delete.php | 100 +++++ .../Modules/Insights/Http/Reports/Get.php | 70 ++++ .../Modules/Insights/Http/Reports/Update.php | 115 ++++++ .../Modules/Insights/Http/Reports/XList.php | 110 +++++ .../Modules/Insights/Services/Http.php | 13 +- .../Database/Validator/Queries/Insights.php | 2 + .../Database/Validator/Queries/Reports.php | 18 + src/Appwrite/Utopia/Response.php | 3 +- .../Utopia/Response/Model/Insight.php | 6 + .../Utopia/Response/Model/InsightCTA.php | 6 +- .../Response/Model/InsightCTAExecution.php | 54 --- src/Appwrite/Utopia/Response/Model/Report.php | 86 ++++ tests/e2e/Services/Insights/InsightsBase.php | 175 ++++++-- .../CTA/Databases/Index/CreateTest.php | 65 --- tests/unit/Insights/Validator/CTAsTest.php | 33 ++ 41 files changed, 1528 insertions(+), 924 deletions(-) delete mode 100644 src/Appwrite/Insights/CTA/Action.php delete mode 100644 src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php delete mode 100644 src/Appwrite/Insights/Validator/CTA/Databases/Index/Create.php delete mode 100644 src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Reports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Reports/Get.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Reports/Update.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Reports/XList.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/Reports.php delete mode 100644 src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php create mode 100644 src/Appwrite/Utopia/Response/Model/Report.php delete mode 100644 tests/unit/Insights/Validator/CTA/Databases/Index/CreateTest.php diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 6195c11724..58dc00bb07 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -1935,6 +1935,390 @@ $platformCollections = [ 'attributes' => [], 'indexes' => [] ], + + 'reports' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('reports'), + 'name' => 'Reports', + 'attributes' => [ + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + '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' => [], + ], + [ + // Analyzer that produced the report. Possible values: lighthouse, audit, databaseAnalyzer + '$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_STRING, + 'format' => '', + 'size' => 4096, + '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). + '$id' => ID::custom('target'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 2048, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + // JSON array of category strings, e.g. ['performance', 'accessibility']. + '$id' => ID::custom('categories'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 2048, + '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'], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_project'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_project_type'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'type'], + 'lengths' => [Database::LENGTH_KEY, 64], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_target'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'targetType', 'target'], + 'lengths' => [Database::LENGTH_KEY, 64, 256], + 'orders' => [], + ], + ], + ], + + 'insights' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('insights'), + 'name' => 'Insights', + 'attributes' => [ + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + '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' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('reportId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + // Possible values: databaseIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance + '$id' => ID::custom('type'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + // Possible values: info, warning, critical + '$id' => ID::custom('severity'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + // Possible values: active, dismissed + '$id' => ID::custom('status'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16, + 'signed' => true, + 'required' => true, + 'default' => 'active', + 'array' => false, + 'filters' => [], + ], + [ + // Possible values: databases, collections, sites, functions + '$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' => false, + 'default' => '', + '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_STRING, + 'format' => '', + 'size' => 4096, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('payload'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('ctas'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + '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'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_project_report'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'reportInternalId'], + 'lengths' => [Database::LENGTH_KEY, 0], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_resource'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'resourceType', 'resourceId', '$sequence'], + 'lengths' => [Database::LENGTH_KEY, 64, Database::LENGTH_KEY], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_type'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'type'], + 'lengths' => [Database::LENGTH_KEY, 64], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_severity'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'severity'], + 'lengths' => [Database::LENGTH_KEY, 16], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_project_status'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'status'], + 'lengths' => [Database::LENGTH_KEY, 16], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_dismissedAt'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['dismissedAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_DESC], + ], + ], + ], ]; // Organization API keys subquery diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index be44627167..9568c59369 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2754,176 +2754,4 @@ return [ ], ], ], - - 'insights' => [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('insights'), - 'name' => 'Insights', - 'attributes' => [ - [ - // Possible values: databaseIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance - '$id' => ID::custom('type'), - 'type' => Database::VAR_STRING, - 'size' => 64, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - // Possible values: info, warning, critical - '$id' => ID::custom('severity'), - 'type' => Database::VAR_STRING, - 'size' => 16, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - // Possible values: active, dismissed - '$id' => ID::custom('status'), - 'type' => Database::VAR_STRING, - 'size' => 16, - 'signed' => true, - 'required' => true, - 'default' => 'active', - 'array' => false, - 'filters' => [], - ], - [ - // Possible values: databases, collections, sites, functions - '$id' => ID::custom('resourceType'), - 'type' => Database::VAR_STRING, - 'size' => 64, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('resourceId'), - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('resourceInternalId'), - 'type' => Database::VAR_ID, - 'size' => 0, - 'signed' => true, - 'required' => false, - 'default' => '', - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'size' => 256, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('summary'), - 'type' => Database::VAR_STRING, - 'size' => 4096, - 'signed' => true, - 'required' => false, - 'default' => '', - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('payload'), - 'type' => Database::VAR_STRING, - 'size' => 65535, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('ctas'), - 'type' => Database::VAR_STRING, - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('analyzedAt'), - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => ID::custom('dismissedAt'), - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => ID::custom('dismissedBy'), - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => '', - 'array' => false, - 'filters' => [], - ], - ], - 'indexes' => [ - [ - '$id' => ID::custom('_key_resource'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['resourceType', 'resourceId', '$sequence'], - 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], - 'orders' => [], - ], - [ - '$id' => ID::custom('_key_type'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['type'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => ID::custom('_key_severity'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['severity'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => ID::custom('_key_dismissedAt'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['dismissedAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_DESC], - ], - ], - ], ]; diff --git a/app/config/errors.php b/app/config/errors.php index 62a4f444d1..209783a290 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1435,19 +1435,16 @@ return [ '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, ], - Exception::INSIGHT_CTA_NOT_FOUND => [ - 'name' => Exception::INSIGHT_CTA_NOT_FOUND, - 'description' => 'CTA with the requested ID could not be found on the insight.', + + /** Reports */ + Exception::REPORT_NOT_FOUND => [ + 'name' => Exception::REPORT_NOT_FOUND, + 'description' => 'Report with the requested ID could not be found.', 'code' => 404, ], - Exception::INSIGHT_CTA_ACTION_NOT_REGISTERED => [ - 'name' => Exception::INSIGHT_CTA_ACTION_NOT_REGISTERED, - 'description' => 'The CTA action requested is not registered on the server.', - 'code' => 501, - ], - Exception::INSIGHT_CTA_VALIDATION_FAILED => [ - 'name' => Exception::INSIGHT_CTA_VALIDATION_FAILED, - 'description' => 'CTA parameter validation failed. Please ensure all required parameters are provided and well formed.', - 'code' => 400, + 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 3b4d636471..b708d785b0 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -440,18 +440,19 @@ return [ 'delete' => [ '$description' => 'This event triggers when an insight is deleted.', ], - 'ctas' => [ - '$model' => Response::MODEL_INSIGHT_CTA, - '$resource' => true, - '$description' => 'This event triggers on any insight CTA event.', - 'executions' => [ - '$model' => Response::MODEL_INSIGHT_CTA_EXECUTION, - '$resource' => true, - '$description' => 'This event triggers on any insight CTA execution event.', - 'create' => [ - '$description' => 'This event triggers when an insight CTA is executed.', - ], - ], + ], + '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.', ], ], ]; diff --git a/app/config/roles.php b/app/config/roles.php index fa92d16e4e..6f96ef278e 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -105,6 +105,8 @@ $admins = [ '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 3fbdc0fc17..f0bac03a0c 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -344,7 +344,17 @@ return [ 'category' => 'Other', ], 'insights.write' => [ - 'description' => 'Access to create, update, dismiss, delete insights, and trigger their CTAs.', + 'description' => 'Access to create, update, dismiss, and delete insights.', + 'category' => 'Other', + ], + + // Reports + 'reports.read' => [ + 'description' => 'Access to read analyzer reports and their insights.', + 'category' => 'Other', + ], + 'reports.write' => [ + 'description' => 'Access to create, update, and delete analyzer reports.', 'category' => 'Other', ], ]; diff --git a/app/config/services.php b/app/config/services.php index ea2f29cc52..a285224b1e 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -312,7 +312,7 @@ return [ 'insights' => [ 'key' => 'insights', 'name' => 'Insights', - 'subtitle' => 'The Insights service surfaces actionable reports about your project resources, with CTAs for one-click remediation.', + 'subtitle' => 'The Insights service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the console.', 'description' => '/docs/services/insights.md', 'controller' => '', // Uses modules 'sdk' => true, diff --git a/app/init/constants.php b/app/init/constants.php index 36ca8ebcc2..44e158e5a4 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -424,6 +424,7 @@ const RESOURCE_TYPE_EXECUTIONS = 'executions'; const RESOURCE_TYPE_VCS = 'vcs'; const RESOURCE_TYPE_EMBEDDINGS_TEXT = 'embeddingsText'; const RESOURCE_TYPE_INSIGHTS = 'insights'; +const RESOURCE_TYPE_REPORTS = 'reports'; // Insight types const INSIGHT_TYPE_DATABASE_INDEX = 'databaseIndex'; @@ -462,8 +463,16 @@ const INSIGHT_STATUSES = [ INSIGHT_STATUS_DISMISSED, ]; -// Insight CTA actions -const INSIGHT_CTA_ACTION_DATABASES_INDEXES_CREATE = 'databases.indexes.create'; +// Report types +const REPORT_TYPE_LIGHTHOUSE = 'lighthouse'; +const REPORT_TYPE_AUDIT = 'audit'; +const REPORT_TYPE_DATABASE_ANALYZER = 'databaseAnalyzer'; + +const REPORT_TYPES = [ + REPORT_TYPE_LIGHTHOUSE, + REPORT_TYPE_AUDIT, + REPORT_TYPE_DATABASE_ANALYZER, +]; // Resource types for Tokens const TOKENS_RESOURCE_TYPE_FILES = 'files'; diff --git a/app/init/models.php b/app/init/models.php index f1342ce27f..880252a065 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -92,7 +92,6 @@ 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\InsightCTAExecution; use Appwrite\Utopia\Response\Model\Installation; use Appwrite\Utopia\Response\Model\JWT; use Appwrite\Utopia\Response\Model\Key; @@ -180,6 +179,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; @@ -290,6 +290,7 @@ Response::setModel(new BaseList('VCS Content List', 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()); @@ -511,7 +512,7 @@ Response::setModel(new MigrationReport()); Response::setModel(new MigrationFirebaseProject()); Response::setModel(new Insight()); Response::setModel(new InsightCTA()); -Response::setModel(new InsightCTAExecution()); +Response::setModel(new Report()); // Tests (keep last) Response::setModel(new Mock()); diff --git a/app/init/registers.php b/app/init/registers.php index 1280049e2d..54c0053a33 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -3,7 +3,6 @@ use Appwrite\Extend\Exception; use Appwrite\GraphQL\Promises\Adapter\Swoole; use Appwrite\Hooks\Hooks; -use Appwrite\Insights\CTA\Action\Databases\Indexes\Create as DatabasesIndexesCreate; use Appwrite\PubSub\Adapter\Redis as PubSub; use Appwrite\URL\URL as AppwriteURL; use MaxMind\Db\Reader; @@ -452,11 +451,6 @@ $register->set('promiseAdapter', function () { $register->set('hooks', function () { return new Hooks(); }); -$register->set('insightCTARegistry', function () { - $registry = new Registry(); - $registry->set(DatabasesIndexesCreate::getName(), fn () => new DatabasesIndexesCreate()); - return $registry; -}); $listeners = require __DIR__ . '/../listeners.php'; $register->set('bus', function () use ($listeners) { $bus = new \Utopia\Bus\Bus(); diff --git a/app/init/resources.php b/app/init/resources.php index 73d080c7a6..96457294de 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -128,10 +128,6 @@ $container->set('authorization', function () { return new Authorization(); }, []); -$container->set('insightCTARegistry', function ($register) { - return $register->get('insightCTARegistry'); -}, ['register']); - $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 82891f123e..5fc19dacd6 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -403,9 +403,10 @@ class Exception extends \Exception /** Insights */ public const string INSIGHT_NOT_FOUND = 'insight_not_found'; public const string INSIGHT_ALREADY_EXISTS = 'insight_already_exists'; - public const string INSIGHT_CTA_NOT_FOUND = 'insight_cta_not_found'; - public const string INSIGHT_CTA_ACTION_NOT_REGISTERED = 'insight_cta_action_not_registered'; - public const string INSIGHT_CTA_VALIDATION_FAILED = 'insight_cta_validation_failed'; + + /** 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 = []; diff --git a/src/Appwrite/Insights/CTA/Action.php b/src/Appwrite/Insights/CTA/Action.php deleted file mode 100644 index e99cafdae4..0000000000 --- a/src/Appwrite/Insights/CTA/Action.php +++ /dev/null @@ -1,44 +0,0 @@ -.verb` in camelCase, e.g. `databases.indexes.create`. - */ -interface Action -{ - /** - * Unique, registered name for this action. - */ - public static function getName(): string; - - /** - * Run the action. Implementations may throw any `Appwrite\Extend\Exception` to - * signal a failed execution; the returned Document is surfaced to the caller - * in the CTA execution response. - * - * @param array $params - */ - public function execute( - array $params, - Document $insight, - Document $project, - Database $dbForProject, - callable $getDatabasesDB, - EventDatabase $queueForDatabase, - Event $queueForEvents, - Authorization $authorization, - ): Document; -} diff --git a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php b/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php deleted file mode 100644 index 9894960299..0000000000 --- a/src/Appwrite/Insights/CTA/Action/Databases/Indexes/Create.php +++ /dev/null @@ -1,64 +0,0 @@ -isValid($params)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $validator->getDescription()); - } - - return $this->createIndex( - (string) $params['databaseId'], - (string) $params['collectionId'], - (string) $params['key'], - (string) $params['type'], - $params['attributes'], - $params['orders'] ?? [], - $params['lengths'] ?? [], - $dbForProject, - $getDatabasesDB, - $queueForDatabase, - $queueForEvents, - $authorization, - ); - } -} diff --git a/src/Appwrite/Insights/Validator/CTA/Databases/Index/Create.php b/src/Appwrite/Insights/Validator/CTA/Databases/Index/Create.php deleted file mode 100644 index e5f7110c2a..0000000000 --- a/src/Appwrite/Insights/Validator/CTA/Databases/Index/Create.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ - private const REQUIRED = ['databaseId', 'collectionId', 'key', 'type', 'attributes']; - - protected string $message = 'CTA params must define databaseId, collectionId, key, type, and a non-empty attributes array.'; - - 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; - } - - foreach (self::REQUIRED as $key) { - if (!isset($value[$key])) { - $this->message = 'Missing required param "' . $key . '".'; - return false; - } - } - - if (!\is_array($value['attributes']) || $value['attributes'] === []) { - $this->message = 'Param "attributes" must be a non-empty array of attribute keys.'; - return false; - } - - return true; - } -} diff --git a/src/Appwrite/Insights/Validator/CTAs.php b/src/Appwrite/Insights/Validator/CTAs.php index c9068c3d8b..646c9a601f 100644 --- a/src/Appwrite/Insights/Validator/CTAs.php +++ b/src/Appwrite/Insights/Validator/CTAs.php @@ -6,8 +6,14 @@ use Utopia\Validator; class CTAs extends Validator { + public const MAX_COUNT_DEFAULT = 16; + protected string $message = 'Value must be an array of CTA descriptors. Each entry must define `id`, `label`, `action`, and an optional `params` object.'; + public function __construct(protected int $maxCount = self::MAX_COUNT_DEFAULT) + { + } + public function getDescription(): string { return $this->message; @@ -29,6 +35,11 @@ class CTAs extends Validator 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; diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index b2e12cf2e6..128730d9e0 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -775,8 +775,11 @@ class Realtime extends MessagingAdapter } break; case 'insights': - $channels[] = 'insights'; - $channels[] = 'insights.' . $parts[1]; + case 'reports': + $channels[] = $parts[0]; + if (isset($parts[1])) { + $channels[] = $parts[0] . '.' . $parts[1]; + } $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php index 71b9ccdcbb..251e493cb6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php @@ -2,16 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes; -use Appwrite\Event\Database as EventDatabase; -use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Index as IndexValidator; use Utopia\Platform\Action as UtopiaAction; abstract class Action extends UtopiaAction @@ -161,189 +152,4 @@ abstract class Action extends UtopiaAction { return $this->isCollectionsAPI() ? 'collection' : 'table'; } - - /** - * Build, validate, persist and queue a new index document for the current - * API context. Shared by the public HTTP create-index actions and by the - * insights CTA action that surfaces missing indexes to project members. - * - * @param array $attributes - * @param array $orders - * @param array $lengths - */ - final public function createIndex( - string $databaseId, - string $collectionId, - string $key, - string $type, - array $attributes, - array $orders, - array $lengths, - Database $dbForProject, - callable $getDatabasesDB, - EventDatabase $queueForDatabase, - Event $queueForEvents, - Authorization $authorization, - ): Document { - $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($db->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); - } - - $collection = $dbForProject->getDocument('database_' . $db->getSequence(), $collectionId); - - if ($collection->isEmpty()) { - throw new Exception($this->getGrandParentNotFoundException(), params: [$collectionId]); - } - - $count = $dbForProject->count('indexes', [ - Query::equal('collectionInternalId', [$collection->getSequence()]), - Query::equal('databaseInternalId', [$db->getSequence()]), - ], 61); - - $dbForDatabases = $getDatabasesDB($db); - - if ($count >= $dbForDatabases->getLimitForIndexes()) { - throw new Exception($this->getLimitException(), params: [$collectionId]); - } - - $oldAttributes = \array_map( - fn ($a) => $a->getArrayCopy(), - $collection->getAttribute('attributes') - ); - - $oldAttributes[] = [ - 'key' => '$id', - 'type' => Database::VAR_STRING, - 'status' => 'available', - 'required' => true, - 'array' => false, - 'default' => null, - 'size' => Database::LENGTH_KEY, - ]; - $oldAttributes[] = [ - 'key' => '$createdAt', - 'type' => Database::VAR_DATETIME, - 'status' => 'available', - 'signed' => false, - 'required' => false, - 'array' => false, - 'default' => null, - 'size' => 0, - ]; - $oldAttributes[] = [ - 'key' => '$updatedAt', - 'type' => Database::VAR_DATETIME, - 'status' => 'available', - 'signed' => false, - 'required' => false, - 'array' => false, - 'default' => null, - 'size' => 0, - ]; - - $contextType = $this->getParentContext(); - if ($dbForDatabases->getAdapter()->getSupportForAttributes()) { - foreach ($attributes as $i => $attribute) { - $attributeIndex = \array_search($attribute, \array_column($oldAttributes, 'key')); - - if ($attributeIndex === false) { - throw new Exception($this->getParentUnknownException(), params: [$attribute]); - } - - $attributeStatus = $oldAttributes[$attributeIndex]['status']; - $attributeType = $oldAttributes[$attributeIndex]['type']; - $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; - - if ($attributeType === Database::VAR_RELATIONSHIP) { - throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']); - } - - if ($attributeStatus !== 'available') { - throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]); - } - - if (empty($lengths[$i])) { - $lengths[$i] = null; - } - - if ($attributeArray === true) { - throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); - } - } - } - - $index = new Document([ - '$id' => ID::custom($db->getSequence() . '_' . $collection->getSequence() . '_' . $key), - 'key' => $key, - 'status' => 'processing', - 'databaseInternalId' => $db->getSequence(), - 'databaseId' => $databaseId, - 'collectionInternalId' => $collection->getSequence(), - 'collectionId' => $collectionId, - 'type' => $type, - 'attributes' => $attributes, - 'lengths' => $lengths, - 'orders' => $orders, - ]); - - $validator = new IndexValidator( - $collection->getAttribute('attributes'), - $collection->getAttribute('indexes'), - $dbForDatabases->getAdapter()->getMaxIndexLength(), - $dbForDatabases->getAdapter()->getInternalIndexesKeys(), - $dbForDatabases->getAdapter()->getSupportForIndexArray(), - $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), - $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), - $dbForDatabases->getAdapter()->getSupportForVectors(), - $dbForDatabases->getAdapter()->getSupportForAttributes(), - $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), - $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), - $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), - $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), - $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), - $dbForDatabases->getAdapter()->getSupportForIndex(), - $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), - $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), - $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), - $dbForDatabases->getAdapter()->getSupportForObject() - ); - - if (!$validator->isValid($index)) { - throw new Exception($this->getInvalidTypeException(), $validator->getDescription()); - } - - try { - $index = $dbForProject->createDocument('indexes', $index); - } catch (DuplicateException) { - throw new Exception($this->getDuplicateException(), params: [$key]); - } - - $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); - - $queueForDatabase - ->setType(DATABASE_TYPE_CREATE_INDEX) - ->setDatabase($db); - - if ($this->isCollectionsAPI()) { - $queueForDatabase - ->setCollection($collection) - ->setDocument($index); - } else { - $queueForDatabase - ->setTable($collection) - ->setRow($index); - } - - $queueForEvents - ->setContext('database', $db) - ->setParam('databaseId', $databaseId) - ->setParam('indexId', $index->getId()) - ->setParam('collectionId', $collection->getId()) - ->setParam('tableId', $collection->getId()) - ->setContext($this->getCollectionsEventsContext(), $collection); - - return $index; - } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php index 1c1668056b..7e073c95d4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; @@ -11,7 +12,12 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Helpers\ID; +use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\Index as IndexValidator; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; @@ -80,21 +86,170 @@ class Create extends Action public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void { - $index = $this->createIndex( - $databaseId, - $collectionId, - $key, - $type, - $attributes, - $orders, - $lengths, - $dbForProject, - $getDatabasesDB, - $queueForDatabase, - $queueForEvents, - $authorization, + $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($db->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); + } + + $collection = $dbForProject->getDocument('database_' . $db->getSequence(), $collectionId); + + if ($collection->isEmpty()) { + // table or collection. + throw new Exception($this->getGrandParentNotFoundException(), params: [$collectionId]); + } + + $count = $dbForProject->count('indexes', [ + Query::equal('collectionInternalId', [$collection->getSequence()]), + Query::equal('databaseInternalId', [$db->getSequence()]) + ], 61); + + $dbForDatabases = $getDatabasesDB($db); + + $limit = $dbForDatabases->getLimitForIndexes(); + + if ($count >= $limit) { + throw new Exception($this->getLimitException(), params: [$collectionId]); + } + + $oldAttributes = \array_map( + fn ($a) => $a->getArrayCopy(), + $collection->getAttribute('attributes') ); + $oldAttributes[] = [ + 'key' => '$id', + 'type' => Database::VAR_STRING, + 'status' => 'available', + 'required' => true, + 'array' => false, + 'default' => null, + 'size' => Database::LENGTH_KEY + ]; + $oldAttributes[] = [ + 'key' => '$createdAt', + 'type' => Database::VAR_DATETIME, + 'status' => 'available', + 'signed' => false, + 'required' => false, + 'array' => false, + 'default' => null, + 'size' => 0 + ]; + $oldAttributes[] = [ + 'key' => '$updatedAt', + 'type' => Database::VAR_DATETIME, + 'status' => 'available', + 'signed' => false, + 'required' => false, + 'array' => false, + 'default' => null, + 'size' => 0 + ]; + + $contextType = $this->getParentContext(); + if ($dbForDatabases->getAdapter()->getSupportForAttributes()) { + foreach ($attributes as $i => $attribute) { + // find attribute metadata in collection document + $attributeIndex = \array_search($attribute, array_column($oldAttributes, 'key')); + + if ($attributeIndex === false) { + throw new Exception($this->getParentUnknownException(), params: [$attribute]); + } + + $attributeStatus = $oldAttributes[$attributeIndex]['status']; + $attributeType = $oldAttributes[$attributeIndex]['type']; + $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; + + if ($attributeType === Database::VAR_RELATIONSHIP) { + throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']); + } + + if ($attributeStatus !== 'available') { + throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]); + } + + if (empty($lengths[$i])) { + $lengths[$i] = null; + } + + if ($attributeArray === true) { + // Because of a bug in MySQL, we cannot create indexes on array attributes for now, otherwise queries break. + throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); + } + } + } + + $index = new Document([ + '$id' => ID::custom($db->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'status' => 'processing', // processing, available, failed, deleting, stuck + 'databaseInternalId' => $db->getSequence(), + 'databaseId' => $databaseId, + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collectionId, + 'type' => $type, + 'attributes' => $attributes, + 'lengths' => $lengths, + 'orders' => $orders, + ]); + + $validator = new IndexValidator( + $collection->getAttribute('attributes'), + $collection->getAttribute('indexes'), + $dbForDatabases->getAdapter()->getMaxIndexLength(), + $dbForDatabases->getAdapter()->getInternalIndexesKeys(), + $dbForDatabases->getAdapter()->getSupportForIndexArray(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), + $dbForDatabases->getAdapter()->getSupportForVectors(), + $dbForDatabases->getAdapter()->getSupportForAttributes(), + $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), + $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), + $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), + $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForIndex(), + $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), + $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), + $dbForDatabases->getAdapter()->getSupportForObject() + ); + + if (!$validator->isValid($index)) { + throw new Exception($this->getInvalidTypeException(), $validator->getDescription()); + } + + try { + $index = $dbForProject->createDocument('indexes', $index); + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException(), params: [$key]); + } + + $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); + + $queueForDatabase + ->setType(DATABASE_TYPE_CREATE_INDEX) + ->setDatabase($db); + + if ($this->isCollectionsAPI()) { + $queueForDatabase + ->setCollection($collection) + ->setDocument($index); + } else { + $queueForDatabase + ->setTable($collection) + ->setRow($index); + } + + $queueForEvents + ->setContext('database', $db) + ->setParam('databaseId', $databaseId) + ->setParam('indexId', $index->getId()) + ->setParam('collectionId', $collection->getId()) + ->setParam('tableId', $collection->getId()) + ->setContext($this->getCollectionsEventsContext(), $collection); + $response ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED) ->dynamic($index, $this->getResponseModel()); diff --git a/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php deleted file mode 100644 index 2875c1fe95..0000000000 --- a/src/Appwrite/Platform/Modules/Insights/Http/CTA/Execution/Create.php +++ /dev/null @@ -1,160 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/insights/:insightId/ctas/:ctaId/executions') - ->desc('Create insight CTA execution') - ->groups(['api', 'insights']) - ->label('scope', 'insights.write') - ->label('event', 'insights.[insightId].ctas.[ctaId].executions.create') - ->label('resourceType', RESOURCE_TYPE_INSIGHTS) - ->label('audits.event', 'insight.cta.execution.create') - ->label('audits.resource', 'insight/{request.insightId}') - ->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: 'insights', - group: 'insights', - name: 'createCTAExecution', - description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) - ->param('ctaId', '', new Text(64), 'CTA ID, unique within the parent insight.') - ->inject('response') - ->inject('project') - ->inject('dbForProject') - ->inject('getDatabasesDB') - ->inject('insightCTARegistry') - ->inject('queueForDatabase') - ->inject('queueForEvents') - ->inject('authorization') - ->callback($this->action(...)); - } - - public function action( - string $insightId, - string $ctaId, - Response $response, - Document $project, - Database $dbForProject, - callable $getDatabasesDB, - UtopiaRegistry $insightCTARegistry, - EventDatabase $queueForDatabase, - Event $queueForEvents, - Authorization $authorization - ) { - $insight = $dbForProject->getDocument('insights', $insightId); - - if ($insight->isEmpty()) { - throw new Exception(Exception::INSIGHT_NOT_FOUND); - } - - $cta = null; - foreach ($insight->getAttribute('ctas', []) as $candidate) { - if (($candidate['id'] ?? null) === $ctaId) { - $cta = $candidate; - break; - } - } - - if ($cta === null) { - throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); - } - - $actionName = (string) ($cta['action'] ?? ''); - $params = $cta['params'] ?? []; - - if (\is_object($params)) { - $params = (array) $params; - } - - if (!\is_array($params)) { - $params = []; - } - - try { - $action = $insightCTARegistry->get($actionName); - } catch (\Throwable) { - throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); - } - - if (!$action instanceof CTAAction) { - throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); - } - - $status = 'succeeded'; - $resultPayload = new \stdClass(); - - try { - $result = $action->execute( - $params, - $insight, - $project, - $dbForProject, - $getDatabasesDB, - $queueForDatabase, - $queueForEvents, - $authorization, - ); - $resultPayload = $result->getArrayCopy(); - } catch (Exception $e) { - if ($e->getType() === Exception::GENERAL_NOT_IMPLEMENTED) { - throw $e; - } - $status = 'failed'; - $resultPayload = ['error' => $e->getMessage()]; - } - - $queueForEvents - ->setParam('insightId', $insight->getId()) - ->setParam('ctaId', $ctaId); - - $response->dynamic(new Document([ - 'insightId' => $insight->getId(), - 'ctaId' => $ctaId, - 'action' => $actionName, - 'status' => $status, - 'result' => $resultPayload, - ]), Response::MODEL_INSIGHT_CTA_EXECUTION); - } -} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php index 9b6c17ad75..768e526d48 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php @@ -15,6 +15,7 @@ use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\Datetime as DatetimeValidator; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\JSON; @@ -61,7 +62,8 @@ class Create extends Action ), ] )) - ->param('insightId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('insightId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID. Optional — leave empty for ad-hoc insights not attached to a report.', true, ['dbForPlatform']) ->param('type', '', new WhiteList(INSIGHT_TYPES, true), 'Insight type. Determines the analyzer that owns this insight and the shape of `payload`.') ->param('severity', INSIGHT_SEVERITY_INFO, new WhiteList(INSIGHT_SEVERITIES, true), 'Insight severity. One of `info`, `warning`, `critical`.', true) ->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.') @@ -73,13 +75,15 @@ class Create extends Action ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) ->inject('response') - ->inject('dbForProject') + ->inject('project') + ->inject('dbForPlatform') ->inject('queueForEvents') ->callback($this->action(...)); } public function action( string $insightId, + string $reportId, string $type, string $severity, string $resourceType, @@ -91,15 +95,36 @@ class Create extends Action array $ctas, ?string $analyzedAt, Response $response, - Database $dbForProject, + Document $project, + Database $dbForPlatform, Event $queueForEvents ) { $insightId = ($insightId === 'unique()') ? ID::unique() : $insightId; + $reportInternalId = ''; + + if ($reportId !== '') { + $report = $dbForPlatform->getDocument('reports', $reportId); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + + $reportInternalId = $report->getSequence(); + } + + $seen = []; $normalizedCTAs = []; + foreach ($ctas as $cta) { + $ctaId = (string) $cta['id']; + if (isset($seen[$ctaId])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'CTA `id` values must be unique within an insight.'); + } + $seen[$ctaId] = true; + $normalizedCTAs[] = [ - 'id' => (string) $cta['id'], + 'id' => $ctaId, 'label' => (string) $cta['label'], 'action' => (string) $cta['action'], 'params' => $cta['params'] ?? new \stdClass(), @@ -107,8 +132,12 @@ class Create extends Action } try { - $insight = $dbForProject->createDocument('insights', new Document([ + $insight = $dbForPlatform->createDocument('insights', new Document([ '$id' => $insightId, + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'reportInternalId' => $reportInternalId, + 'reportId' => $reportId, 'type' => $type, 'severity' => $severity, 'status' => INSIGHT_STATUS_ACTIVE, diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php index ad2cd01818..5e2d4b36fe 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php @@ -10,6 +10,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -54,9 +55,10 @@ class Delete extends Action ], contentType: ContentType::NONE )) - ->param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform']) ->inject('response') - ->inject('dbForProject') + ->inject('project') + ->inject('dbForPlatform') ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,16 +66,17 @@ class Delete extends Action public function action( string $insightId, Response $response, - Database $dbForProject, + Document $project, + Database $dbForPlatform, Event $queueForEvents ) { - $insight = $dbForProject->getDocument('insights', $insightId); + $insight = $dbForPlatform->getDocument('insights', $insightId); - if ($insight->isEmpty()) { + if ($insight->isEmpty() || $insight->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::INSIGHT_NOT_FOUND); } - if (!$dbForProject->deleteDocument('insights', $insight->getId())) { + if (!$dbForPlatform->deleteDocument('insights', $insight->getId())) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove insight from DB'); } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php index bc4d33f241..b7de7bccc9 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php @@ -8,6 +8,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -45,20 +46,22 @@ class Get extends Action ), ] )) - ->param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform']) ->inject('response') - ->inject('dbForProject') + ->inject('project') + ->inject('dbForPlatform') ->callback($this->action(...)); } public function action( string $insightId, Response $response, - Database $dbForProject + Document $project, + Database $dbForPlatform ) { - $insight = $dbForProject->getDocument('insights', $insightId); + $insight = $dbForPlatform->getDocument('insights', $insightId); - if ($insight->isEmpty()) { + if ($insight->isEmpty() || $insight->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::INSIGHT_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php index 5601f7f42b..1b3ed983a5 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Insights\Http\Insights; use Appwrite\Event\Event; use Appwrite\Extend\Exception; +use Appwrite\Insights\Validator\CTAs as CTAsValidator; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -15,7 +16,6 @@ use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\ArrayList; use Utopia\Validator\JSON; use Utopia\Validator\Nullable; use Utopia\Validator\Text; @@ -60,17 +60,18 @@ class Update extends Action ), ] )) - ->param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform']) ->param('severity', null, new Nullable(new WhiteList(INSIGHT_SEVERITIES, true)), 'Insight severity. One of `info`, `warning`, `critical`.', true) ->param('status', null, new Nullable(new WhiteList(INSIGHT_STATUSES, true)), 'Insight status. Set to `dismissed` to dismiss the insight, `active` to undo a dismissal.', true) ->param('title', null, new Nullable(new Text(256)), 'Short, human-readable title.', true) ->param('summary', null, new Nullable(new Text(4096, 0)), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) - ->param('ctas', null, new Nullable(new ArrayList(new JSON(), 16)), 'Array of call-to-action descriptors.', true) + ->param('ctas', null, new Nullable(new CTAsValidator()), 'Array of call-to-action descriptors.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format.', true) ->inject('response') ->inject('user') - ->inject('dbForProject') + ->inject('project') + ->inject('dbForPlatform') ->inject('queueForEvents') ->callback($this->action(...)); } @@ -86,12 +87,13 @@ class Update extends Action ?string $analyzedAt, Response $response, Document $user, - Database $dbForProject, + Document $project, + Database $dbForPlatform, Event $queueForEvents ) { - $insight = $dbForProject->getDocument('insights', $insightId); + $insight = $dbForPlatform->getDocument('insights', $insightId); - if ($insight->isEmpty()) { + if ($insight->isEmpty() || $insight->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::INSIGHT_NOT_FOUND); } @@ -120,13 +122,17 @@ class Update extends Action $changes['payload'] = $payload; } if ($ctas !== null) { + $seen = []; $normalized = []; foreach ($ctas as $cta) { - if (!isset($cta['id'], $cta['label'], $cta['action'])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Each CTA must define `id`, `label`, and `action`.'); + $ctaId = (string) $cta['id']; + if (isset($seen[$ctaId])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'CTA `id` values must be unique within an insight.'); } + $seen[$ctaId] = true; + $normalized[] = [ - 'id' => (string) $cta['id'], + 'id' => $ctaId, 'label' => (string) $cta['label'], 'action' => (string) $cta['action'], 'params' => $cta['params'] ?? new \stdClass(), @@ -139,7 +145,10 @@ class Update extends Action } if ($changes !== []) { - $insight = $dbForProject->updateDocument('insights', $insight->getId(), new Document($changes)); + foreach ($changes as $key => $value) { + $insight->setAttribute($key, $value); + } + $insight = $dbForPlatform->updateDocument('insights', $insight->getId(), $insight); } $queueForEvents->setParam('insightId', $insight->getId()); diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php index 9ab6dfffc8..d0674178c3 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php @@ -54,7 +54,8 @@ class XList extends Action ->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('dbForProject') + ->inject('project') + ->inject('dbForPlatform') ->callback($this->action(...)); } @@ -62,7 +63,8 @@ class XList extends Action array $queries, bool $includeTotal, Response $response, - Database $dbForProject + Document $project, + Database $dbForPlatform ) { try { $queries = Query::parseQueries($queries); @@ -70,6 +72,8 @@ class XList extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + $cursor = Query::getCursorQueries($queries, false); $cursor = \reset($cursor); @@ -80,9 +84,9 @@ class XList extends Action } $insightId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('insights', $insightId); + $cursorDocument = $dbForPlatform->getDocument('insights', $insightId); - if ($cursorDocument->isEmpty()) { + if ($cursorDocument->isEmpty() || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Insight '{$insightId}' for the 'cursor' value not found."); } @@ -92,8 +96,8 @@ class XList extends Action $filterQueries = Query::groupByType($queries)['filters']; try { - $insights = $dbForProject->find('insights', $queries); - $total = $includeTotal ? $dbForProject->count('insights', $filterQueries, APP_LIMIT_COUNT) : 0; + $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."); } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Reports/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Create.php new file mode 100644 index 0000000000..2f6debff98 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Create.php @@ -0,0 +1,117 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/reports') + ->desc('Create report') + ->groups(['api', 'insights']) + ->label('scope', 'reports.write') + ->label('event', 'reports.[reportId].create') + ->label('resourceType', RESOURCE_TYPE_REPORTS) + ->label('audits.event', 'report.create') + ->label('audits.resource', 'report/{response.$id}') + ->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: 'insights', + group: 'reports', + name: 'createReport', + description: <<param('reportId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('type', '', new WhiteList(REPORT_TYPES, true), 'Analyzer type. One of `lighthouse`, `audit`, `databaseAnalyzer`.') + ->param('title', '', new Text(256), 'Short, human-readable title.') + ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the report.', true) + ->param('targetType', '', new Text(64), 'Plural noun describing what the report analyzes, e.g. `databases`, `sites`, `urls`.') + ->param('target', '', new Text(2048), 'Free-form target identifier (URL for lighthouse, resource ID for db).') + ->param('categories', [], new ArrayList(new Text(64), 32), 'Categories covered by the report, e.g. `performance`, `accessibility`. Max 32 entries, each 64 chars.', true) + ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the report was analyzed in ISO 8601 format. Defaults to now.', true) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $reportId, + string $type, + string $title, + string $summary, + string $targetType, + string $target, + array $categories, + ?string $analyzedAt, + Response $response, + Document $project, + Database $dbForPlatform, + Event $queueForEvents + ) { + $reportId = ($reportId === 'unique()') ? ID::unique() : $reportId; + + try { + $report = $dbForPlatform->createDocument('reports', new Document([ + '$id' => $reportId, + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => $type, + 'title' => $title, + 'summary' => $summary, + 'targetType' => $targetType, + 'target' => $target, + 'categories' => $categories, + 'analyzedAt' => $analyzedAt, + ])); + } catch (DuplicateException) { + throw new Exception(Exception::REPORT_ALREADY_EXISTS); + } + + $queueForEvents->setParam('reportId', $report->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($report, Response::MODEL_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php new file mode 100644 index 0000000000..f5bfe4a651 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php @@ -0,0 +1,100 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/reports/:reportId') + ->desc('Delete report') + ->groups(['api', 'insights']) + ->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: 'insights', + group: 'reports', + name: 'deleteReport', + description: <<param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $reportId, + Response $response, + Document $project, + Database $dbForPlatform, + Event $queueForEvents + ) { + $report = $dbForPlatform->getDocument('reports', $reportId); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + + $childInsights = $dbForPlatform->find('insights', [ + Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('reportInternalId', [$report->getSequence()]), + Query::limit(APP_LIMIT_COUNT), + ]); + + foreach ($childInsights as $insight) { + $dbForPlatform->deleteDocument('insights', $insight->getId()); + } + + if (!$dbForPlatform->deleteDocument('reports', $report->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove report from DB'); + } + + $queueForEvents + ->setParam('reportId', $report->getId()) + ->setPayload($response->output($report, Response::MODEL_REPORT)); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Reports/Get.php b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Get.php new file mode 100644 index 0000000000..c62c7fb8e8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Get.php @@ -0,0 +1,70 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/reports/:reportId') + ->desc('Get report') + ->groups(['api', 'insights']) + ->label('scope', 'reports.read') + ->label('resourceType', RESOURCE_TYPE_REPORTS) + ->label('sdk', new Method( + namespace: 'insights', + group: 'reports', + name: 'getReport', + description: <<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->getDocument('reports', $reportId); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + + $response->dynamic($report, Response::MODEL_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Reports/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Update.php new file mode 100644 index 0000000000..68a5828f30 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Update.php @@ -0,0 +1,115 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/reports/:reportId') + ->desc('Update report') + ->groups(['api', 'insights']) + ->label('scope', 'reports.write') + ->label('event', 'reports.[reportId].update') + ->label('resourceType', RESOURCE_TYPE_REPORTS) + ->label('audits.event', 'report.update') + ->label('audits.resource', 'report/{response.$id}') + ->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: 'insights', + group: 'reports', + name: 'updateReport', + description: <<param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform']) + ->param('title', null, new Nullable(new Text(256)), 'Short, human-readable title.', true) + ->param('summary', null, new Nullable(new Text(4096, 0)), 'Markdown summary describing the report.', true) + ->param('categories', null, new Nullable(new ArrayList(new Text(64), 32)), 'Categories covered by the report.', true) + ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the report was analyzed in ISO 8601 format.', true) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $reportId, + ?string $title, + ?string $summary, + ?array $categories, + ?string $analyzedAt, + Response $response, + Document $project, + Database $dbForPlatform, + Event $queueForEvents + ) { + $report = $dbForPlatform->getDocument('reports', $reportId); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + + $changes = []; + + if ($title !== null) { + $changes['title'] = $title; + } + if ($summary !== null) { + $changes['summary'] = $summary; + } + if ($categories !== null) { + $changes['categories'] = $categories; + } + if ($analyzedAt !== null) { + $changes['analyzedAt'] = $analyzedAt; + } + + if ($changes !== []) { + foreach ($changes as $key => $value) { + $report->setAttribute($key, $value); + } + $report = $dbForPlatform->updateDocument('reports', $report->getId(), $report); + } + + $queueForEvents->setParam('reportId', $report->getId()); + + $response->dynamic($report, Response::MODEL_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Reports/XList.php b/src/Appwrite/Platform/Modules/Insights/Http/Reports/XList.php new file mode 100644 index 0000000000..bc1d4e15d2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Reports/XList.php @@ -0,0 +1,110 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/reports') + ->desc('List reports') + ->groups(['api', 'insights']) + ->label('scope', 'reports.read') + ->label('resourceType', RESOURCE_TYPE_REPORTS) + ->label('sdk', new Method( + namespace: 'insights', + group: 'reports', + name: 'listReports', + description: <<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->getDocument('reports', $reportId); + + 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->find('reports', $queries); + $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."); + } + + $response->dynamic(new Document([ + 'reports' => $reports, + 'total' => $total, + ]), Response::MODEL_REPORT_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Services/Http.php b/src/Appwrite/Platform/Modules/Insights/Services/Http.php index 433df62865..f51e1daa05 100644 --- a/src/Appwrite/Platform/Modules/Insights/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Insights/Services/Http.php @@ -2,12 +2,16 @@ namespace Appwrite\Platform\Modules\Insights\Services; -use Appwrite\Platform\Modules\Insights\Http\CTA\Execution\Create as CreateInsightCTAExecution; use Appwrite\Platform\Modules\Insights\Http\Insights\Create as CreateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Delete as DeleteInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Get as GetInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Update as UpdateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\XList as ListInsights; +use Appwrite\Platform\Modules\Insights\Http\Reports\Create as CreateReport; +use Appwrite\Platform\Modules\Insights\Http\Reports\Delete as DeleteReport; +use Appwrite\Platform\Modules\Insights\Http\Reports\Get as GetReport; +use Appwrite\Platform\Modules\Insights\Http\Reports\Update as UpdateReport; +use Appwrite\Platform\Modules\Insights\Http\Reports\XList as ListReports; use Utopia\Platform\Service; class Http extends Service @@ -16,11 +20,16 @@ class Http extends Service { $this->type = Service::TYPE_HTTP; + $this->addAction(CreateReport::getName(), new CreateReport()); + $this->addAction(GetReport::getName(), new GetReport()); + $this->addAction(ListReports::getName(), new ListReports()); + $this->addAction(UpdateReport::getName(), new UpdateReport()); + $this->addAction(DeleteReport::getName(), new DeleteReport()); + $this->addAction(CreateInsight::getName(), new CreateInsight()); $this->addAction(GetInsight::getName(), new GetInsight()); $this->addAction(ListInsights::getName(), new ListInsights()); $this->addAction(UpdateInsight::getName(), new UpdateInsight()); $this->addAction(DeleteInsight::getName(), new DeleteInsight()); - $this->addAction(CreateInsightCTAExecution::getName(), new CreateInsightCTAExecution()); } } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php index 607c2b915e..b7e2cadf03 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php @@ -7,6 +7,8 @@ class Insights extends Base public const ALLOWED_ATTRIBUTES = [ 'type', 'severity', + 'status', + 'reportId', 'resourceType', 'resourceId', 'analyzedAt', diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Reports.php b/src/Appwrite/Utopia/Database/Validator/Queries/Reports.php new file mode 100644 index 0000000000..7d15e40152 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Reports.php @@ -0,0 +1,18 @@ + ['read("any")'], 'array' => true, ]) + ->addRule('reportId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Parent report ID, if this insight belongs to a report. Empty for ad-hoc insights.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) ->addRule('type', [ 'type' => self::TYPE_STRING, 'description' => 'Insight type. One of databaseIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance.', diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTA.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php index 9395062401..252bf1e208 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCTA.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php @@ -24,13 +24,13 @@ class InsightCTA extends Model ]) ->addRule('action', [ 'type' => self::TYPE_STRING, - 'description' => 'Registered server-side action name to execute when this CTA is triggered.', + 'description' => 'Public API method the client should invoke when this CTA is triggered.', 'default' => '', - 'example' => 'databases.indexes.create', + 'example' => 'databases.createIndex', ]) ->addRule('params', [ 'type' => self::TYPE_JSON, - 'description' => 'Parameter map passed to the action when this CTA is triggered.', + 'description' => 'Parameter map the client should pass to the action when this CTA is triggered.', 'default' => new \stdClass(), 'example' => ['databaseId' => 'main', 'collectionId' => 'orders', 'key' => '_idx_status'], ]); diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php b/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php deleted file mode 100644 index b52056c28c..0000000000 --- a/src/Appwrite/Utopia/Response/Model/InsightCTAExecution.php +++ /dev/null @@ -1,54 +0,0 @@ -addRule('insightId', [ - 'type' => self::TYPE_STRING, - 'description' => 'ID of the insight the CTA was executed against.', - 'default' => '', - 'example' => '5e5ea5c16897e', - ]) - ->addRule('ctaId', [ - 'type' => self::TYPE_STRING, - 'description' => 'ID of the CTA that was executed.', - 'default' => '', - 'example' => 'createIndex', - ]) - ->addRule('action', [ - 'type' => self::TYPE_STRING, - 'description' => 'Registered server-side action that was executed.', - 'default' => '', - 'example' => 'databases.indexes.create', - ]) - ->addRule('status', [ - 'type' => self::TYPE_STRING, - 'description' => 'Outcome of the CTA execution. One of succeeded, failed.', - 'default' => 'succeeded', - 'example' => 'succeeded', - ]) - ->addRule('result', [ - 'type' => self::TYPE_JSON, - 'description' => 'Action-specific result data. May reference the resource that was created or updated.', - 'default' => new \stdClass(), - 'example' => ['indexId' => '_idx_status'], - ]); - } - - public function getName(): string - { - return 'InsightCTAExecution'; - } - - public function getType(): string - { - return Response::MODEL_INSIGHT_CTA_EXECUTION; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/Report.php b/src/Appwrite/Utopia/Response/Model/Report.php new file mode 100644 index 0000000000..28003af164 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Report.php @@ -0,0 +1,86 @@ +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('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('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/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 44e37a2768..58d65c13df 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -24,12 +24,66 @@ trait InsightsBase ], $this->getHeaders()); } - public function testCreate(): array + public function testCreateReport(): array + { + $reportId = ID::unique(); + + $response = $this->client->call(Client::METHOD_POST, '/reports', $this->serverHeaders(), [ + 'reportId' => $reportId, + 'type' => 'databaseAnalyzer', + 'title' => 'Database analyzer report', + 'targetType' => 'databases', + 'target' => 'main', + 'categories' => ['performance'], + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($reportId, $response['body']['$id']); + $this->assertSame('databaseAnalyzer', $response['body']['type']); + $this->assertSame('main', $response['body']['target']); + + return ['reportId' => $reportId]; + } + + /** + * @depends testCreateReport + */ + public function testGetReport(array $data): array + { + $reportId = $data['reportId']; + + $response = $this->client->call(Client::METHOD_GET, '/reports/' . $reportId, $this->serverHeaders()); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($reportId, $response['body']['$id']); + + $missing = $this->client->call(Client::METHOD_GET, '/reports/missing', $this->serverHeaders()); + $this->assertSame(404, $missing['headers']['status-code']); + + return $data; + } + + /** + * @depends testGetReport + */ + public function testListReports(array $data): array + { + $response = $this->client->call(Client::METHOD_GET, '/reports', $this->serverHeaders()); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + + return $data; + } + + /** + * @depends testListReports + */ + public function testCreate(array $data): array { $insightId = ID::unique(); $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ 'insightId' => $insightId, + 'reportId' => $data['reportId'], 'type' => 'databaseIndex', 'severity' => 'warning', 'resourceType' => 'databases', @@ -40,7 +94,7 @@ trait InsightsBase 'ctas' => [[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.indexes.create', + 'action' => 'databases.createIndex', 'params' => [ 'databaseId' => 'main', 'collectionId' => 'orders', @@ -53,6 +107,7 @@ trait InsightsBase $this->assertSame(201, $response['headers']['status-code']); $this->assertSame($insightId, $response['body']['$id']); + $this->assertSame($data['reportId'], $response['body']['reportId']); $this->assertSame('databaseIndex', $response['body']['type']); $this->assertSame('warning', $response['body']['severity']); $this->assertSame('databases', $response['body']['resourceType']); @@ -60,8 +115,42 @@ trait InsightsBase $this->assertSame('Missing index on collection orders', $response['body']['title']); $this->assertCount(1, $response['body']['ctas']); $this->assertSame('createIndex', $response['body']['ctas'][0]['id']); + $this->assertSame('databases.createIndex', $response['body']['ctas'][0]['action']); - return ['insightId' => $insightId]; + return $data + ['insightId' => $insightId]; + } + + public function testCreateRejectsDuplicateCTAIds(): void + { + $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + 'insightId' => ID::unique(), + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + 'ctas' => [ + ['id' => 'dup', 'label' => 'A', 'action' => 'databases.createIndex'], + ['id' => 'dup', 'label' => 'B', 'action' => 'databases.createIndex'], + ], + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testCreateRejectsUnknownReport(): void + { + $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + 'insightId' => ID::unique(), + 'reportId' => 'definitely-missing', + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + ]); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('report_not_found', $response['body']['type']); } /** @@ -103,6 +192,16 @@ trait InsightsBase $this->assertSame('databases', $insight['resourceType']); } + $byStatus = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders(), [ + 'queries' => [ + 'equal("status", "active")', + ], + ]); + $this->assertSame(200, $byStatus['headers']['status-code']); + foreach ($byStatus['body']['insights'] as $insight) { + $this->assertSame('active', $insight['status']); + } + return $data; } @@ -129,6 +228,26 @@ trait InsightsBase /** * @depends testUpdate */ + public function testUpdateRejectsDuplicateCTAIds(array $data): array + { + $insightId = $data['insightId']; + + $response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ + 'ctas' => [ + ['id' => 'dup', 'label' => 'A', 'action' => 'databases.createIndex'], + ['id' => 'dup', 'label' => 'B', 'action' => 'databases.createIndex'], + ], + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + return $data; + } + + /** + * @depends testUpdateRejectsDuplicateCTAIds + */ public function testDismissViaUpdate(array $data): array { $insightId = $data['insightId']; @@ -152,26 +271,6 @@ trait InsightsBase return $data; } - /** - * @depends testDismissViaUpdate - */ - public function testCreateCTAExecution(array $data): void - { - $insightId = $data['insightId']; - - $missingCTA = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/missing/executions', $this->serverHeaders()); - $this->assertSame(404, $missingCTA['headers']['status-code']); - $this->assertSame('insight_cta_not_found', $missingCTA['body']['type']); - - $response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/ctas/createIndex/executions', $this->serverHeaders()); - - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($insightId, $response['body']['insightId']); - $this->assertSame('createIndex', $response['body']['ctaId']); - $this->assertSame('databases.indexes.create', $response['body']['action']); - $this->assertContains($response['body']['status'], ['succeeded', 'failed']); - } - public function testCreateRequiresServerKey(): void { $response = $this->client->call(Client::METHOD_POST, '/insights', [ @@ -207,4 +306,34 @@ trait InsightsBase $missing = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); $this->assertSame(404, $missing['headers']['status-code']); } + + public function testDeleteReportCascadesToInsights(): void + { + $reportId = ID::unique(); + $createReport = $this->client->call(Client::METHOD_POST, '/reports', $this->serverHeaders(), [ + 'reportId' => $reportId, + 'type' => 'audit', + 'title' => 'Cascade-target report', + 'targetType' => 'sites', + 'target' => 'home', + ]); + $this->assertSame(201, $createReport['headers']['status-code']); + + $insightId = ID::unique(); + $createInsight = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + 'insightId' => $insightId, + 'reportId' => $reportId, + 'type' => 'sitePerformance', + 'resourceType' => 'sites', + 'resourceId' => 'home', + 'title' => 'Largest contentful paint regressed', + ]); + $this->assertSame(201, $createInsight['headers']['status-code']); + + $deleteReport = $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId, $this->serverHeaders()); + $this->assertSame(204, $deleteReport['headers']['status-code']); + + $orphaned = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); + $this->assertSame(404, $orphaned['headers']['status-code']); + } } diff --git a/tests/unit/Insights/Validator/CTA/Databases/Index/CreateTest.php b/tests/unit/Insights/Validator/CTA/Databases/Index/CreateTest.php deleted file mode 100644 index a80d854919..0000000000 --- a/tests/unit/Insights/Validator/CTA/Databases/Index/CreateTest.php +++ /dev/null @@ -1,65 +0,0 @@ -assertTrue($validator->isValid([ - 'databaseId' => 'main', - 'collectionId' => 'orders', - 'key' => '_idx_status', - 'type' => 'key', - 'attributes' => ['status'], - ])); - } - - public function testRejectsNonArray(): void - { - $validator = new Create(); - - $this->assertFalse($validator->isValid('not-an-array')); - $this->assertFalse($validator->isValid(null)); - } - - public function testRejectsMissingRequiredParam(): void - { - $validator = new Create(); - - $this->assertFalse($validator->isValid([ - 'databaseId' => 'main', - 'collectionId' => 'orders', - 'key' => '_idx_status', - 'type' => 'key', - ])); - $this->assertStringContainsString('attributes', $validator->getDescription()); - } - - public function testRejectsEmptyAttributes(): void - { - $validator = new Create(); - - $this->assertFalse($validator->isValid([ - 'databaseId' => 'main', - 'collectionId' => 'orders', - 'key' => '_idx_status', - 'type' => 'key', - 'attributes' => [], - ])); - $this->assertStringContainsString('non-empty', $validator->getDescription()); - } - - public function testReportsArrayType(): void - { - $validator = new Create(); - - $this->assertTrue($validator->isArray()); - $this->assertSame($validator::TYPE_ARRAY, $validator->getType()); - } -} diff --git a/tests/unit/Insights/Validator/CTAsTest.php b/tests/unit/Insights/Validator/CTAsTest.php index 7d520d7a9a..a16f7933aa 100644 --- a/tests/unit/Insights/Validator/CTAsTest.php +++ b/tests/unit/Insights/Validator/CTAsTest.php @@ -98,4 +98,37 @@ class CTAsTest extends TestCase $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[] = [ + 'id' => 'cta-' . $i, + 'label' => 'Label ' . $i, + 'action' => 'databases.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[] = [ + 'id' => 'cta-' . $i, + 'label' => 'Label ' . $i, + 'action' => 'databases.createIndex', + ]; + } + + $this->assertTrue($validator->isValid($entries)); + } } From 8f79379b6e807a71e7f1a935faef4c6b65b45065 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 16:25:49 +1200 Subject: [PATCH 25/70] test(insights): full e2e + per-engine CTA action mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructure InsightsBase trait with explicit helper methods (createInsight/getInsight/listInsights/updateInsight/deleteInsight, createReport/getReport/listReports/updateReport/deleteReport, plus sampleInsight/sampleCTA factories) — same shape ProxyBase uses. - Add coverage for: report CRUD + duplicate-id rejection, invalid type rejection, list filtering by all allowed attributes, cursor pagination + missing-cursor, update preserving untouched fields, CTA validation edge cases (duplicate ids, empty fields, count > 16), dismissal round-trip + status filter, report cascade delete, unauthorized access (no server key), empty-result list. - Engine-specific insight types (tablesDBIndex, documentsDBIndex, vectorsDBIndex, plus the legacy databaseIndex) so the CTA's `action` can map to the matching public API: databases.createIndex, tablesDB.createIndex, documentsDB.createIndex, vectorsDB.createIndex. dataProvider drives the engine matrix and asserts the right action lands in the persisted CTA. Constants for each action name live in app/init/constants.php. - InsightCTA model docs spell out which action belongs to which engine and that the params keys differ between APIs (tableId/columns for tablesDB vs collectionId/attributes for the legacy / DocumentsDB / VectorsDB APIs). - Insight model `type` description now lists every engine variant. - CTAsTest gains coverage for object-shaped params, empty-action and empty-label rejection, and the default 16-entry cap. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/constants.php | 17 +- .../Utopia/Response/Model/Insight.php | 4 +- .../Utopia/Response/Model/InsightCTA.php | 8 +- tests/e2e/Services/Insights/InsightsBase.php | 785 ++++++++++++++---- tests/unit/Insights/Validator/CTAsTest.php | 72 +- 5 files changed, 700 insertions(+), 186 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 44e158e5a4..bbd8df037a 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -426,8 +426,11 @@ const RESOURCE_TYPE_EMBEDDINGS_TEXT = 'embeddingsText'; const RESOURCE_TYPE_INSIGHTS = 'insights'; const RESOURCE_TYPE_REPORTS = 'reports'; -// Insight types -const INSIGHT_TYPE_DATABASE_INDEX = 'databaseIndex'; +// Insight types — engine-specific so the CTA action can reference the right public API. +const INSIGHT_TYPE_DATABASE_INDEX = 'databaseIndex'; // legacy databases.createIndex +const INSIGHT_TYPE_TABLES_DB_INDEX = 'tablesDBIndex'; // tablesDB.createIndex +const INSIGHT_TYPE_DOCUMENTS_DB_INDEX = 'documentsDBIndex'; // documentsDB.createIndex +const INSIGHT_TYPE_VECTORS_DB_INDEX = 'vectorsDBIndex'; // vectorsDB.createIndex const INSIGHT_TYPE_DATABASE_PERFORMANCE = 'databasePerformance'; const INSIGHT_TYPE_SITE_PERFORMANCE = 'sitePerformance'; const INSIGHT_TYPE_SITE_ACCESSIBILITY = 'siteAccessibility'; @@ -436,6 +439,9 @@ const INSIGHT_TYPE_FUNCTION_PERFORMANCE = 'functionPerformance'; const INSIGHT_TYPES = [ INSIGHT_TYPE_DATABASE_INDEX, + INSIGHT_TYPE_TABLES_DB_INDEX, + INSIGHT_TYPE_DOCUMENTS_DB_INDEX, + INSIGHT_TYPE_VECTORS_DB_INDEX, INSIGHT_TYPE_DATABASE_PERFORMANCE, INSIGHT_TYPE_SITE_PERFORMANCE, INSIGHT_TYPE_SITE_ACCESSIBILITY, @@ -443,6 +449,13 @@ const INSIGHT_TYPES = [ INSIGHT_TYPE_FUNCTION_PERFORMANCE, ]; +// Public API method names that an insight CTA's `action` can reference for index suggestions. +// Analyzers must pick the one matching the engine the resource lives in. +const INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX = 'databases.createIndex'; +const INSIGHT_CTA_ACTION_TABLES_DB_CREATE_INDEX = 'tablesDB.createIndex'; +const INSIGHT_CTA_ACTION_DOCUMENTS_DB_CREATE_INDEX = 'documentsDB.createIndex'; +const INSIGHT_CTA_ACTION_VECTORS_DB_CREATE_INDEX = 'vectorsDB.createIndex'; + // Insight severities const INSIGHT_SEVERITY_INFO = 'info'; const INSIGHT_SEVERITY_WARNING = 'warning'; diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php index c4bf121e20..c9084c4e2e 100644 --- a/src/Appwrite/Utopia/Response/Model/Insight.php +++ b/src/Appwrite/Utopia/Response/Model/Insight.php @@ -43,9 +43,9 @@ class Insight extends Model ]) ->addRule('type', [ 'type' => self::TYPE_STRING, - 'description' => 'Insight type. One of databaseIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance.', + 'description' => 'Insight type. One of databaseIndex (legacy), tablesDBIndex, documentsDBIndex, vectorsDBIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance. The index types are engine-specific so the CTA action can map to the correct public API (databases.createIndex, tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex).', 'default' => '', - 'example' => 'databaseIndex', + 'example' => 'tablesDBIndex', ]) ->addRule('severity', [ 'type' => self::TYPE_STRING, diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTA.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php index 252bf1e208..ddb5821336 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCTA.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php @@ -24,15 +24,15 @@ class InsightCTA extends Model ]) ->addRule('action', [ 'type' => self::TYPE_STRING, - 'description' => 'Public API method the client should invoke when this CTA is triggered.', + 'description' => 'Public API method the client should invoke when this CTA is triggered. Must match the engine that owns the resource: databases.createIndex (legacy), tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex for index suggestions.', 'default' => '', - 'example' => 'databases.createIndex', + 'example' => 'tablesDB.createIndex', ]) ->addRule('params', [ 'type' => self::TYPE_JSON, - 'description' => 'Parameter map the client should pass to the action when this CTA is triggered.', + 'description' => 'Parameter map the client should pass to the action 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', 'collectionId' => 'orders', 'key' => '_idx_status'], + 'example' => ['databaseId' => 'main', 'tableId' => 'orders', 'key' => '_idx_status', 'type' => 'key', 'columns' => ['status']], ]); } diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 58d65c13df..0d82cfae07 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -24,75 +24,70 @@ trait InsightsBase ], $this->getHeaders()); } - public function testCreateReport(): array + protected function createReport(array $body, array $headers = null): array { - $reportId = ID::unique(); + return $this->client->call(Client::METHOD_POST, '/reports', $headers ?? $this->serverHeaders(), $body); + } - $response = $this->client->call(Client::METHOD_POST, '/reports', $this->serverHeaders(), [ - 'reportId' => $reportId, - 'type' => 'databaseAnalyzer', - 'title' => 'Database analyzer report', - 'targetType' => 'databases', - 'target' => 'main', - 'categories' => ['performance'], - ]); + protected function getReport(string $reportId, array $headers = null): array + { + return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId, $headers ?? $this->serverHeaders()); + } - $this->assertSame(201, $response['headers']['status-code']); - $this->assertSame($reportId, $response['body']['$id']); - $this->assertSame('databaseAnalyzer', $response['body']['type']); - $this->assertSame('main', $response['body']['target']); + protected function listReports(array $params = [], array $headers = null): array + { + return $this->client->call(Client::METHOD_GET, '/reports', $headers ?? $this->serverHeaders(), $params); + } - return ['reportId' => $reportId]; + protected function updateReport(string $reportId, array $body, array $headers = null): array + { + return $this->client->call(Client::METHOD_PATCH, '/reports/' . $reportId, $headers ?? $this->serverHeaders(), $body); + } + + protected function deleteReport(string $reportId, array $headers = null): array + { + return $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId, $headers ?? $this->serverHeaders()); + } + + protected function createInsight(array $body, array $headers = null): array + { + return $this->client->call(Client::METHOD_POST, '/insights', $headers ?? $this->serverHeaders(), $body); + } + + protected function getInsight(string $insightId, array $headers = null): array + { + return $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $headers ?? $this->serverHeaders()); + } + + protected function listInsights(array $params = [], array $headers = null): array + { + return $this->client->call(Client::METHOD_GET, '/insights', $headers ?? $this->serverHeaders(), $params); + } + + protected function updateInsight(string $insightId, array $body, array $headers = null): array + { + return $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $headers ?? $this->serverHeaders(), $body); + } + + protected function deleteInsight(string $insightId, array $headers = null): array + { + return $this->client->call(Client::METHOD_DELETE, '/insights/' . $insightId, $headers ?? $this->serverHeaders()); } /** - * @depends testCreateReport + * Sample CTA pointing at the engine-specific public API. + * + * The `engine` parameter selects which API the CTA targets: + * - `databases` → databases.createIndex (legacy, params use collectionId/attributes) + * - `tablesDB` → tablesDB.createIndex (params use tableId/columns) + * - `documentsDB` → documentsDB.createIndex (params use collectionId/attributes) + * - `vectorsDB` → vectorsDB.createIndex (params use collectionId/attributes) */ - public function testGetReport(array $data): array + protected function sampleCTA(string $id = 'createIndex', string $engine = 'tablesDB'): array { - $reportId = $data['reportId']; - - $response = $this->client->call(Client::METHOD_GET, '/reports/' . $reportId, $this->serverHeaders()); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($reportId, $response['body']['$id']); - - $missing = $this->client->call(Client::METHOD_GET, '/reports/missing', $this->serverHeaders()); - $this->assertSame(404, $missing['headers']['status-code']); - - return $data; - } - - /** - * @depends testGetReport - */ - public function testListReports(array $data): array - { - $response = $this->client->call(Client::METHOD_GET, '/reports', $this->serverHeaders()); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertGreaterThanOrEqual(1, $response['body']['total']); - - return $data; - } - - /** - * @depends testListReports - */ - public function testCreate(array $data): array - { - $insightId = ID::unique(); - - $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ - 'insightId' => $insightId, - 'reportId' => $data['reportId'], - 'type' => 'databaseIndex', - 'severity' => 'warning', - 'resourceType' => 'databases', - 'resourceId' => 'main', - 'title' => 'Missing index on collection orders', - 'summary' => 'Queries against `orders.status` are scanning the full collection.', - 'payload' => ['databaseId' => 'main', 'collectionId' => 'orders'], - 'ctas' => [[ - 'id' => 'createIndex', + return match ($engine) { + 'databases' => [ + 'id' => $id, 'label' => 'Create missing index', 'action' => 'databases.createIndex', 'params' => [ @@ -102,27 +97,355 @@ trait InsightsBase 'type' => 'key', 'attributes' => ['status'], ], - ]], + ], + 'tablesDB' => [ + 'id' => $id, + 'label' => 'Create missing index', + 'action' => 'tablesDB.createIndex', + 'params' => [ + 'databaseId' => 'main', + 'tableId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'columns' => ['status'], + ], + ], + 'documentsDB' => [ + 'id' => $id, + 'label' => 'Create missing index', + 'action' => 'documentsDB.createIndex', + 'params' => [ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => ['status'], + ], + ], + 'vectorsDB' => [ + 'id' => $id, + 'label' => 'Create missing index', + 'action' => 'vectorsDB.createIndex', + 'params' => [ + 'databaseId' => 'main', + 'collectionId' => 'orders', + 'key' => '_idx_status', + 'type' => 'key', + 'attributes' => ['status'], + ], + ], + default => throw new \InvalidArgumentException("Unknown engine: {$engine}"), + }; + } + + protected function sampleInsight(string $insightId = null, string $reportId = null, string $engine = 'tablesDB'): array + { + $type = match ($engine) { + 'databases' => 'databaseIndex', + 'tablesDB' => 'tablesDBIndex', + 'documentsDB' => 'documentsDBIndex', + 'vectorsDB' => 'vectorsDBIndex', + default => throw new \InvalidArgumentException("Unknown engine: {$engine}"), + }; + + $resourceType = match ($engine) { + 'databases' => 'databases', + 'tablesDB' => 'tables', + 'documentsDB' => 'collections', + 'vectorsDB' => 'collections', + default => 'databases', + }; + + $body = [ + 'insightId' => $insightId ?? ID::unique(), + 'type' => $type, + 'severity' => 'warning', + 'resourceType' => $resourceType, + 'resourceId' => 'main', + 'title' => 'Missing index on collection orders', + 'summary' => 'Queries against `orders.status` are scanning the full collection.', + 'payload' => ['databaseId' => 'main', 'engine' => $engine], + 'ctas' => [$this->sampleCTA('createIndex', $engine)], + ]; + + if ($reportId !== null) { + $body['reportId'] = $reportId; + } + + return $body; + } + + public function testCreateReport(): array + { + $reportId = ID::unique(); + + $report = $this->createReport([ + 'reportId' => $reportId, + 'type' => 'databaseAnalyzer', + 'title' => 'Database analyzer report', + 'summary' => 'Daily scan of project DB.', + 'targetType' => 'databases', + 'target' => 'main', + 'categories' => ['performance', 'integrity'], ]); - $this->assertSame(201, $response['headers']['status-code']); - $this->assertSame($insightId, $response['body']['$id']); - $this->assertSame($data['reportId'], $response['body']['reportId']); - $this->assertSame('databaseIndex', $response['body']['type']); - $this->assertSame('warning', $response['body']['severity']); - $this->assertSame('databases', $response['body']['resourceType']); - $this->assertSame('main', $response['body']['resourceId']); - $this->assertSame('Missing index on collection orders', $response['body']['title']); - $this->assertCount(1, $response['body']['ctas']); - $this->assertSame('createIndex', $response['body']['ctas'][0]['id']); - $this->assertSame('databases.createIndex', $response['body']['ctas'][0]['action']); + $this->assertSame(201, $report['headers']['status-code']); + $this->assertSame($reportId, $report['body']['$id']); + $this->assertSame('databaseAnalyzer', $report['body']['type']); + $this->assertSame('Database analyzer report', $report['body']['title']); + $this->assertSame('main', $report['body']['target']); + $this->assertSame('databases', $report['body']['targetType']); + $this->assertSame(['performance', 'integrity'], $report['body']['categories']); + $this->assertArrayHasKey('$createdAt', $report['body']); + $this->assertArrayHasKey('$updatedAt', $report['body']); + + return ['reportId' => $reportId]; + } + + public function testCreateReportRejectsInvalidType(): void + { + $report = $this->createReport([ + 'reportId' => ID::unique(), + 'type' => 'unknownAnalyzer', + 'title' => 'Bad type', + 'targetType' => 'databases', + 'target' => 'main', + ]); + + $this->assertSame(400, $report['headers']['status-code']); + } + + public function testCreateReportRejectsDuplicateId(): void + { + $reportId = ID::unique(); + + $first = $this->createReport([ + 'reportId' => $reportId, + 'type' => 'audit', + 'title' => 'First', + 'targetType' => 'sites', + 'target' => 'home', + ]); + $this->assertSame(201, $first['headers']['status-code']); + + $second = $this->createReport([ + 'reportId' => $reportId, + 'type' => 'audit', + 'title' => 'Second', + 'targetType' => 'sites', + 'target' => 'home', + ]); + $this->assertSame(409, $second['headers']['status-code']); + $this->assertSame('report_already_exists', $second['body']['type']); + + // cleanup + $this->deleteReport($reportId); + } + + /** + * @depends testCreateReport + */ + public function testGetReport(array $data): array + { + $report = $this->getReport($data['reportId']); + + $this->assertSame(200, $report['headers']['status-code']); + $this->assertSame($data['reportId'], $report['body']['$id']); + $this->assertSame('databaseAnalyzer', $report['body']['type']); + + $missing = $this->getReport('missing'); + $this->assertSame(404, $missing['headers']['status-code']); + $this->assertSame('report_not_found', $missing['body']['type']); + + return $data; + } + + /** + * @depends testGetReport + */ + public function testListReports(array $data): array + { + $list = $this->listReports(); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertNotEmpty($list['body']['reports']); + + $byType = $this->listReports([ + 'queries' => [ + 'equal("type", "databaseAnalyzer")', + ], + ]); + $this->assertSame(200, $byType['headers']['status-code']); + foreach ($byType['body']['reports'] as $report) { + $this->assertSame('databaseAnalyzer', $report['type']); + } + + $byTarget = $this->listReports([ + 'queries' => [ + 'equal("targetType", "databases")', + 'equal("target", "main")', + ], + ]); + $this->assertSame(200, $byTarget['headers']['status-code']); + foreach ($byTarget['body']['reports'] as $report) { + $this->assertSame('databases', $report['targetType']); + $this->assertSame('main', $report['target']); + } + + return $data; + } + + /** + * @depends testListReports + */ + public function testUpdateReport(array $data): array + { + $original = $this->getReport($data['reportId']); + $this->assertSame(200, $original['headers']['status-code']); + + $updated = $this->updateReport($data['reportId'], [ + 'title' => 'Updated database analyzer report', + 'summary' => 'Updated summary.', + ]); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('Updated database analyzer report', $updated['body']['title']); + $this->assertSame('Updated summary.', $updated['body']['summary']); + + // Unchanged fields preserved + $this->assertSame($original['body']['type'], $updated['body']['type']); + $this->assertSame($original['body']['target'], $updated['body']['target']); + $this->assertSame($original['body']['targetType'], $updated['body']['targetType']); + + $missing = $this->updateReport('missing', ['title' => 'x']); + $this->assertSame(404, $missing['headers']['status-code']); + + return $data; + } + + /** + * @depends testUpdateReport + */ + public function testCreate(array $data): array + { + $insightId = ID::unique(); + + $insight = $this->createInsight($this->sampleInsight($insightId, $data['reportId'], 'tablesDB')); + + $this->assertSame(201, $insight['headers']['status-code']); + $this->assertSame($insightId, $insight['body']['$id']); + $this->assertSame($data['reportId'], $insight['body']['reportId']); + $this->assertSame('tablesDBIndex', $insight['body']['type']); + $this->assertSame('warning', $insight['body']['severity']); + $this->assertSame('active', $insight['body']['status']); + $this->assertSame('tables', $insight['body']['resourceType']); + $this->assertSame('main', $insight['body']['resourceId']); + $this->assertSame('Missing index on collection orders', $insight['body']['title']); + $this->assertCount(1, $insight['body']['ctas']); + $this->assertSame('createIndex', $insight['body']['ctas'][0]['id']); + $this->assertSame('tablesDB.createIndex', $insight['body']['ctas'][0]['action']); + $this->assertSame('orders', $insight['body']['ctas'][0]['params']['tableId']); + $this->assertSame(['status'], $insight['body']['ctas'][0]['params']['columns']); + $this->assertEmpty($insight['body']['dismissedAt']); + $this->assertEmpty($insight['body']['dismissedBy']); return $data + ['insightId' => $insightId]; } + /** + * Each engine — legacy databases, tablesDB, documentsDB, vectorsDB — should be + * createable with its own insight type and a CTA that points at the matching + * public API method. + * + * @dataProvider engineMatrixProvider + */ + public function testCreateForEachEngine(string $engine, string $expectedType, string $expectedAction): void + { + $insightId = ID::unique(); + + $insight = $this->createInsight($this->sampleInsight($insightId, null, $engine)); + + $this->assertSame(201, $insight['headers']['status-code']); + $this->assertSame($expectedType, $insight['body']['type']); + $this->assertSame($expectedAction, $insight['body']['ctas'][0]['action']); + + $this->deleteInsight($insightId); + } + + public static function engineMatrixProvider(): array + { + return [ + 'legacy databases' => ['databases', 'databaseIndex', 'databases.createIndex'], + 'tablesDB' => ['tablesDB', 'tablesDBIndex', 'tablesDB.createIndex'], + 'documentsDB' => ['documentsDB', 'documentsDBIndex', 'documentsDB.createIndex'], + 'vectorsDB' => ['vectorsDB', 'vectorsDBIndex', 'vectorsDB.createIndex'], + ]; + } + + public function testCreateWithoutReport(): void + { + $insightId = ID::unique(); + + $insight = $this->createInsight($this->sampleInsight($insightId)); + + $this->assertSame(201, $insight['headers']['status-code']); + $this->assertSame($insightId, $insight['body']['$id']); + $this->assertEmpty($insight['body']['reportId']); + + $this->deleteInsight($insightId); + } + + public function testCreateRejectsInvalidType(): void + { + $insight = $this->createInsight([ + 'insightId' => ID::unique(), + 'type' => 'unknownType', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + ]); + $this->assertSame(400, $insight['headers']['status-code']); + } + + public function testCreateRejectsInvalidSeverity(): void + { + $insight = $this->createInsight([ + 'insightId' => ID::unique(), + 'type' => 'databaseIndex', + 'severity' => 'catastrophic', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + ]); + $this->assertSame(400, $insight['headers']['status-code']); + } + + public function testCreateRejectsDuplicateId(): void + { + $insightId = ID::unique(); + + $first = $this->createInsight($this->sampleInsight($insightId)); + $this->assertSame(201, $first['headers']['status-code']); + + $second = $this->createInsight($this->sampleInsight($insightId)); + $this->assertSame(409, $second['headers']['status-code']); + $this->assertSame('insight_already_exists', $second['body']['type']); + + $this->deleteInsight($insightId); + } + + public function testCreateRejectsUnknownReport(): void + { + $insight = $this->createInsight($this->sampleInsight(null, 'definitely-missing')); + + $this->assertSame(404, $insight['headers']['status-code']); + $this->assertSame('report_not_found', $insight['body']['type']); + } + public function testCreateRejectsDuplicateCTAIds(): void { - $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + $insight = $this->createInsight([ 'insightId' => ID::unique(), 'type' => 'databaseIndex', 'resourceType' => 'databases', @@ -134,23 +457,47 @@ trait InsightsBase ], ]); - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertSame(400, $insight['headers']['status-code']); + $this->assertSame('general_argument_invalid', $insight['body']['type']); } - public function testCreateRejectsUnknownReport(): void + public function testCreateRejectsCTAWithEmptyFields(): void { - $response = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ + $insight = $this->createInsight([ 'insightId' => ID::unique(), - 'reportId' => 'definitely-missing', 'type' => 'databaseIndex', 'resourceType' => 'databases', 'resourceId' => 'main', 'title' => 'Should not be created', + 'ctas' => [ + ['id' => '', 'label' => 'Has empty id', 'action' => 'databases.createIndex'], + ], ]); - $this->assertSame(404, $response['headers']['status-code']); - $this->assertSame('report_not_found', $response['body']['type']); + $this->assertSame(400, $insight['headers']['status-code']); + } + + public function testCreateRejectsTooManyCTAs(): void + { + $ctas = []; + for ($i = 0; $i < 17; $i++) { + $ctas[] = [ + 'id' => 'cta-' . $i, + 'label' => 'CTA ' . $i, + 'action' => 'databases.createIndex', + ]; + } + + $insight = $this->createInsight([ + 'insightId' => ID::unique(), + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + 'ctas' => $ctas, + ]); + + $this->assertSame(400, $insight['headers']['status-code']); } /** @@ -158,15 +505,15 @@ trait InsightsBase */ public function testGet(array $data): array { - $insightId = $data['insightId']; + $insight = $this->getInsight($data['insightId']); - $response = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); + $this->assertSame(200, $insight['headers']['status-code']); + $this->assertSame($data['insightId'], $insight['body']['$id']); + $this->assertSame($data['reportId'], $insight['body']['reportId']); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($insightId, $response['body']['$id']); - - $missing = $this->client->call(Client::METHOD_GET, '/insights/missing', $this->serverHeaders()); + $missing = $this->getInsight('missing'); $this->assertSame(404, $missing['headers']['status-code']); + $this->assertSame('insight_not_found', $missing['body']['type']); return $data; } @@ -176,51 +523,128 @@ trait InsightsBase */ public function testList(array $data): array { - $response = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders()); + $list = $this->listInsights(); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertNotEmpty($list['body']['insights']); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertGreaterThanOrEqual(1, $response['body']['total']); - $this->assertNotEmpty($response['body']['insights']); - - $filtered = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders(), [ - 'queries' => [ - 'equal("resourceType", "databases")', - ], + $byResourceType = $this->listInsights([ + 'queries' => ['equal("resourceType", "tables")'], ]); - $this->assertSame(200, $filtered['headers']['status-code']); - foreach ($filtered['body']['insights'] as $insight) { - $this->assertSame('databases', $insight['resourceType']); + $this->assertSame(200, $byResourceType['headers']['status-code']); + foreach ($byResourceType['body']['insights'] as $insight) { + $this->assertSame('tables', $insight['resourceType']); } - $byStatus = $this->client->call(Client::METHOD_GET, '/insights', $this->serverHeaders(), [ - 'queries' => [ - 'equal("status", "active")', - ], + $byStatus = $this->listInsights([ + 'queries' => ['equal("status", "active")'], ]); $this->assertSame(200, $byStatus['headers']['status-code']); foreach ($byStatus['body']['insights'] as $insight) { $this->assertSame('active', $insight['status']); } + $byType = $this->listInsights([ + 'queries' => ['equal("type", "tablesDBIndex")'], + ]); + $this->assertSame(200, $byType['headers']['status-code']); + foreach ($byType['body']['insights'] as $insight) { + $this->assertSame('tablesDBIndex', $insight['type']); + } + + $bySeverity = $this->listInsights([ + 'queries' => ['equal("severity", "warning")'], + ]); + $this->assertSame(200, $bySeverity['headers']['status-code']); + foreach ($bySeverity['body']['insights'] as $insight) { + $this->assertSame('warning', $insight['severity']); + } + + $byReport = $this->listInsights([ + 'queries' => ['equal("reportId", "' . $data['reportId'] . '")'], + ]); + $this->assertSame(200, $byReport['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $byReport['body']['total']); + foreach ($byReport['body']['insights'] as $insight) { + $this->assertSame($data['reportId'], $insight['reportId']); + } + return $data; } /** * @depends testList */ + public function testListRejectsInvalidQueryAttribute(array $data): array + { + $invalid = $this->listInsights([ + 'queries' => ['equal("unknownField", "x")'], + ]); + $this->assertSame(400, $invalid['headers']['status-code']); + + return $data; + } + + /** + * @depends testListRejectsInvalidQueryAttribute + */ + public function testListWithCursor(array $data): array + { + // Seed two extra insights so pagination has something to chew through + $first = ID::unique(); + $second = ID::unique(); + $this->createInsight($this->sampleInsight($first)); + $this->createInsight($this->sampleInsight($second)); + + $page1 = $this->listInsights([ + 'queries' => ['limit(1)'], + ]); + $this->assertSame(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['insights']); + + $cursorId = $page1['body']['insights'][0]['$id']; + $page2 = $this->listInsights([ + 'queries' => ['limit(1)', 'cursorAfter("' . $cursorId . '")'], + ]); + $this->assertSame(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['insights']); + $this->assertNotSame($cursorId, $page2['body']['insights'][0]['$id']); + + $missingCursor = $this->listInsights([ + 'queries' => ['cursorAfter("definitely-missing")'], + ]); + $this->assertSame(400, $missingCursor['headers']['status-code']); + + $this->deleteInsight($first); + $this->deleteInsight($second); + + return $data; + } + + /** + * @depends testListWithCursor + */ public function testUpdate(array $data): array { - $insightId = $data['insightId']; + $original = $this->getInsight($data['insightId'])['body']; - $response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ + $updated = $this->updateInsight($data['insightId'], [ 'severity' => 'critical', 'summary' => 'Updated summary.', ]); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('critical', $response['body']['severity']); - $this->assertSame('Updated summary.', $response['body']['summary']); - $this->assertSame('Missing index on collection orders', $response['body']['title']); + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('critical', $updated['body']['severity']); + $this->assertSame('Updated summary.', $updated['body']['summary']); + + // Untouched fields preserved (regression for partial-document overwrite) + $this->assertSame($original['title'], $updated['body']['title']); + $this->assertSame($original['type'], $updated['body']['type']); + $this->assertSame($original['resourceType'], $updated['body']['resourceType']); + $this->assertSame($original['resourceId'], $updated['body']['resourceId']); + $this->assertSame($original['reportId'], $updated['body']['reportId']); + $this->assertSame($original['ctas'], $updated['body']['ctas']); + $this->assertSame($original['payload'], $updated['body']['payload']); return $data; } @@ -230,9 +654,7 @@ trait InsightsBase */ public function testUpdateRejectsDuplicateCTAIds(array $data): array { - $insightId = $data['insightId']; - - $response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ + $response = $this->updateInsight($data['insightId'], [ 'ctas' => [ ['id' => 'dup', 'label' => 'A', 'action' => 'databases.createIndex'], ['id' => 'dup', 'label' => 'B', 'action' => 'databases.createIndex'], @@ -248,92 +670,109 @@ trait InsightsBase /** * @depends testUpdateRejectsDuplicateCTAIds */ - public function testDismissViaUpdate(array $data): array + public function testUpdateRejectsCTAWithEmptyFields(array $data): array { - $insightId = $data['insightId']; - - $response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ - 'status' => 'dismissed', + $response = $this->updateInsight($data['insightId'], [ + 'ctas' => [ + ['id' => '', 'label' => 'Has empty id', 'action' => 'databases.createIndex'], + ], ]); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('dismissed', $response['body']['status']); - $this->assertNotEmpty($response['body']['dismissedAt']); - - $undismiss = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [ - 'status' => 'active', - ]); - - $this->assertSame(200, $undismiss['headers']['status-code']); - $this->assertSame('active', $undismiss['body']['status']); - $this->assertEmpty($undismiss['body']['dismissedAt']); + $this->assertSame(400, $response['headers']['status-code']); return $data; } - public function testCreateRequiresServerKey(): void + /** + * @depends testUpdateRejectsCTAWithEmptyFields + */ + public function testDismissViaUpdate(array $data): array { - $response = $this->client->call(Client::METHOD_POST, '/insights', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], [ - 'insightId' => ID::unique(), - 'type' => 'databaseIndex', - 'resourceType' => 'databases', - 'resourceId' => 'main', - 'title' => 'Should not be created', - ]); + $dismissed = $this->updateInsight($data['insightId'], ['status' => 'dismissed']); - $this->assertSame(401, $response['headers']['status-code']); + $this->assertSame(200, $dismissed['headers']['status-code']); + $this->assertSame('dismissed', $dismissed['body']['status']); + $this->assertNotEmpty($dismissed['body']['dismissedAt']); + $this->assertNotEmpty($dismissed['body']['dismissedBy']); + + $byDismissed = $this->listInsights([ + 'queries' => ['equal("status", "dismissed")'], + ]); + $this->assertSame(200, $byDismissed['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $byDismissed['body']['total']); + + $undismiss = $this->updateInsight($data['insightId'], ['status' => 'active']); + + $this->assertSame(200, $undismiss['headers']['status-code']); + $this->assertSame('active', $undismiss['body']['status']); + $this->assertEmpty($undismiss['body']['dismissedAt']); + $this->assertEmpty($undismiss['body']['dismissedBy']); + + return $data; } - public function testDelete(): void + /** + * @depends testDismissViaUpdate + */ + public function testUpdateMissing(array $data): array { - $insightId = ID::unique(); + $missing = $this->updateInsight('missing', ['severity' => 'critical']); + $this->assertSame(404, $missing['headers']['status-code']); + $this->assertSame('insight_not_found', $missing['body']['type']); - $create = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ - 'insightId' => $insightId, - 'type' => 'databaseIndex', - 'resourceType' => 'databases', - 'resourceId' => 'main', - 'title' => 'Insight to be deleted', - ]); - $this->assertSame(201, $create['headers']['status-code']); + return $data; + } - $delete = $this->client->call(Client::METHOD_DELETE, '/insights/' . $insightId, $this->serverHeaders()); + /** + * @depends testUpdateMissing + */ + public function testDelete(array $data): array + { + $delete = $this->deleteInsight($data['insightId']); $this->assertSame(204, $delete['headers']['status-code']); - $missing = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); + $missing = $this->getInsight($data['insightId']); $this->assertSame(404, $missing['headers']['status-code']); + + return $data; } - public function testDeleteReportCascadesToInsights(): void + /** + * @depends testDelete + */ + public function testDeleteReportCascadesToInsights(array $data): void { - $reportId = ID::unique(); - $createReport = $this->client->call(Client::METHOD_POST, '/reports', $this->serverHeaders(), [ - 'reportId' => $reportId, - 'type' => 'audit', - 'title' => 'Cascade-target report', - 'targetType' => 'sites', - 'target' => 'home', - ]); - $this->assertSame(201, $createReport['headers']['status-code']); - $insightId = ID::unique(); - $createInsight = $this->client->call(Client::METHOD_POST, '/insights', $this->serverHeaders(), [ - 'insightId' => $insightId, - 'reportId' => $reportId, - 'type' => 'sitePerformance', - 'resourceType' => 'sites', - 'resourceId' => 'home', - 'title' => 'Largest contentful paint regressed', - ]); - $this->assertSame(201, $createInsight['headers']['status-code']); + $create = $this->createInsight($this->sampleInsight($insightId, $data['reportId'])); + $this->assertSame(201, $create['headers']['status-code']); - $deleteReport = $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId, $this->serverHeaders()); + $deleteReport = $this->deleteReport($data['reportId']); $this->assertSame(204, $deleteReport['headers']['status-code']); - $orphaned = $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $this->serverHeaders()); + $missingReport = $this->getReport($data['reportId']); + $this->assertSame(404, $missingReport['headers']['status-code']); + + $orphaned = $this->getInsight($insightId); $this->assertSame(404, $orphaned['headers']['status-code']); } + + public function testCreateRequiresServerKey(): void + { + $unauthorized = $this->createInsight($this->sampleInsight(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(401, $unauthorized['headers']['status-code']); + } + + public function testListSurvivesEmptyDatabase(): void + { + $list = $this->listInsights([ + 'queries' => ['equal("type", "siteSeo")'], + ]); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertEmpty($list['body']['insights']); + } } diff --git a/tests/unit/Insights/Validator/CTAsTest.php b/tests/unit/Insights/Validator/CTAsTest.php index a16f7933aa..5545d83191 100644 --- a/tests/unit/Insights/Validator/CTAsTest.php +++ b/tests/unit/Insights/Validator/CTAsTest.php @@ -30,7 +30,7 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.indexes.create', + 'action' => 'databases.createIndex', 'params' => [ 'databaseId' => 'main', 'collectionId' => 'orders', @@ -45,7 +45,7 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.indexes.create', + 'action' => 'databases.createIndex', ]])); } @@ -64,7 +64,7 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => '', 'label' => 'Create missing index', - 'action' => 'databases.indexes.create', + 'action' => 'databases.createIndex', ]])); } @@ -75,7 +75,7 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => 123, 'label' => 'Create missing index', - 'action' => 'databases.indexes.create', + 'action' => 'databases.createIndex', ]])); } @@ -86,7 +86,7 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.indexes.create', + 'action' => 'databases.createIndex', 'params' => 'not-a-map', ]])); } @@ -131,4 +131,66 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid($entries)); } + + public function testAcceptsObjectParams(): void + { + $validator = new CTAs(); + + $entry = [ + 'id' => 'createIndex', + 'label' => 'Create missing index', + 'action' => 'databases.createIndex', + 'params' => new \stdClass(), + ]; + + $this->assertTrue($validator->isValid([$entry])); + } + + public function testRejectsEntryWithEmptyAction(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'id' => 'createIndex', + 'label' => 'Create missing index', + 'action' => '', + ]])); + } + + public function testRejectsEntryWithEmptyLabel(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'id' => 'createIndex', + 'label' => '', + 'action' => 'databases.createIndex', + ]])); + } + + public function testDefaultMaxCountIsSixteen(): void + { + $validator = new CTAs(); + + $this->assertSame(CTAs::MAX_COUNT_DEFAULT, 16); + + $entries = []; + for ($i = 0; $i < 16; $i++) { + $entries[] = [ + 'id' => 'cta-' . $i, + 'label' => 'Label ' . $i, + 'action' => 'databases.createIndex', + ]; + } + + $this->assertTrue($validator->isValid($entries)); + + $entries[] = [ + 'id' => 'cta-16', + 'label' => 'Label 16', + 'action' => 'databases.createIndex', + ]; + + $this->assertFalse($validator->isValid($entries)); + } } From a1f64c6f71623037bafc949a0e76b1c6e32ca0f0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 16:35:33 +1200 Subject: [PATCH 26/70] refactor(insights): split CTA action into service + method - InsightCTA model now exposes `service` (SDK namespace) and `method` (function name) instead of a single `action` string. Drops the string-splitting burden on every consumer and lets the console reach the right SDK method directly. - Validator requires both `service` and `method` non-empty; same 16-entry max still enforced. - Endpoint normalization (Create + Update) splits the new shape into the persisted CTA descriptor. - Constants split: INSIGHT_CTA_SERVICE_* (databases / tablesDB / documentsDB / vectorsDB) and INSIGHT_CTA_METHOD_* (createIndex). - Insight model + InsightCTA model docs updated with the new field semantics and per-engine examples. - E2E factory `sampleCTA($id, $engine)` emits the correct service and engine-appropriate params keys (tableId/columns for tablesDB; collectionId/attributes everywhere else). Engine matrix asserts `service` and `method` independently. - Added e2e + unit coverage for the new failure modes (missing service, missing method, empty service, empty method). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/constants.php | 13 ++- src/Appwrite/Insights/Validator/CTAs.php | 4 +- .../Modules/Insights/Http/Insights/Create.php | 3 +- .../Modules/Insights/Http/Insights/Update.php | 3 +- .../Utopia/Response/Model/Insight.php | 2 +- .../Utopia/Response/Model/InsightCTA.php | 14 ++- tests/e2e/Services/Insights/InsightsBase.php | 105 ++++++++++++------ tests/unit/Insights/Validator/CTAsTest.php | 54 ++++++--- 8 files changed, 134 insertions(+), 64 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index bbd8df037a..959f0f6454 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -449,12 +449,15 @@ const INSIGHT_TYPES = [ INSIGHT_TYPE_FUNCTION_PERFORMANCE, ]; -// Public API method names that an insight CTA's `action` can reference for index suggestions. +// 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 INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX = 'databases.createIndex'; -const INSIGHT_CTA_ACTION_TABLES_DB_CREATE_INDEX = 'tablesDB.createIndex'; -const INSIGHT_CTA_ACTION_DOCUMENTS_DB_CREATE_INDEX = 'documentsDB.createIndex'; -const INSIGHT_CTA_ACTION_VECTORS_DB_CREATE_INDEX = 'vectorsDB.createIndex'; +const INSIGHT_CTA_SERVICE_DATABASES = 'databases'; // legacy +const INSIGHT_CTA_SERVICE_TABLES_DB = 'tablesDB'; +const INSIGHT_CTA_SERVICE_DOCUMENTS_DB = 'documentsDB'; +const INSIGHT_CTA_SERVICE_VECTORS_DB = 'vectorsDB'; + +// Public API method names that an insight CTA's `method` can reference for index suggestions. +const INSIGHT_CTA_METHOD_CREATE_INDEX = 'createIndex'; // Insight severities const INSIGHT_SEVERITY_INFO = 'info'; diff --git a/src/Appwrite/Insights/Validator/CTAs.php b/src/Appwrite/Insights/Validator/CTAs.php index 646c9a601f..e7e9de8205 100644 --- a/src/Appwrite/Insights/Validator/CTAs.php +++ b/src/Appwrite/Insights/Validator/CTAs.php @@ -8,7 +8,7 @@ class CTAs extends Validator { public const MAX_COUNT_DEFAULT = 16; - protected string $message = 'Value must be an array of CTA descriptors. Each entry must define `id`, `label`, `action`, and an optional `params` object.'; + protected string $message = 'Value must be an array of CTA descriptors. Each entry must define `id`, `label`, `service`, `method`, and an optional `params` object.'; public function __construct(protected int $maxCount = self::MAX_COUNT_DEFAULT) { @@ -45,7 +45,7 @@ class CTAs extends Validator return false; } - foreach (['id', 'label', 'action'] as $required) { + foreach (['id', 'label', 'service', 'method'] as $required) { if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') { return false; } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php index 768e526d48..e08f493db1 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php @@ -126,7 +126,8 @@ class Create extends Action $normalizedCTAs[] = [ 'id' => $ctaId, 'label' => (string) $cta['label'], - 'action' => (string) $cta['action'], + 'service' => (string) $cta['service'], + 'method' => (string) $cta['method'], 'params' => $cta['params'] ?? new \stdClass(), ]; } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php index 1b3ed983a5..01469e64d2 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php @@ -134,7 +134,8 @@ class Update extends Action $normalized[] = [ 'id' => $ctaId, 'label' => (string) $cta['label'], - 'action' => (string) $cta['action'], + 'service' => (string) $cta['service'], + 'method' => (string) $cta['method'], 'params' => $cta['params'] ?? new \stdClass(), ]; } diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php index c9084c4e2e..d81c5ef330 100644 --- a/src/Appwrite/Utopia/Response/Model/Insight.php +++ b/src/Appwrite/Utopia/Response/Model/Insight.php @@ -43,7 +43,7 @@ class Insight extends Model ]) ->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 the CTA action can map to the correct public API (databases.createIndex, tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex).', + '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', ]) diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTA.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php index ddb5821336..fbdecd9951 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCTA.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php @@ -22,15 +22,21 @@ class InsightCTA extends Model 'default' => '', 'example' => 'Create missing index', ]) - ->addRule('action', [ + ->addRule('service', [ 'type' => self::TYPE_STRING, - 'description' => 'Public API method the client should invoke when this CTA is triggered. Must match the engine that owns the resource: databases.createIndex (legacy), tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex for index suggestions.', + '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.createIndex', + '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 action 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).', + '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']], ]); diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 0d82cfae07..8ff497086c 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -78,18 +78,22 @@ trait InsightsBase * Sample CTA pointing at the engine-specific public API. * * The `engine` parameter selects which API the CTA targets: - * - `databases` → databases.createIndex (legacy, params use collectionId/attributes) - * - `tablesDB` → tablesDB.createIndex (params use tableId/columns) - * - `documentsDB` → documentsDB.createIndex (params use collectionId/attributes) - * - `vectorsDB` → vectorsDB.createIndex (params use collectionId/attributes) + * - `databases` → service `databases`, method `createIndex` (legacy, params use collectionId/attributes) + * - `tablesDB` → service `tablesDB`, method `createIndex` (params use tableId/columns) + * - `documentsDB` → service `documentsDB`, method `createIndex` (params use collectionId/attributes) + * - `vectorsDB` → service `vectorsDB`, method `createIndex` (params use collectionId/attributes) */ protected function sampleCTA(string $id = 'createIndex', string $engine = 'tablesDB'): array { + $base = [ + 'id' => $id, + 'label' => 'Create missing index', + 'method' => 'createIndex', + ]; + return match ($engine) { - 'databases' => [ - 'id' => $id, - 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'databases' => $base + [ + 'service' => 'databases', 'params' => [ 'databaseId' => 'main', 'collectionId' => 'orders', @@ -98,10 +102,8 @@ trait InsightsBase 'attributes' => ['status'], ], ], - 'tablesDB' => [ - 'id' => $id, - 'label' => 'Create missing index', - 'action' => 'tablesDB.createIndex', + 'tablesDB' => $base + [ + 'service' => 'tablesDB', 'params' => [ 'databaseId' => 'main', 'tableId' => 'orders', @@ -110,10 +112,8 @@ trait InsightsBase 'columns' => ['status'], ], ], - 'documentsDB' => [ - 'id' => $id, - 'label' => 'Create missing index', - 'action' => 'documentsDB.createIndex', + 'documentsDB' => $base + [ + 'service' => 'documentsDB', 'params' => [ 'databaseId' => 'main', 'collectionId' => 'orders', @@ -122,10 +122,8 @@ trait InsightsBase 'attributes' => ['status'], ], ], - 'vectorsDB' => [ - 'id' => $id, - 'label' => 'Create missing index', - 'action' => 'vectorsDB.createIndex', + 'vectorsDB' => $base + [ + 'service' => 'vectorsDB', 'params' => [ 'databaseId' => 'main', 'collectionId' => 'orders', @@ -344,7 +342,8 @@ trait InsightsBase $this->assertSame('Missing index on collection orders', $insight['body']['title']); $this->assertCount(1, $insight['body']['ctas']); $this->assertSame('createIndex', $insight['body']['ctas'][0]['id']); - $this->assertSame('tablesDB.createIndex', $insight['body']['ctas'][0]['action']); + $this->assertSame('tablesDB', $insight['body']['ctas'][0]['service']); + $this->assertSame('createIndex', $insight['body']['ctas'][0]['method']); $this->assertSame('orders', $insight['body']['ctas'][0]['params']['tableId']); $this->assertSame(['status'], $insight['body']['ctas'][0]['params']['columns']); $this->assertEmpty($insight['body']['dismissedAt']); @@ -355,12 +354,12 @@ trait InsightsBase /** * Each engine — legacy databases, tablesDB, documentsDB, vectorsDB — should be - * createable with its own insight type and a CTA that points at the matching - * public API method. + * createable with its own insight type and a CTA whose service+method points + * at the matching public API. * * @dataProvider engineMatrixProvider */ - public function testCreateForEachEngine(string $engine, string $expectedType, string $expectedAction): void + public function testCreateForEachEngine(string $engine, string $expectedType, string $expectedService, string $expectedMethod): void { $insightId = ID::unique(); @@ -368,7 +367,8 @@ trait InsightsBase $this->assertSame(201, $insight['headers']['status-code']); $this->assertSame($expectedType, $insight['body']['type']); - $this->assertSame($expectedAction, $insight['body']['ctas'][0]['action']); + $this->assertSame($expectedService, $insight['body']['ctas'][0]['service']); + $this->assertSame($expectedMethod, $insight['body']['ctas'][0]['method']); $this->deleteInsight($insightId); } @@ -376,10 +376,10 @@ trait InsightsBase public static function engineMatrixProvider(): array { return [ - 'legacy databases' => ['databases', 'databaseIndex', 'databases.createIndex'], - 'tablesDB' => ['tablesDB', 'tablesDBIndex', 'tablesDB.createIndex'], - 'documentsDB' => ['documentsDB', 'documentsDBIndex', 'documentsDB.createIndex'], - 'vectorsDB' => ['vectorsDB', 'vectorsDBIndex', 'vectorsDB.createIndex'], + 'legacy databases' => ['databases', 'databaseIndex', 'databases', 'createIndex'], + 'tablesDB' => ['tablesDB', 'tablesDBIndex', 'tablesDB', 'createIndex'], + 'documentsDB' => ['documentsDB', 'documentsDBIndex', 'documentsDB', 'createIndex'], + 'vectorsDB' => ['vectorsDB', 'vectorsDBIndex', 'vectorsDB', 'createIndex'], ]; } @@ -452,8 +452,8 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['id' => 'dup', 'label' => 'A', 'action' => 'databases.createIndex'], - ['id' => 'dup', 'label' => 'B', 'action' => 'databases.createIndex'], + ['id' => 'dup', 'label' => 'A', 'service' => 'databases', 'method' => 'createIndex'], + ['id' => 'dup', 'label' => 'B', 'service' => 'databases', 'method' => 'createIndex'], ], ]); @@ -470,7 +470,39 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['id' => '', 'label' => 'Has empty id', 'action' => 'databases.createIndex'], + ['id' => '', 'label' => 'Has empty id', 'service' => 'databases', 'method' => 'createIndex'], + ], + ]); + + $this->assertSame(400, $insight['headers']['status-code']); + } + + public function testCreateRejectsCTAWithMissingMethod(): void + { + $insight = $this->createInsight([ + 'insightId' => ID::unique(), + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + 'ctas' => [ + ['id' => 'createIndex', 'label' => 'Missing method', 'service' => 'tablesDB'], + ], + ]); + + $this->assertSame(400, $insight['headers']['status-code']); + } + + public function testCreateRejectsCTAWithMissingService(): void + { + $insight = $this->createInsight([ + 'insightId' => ID::unique(), + 'type' => 'databaseIndex', + 'resourceType' => 'databases', + 'resourceId' => 'main', + 'title' => 'Should not be created', + 'ctas' => [ + ['id' => 'createIndex', 'label' => 'Missing service', 'method' => 'createIndex'], ], ]); @@ -484,7 +516,8 @@ trait InsightsBase $ctas[] = [ 'id' => 'cta-' . $i, 'label' => 'CTA ' . $i, - 'action' => 'databases.createIndex', + 'service' => 'databases', + 'method' => 'createIndex', ]; } @@ -656,8 +689,8 @@ trait InsightsBase { $response = $this->updateInsight($data['insightId'], [ 'ctas' => [ - ['id' => 'dup', 'label' => 'A', 'action' => 'databases.createIndex'], - ['id' => 'dup', 'label' => 'B', 'action' => 'databases.createIndex'], + ['id' => 'dup', 'label' => 'A', 'service' => 'databases', 'method' => 'createIndex'], + ['id' => 'dup', 'label' => 'B', 'service' => 'databases', 'method' => 'createIndex'], ], ]); @@ -674,7 +707,7 @@ trait InsightsBase { $response = $this->updateInsight($data['insightId'], [ 'ctas' => [ - ['id' => '', 'label' => 'Has empty id', 'action' => 'databases.createIndex'], + ['id' => '', 'label' => 'Has empty id', 'service' => 'databases', 'method' => 'createIndex'], ], ]); diff --git a/tests/unit/Insights/Validator/CTAsTest.php b/tests/unit/Insights/Validator/CTAsTest.php index 5545d83191..629cb06b57 100644 --- a/tests/unit/Insights/Validator/CTAsTest.php +++ b/tests/unit/Insights/Validator/CTAsTest.php @@ -30,10 +30,11 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', 'params' => [ 'databaseId' => 'main', - 'collectionId' => 'orders', + 'tableId' => 'orders', ], ]])); } @@ -45,7 +46,8 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', ]])); } @@ -55,6 +57,8 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([['id' => 'x']])); $this->assertFalse($validator->isValid([['id' => 'x', 'label' => 'y']])); + $this->assertFalse($validator->isValid([['id' => 'x', 'label' => 'y', 'service' => 'tablesDB']])); + $this->assertFalse($validator->isValid([['id' => 'x', 'label' => 'y', 'method' => 'createIndex']])); } public function testRejectsEntryWithEmptyStrings(): void @@ -64,7 +68,8 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => '', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', ]])); } @@ -75,7 +80,8 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => 123, 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', ]])); } @@ -86,7 +92,8 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', 'params' => 'not-a-map', ]])); } @@ -108,7 +115,8 @@ class CTAsTest extends TestCase $entries[] = [ 'id' => 'cta-' . $i, 'label' => 'Label ' . $i, - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', ]; } @@ -125,7 +133,8 @@ class CTAsTest extends TestCase $entries[] = [ 'id' => 'cta-' . $i, 'label' => 'Label ' . $i, - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', ]; } @@ -139,21 +148,35 @@ class CTAsTest extends TestCase $entry = [ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', 'params' => new \stdClass(), ]; $this->assertTrue($validator->isValid([$entry])); } - public function testRejectsEntryWithEmptyAction(): void + public function testRejectsEntryWithEmptyService(): void { $validator = new CTAs(); $this->assertFalse($validator->isValid([[ 'id' => 'createIndex', 'label' => 'Create missing index', - 'action' => '', + 'service' => '', + 'method' => 'createIndex', + ]])); + } + + public function testRejectsEntryWithEmptyMethod(): void + { + $validator = new CTAs(); + + $this->assertFalse($validator->isValid([[ + 'id' => 'createIndex', + 'label' => 'Create missing index', + 'service' => 'tablesDB', + 'method' => '', ]])); } @@ -164,7 +187,8 @@ class CTAsTest extends TestCase $this->assertFalse($validator->isValid([[ 'id' => 'createIndex', 'label' => '', - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', ]])); } @@ -179,7 +203,8 @@ class CTAsTest extends TestCase $entries[] = [ 'id' => 'cta-' . $i, 'label' => 'Label ' . $i, - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', ]; } @@ -188,7 +213,8 @@ class CTAsTest extends TestCase $entries[] = [ 'id' => 'cta-16', 'label' => 'Label 16', - 'action' => 'databases.createIndex', + 'service' => 'tablesDB', + 'method' => 'createIndex', ]; $this->assertFalse($validator->isValid($entries)); From 4fc3e9c3863faa4526653dda89ff94ef9b43b963 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 17:20:49 +1200 Subject: [PATCH 27/70] refactor(insights): manager-only Create endpoint + native categories array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insights are produced by internal Appwrite services (edge, executor, background analyzers) — never by user clients. Move the ingestion endpoint accordingly. - Move Http/Insights/Create.php → Http/Manager/Insights/Create.php. - Path: /v1/insights → /v1/manager/insights. SDK Method marked `hide: true` and namespaced under `manager` so generated SDKs don't expose it. Auth narrowed from [ADMIN, KEY] to [KEY] only. - New scope `insights.manager`. Not granted by any user role (app/config/roles.php) — Cloud/edge teams configure their internal key issuance to grant it. `insights.write` description trimmed to the user-facing surface (update/dismiss/delete) since create is now manager-only. - Reports, ListInsights, GetInsight, UpdateInsight, DeleteInsight remain at /v1/insights/*. Existing scopes unchanged. - Reports `categories` switched from JSON-encoded string to a native array column (size 64 per entry, up to 32 entries via the endpoint validator). MySQL JSON-array indexes are weak and we never query individual entries — read+rewrite only. - E2E test API key in tests/e2e/Scopes/ProjectCustom.php gains insights.read/write/manager + reports.read/write so the manager endpoint is reachable from the test harness. - E2E InsightsBase.createInsight() helper now POSTs /manager/insights. - New testCreateRequiresManagerScope verifies a key with insights.read/write but no insights.manager is rejected with 401. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/platform.php | 10 ++++--- app/config/scopes/project.php | 6 +++- .../Http/{ => Manager}/Insights/Create.php | 30 ++++++++++++------- .../Modules/Insights/Services/Http.php | 4 ++- tests/e2e/Scopes/ProjectCustom.php | 5 ++++ tests/e2e/Services/Insights/InsightsBase.php | 22 +++++++++++++- 6 files changed, 60 insertions(+), 17 deletions(-) rename src/Appwrite/Platform/Modules/Insights/Http/{ => Manager}/Insights/Create.php (86%) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 58dc00bb07..8196189197 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2022,16 +2022,18 @@ $platformCollections = [ 'filters' => [], ], [ - // JSON array of category strings, e.g. ['performance', 'accessibility']. + // 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' => 2048, + 'size' => 64, 'signed' => true, 'required' => false, 'default' => null, - 'array' => false, - 'filters' => ['json'], + 'array' => true, + 'filters' => [], ], [ '$id' => ID::custom('analyzedAt'), diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index f0bac03a0c..984510289a 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -344,7 +344,11 @@ return [ 'category' => 'Other', ], 'insights.write' => [ - 'description' => 'Access to create, update, dismiss, and delete insights.', + 'description' => 'Access to update, dismiss, and delete insights.', + 'category' => 'Other', + ], + 'insights.manager' => [ + 'description' => 'Internal-only: ingest insights produced by Appwrite analyzers (edge, executor, …). Not granted to user roles.', 'category' => 'Other', ], diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php similarity index 86% rename from src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php rename to src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index e08f493db1..8ff67a9370 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/insights') + ->setHttpPath('/v1/manager/insights') ->desc('Create insight') - ->groups(['api', 'insights']) - ->label('scope', 'insights.write') + ->groups(['api', 'manager', 'insights']) + ->label('scope', 'insights.manager') ->label('event', 'insights.[insightId].create') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) ->label('audits.event', 'insight.create') @@ -48,19 +57,20 @@ class Create extends Action ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) ->label('sdk', new Method( - namespace: 'insights', + namespace: 'manager', group: 'insights', - name: 'create', + name: 'createInsight', description: <<param('insightId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID. Optional — leave empty for ad-hoc insights not attached to a report.', true, ['dbForPlatform']) @@ -72,7 +82,7 @@ class Create extends Action ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) - ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true) + ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `service`, `method`, and an optional `params` object.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) ->inject('response') ->inject('project') diff --git a/src/Appwrite/Platform/Modules/Insights/Services/Http.php b/src/Appwrite/Platform/Modules/Insights/Services/Http.php index f51e1daa05..b2ca226cf0 100644 --- a/src/Appwrite/Platform/Modules/Insights/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Insights/Services/Http.php @@ -2,11 +2,11 @@ namespace Appwrite\Platform\Modules\Insights\Services; -use Appwrite\Platform\Modules\Insights\Http\Insights\Create as CreateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Delete as DeleteInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Get as GetInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Update as UpdateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\XList as ListInsights; +use Appwrite\Platform\Modules\Insights\Http\Manager\Insights\Create as CreateInsight; use Appwrite\Platform\Modules\Insights\Http\Reports\Create as CreateReport; use Appwrite\Platform\Modules\Insights\Http\Reports\Delete as DeleteReport; use Appwrite\Platform\Modules\Insights\Http\Reports\Get as GetReport; @@ -26,7 +26,9 @@ class Http extends Service $this->addAction(UpdateReport::getName(), new UpdateReport()); $this->addAction(DeleteReport::getName(), new DeleteReport()); + // Manager-only ingestion (hidden from SDKs, /v1/manager/insights). $this->addAction(CreateInsight::getName(), new CreateInsight()); + $this->addAction(GetInsight::getName(), new GetInsight()); $this->addAction(ListInsights::getName(), new ListInsights()); $this->addAction(UpdateInsight::getName(), new UpdateInsight()); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 3071ddfa2a..7bb85b4731 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -177,6 +177,11 @@ trait ProjectCustom 'policies.write', 'templates.read', 'templates.write', + 'insights.read', + 'insights.write', + 'insights.manager', + 'reports.read', + 'reports.write', ], ]); diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 8ff497086c..cb59df7fd5 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -51,7 +51,8 @@ trait InsightsBase protected function createInsight(array $body, array $headers = null): array { - return $this->client->call(Client::METHOD_POST, '/insights', $headers ?? $this->serverHeaders(), $body); + // Manager-only endpoint — internal Appwrite services ingest here, not user SDKs. + return $this->client->call(Client::METHOD_POST, '/manager/insights', $headers ?? $this->serverHeaders(), $body); } protected function getInsight(string $insightId, array $headers = null): array @@ -799,6 +800,25 @@ trait InsightsBase $this->assertSame(401, $unauthorized['headers']['status-code']); } + public function testCreateRequiresManagerScope(): void + { + // A server key with insights.read + insights.write but NOT insights.manager + // must be rejected — Create lives behind /v1/manager/insights and only + // internal Appwrite services hold the manager scope. + $userKey = $this->getNewKey([ + 'insights.read', + 'insights.write', + ]); + + $rejected = $this->createInsight($this->sampleInsight(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $userKey, + ]); + + $this->assertSame(401, $rejected['headers']['status-code']); + } + public function testListSurvivesEmptyDatabase(): void { $list = $this->listInsights([ From 38efdf18e2082d32c7d7036584cfd3619392800a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 17:25:34 +1200 Subject: [PATCH 28/70] feat(insights): add parent resource pointer Eldad's review comment: insights about nested resources need a pointer to the containing parent (the file-in-bucket pattern). Add three optional fields: - parentResourceType (plural noun, e.g. `tables`, `collections`) - parentResourceId - parentResourceInternalId so an insight whose `resourceType=indexes` / `resourceId=_idx_status` can also carry `parentResourceType=tables` / `parentResourceId=orders` to identify the table that owns the index. All three are nullable for top-level resources (e.g. a project-wide audit finding). Schema, response model, manager Create endpoint, and the listInsights query validator (parent fields are filterable). New compound index `_key_project_parent_resource(projectInternalId, parentResourceType, parentResourceId, $sequence)` to support the parent lookup pattern the console will use ("show all insights for table X"). E2E factory generates a parent by default (engine-aware: tables for tablesDB, collections for the others). New testCreateWithoutParentResource exercises the top-level case; testList gains a parent-resource filter assertion; testUpdate's preserved-fields check picks up the new attributes. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/platform.php | 43 ++++++++++++++ .../Insights/Http/Manager/Insights/Create.php | 9 +++ .../Database/Validator/Queries/Insights.php | 2 + .../Utopia/Response/Model/Insight.php | 18 ++++++ tests/e2e/Services/Insights/InsightsBase.php | 59 ++++++++++++++++--- 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 8196189197..7c54da0f0a 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2191,6 +2191,42 @@ $platformCollections = [ 'array' => false, 'filters' => [], ], + [ + // Plural noun for the parent (containing) resource. Optional. + // e.g. an insight about a column index → resourceType=indexes, + // parentResourceType=tables. Mirrors the file-in-bucket pointer. + '$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' => '', + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('title'), 'type' => Database::VAR_STRING, @@ -2291,6 +2327,13 @@ $platformCollections = [ 'lengths' => [Database::LENGTH_KEY, 64, Database::LENGTH_KEY], 'orders' => [], ], + [ + '$id' => ID::custom('_key_project_parent_resource'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'parentResourceType', 'parentResourceId', '$sequence'], + 'lengths' => [Database::LENGTH_KEY, 64, Database::LENGTH_KEY], + 'orders' => [], + ], [ '$id' => ID::custom('_key_project_type'), 'type' => Database::INDEX_KEY, diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index 8ff67a9370..5e909ab25e 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -79,6 +79,9 @@ class Create extends Action ->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.') ->param('resourceId', '', new Text(36), 'ID of the resource the insight is about.') ->param('resourceInternalId', '', new Text(36), 'Internal ID of the resource the insight is about.', true) + ->param('parentResourceType', '', new Text(64), 'Plural noun for the parent (containing) resource, e.g. `tables` for an insight about a column index. Optional.', true) + ->param('parentResourceId', '', new Text(36), 'ID of the parent resource.', true) + ->param('parentResourceInternalId', '', new Text(36), 'Internal ID of the parent resource.', true) ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) @@ -99,6 +102,9 @@ class Create extends Action string $resourceType, string $resourceId, string $resourceInternalId, + string $parentResourceType, + string $parentResourceId, + string $parentResourceInternalId, string $title, string $summary, ?array $payload, @@ -155,6 +161,9 @@ class Create extends Action 'resourceType' => $resourceType, 'resourceId' => $resourceId, 'resourceInternalId' => $resourceInternalId, + 'parentResourceType' => $parentResourceType, + 'parentResourceId' => $parentResourceId, + 'parentResourceInternalId' => $parentResourceInternalId, 'title' => $title, 'summary' => $summary, 'payload' => $payload, diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php index b7e2cadf03..c0afd56134 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php @@ -11,6 +11,8 @@ class Insights extends Base 'reportId', 'resourceType', 'resourceId', + 'parentResourceType', + 'parentResourceId', 'analyzedAt', 'dismissedAt', 'dismissedBy', diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php index d81c5ef330..151301df41 100644 --- a/src/Appwrite/Utopia/Response/Model/Insight.php +++ b/src/Appwrite/Utopia/Response/Model/Insight.php @@ -77,6 +77,24 @@ class Insight extends Model 'default' => '', 'example' => '5e5ea5c16897e', ]) + ->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('parentResourceInternalId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Internal ID of the parent resource. Empty when the resource has no parent.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) ->addRule('title', [ 'type' => self::TYPE_STRING, 'description' => 'Insight title.', diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index cb59df7fd5..a42fda8e6c 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -147,20 +147,25 @@ trait InsightsBase default => throw new \InvalidArgumentException("Unknown engine: {$engine}"), }; - $resourceType = match ($engine) { - 'databases' => 'databases', + // The insight is *about* a missing index, contained within a table/collection. + // resourceType=indexes points at the index that should exist; the parent + // points at the table/collection that owns it. + $parentResourceType = match ($engine) { + 'databases' => 'collections', 'tablesDB' => 'tables', 'documentsDB' => 'collections', 'vectorsDB' => 'collections', - default => 'databases', + default => 'collections', }; $body = [ 'insightId' => $insightId ?? ID::unique(), 'type' => $type, 'severity' => 'warning', - 'resourceType' => $resourceType, - 'resourceId' => 'main', + 'resourceType' => 'indexes', + 'resourceId' => '_idx_status', + 'parentResourceType' => $parentResourceType, + 'parentResourceId' => 'orders', 'title' => 'Missing index on collection orders', 'summary' => 'Queries against `orders.status` are scanning the full collection.', 'payload' => ['databaseId' => 'main', 'engine' => $engine], @@ -338,8 +343,10 @@ trait InsightsBase $this->assertSame('tablesDBIndex', $insight['body']['type']); $this->assertSame('warning', $insight['body']['severity']); $this->assertSame('active', $insight['body']['status']); - $this->assertSame('tables', $insight['body']['resourceType']); - $this->assertSame('main', $insight['body']['resourceId']); + $this->assertSame('indexes', $insight['body']['resourceType']); + $this->assertSame('_idx_status', $insight['body']['resourceId']); + $this->assertSame('tables', $insight['body']['parentResourceType']); + $this->assertSame('orders', $insight['body']['parentResourceId']); $this->assertSame('Missing index on collection orders', $insight['body']['title']); $this->assertCount(1, $insight['body']['ctas']); $this->assertSame('createIndex', $insight['body']['ctas'][0]['id']); @@ -397,6 +404,26 @@ trait InsightsBase $this->deleteInsight($insightId); } + public function testCreateWithoutParentResource(): void + { + // Top-level resource (no parent) — e.g. a project-wide audit finding. + $insightId = ID::unique(); + $body = $this->sampleInsight($insightId); + unset($body['parentResourceType'], $body['parentResourceId']); + $body['resourceType'] = 'projects'; + $body['resourceId'] = $this->getProject()['$id']; + + $insight = $this->createInsight($body); + + $this->assertSame(201, $insight['headers']['status-code']); + $this->assertSame('projects', $insight['body']['resourceType']); + $this->assertEmpty($insight['body']['parentResourceType']); + $this->assertEmpty($insight['body']['parentResourceId']); + $this->assertEmpty($insight['body']['parentResourceInternalId']); + + $this->deleteInsight($insightId); + } + public function testCreateRejectsInvalidType(): void { $insight = $this->createInsight([ @@ -563,11 +590,23 @@ trait InsightsBase $this->assertNotEmpty($list['body']['insights']); $byResourceType = $this->listInsights([ - 'queries' => ['equal("resourceType", "tables")'], + 'queries' => ['equal("resourceType", "indexes")'], ]); $this->assertSame(200, $byResourceType['headers']['status-code']); foreach ($byResourceType['body']['insights'] as $insight) { - $this->assertSame('tables', $insight['resourceType']); + $this->assertSame('indexes', $insight['resourceType']); + } + + $byParentResource = $this->listInsights([ + 'queries' => [ + 'equal("parentResourceType", "tables")', + 'equal("parentResourceId", "orders")', + ], + ]); + $this->assertSame(200, $byParentResource['headers']['status-code']); + foreach ($byParentResource['body']['insights'] as $insight) { + $this->assertSame('tables', $insight['parentResourceType']); + $this->assertSame('orders', $insight['parentResourceId']); } $byStatus = $this->listInsights([ @@ -676,6 +715,8 @@ trait InsightsBase $this->assertSame($original['type'], $updated['body']['type']); $this->assertSame($original['resourceType'], $updated['body']['resourceType']); $this->assertSame($original['resourceId'], $updated['body']['resourceId']); + $this->assertSame($original['parentResourceType'], $updated['body']['parentResourceType']); + $this->assertSame($original['parentResourceId'], $updated['body']['parentResourceId']); $this->assertSame($original['reportId'], $updated['body']['reportId']); $this->assertSame($original['ctas'], $updated['body']['ctas']); $this->assertSame($original['payload'], $updated['body']['payload']); From 5404bfec7559d3f6a3a03577ec76e9e2ecb659ee Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 18:07:49 +1200 Subject: [PATCH 29/70] refactor(insights): promote CTAs to own collection with backref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedding CTAs as a 16384-byte JSON blob on `insights` was the wrong shape — they're real documents with their own lifecycle. Move them out. Schema: - New platform `ctas` collection. Each row carries `projectInternalId`, `projectId`, `insightInternalId`, `insightId` (backref), plus the CTA fields: `key`, `label`, `service`, `method`, `params`. - Indexes: `(projectInternalId, insightInternalId)` for the subquery lookup and a UNIQUE `(insightInternalId, key)` so the per-insight uniqueness invariant lives at the DB layer (not just in PHP). - The `ctas` field on `insights` becomes a virtual attribute backed by a new `subQueryInsightCTAs` filter that joins child docs at read time. Consumers still get CTAs embedded on the insight response — one round-trip from their perspective. - The CTA descriptor's within-insight identifier renamed `id` → `key` (clashed with the document `$id`). Validator updated. Endpoints: - Manager Create now persists CTAs as separate `ctas` documents after the parent insight, then re-fetches the insight so the response carries the freshly-joined CTA list. - User Update trimmed to user-controlled state only (`severity`, `status`). `title`, `summary`, `payload`, `ctas`, and `analyzedAt` are analyzer-controlled — analyzers re-ingest by deleting and POSTing again to the manager endpoint. - Insight Delete cascades to CTAs. - Report Delete cascades through Insights → CTAs. Response model: - InsightCTA gains the standard document headers (`$id`, `$createdAt`, `$updatedAt`) and an `insightId` backref. The caller-supplied identifier is now `key`. Tests: - E2E sampleCTA factory uses `key` everywhere; testCreate asserts the freshly-created CTA carries `$id`, `$createdAt`, `insightId`, and the right shape. - Dropped the testUpdate*CTA* tests — user Update no longer accepts CTAs. testDismissViaUpdate now depends on testUpdate directly. - Unit tests rewritten to validate `key` instead of `id`. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/platform.php | 140 +++++++++++++++++- app/init/database/filters.php | 14 ++ src/Appwrite/Insights/Validator/CTAs.php | 4 +- .../Modules/Insights/Http/Insights/Delete.php | 11 ++ .../Modules/Insights/Http/Insights/Update.php | 56 ++----- .../Insights/Http/Manager/Insights/Create.php | 32 +++- .../Modules/Insights/Http/Reports/Delete.php | 9 ++ .../Utopia/Response/Model/InsightCTA.php | 28 +++- tests/e2e/Services/Insights/InsightsBase.php | 56 ++----- tests/unit/Insights/Validator/CTAsTest.php | 34 ++--- 10 files changed, 265 insertions(+), 119 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 7c54da0f0a..ea65759876 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2261,6 +2261,10 @@ $platformCollections = [ 'filters' => ['json'], ], [ + // Virtual attribute — CTAs live in their own `ctas` collection + // back-referenced by `insightInternalId`. The subQuery filter + // joins them in at read time, so consumers still see them + // embedded on the insight response. '$id' => ID::custom('ctas'), 'type' => Database::VAR_STRING, 'format' => '', @@ -2269,7 +2273,7 @@ $platformCollections = [ 'required' => false, 'default' => null, 'array' => false, - 'filters' => ['json'], + 'filters' => ['subQueryInsightCTAs'], ], [ '$id' => ID::custom('analyzedAt'), @@ -2364,6 +2368,140 @@ $platformCollections = [ ], ], ], + + 'ctas' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('ctas'), + 'name' => 'Insight CTAs', + 'attributes' => [ + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + '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('insightInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('insightId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + // Caller-supplied identifier, unique within the parent insight. + '$id' => ID::custom('key'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('label'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + // SDK namespace (databases / tablesDB / documentsDB / vectorsDB / …). + '$id' => ID::custom('service'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('method'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('params'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['json'], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_project'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + // Primary lookup — fetch all CTAs of an insight (subQuery filter). + '$id' => ID::custom('_key_project_insight'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'insightInternalId'], + 'lengths' => [Database::LENGTH_KEY, 0], + 'orders' => [], + ], + [ + // Enforce per-insight key uniqueness at the DB layer. + '$id' => ID::custom('_key_insight_key'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['insightInternalId', 'key'], + 'lengths' => [0, Database::LENGTH_KEY], + 'orders' => [], + ], + ], + ], ]; // Organization API keys subquery diff --git a/app/init/database/filters.php b/app/init/database/filters.php index 5a65479424..3d9a20a24b 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -475,3 +475,17 @@ Database::addFilter( ])); } ); + +Database::addFilter( + 'subQueryInsightCTAs', + function (mixed $value) { + return; + }, + function (mixed $value, Document $document, Database $database) { + return $database->getAuthorization()->skip(fn () => $database + ->find('ctas', [ + Query::equal('insightInternalId', [$document->getSequence()]), + Query::limit(APP_LIMIT_SUBQUERY), + ])); + } +); diff --git a/src/Appwrite/Insights/Validator/CTAs.php b/src/Appwrite/Insights/Validator/CTAs.php index e7e9de8205..565b4be93f 100644 --- a/src/Appwrite/Insights/Validator/CTAs.php +++ b/src/Appwrite/Insights/Validator/CTAs.php @@ -8,7 +8,7 @@ class CTAs extends Validator { public const MAX_COUNT_DEFAULT = 16; - protected string $message = 'Value must be an array of CTA descriptors. Each entry must define `id`, `label`, `service`, `method`, and an optional `params` object.'; + protected string $message = 'Value must be an array of CTA descriptors. Each entry must define `key`, `label`, `service`, `method`, and an optional `params` object.'; public function __construct(protected int $maxCount = self::MAX_COUNT_DEFAULT) { @@ -45,7 +45,7 @@ class CTAs extends Validator return false; } - foreach (['id', 'label', 'service', 'method'] as $required) { + foreach (['key', 'label', 'service', 'method'] as $required) { if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') { return false; } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php index 5e2d4b36fe..2f7974b965 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php @@ -11,6 +11,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Query; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -76,6 +77,16 @@ class Delete extends Action throw new Exception(Exception::INSIGHT_NOT_FOUND); } + // Cascade delete child CTAs first. + $childCTAs = $dbForPlatform->find('ctas', [ + Query::equal('insightInternalId', [$insight->getSequence()]), + Query::limit(APP_LIMIT_COUNT), + ]); + + foreach ($childCTAs as $cta) { + $dbForPlatform->deleteDocument('ctas', $cta->getId()); + } + if (!$dbForPlatform->deleteDocument('insights', $insight->getId())) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove insight from DB'); } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php index 01469e64d2..a8cc5803a5 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Insights\Http\Insights; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Insights\Validator\CTAs as CTAsValidator; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -12,15 +11,20 @@ use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\JSON; use Utopia\Validator\Nullable; -use Utopia\Validator\Text; use Utopia\Validator\WhiteList; +/** + * User-facing Update endpoint. + * + * Limited to user-controlled state: dismissal (status), and severity overrides. + * Analyzer-controlled fields (title, summary, payload, ctas, analyzedAt) flow + * through the manager-only Create endpoint — analyzers re-ingest by deleting + * the stale insight and submitting a fresh one. + */ class Update extends Action { use HTTP; @@ -50,7 +54,7 @@ class Update extends Action group: 'insights', name: 'update', description: <<param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform']) ->param('severity', null, new Nullable(new WhiteList(INSIGHT_SEVERITIES, true)), 'Insight severity. One of `info`, `warning`, `critical`.', true) ->param('status', null, new Nullable(new WhiteList(INSIGHT_STATUSES, true)), 'Insight status. Set to `dismissed` to dismiss the insight, `active` to undo a dismissal.', true) - ->param('title', null, new Nullable(new Text(256)), 'Short, human-readable title.', true) - ->param('summary', null, new Nullable(new Text(4096, 0)), 'Markdown summary describing the insight.', true) - ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) - ->param('ctas', null, new Nullable(new CTAsValidator()), 'Array of call-to-action descriptors.', true) - ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format.', true) ->inject('response') ->inject('user') ->inject('project') @@ -80,11 +79,6 @@ class Update extends Action string $insightId, ?string $severity, ?string $status, - ?string $title, - ?string $summary, - ?array $payload, - ?array $ctas, - ?string $analyzedAt, Response $response, Document $user, Document $project, @@ -112,38 +106,6 @@ class Update extends Action $changes['dismissedBy'] = ''; } } - if ($title !== null) { - $changes['title'] = $title; - } - if ($summary !== null) { - $changes['summary'] = $summary; - } - if ($payload !== null) { - $changes['payload'] = $payload; - } - if ($ctas !== null) { - $seen = []; - $normalized = []; - foreach ($ctas as $cta) { - $ctaId = (string) $cta['id']; - if (isset($seen[$ctaId])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'CTA `id` values must be unique within an insight.'); - } - $seen[$ctaId] = true; - - $normalized[] = [ - 'id' => $ctaId, - 'label' => (string) $cta['label'], - 'service' => (string) $cta['service'], - 'method' => (string) $cta['method'], - 'params' => $cta['params'] ?? new \stdClass(), - ]; - } - $changes['ctas'] = $normalized; - } - if ($analyzedAt !== null) { - $changes['analyzedAt'] = $analyzedAt; - } if ($changes !== []) { foreach ($changes as $key => $value) { diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index 5e909ab25e..6455899638 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -85,7 +85,7 @@ class Create extends Action ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) - ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `service`, `method`, and an optional `params` object.', true) + ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `key` (unique within the insight), `label`, `service`, `method`, and an optional `params` object.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) ->inject('response') ->inject('project') @@ -133,14 +133,14 @@ class Create extends Action $normalizedCTAs = []; foreach ($ctas as $cta) { - $ctaId = (string) $cta['id']; - if (isset($seen[$ctaId])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'CTA `id` values must be unique within an insight.'); + $key = (string) $cta['key']; + if (isset($seen[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'CTA `key` values must be unique within an insight.'); } - $seen[$ctaId] = true; + $seen[$key] = true; $normalizedCTAs[] = [ - 'id' => $ctaId, + 'key' => $key, 'label' => (string) $cta['label'], 'service' => (string) $cta['service'], 'method' => (string) $cta['method'], @@ -167,7 +167,6 @@ class Create extends Action 'title' => $title, 'summary' => $summary, 'payload' => $payload, - 'ctas' => $normalizedCTAs, 'analyzedAt' => $analyzedAt, 'dismissedAt' => null, 'dismissedBy' => '', @@ -176,6 +175,25 @@ class Create extends Action throw new Exception(Exception::INSIGHT_ALREADY_EXISTS); } + foreach ($normalizedCTAs as $cta) { + $dbForPlatform->createDocument('ctas', new Document([ + '$id' => ID::unique(), + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'insightInternalId' => $insight->getSequence(), + 'insightId' => $insight->getId(), + 'key' => $cta['key'], + 'label' => $cta['label'], + 'service' => $cta['service'], + 'method' => $cta['method'], + 'params' => $cta['params'], + ])); + } + + // Re-fetch so the subQueryInsightCTAs filter embeds the freshly-created + // CTA documents on the response — keeps a single round-trip for callers. + $insight = $dbForPlatform->getDocument('insights', $insight->getId()); + $queueForEvents->setParam('insightId', $insight->getId()); $response diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php index f5bfe4a651..5560d3060c 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php @@ -84,6 +84,15 @@ class Delete extends Action ]); foreach ($childInsights as $insight) { + // Cascade through CTAs first. + $childCTAs = $dbForPlatform->find('ctas', [ + Query::equal('insightInternalId', [$insight->getSequence()]), + Query::limit(APP_LIMIT_COUNT), + ]); + foreach ($childCTAs as $cta) { + $dbForPlatform->deleteDocument('ctas', $cta->getId()); + } + $dbForPlatform->deleteDocument('insights', $insight->getId()); } diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTA.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php index fbdecd9951..2fd493cd57 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCTA.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php @@ -10,9 +10,33 @@ class InsightCTA extends Model public function __construct() { $this - ->addRule('id', [ + ->addRule('$id', [ 'type' => self::TYPE_STRING, - 'description' => 'CTA identifier, unique within the parent insight.', + 'description' => 'CTA document ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'CTA creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'CTA update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('insightId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the insight that owns this CTA.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('key', [ + 'type' => self::TYPE_STRING, + 'description' => 'Caller-supplied identifier, unique within the parent insight.', 'default' => '', 'example' => 'createIndex', ]) diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index a42fda8e6c..032dc2d7b4 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -84,10 +84,10 @@ trait InsightsBase * - `documentsDB` → service `documentsDB`, method `createIndex` (params use collectionId/attributes) * - `vectorsDB` → service `vectorsDB`, method `createIndex` (params use collectionId/attributes) */ - protected function sampleCTA(string $id = 'createIndex', string $engine = 'tablesDB'): array + protected function sampleCTA(string $key = 'createIndex', string $engine = 'tablesDB'): array { $base = [ - 'id' => $id, + 'key' => $key, 'label' => 'Create missing index', 'method' => 'createIndex', ]; @@ -349,11 +349,15 @@ trait InsightsBase $this->assertSame('orders', $insight['body']['parentResourceId']); $this->assertSame('Missing index on collection orders', $insight['body']['title']); $this->assertCount(1, $insight['body']['ctas']); - $this->assertSame('createIndex', $insight['body']['ctas'][0]['id']); + $this->assertSame('createIndex', $insight['body']['ctas'][0]['key']); + $this->assertSame($insightId, $insight['body']['ctas'][0]['insightId']); + $this->assertSame('Create missing index', $insight['body']['ctas'][0]['label']); $this->assertSame('tablesDB', $insight['body']['ctas'][0]['service']); $this->assertSame('createIndex', $insight['body']['ctas'][0]['method']); $this->assertSame('orders', $insight['body']['ctas'][0]['params']['tableId']); $this->assertSame(['status'], $insight['body']['ctas'][0]['params']['columns']); + $this->assertArrayHasKey('$id', $insight['body']['ctas'][0]); + $this->assertArrayHasKey('$createdAt', $insight['body']['ctas'][0]); $this->assertEmpty($insight['body']['dismissedAt']); $this->assertEmpty($insight['body']['dismissedBy']); @@ -480,8 +484,8 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['id' => 'dup', 'label' => 'A', 'service' => 'databases', 'method' => 'createIndex'], - ['id' => 'dup', 'label' => 'B', 'service' => 'databases', 'method' => 'createIndex'], + ['key' => 'dup', 'label' => 'A', 'service' => 'databases', 'method' => 'createIndex'], + ['key' => 'dup', 'label' => 'B', 'service' => 'databases', 'method' => 'createIndex'], ], ]); @@ -498,7 +502,7 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['id' => '', 'label' => 'Has empty id', 'service' => 'databases', 'method' => 'createIndex'], + ['key' => '', 'label' => 'Has empty id', 'service' => 'databases', 'method' => 'createIndex'], ], ]); @@ -514,7 +518,7 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['id' => 'createIndex', 'label' => 'Missing method', 'service' => 'tablesDB'], + ['key' => 'createIndex', 'label' => 'Missing method', 'service' => 'tablesDB'], ], ]); @@ -530,7 +534,7 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['id' => 'createIndex', 'label' => 'Missing service', 'method' => 'createIndex'], + ['key' => 'createIndex', 'label' => 'Missing service', 'method' => 'createIndex'], ], ]); @@ -542,7 +546,7 @@ trait InsightsBase $ctas = []; for ($i = 0; $i < 17; $i++) { $ctas[] = [ - 'id' => 'cta-' . $i, + 'key' => 'cta-' . $i, 'label' => 'CTA ' . $i, 'service' => 'databases', 'method' => 'createIndex', @@ -727,40 +731,6 @@ trait InsightsBase /** * @depends testUpdate */ - public function testUpdateRejectsDuplicateCTAIds(array $data): array - { - $response = $this->updateInsight($data['insightId'], [ - 'ctas' => [ - ['id' => 'dup', 'label' => 'A', 'service' => 'databases', 'method' => 'createIndex'], - ['id' => 'dup', 'label' => 'B', 'service' => 'databases', 'method' => 'createIndex'], - ], - ]); - - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); - - return $data; - } - - /** - * @depends testUpdateRejectsDuplicateCTAIds - */ - public function testUpdateRejectsCTAWithEmptyFields(array $data): array - { - $response = $this->updateInsight($data['insightId'], [ - 'ctas' => [ - ['id' => '', 'label' => 'Has empty id', 'service' => 'databases', 'method' => 'createIndex'], - ], - ]); - - $this->assertSame(400, $response['headers']['status-code']); - - return $data; - } - - /** - * @depends testUpdateRejectsCTAWithEmptyFields - */ public function testDismissViaUpdate(array $data): array { $dismissed = $this->updateInsight($data['insightId'], ['status' => 'dismissed']); diff --git a/tests/unit/Insights/Validator/CTAsTest.php b/tests/unit/Insights/Validator/CTAsTest.php index 629cb06b57..fbc30cad81 100644 --- a/tests/unit/Insights/Validator/CTAsTest.php +++ b/tests/unit/Insights/Validator/CTAsTest.php @@ -28,7 +28,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertTrue($validator->isValid([[ - 'id' => 'createIndex', + 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -44,7 +44,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertTrue($validator->isValid([[ - 'id' => 'createIndex', + 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -55,10 +55,10 @@ class CTAsTest extends TestCase { $validator = new CTAs(); - $this->assertFalse($validator->isValid([['id' => 'x']])); - $this->assertFalse($validator->isValid([['id' => 'x', 'label' => 'y']])); - $this->assertFalse($validator->isValid([['id' => 'x', 'label' => 'y', 'service' => 'tablesDB']])); - $this->assertFalse($validator->isValid([['id' => 'x', 'label' => 'y', 'method' => 'createIndex']])); + $this->assertFalse($validator->isValid([['key' => 'x']])); + $this->assertFalse($validator->isValid([['key' => 'x', 'label' => 'y']])); + $this->assertFalse($validator->isValid([['key' => 'x', 'label' => 'y', 'service' => 'tablesDB']])); + $this->assertFalse($validator->isValid([['key' => 'x', 'label' => 'y', 'method' => 'createIndex']])); } public function testRejectsEntryWithEmptyStrings(): void @@ -66,7 +66,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'id' => '', + 'key' => '', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -78,7 +78,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'id' => 123, + 'key' => 123, 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -90,7 +90,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'id' => 'createIndex', + 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -113,7 +113,7 @@ class CTAsTest extends TestCase $entries = []; for ($i = 0; $i < 4; $i++) { $entries[] = [ - 'id' => 'cta-' . $i, + 'key' => 'cta-' . $i, 'label' => 'Label ' . $i, 'service' => 'tablesDB', 'method' => 'createIndex', @@ -131,7 +131,7 @@ class CTAsTest extends TestCase $entries = []; for ($i = 0; $i < 3; $i++) { $entries[] = [ - 'id' => 'cta-' . $i, + 'key' => 'cta-' . $i, 'label' => 'Label ' . $i, 'service' => 'tablesDB', 'method' => 'createIndex', @@ -146,7 +146,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $entry = [ - 'id' => 'createIndex', + 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -161,7 +161,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'id' => 'createIndex', + 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => '', 'method' => 'createIndex', @@ -173,7 +173,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'id' => 'createIndex', + 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => '', @@ -185,7 +185,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'id' => 'createIndex', + 'key' => 'createIndex', 'label' => '', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -201,7 +201,7 @@ class CTAsTest extends TestCase $entries = []; for ($i = 0; $i < 16; $i++) { $entries[] = [ - 'id' => 'cta-' . $i, + 'key' => 'cta-' . $i, 'label' => 'Label ' . $i, 'service' => 'tablesDB', 'method' => 'createIndex', @@ -211,7 +211,7 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid($entries)); $entries[] = [ - 'id' => 'cta-16', + 'key' => 'cta-16', 'label' => 'Label 16', 'service' => 'tablesDB', 'method' => 'createIndex', From c5dfc42a606fa09bbd99fca0cb364e14c030a7e6 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 18:12:17 +1200 Subject: [PATCH 30/70] refactor(insights): rename ctas collection to insightCTAs Disambiguate the platform-level collection name. Field/request-param remains `ctas` (the embedded array on the insight response). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/platform.php | 4 ++-- app/init/database/filters.php | 2 +- .../Platform/Modules/Insights/Http/Insights/Delete.php | 4 ++-- .../Modules/Insights/Http/Manager/Insights/Create.php | 2 +- .../Platform/Modules/Insights/Http/Reports/Delete.php | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index ea65759876..db6bc29d10 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2369,9 +2369,9 @@ $platformCollections = [ ], ], - 'ctas' => [ + 'insightCTAs' => [ '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('ctas'), + '$id' => ID::custom('insightCTAs'), 'name' => 'Insight CTAs', 'attributes' => [ [ diff --git a/app/init/database/filters.php b/app/init/database/filters.php index 3d9a20a24b..f6afb28304 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -483,7 +483,7 @@ Database::addFilter( }, function (mixed $value, Document $document, Database $database) { return $database->getAuthorization()->skip(fn () => $database - ->find('ctas', [ + ->find('insightCTAs', [ Query::equal('insightInternalId', [$document->getSequence()]), Query::limit(APP_LIMIT_SUBQUERY), ])); diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php index 2f7974b965..d8097f4126 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php @@ -78,13 +78,13 @@ class Delete extends Action } // Cascade delete child CTAs first. - $childCTAs = $dbForPlatform->find('ctas', [ + $childCTAs = $dbForPlatform->find('insightCTAs', [ Query::equal('insightInternalId', [$insight->getSequence()]), Query::limit(APP_LIMIT_COUNT), ]); foreach ($childCTAs as $cta) { - $dbForPlatform->deleteDocument('ctas', $cta->getId()); + $dbForPlatform->deleteDocument('insightCTAs', $cta->getId()); } if (!$dbForPlatform->deleteDocument('insights', $insight->getId())) { diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index 6455899638..1688867a6f 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -176,7 +176,7 @@ class Create extends Action } foreach ($normalizedCTAs as $cta) { - $dbForPlatform->createDocument('ctas', new Document([ + $dbForPlatform->createDocument('insightCTAs', new Document([ '$id' => ID::unique(), 'projectInternalId' => $project->getSequence(), 'projectId' => $project->getId(), diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php index 5560d3060c..81bc5bf12d 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php @@ -85,12 +85,12 @@ class Delete extends Action foreach ($childInsights as $insight) { // Cascade through CTAs first. - $childCTAs = $dbForPlatform->find('ctas', [ + $childCTAs = $dbForPlatform->find('insightCTAs', [ Query::equal('insightInternalId', [$insight->getSequence()]), Query::limit(APP_LIMIT_COUNT), ]); foreach ($childCTAs as $cta) { - $dbForPlatform->deleteDocument('ctas', $cta->getId()); + $dbForPlatform->deleteDocument('insightCTAs', $cta->getId()); } $dbForPlatform->deleteDocument('insights', $insight->getId()); From 0b72dba817932f7edc47e4fc036cd43525786bcb Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 18:29:40 +1200 Subject: [PATCH 31/70] refactor(insights): drop CTA `key` field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `key` was a leftover from when CTAs were embedded JSON — there's no remaining reason to require analyzers to invent a within-insight identifier. The execution layer is gone (no `cta.key` event format), insights are immutable from the user side (analyzers re-ingest by delete + recreate, so idempotent matching never happens), and `label` already covers human-facing identification. The console can group/sort CTAs by `service`+`method` if needed. - Schema: drop `key` attribute and the UNIQUE `(insightInternalId, key)` index from insightCTAs. Required fields are now `label`, `service`, `method` (+ optional `params`). - Validator no longer requires `key`. Drop the dup-key normalization loop in the manager Create endpoint — there's no semantic uniqueness to enforce. - Response model: `InsightCTA` keeps `$id` + standard headers, `insightId` backref, and the four functional fields. - E2E: drop sampleCTA's `$key` parameter, drop the testCreateRejectsDuplicateCTAIds test entirely, rename empty-fields test to testCreateRejectsCTAWithEmptyLabel and update the missing- fields tests to drop `key` from their payloads. - Unit tests rewritten to drop `key`. - Comment on the `insights.ctas` virtual attribute updated to reference the renamed `insightCTAs` collection. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/platform.php | 22 +----------- src/Appwrite/Insights/Validator/CTAs.php | 4 +-- .../Insights/Http/Manager/Insights/Create.php | 11 +----- .../Utopia/Response/Model/InsightCTA.php | 6 ---- tests/e2e/Services/Insights/InsightsBase.php | 33 ++++------------- tests/unit/Insights/Validator/CTAsTest.php | 35 +++---------------- 6 files changed, 15 insertions(+), 96 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index db6bc29d10..d429f11ff1 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2261,7 +2261,7 @@ $platformCollections = [ 'filters' => ['json'], ], [ - // Virtual attribute — CTAs live in their own `ctas` collection + // Virtual attribute — CTAs live in the `insightCTAs` collection // back-referenced by `insightInternalId`. The subQuery filter // joins them in at read time, so consumers still see them // embedded on the insight response. @@ -2418,18 +2418,6 @@ $platformCollections = [ 'array' => false, 'filters' => [], ], - [ - // Caller-supplied identifier, unique within the parent insight. - '$id' => ID::custom('key'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], [ '$id' => ID::custom('label'), 'type' => Database::VAR_STRING, @@ -2492,14 +2480,6 @@ $platformCollections = [ 'lengths' => [Database::LENGTH_KEY, 0], 'orders' => [], ], - [ - // Enforce per-insight key uniqueness at the DB layer. - '$id' => ID::custom('_key_insight_key'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['insightInternalId', 'key'], - 'lengths' => [0, Database::LENGTH_KEY], - 'orders' => [], - ], ], ], ]; diff --git a/src/Appwrite/Insights/Validator/CTAs.php b/src/Appwrite/Insights/Validator/CTAs.php index 565b4be93f..848253bc13 100644 --- a/src/Appwrite/Insights/Validator/CTAs.php +++ b/src/Appwrite/Insights/Validator/CTAs.php @@ -8,7 +8,7 @@ class CTAs extends Validator { public const MAX_COUNT_DEFAULT = 16; - protected string $message = 'Value must be an array of CTA descriptors. Each entry must define `key`, `label`, `service`, `method`, and an optional `params` object.'; + protected string $message = 'Value must be an array of CTA descriptors. Each entry must define `label`, `service`, `method`, and an optional `params` object.'; public function __construct(protected int $maxCount = self::MAX_COUNT_DEFAULT) { @@ -45,7 +45,7 @@ class CTAs extends Validator return false; } - foreach (['key', 'label', 'service', 'method'] as $required) { + foreach (['label', 'service', 'method'] as $required) { if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') { return false; } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index 1688867a6f..17568e15d0 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -85,7 +85,7 @@ class Create extends Action ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) - ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `key` (unique within the insight), `label`, `service`, `method`, and an optional `params` object.', true) + ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `label`, `service`, `method`, and an optional `params` object.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) ->inject('response') ->inject('project') @@ -129,18 +129,10 @@ class Create extends Action $reportInternalId = $report->getSequence(); } - $seen = []; $normalizedCTAs = []; foreach ($ctas as $cta) { - $key = (string) $cta['key']; - if (isset($seen[$key])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'CTA `key` values must be unique within an insight.'); - } - $seen[$key] = true; - $normalizedCTAs[] = [ - 'key' => $key, 'label' => (string) $cta['label'], 'service' => (string) $cta['service'], 'method' => (string) $cta['method'], @@ -182,7 +174,6 @@ class Create extends Action 'projectId' => $project->getId(), 'insightInternalId' => $insight->getSequence(), 'insightId' => $insight->getId(), - 'key' => $cta['key'], 'label' => $cta['label'], 'service' => $cta['service'], 'method' => $cta['method'], diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTA.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php index 2fd493cd57..75f42a9bf8 100644 --- a/src/Appwrite/Utopia/Response/Model/InsightCTA.php +++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php @@ -34,12 +34,6 @@ class InsightCTA extends Model 'default' => '', 'example' => '5e5ea5c16897e', ]) - ->addRule('key', [ - 'type' => self::TYPE_STRING, - 'description' => 'Caller-supplied identifier, unique within the parent insight.', - 'default' => '', - 'example' => 'createIndex', - ]) ->addRule('label', [ 'type' => self::TYPE_STRING, 'description' => 'Human-readable label for the CTA, used in UI.', diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 032dc2d7b4..d0ddaac89d 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -84,10 +84,9 @@ trait InsightsBase * - `documentsDB` → service `documentsDB`, method `createIndex` (params use collectionId/attributes) * - `vectorsDB` → service `vectorsDB`, method `createIndex` (params use collectionId/attributes) */ - protected function sampleCTA(string $key = 'createIndex', string $engine = 'tablesDB'): array + protected function sampleCTA(string $engine = 'tablesDB'): array { $base = [ - 'key' => $key, 'label' => 'Create missing index', 'method' => 'createIndex', ]; @@ -169,7 +168,7 @@ trait InsightsBase 'title' => 'Missing index on collection orders', 'summary' => 'Queries against `orders.status` are scanning the full collection.', 'payload' => ['databaseId' => 'main', 'engine' => $engine], - 'ctas' => [$this->sampleCTA('createIndex', $engine)], + 'ctas' => [$this->sampleCTA($engine)], ]; if ($reportId !== null) { @@ -349,7 +348,6 @@ trait InsightsBase $this->assertSame('orders', $insight['body']['parentResourceId']); $this->assertSame('Missing index on collection orders', $insight['body']['title']); $this->assertCount(1, $insight['body']['ctas']); - $this->assertSame('createIndex', $insight['body']['ctas'][0]['key']); $this->assertSame($insightId, $insight['body']['ctas'][0]['insightId']); $this->assertSame('Create missing index', $insight['body']['ctas'][0]['label']); $this->assertSame('tablesDB', $insight['body']['ctas'][0]['service']); @@ -475,7 +473,7 @@ trait InsightsBase $this->assertSame('report_not_found', $insight['body']['type']); } - public function testCreateRejectsDuplicateCTAIds(): void + public function testCreateRejectsCTAWithEmptyLabel(): void { $insight = $this->createInsight([ 'insightId' => ID::unique(), @@ -484,25 +482,7 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['key' => 'dup', 'label' => 'A', 'service' => 'databases', 'method' => 'createIndex'], - ['key' => 'dup', 'label' => 'B', 'service' => 'databases', 'method' => 'createIndex'], - ], - ]); - - $this->assertSame(400, $insight['headers']['status-code']); - $this->assertSame('general_argument_invalid', $insight['body']['type']); - } - - public function testCreateRejectsCTAWithEmptyFields(): void - { - $insight = $this->createInsight([ - 'insightId' => ID::unique(), - 'type' => 'databaseIndex', - 'resourceType' => 'databases', - 'resourceId' => 'main', - 'title' => 'Should not be created', - 'ctas' => [ - ['key' => '', 'label' => 'Has empty id', 'service' => 'databases', 'method' => 'createIndex'], + ['label' => '', 'service' => 'databases', 'method' => 'createIndex'], ], ]); @@ -518,7 +498,7 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['key' => 'createIndex', 'label' => 'Missing method', 'service' => 'tablesDB'], + ['label' => 'Missing method', 'service' => 'tablesDB'], ], ]); @@ -534,7 +514,7 @@ trait InsightsBase 'resourceId' => 'main', 'title' => 'Should not be created', 'ctas' => [ - ['key' => 'createIndex', 'label' => 'Missing service', 'method' => 'createIndex'], + ['label' => 'Missing service', 'method' => 'createIndex'], ], ]); @@ -546,7 +526,6 @@ trait InsightsBase $ctas = []; for ($i = 0; $i < 17; $i++) { $ctas[] = [ - 'key' => 'cta-' . $i, 'label' => 'CTA ' . $i, 'service' => 'databases', 'method' => 'createIndex', diff --git a/tests/unit/Insights/Validator/CTAsTest.php b/tests/unit/Insights/Validator/CTAsTest.php index fbc30cad81..d1208da510 100644 --- a/tests/unit/Insights/Validator/CTAsTest.php +++ b/tests/unit/Insights/Validator/CTAsTest.php @@ -28,7 +28,6 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertTrue($validator->isValid([[ - 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -44,7 +43,6 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertTrue($validator->isValid([[ - 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -55,10 +53,9 @@ class CTAsTest extends TestCase { $validator = new CTAs(); - $this->assertFalse($validator->isValid([['key' => 'x']])); - $this->assertFalse($validator->isValid([['key' => 'x', 'label' => 'y']])); - $this->assertFalse($validator->isValid([['key' => 'x', 'label' => 'y', 'service' => 'tablesDB']])); - $this->assertFalse($validator->isValid([['key' => 'x', 'label' => 'y', 'method' => 'createIndex']])); + $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 @@ -66,8 +63,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'key' => '', - 'label' => 'Create missing index', + 'label' => '', 'service' => 'tablesDB', 'method' => 'createIndex', ]])); @@ -78,8 +74,7 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'key' => 123, - 'label' => 'Create missing index', + 'label' => 123, 'service' => 'tablesDB', 'method' => 'createIndex', ]])); @@ -90,7 +85,6 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -113,7 +107,6 @@ class CTAsTest extends TestCase $entries = []; for ($i = 0; $i < 4; $i++) { $entries[] = [ - 'key' => 'cta-' . $i, 'label' => 'Label ' . $i, 'service' => 'tablesDB', 'method' => 'createIndex', @@ -131,7 +124,6 @@ class CTAsTest extends TestCase $entries = []; for ($i = 0; $i < 3; $i++) { $entries[] = [ - 'key' => 'cta-' . $i, 'label' => 'Label ' . $i, 'service' => 'tablesDB', 'method' => 'createIndex', @@ -146,7 +138,6 @@ class CTAsTest extends TestCase $validator = new CTAs(); $entry = [ - 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => 'createIndex', @@ -161,7 +152,6 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => '', 'method' => 'createIndex', @@ -173,25 +163,12 @@ class CTAsTest extends TestCase $validator = new CTAs(); $this->assertFalse($validator->isValid([[ - 'key' => 'createIndex', 'label' => 'Create missing index', 'service' => 'tablesDB', 'method' => '', ]])); } - public function testRejectsEntryWithEmptyLabel(): void - { - $validator = new CTAs(); - - $this->assertFalse($validator->isValid([[ - 'key' => 'createIndex', - 'label' => '', - 'service' => 'tablesDB', - 'method' => 'createIndex', - ]])); - } - public function testDefaultMaxCountIsSixteen(): void { $validator = new CTAs(); @@ -201,7 +178,6 @@ class CTAsTest extends TestCase $entries = []; for ($i = 0; $i < 16; $i++) { $entries[] = [ - 'key' => 'cta-' . $i, 'label' => 'Label ' . $i, 'service' => 'tablesDB', 'method' => 'createIndex', @@ -211,7 +187,6 @@ class CTAsTest extends TestCase $this->assertTrue($validator->isValid($entries)); $entries[] = [ - 'key' => 'cta-16', 'label' => 'Label 16', 'service' => 'tablesDB', 'method' => 'createIndex', From 1f4111d6f279ea351711fe3360d66af882ec59a7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 18:34:04 +1200 Subject: [PATCH 32/70] test(insights): drop summary update from testUpdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User Update only accepts severity + status now — analyzer-controlled fields like summary flow through the manager Create endpoint. The previous testUpdate sent `summary: 'Updated summary.'` and asserted it changed; Utopia silently dropped the unknown param so the assertion would have failed. Trim the call to severity-only and verify the analyzer fields (including summary) are preserved on the response. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/Services/Insights/InsightsBase.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index d0ddaac89d..efc00641df 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -686,15 +686,16 @@ trait InsightsBase $updated = $this->updateInsight($data['insightId'], [ 'severity' => 'critical', - 'summary' => 'Updated summary.', ]); $this->assertSame(200, $updated['headers']['status-code']); $this->assertSame('critical', $updated['body']['severity']); - $this->assertSame('Updated summary.', $updated['body']['summary']); - // Untouched fields preserved (regression for partial-document overwrite) + // Analyzer-controlled fields preserved (regression for partial-document + // overwrite). User Update only takes `severity` and `status`; everything + // else flows through the manager Create endpoint. $this->assertSame($original['title'], $updated['body']['title']); + $this->assertSame($original['summary'], $updated['body']['summary']); $this->assertSame($original['type'], $updated['body']['type']); $this->assertSame($original['resourceType'], $updated['body']['resourceType']); $this->assertSame($original['resourceId'], $updated['body']['resourceId']); From 68c354e09b9d7e877511c4cab8eac07874340b3c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 18:53:54 +1200 Subject: [PATCH 33/70] refactor(insights): nest insights API under reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insights are children of reports — make the URL hierarchy reflect that. Endpoints: - POST /v1/manager/reports/:reportId/insights (manager Create) - GET /v1/reports/:reportId/insights (List) - GET /v1/reports/:reportId/insights/:insightId (Get) - PATCH /v1/reports/:reportId/insights/:insightId (Update) - DELETE /v1/reports/:reportId/insights/:insightId (Delete) `reportId` moves from optional body field to required path param. All endpoints fetch the report first (404 REPORT_NOT_FOUND if missing or in another project), then verify the insight's `reportInternalId` matches before doing anything else. Side effects: - Event names nested: `reports.[reportId].insights.[insightId].create` etc. Top-level `insights.*` event tree removed from events.php. - Realtime channel parser handles the nested form: a `reports.{rid}` event lights up `reports`, `reports.{rid}` channels; a nested `reports.{rid}.insights.{iid}` event also lights up `reports.{rid}.insights` and `reports.{rid}.insights.{iid}`. - Audit resource paths nested similarly: `report/{request.reportId}/insight/{response.$id}`. - listInsights query validator drops `reportId` from ALLOWED_ATTRIBUTES — it's path-scoped now, not a query filter. Tests: - E2E helpers `createInsight`/`getInsight`/`listInsights`/ `updateInsight`/`deleteInsight` all take `reportId` as the first argument. - New `createFixtureReport()` helper for standalone validation tests that need a parent. - Dropped `testCreateWithoutReport` — reportId is mandatory now. - `testCreateRejectsUnknownReport` now exercises the path-level 404 rather than a body-level check. - `testGet` and `testUpdateMissing` exercise the wrong-reportId-but-valid-insightId case (returns `report_not_found`). - `testList` asserts every result carries the path's reportId, plus a 404 case for a nonexistent parent. - `testCreateForEachEngine` and the standalone create-rejection tests inline-create their own fixture report and clean up after. - `testListSurvivesEmptyDatabase` renamed to `testListSurvivesEmptyReport` and uses a fresh fixture report. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/events.php | 28 +- src/Appwrite/Messaging/Adapter/Realtime.php | 13 +- .../Modules/Insights/Http/Insights/Delete.php | 21 +- .../Modules/Insights/Http/Insights/Get.php | 18 +- .../Modules/Insights/Http/Insights/Update.php | 24 +- .../Modules/Insights/Http/Insights/XList.php | 20 +- .../Insights/Http/Manager/Insights/Create.php | 28 +- .../Modules/Insights/Services/Http.php | 2 +- .../Database/Validator/Queries/Insights.php | 1 - tests/e2e/Services/Insights/InsightsBase.php | 241 ++++++++++-------- 10 files changed, 242 insertions(+), 154 deletions(-) diff --git a/app/config/events.php b/app/config/events.php index b708d785b0..2825562ab7 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -427,20 +427,6 @@ return [ '$description' => 'This event triggers when a proxy rule is updated.', ] ], - '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.', - ], - ], 'reports' => [ '$model' => Response::MODEL_REPORT, '$resource' => true, @@ -454,5 +440,19 @@ return [ '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/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 128730d9e0..c4cd2c08d5 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -774,11 +774,18 @@ class Realtime extends MessagingAdapter $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } break; - case 'insights': case 'reports': - $channels[] = $parts[0]; + // Plain report event: `reports.{reportId}.{action}` + $channels[] = 'reports'; if (isset($parts[1])) { - $channels[] = $parts[0] . '.' . $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; diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php index d8097f4126..dd1d0843a6 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php @@ -29,14 +29,14 @@ class Delete extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) - ->setHttpPath('/v1/insights/:insightId') + ->setHttpPath('/v1/reports/:reportId/insights/:insightId') ->desc('Delete insight') ->groups(['api', 'insights']) ->label('scope', 'insights.write') - ->label('event', 'insights.[insightId].delete') + ->label('event', 'reports.[reportId].insights.[insightId].delete') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) ->label('audits.event', 'insight.delete') - ->label('audits.resource', 'insight/{request.insightId}') + ->label('audits.resource', 'report/{request.reportId}/insight/{request.insightId}') ->label('abuse-key', 'projectId:{projectId},userId:{userId}') ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) @@ -56,6 +56,7 @@ class Delete extends Action ], contentType: ContentType::NONE )) + ->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') @@ -65,15 +66,26 @@ class Delete extends Action } public function action( + string $reportId, string $insightId, Response $response, Document $project, Database $dbForPlatform, Event $queueForEvents ) { + $report = $dbForPlatform->getDocument('reports', $reportId); + + 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()) { + if ( + $insight->isEmpty() + || $insight->getAttribute('projectInternalId') !== $project->getSequence() + || $insight->getAttribute('reportInternalId') !== $report->getSequence() + ) { throw new Exception(Exception::INSIGHT_NOT_FOUND); } @@ -92,6 +104,7 @@ class Delete extends Action } $queueForEvents + ->setParam('reportId', $report->getId()) ->setParam('insightId', $insight->getId()) ->setPayload($response->output($insight, Response::MODEL_INSIGHT)); diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php index b7de7bccc9..ea3c88349c 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php @@ -26,7 +26,7 @@ class Get extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/insights/:insightId') + ->setHttpPath('/v1/reports/:reportId/insights/:insightId') ->desc('Get insight') ->groups(['api', 'insights']) ->label('scope', 'insights.read') @@ -36,7 +36,7 @@ class Get extends Action group: 'insights', name: 'get', description: <<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') @@ -54,14 +55,25 @@ class Get extends Action } public function action( + string $reportId, string $insightId, Response $response, Document $project, Database $dbForPlatform ) { + $report = $dbForPlatform->getDocument('reports', $reportId); + + 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()) { + if ( + $insight->isEmpty() + || $insight->getAttribute('projectInternalId') !== $project->getSequence() + || $insight->getAttribute('reportInternalId') !== $report->getSequence() + ) { throw new Exception(Exception::INSIGHT_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php index a8cc5803a5..241537f08e 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php @@ -38,14 +38,14 @@ class Update extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/insights/:insightId') + ->setHttpPath('/v1/reports/:reportId/insights/:insightId') ->desc('Update insight') ->groups(['api', 'insights']) ->label('scope', 'insights.write') - ->label('event', 'insights.[insightId].update') + ->label('event', 'reports.[reportId].insights.[insightId].update') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) ->label('audits.event', 'insight.update') - ->label('audits.resource', 'insight/{response.$id}') + ->label('audits.resource', 'report/{request.reportId}/insight/{response.$id}') ->label('abuse-key', 'projectId:{projectId},userId:{userId}') ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) @@ -64,6 +64,7 @@ class Update extends Action ), ] )) + ->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']) ->param('severity', null, new Nullable(new WhiteList(INSIGHT_SEVERITIES, true)), 'Insight severity. One of `info`, `warning`, `critical`.', true) ->param('status', null, new Nullable(new WhiteList(INSIGHT_STATUSES, true)), 'Insight status. Set to `dismissed` to dismiss the insight, `active` to undo a dismissal.', true) @@ -76,6 +77,7 @@ class Update extends Action } public function action( + string $reportId, string $insightId, ?string $severity, ?string $status, @@ -85,9 +87,19 @@ class Update extends Action Database $dbForPlatform, Event $queueForEvents ) { + $report = $dbForPlatform->getDocument('reports', $reportId); + + 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()) { + if ( + $insight->isEmpty() + || $insight->getAttribute('projectInternalId') !== $project->getSequence() + || $insight->getAttribute('reportInternalId') !== $report->getSequence() + ) { throw new Exception(Exception::INSIGHT_NOT_FOUND); } @@ -114,7 +126,9 @@ class Update extends Action $insight = $dbForPlatform->updateDocument('insights', $insight->getId(), $insight); } - $queueForEvents->setParam('insightId', $insight->getId()); + $queueForEvents + ->setParam('reportId', $report->getId()) + ->setParam('insightId', $insight->getId()); $response->dynamic($insight, Response::MODEL_INSIGHT); } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php index d0674178c3..dba5b6da7b 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php @@ -14,6 +14,7 @@ use Utopia\Database\Exception\Order as OrderException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Cursor; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -31,7 +32,7 @@ class XList extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/insights') + ->setHttpPath('/v1/reports/:reportId/insights') ->desc('List insights') ->groups(['api', 'insights']) ->label('scope', 'insights.read') @@ -41,7 +42,7 @@ class XList extends Action group: 'insights', name: 'list', description: <<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') @@ -60,12 +62,19 @@ class XList extends Action } public function action( + string $reportId, array $queries, bool $includeTotal, Response $response, Document $project, Database $dbForPlatform ) { + $report = $dbForPlatform->getDocument('reports', $reportId); + + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); + } + try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { @@ -73,6 +82,7 @@ class XList extends Action } $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + $queries[] = Query::equal('reportInternalId', [$report->getSequence()]); $cursor = Query::getCursorQueries($queries, false); $cursor = \reset($cursor); @@ -86,7 +96,11 @@ class XList extends Action $insightId = $cursor->getValue(); $cursorDocument = $dbForPlatform->getDocument('insights', $insightId); - if ($cursorDocument->isEmpty() || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()) { + 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."); } diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index 17568e15d0..4bf5c8cea6 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -45,14 +45,14 @@ class Create extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/manager/insights') + ->setHttpPath('/v1/manager/reports/:reportId/insights') ->desc('Create insight') ->groups(['api', 'manager', 'insights']) ->label('scope', 'insights.manager') - ->label('event', 'insights.[insightId].create') + ->label('event', 'reports.[reportId].insights.[insightId].create') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) ->label('audits.event', 'insight.create') - ->label('audits.resource', 'insight/{response.$id}') + ->label('audits.resource', 'report/{request.reportId}/insight/{response.$id}') ->label('abuse-key', 'projectId:{projectId},userId:{userId}') ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) @@ -72,8 +72,8 @@ class Create extends Action ], hide: true, )) + ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform']) ->param('insightId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) - ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID. Optional — leave empty for ad-hoc insights not attached to a report.', true, ['dbForPlatform']) ->param('type', '', new WhiteList(INSIGHT_TYPES, true), 'Insight type. Determines the analyzer that owns this insight and the shape of `payload`.') ->param('severity', INSIGHT_SEVERITY_INFO, new WhiteList(INSIGHT_SEVERITIES, true), 'Insight severity. One of `info`, `warning`, `critical`.', true) ->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.') @@ -95,8 +95,8 @@ class Create extends Action } public function action( - string $insightId, string $reportId, + string $insightId, string $type, string $severity, string $resourceType, @@ -117,18 +117,14 @@ class Create extends Action ) { $insightId = ($insightId === 'unique()') ? ID::unique() : $insightId; - $reportInternalId = ''; + $report = $dbForPlatform->getDocument('reports', $reportId); - if ($reportId !== '') { - $report = $dbForPlatform->getDocument('reports', $reportId); - - if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { - throw new Exception(Exception::REPORT_NOT_FOUND); - } - - $reportInternalId = $report->getSequence(); + if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::REPORT_NOT_FOUND); } + $reportInternalId = $report->getSequence(); + $normalizedCTAs = []; foreach ($ctas as $cta) { @@ -185,7 +181,9 @@ class Create extends Action // CTA documents on the response — keeps a single round-trip for callers. $insight = $dbForPlatform->getDocument('insights', $insight->getId()); - $queueForEvents->setParam('insightId', $insight->getId()); + $queueForEvents + ->setParam('reportId', $report->getId()) + ->setParam('insightId', $insight->getId()); $response ->setStatusCode(Response::STATUS_CODE_CREATED) diff --git a/src/Appwrite/Platform/Modules/Insights/Services/Http.php b/src/Appwrite/Platform/Modules/Insights/Services/Http.php index b2ca226cf0..459df853c5 100644 --- a/src/Appwrite/Platform/Modules/Insights/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Insights/Services/Http.php @@ -26,7 +26,7 @@ class Http extends Service $this->addAction(UpdateReport::getName(), new UpdateReport()); $this->addAction(DeleteReport::getName(), new DeleteReport()); - // Manager-only ingestion (hidden from SDKs, /v1/manager/insights). + // Manager-only ingestion (hidden from SDKs, /v1/manager/reports/:reportId/insights). $this->addAction(CreateInsight::getName(), new CreateInsight()); $this->addAction(GetInsight::getName(), new GetInsight()); diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php index c0afd56134..18badf8722 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php @@ -8,7 +8,6 @@ class Insights extends Base 'type', 'severity', 'status', - 'reportId', 'resourceType', 'resourceId', 'parentResourceType', diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index efc00641df..4521e60013 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -49,30 +49,48 @@ trait InsightsBase return $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId, $headers ?? $this->serverHeaders()); } - protected function createInsight(array $body, array $headers = null): array + protected function createInsight(string $reportId, array $body, array $headers = null): array { // Manager-only endpoint — internal Appwrite services ingest here, not user SDKs. - return $this->client->call(Client::METHOD_POST, '/manager/insights', $headers ?? $this->serverHeaders(), $body); + return $this->client->call(Client::METHOD_POST, '/manager/reports/' . $reportId . '/insights', $headers ?? $this->serverHeaders(), $body); } - protected function getInsight(string $insightId, array $headers = null): array + protected function getInsight(string $reportId, string $insightId, array $headers = null): array { - return $this->client->call(Client::METHOD_GET, '/insights/' . $insightId, $headers ?? $this->serverHeaders()); + return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders()); } - protected function listInsights(array $params = [], array $headers = null): array + protected function listInsights(string $reportId, array $params = [], array $headers = null): array { - return $this->client->call(Client::METHOD_GET, '/insights', $headers ?? $this->serverHeaders(), $params); + return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights', $headers ?? $this->serverHeaders(), $params); } - protected function updateInsight(string $insightId, array $body, array $headers = null): array + protected function updateInsight(string $reportId, string $insightId, array $body, array $headers = null): array { - return $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $headers ?? $this->serverHeaders(), $body); + return $this->client->call(Client::METHOD_PATCH, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders(), $body); } - protected function deleteInsight(string $insightId, array $headers = null): array + protected function deleteInsight(string $reportId, string $insightId, array $headers = null): array { - return $this->client->call(Client::METHOD_DELETE, '/insights/' . $insightId, $headers ?? $this->serverHeaders()); + return $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders()); + } + + /** + * Create a throwaway report so a standalone validation test has a parent + * report to nest under. Caller is responsible for `deleteReport()`. + */ + protected function createFixtureReport(string $type = 'audit'): string + { + $reportId = ID::unique(); + $report = $this->createReport([ + 'reportId' => $reportId, + 'type' => $type, + 'title' => 'Fixture report', + 'targetType' => 'sites', + 'target' => 'fixture', + ]); + $this->assertSame(201, $report['headers']['status-code']); + return $reportId; } /** @@ -136,7 +154,7 @@ trait InsightsBase }; } - protected function sampleInsight(string $insightId = null, string $reportId = null, string $engine = 'tablesDB'): array + protected function sampleInsight(string $insightId = null, string $engine = 'tablesDB'): array { $type = match ($engine) { 'databases' => 'databaseIndex', @@ -146,9 +164,6 @@ trait InsightsBase default => throw new \InvalidArgumentException("Unknown engine: {$engine}"), }; - // The insight is *about* a missing index, contained within a table/collection. - // resourceType=indexes points at the index that should exist; the parent - // points at the table/collection that owns it. $parentResourceType = match ($engine) { 'databases' => 'collections', 'tablesDB' => 'tables', @@ -157,7 +172,7 @@ trait InsightsBase default => 'collections', }; - $body = [ + return [ 'insightId' => $insightId ?? ID::unique(), 'type' => $type, 'severity' => 'warning', @@ -170,12 +185,6 @@ trait InsightsBase 'payload' => ['databaseId' => 'main', 'engine' => $engine], 'ctas' => [$this->sampleCTA($engine)], ]; - - if ($reportId !== null) { - $body['reportId'] = $reportId; - } - - return $body; } public function testCreateReport(): array @@ -241,7 +250,6 @@ trait InsightsBase $this->assertSame(409, $second['headers']['status-code']); $this->assertSame('report_already_exists', $second['body']['type']); - // cleanup $this->deleteReport($reportId); } @@ -316,7 +324,6 @@ trait InsightsBase $this->assertSame('Updated database analyzer report', $updated['body']['title']); $this->assertSame('Updated summary.', $updated['body']['summary']); - // Unchanged fields preserved $this->assertSame($original['body']['type'], $updated['body']['type']); $this->assertSame($original['body']['target'], $updated['body']['target']); $this->assertSame($original['body']['targetType'], $updated['body']['targetType']); @@ -334,7 +341,7 @@ trait InsightsBase { $insightId = ID::unique(); - $insight = $this->createInsight($this->sampleInsight($insightId, $data['reportId'], 'tablesDB')); + $insight = $this->createInsight($data['reportId'], $this->sampleInsight($insightId, 'tablesDB')); $this->assertSame(201, $insight['headers']['status-code']); $this->assertSame($insightId, $insight['body']['$id']); @@ -363,24 +370,22 @@ trait InsightsBase } /** - * Each engine — legacy databases, tablesDB, documentsDB, vectorsDB — should be - * createable with its own insight type and a CTA whose service+method points - * at the matching public API. - * * @dataProvider engineMatrixProvider */ public function testCreateForEachEngine(string $engine, string $expectedType, string $expectedService, string $expectedMethod): void { + $reportId = $this->createFixtureReport(); $insightId = ID::unique(); - $insight = $this->createInsight($this->sampleInsight($insightId, null, $engine)); + $insight = $this->createInsight($reportId, $this->sampleInsight($insightId, $engine)); $this->assertSame(201, $insight['headers']['status-code']); $this->assertSame($expectedType, $insight['body']['type']); $this->assertSame($expectedService, $insight['body']['ctas'][0]['service']); $this->assertSame($expectedMethod, $insight['body']['ctas'][0]['method']); - $this->deleteInsight($insightId); + $this->deleteInsight($reportId, $insightId); + $this->deleteReport($reportId); } public static function engineMatrixProvider(): array @@ -393,29 +398,17 @@ trait InsightsBase ]; } - public function testCreateWithoutReport(): void - { - $insightId = ID::unique(); - - $insight = $this->createInsight($this->sampleInsight($insightId)); - - $this->assertSame(201, $insight['headers']['status-code']); - $this->assertSame($insightId, $insight['body']['$id']); - $this->assertEmpty($insight['body']['reportId']); - - $this->deleteInsight($insightId); - } - public function testCreateWithoutParentResource(): void { // Top-level resource (no parent) — e.g. a project-wide audit finding. + $reportId = $this->createFixtureReport(); $insightId = ID::unique(); $body = $this->sampleInsight($insightId); unset($body['parentResourceType'], $body['parentResourceId']); $body['resourceType'] = 'projects'; $body['resourceId'] = $this->getProject()['$id']; - $insight = $this->createInsight($body); + $insight = $this->createInsight($reportId, $body); $this->assertSame(201, $insight['headers']['status-code']); $this->assertSame('projects', $insight['body']['resourceType']); @@ -423,12 +416,14 @@ trait InsightsBase $this->assertEmpty($insight['body']['parentResourceId']); $this->assertEmpty($insight['body']['parentResourceInternalId']); - $this->deleteInsight($insightId); + $this->deleteInsight($reportId, $insightId); + $this->deleteReport($reportId); } public function testCreateRejectsInvalidType(): void { - $insight = $this->createInsight([ + $reportId = $this->createFixtureReport(); + $insight = $this->createInsight($reportId, [ 'insightId' => ID::unique(), 'type' => 'unknownType', 'resourceType' => 'databases', @@ -436,11 +431,14 @@ trait InsightsBase 'title' => 'Should not be created', ]); $this->assertSame(400, $insight['headers']['status-code']); + + $this->deleteReport($reportId); } public function testCreateRejectsInvalidSeverity(): void { - $insight = $this->createInsight([ + $reportId = $this->createFixtureReport(); + $insight = $this->createInsight($reportId, [ 'insightId' => ID::unique(), 'type' => 'databaseIndex', 'severity' => 'catastrophic', @@ -449,25 +447,31 @@ trait InsightsBase 'title' => 'Should not be created', ]); $this->assertSame(400, $insight['headers']['status-code']); + + $this->deleteReport($reportId); } public function testCreateRejectsDuplicateId(): void { + $reportId = $this->createFixtureReport(); $insightId = ID::unique(); - $first = $this->createInsight($this->sampleInsight($insightId)); + $first = $this->createInsight($reportId, $this->sampleInsight($insightId)); $this->assertSame(201, $first['headers']['status-code']); - $second = $this->createInsight($this->sampleInsight($insightId)); + $second = $this->createInsight($reportId, $this->sampleInsight($insightId)); $this->assertSame(409, $second['headers']['status-code']); $this->assertSame('insight_already_exists', $second['body']['type']); - $this->deleteInsight($insightId); + $this->deleteInsight($reportId, $insightId); + $this->deleteReport($reportId); } public function testCreateRejectsUnknownReport(): void { - $insight = $this->createInsight($this->sampleInsight(null, 'definitely-missing')); + // Path-level reportId doesn't exist — endpoint 404s before touching any + // insight logic. + $insight = $this->createInsight('definitely-missing', $this->sampleInsight()); $this->assertSame(404, $insight['headers']['status-code']); $this->assertSame('report_not_found', $insight['body']['type']); @@ -475,7 +479,8 @@ trait InsightsBase public function testCreateRejectsCTAWithEmptyLabel(): void { - $insight = $this->createInsight([ + $reportId = $this->createFixtureReport(); + $insight = $this->createInsight($reportId, [ 'insightId' => ID::unique(), 'type' => 'databaseIndex', 'resourceType' => 'databases', @@ -485,13 +490,15 @@ trait InsightsBase ['label' => '', 'service' => 'databases', 'method' => 'createIndex'], ], ]); - $this->assertSame(400, $insight['headers']['status-code']); + + $this->deleteReport($reportId); } public function testCreateRejectsCTAWithMissingMethod(): void { - $insight = $this->createInsight([ + $reportId = $this->createFixtureReport(); + $insight = $this->createInsight($reportId, [ 'insightId' => ID::unique(), 'type' => 'databaseIndex', 'resourceType' => 'databases', @@ -501,13 +508,15 @@ trait InsightsBase ['label' => 'Missing method', 'service' => 'tablesDB'], ], ]); - $this->assertSame(400, $insight['headers']['status-code']); + + $this->deleteReport($reportId); } public function testCreateRejectsCTAWithMissingService(): void { - $insight = $this->createInsight([ + $reportId = $this->createFixtureReport(); + $insight = $this->createInsight($reportId, [ 'insightId' => ID::unique(), 'type' => 'databaseIndex', 'resourceType' => 'databases', @@ -517,12 +526,14 @@ trait InsightsBase ['label' => 'Missing service', 'method' => 'createIndex'], ], ]); - $this->assertSame(400, $insight['headers']['status-code']); + + $this->deleteReport($reportId); } public function testCreateRejectsTooManyCTAs(): void { + $reportId = $this->createFixtureReport(); $ctas = []; for ($i = 0; $i < 17; $i++) { $ctas[] = [ @@ -532,7 +543,7 @@ trait InsightsBase ]; } - $insight = $this->createInsight([ + $insight = $this->createInsight($reportId, [ 'insightId' => ID::unique(), 'type' => 'databaseIndex', 'resourceType' => 'databases', @@ -540,8 +551,9 @@ trait InsightsBase 'title' => 'Should not be created', 'ctas' => $ctas, ]); - $this->assertSame(400, $insight['headers']['status-code']); + + $this->deleteReport($reportId); } /** @@ -549,16 +561,21 @@ trait InsightsBase */ public function testGet(array $data): array { - $insight = $this->getInsight($data['insightId']); + $insight = $this->getInsight($data['reportId'], $data['insightId']); $this->assertSame(200, $insight['headers']['status-code']); $this->assertSame($data['insightId'], $insight['body']['$id']); $this->assertSame($data['reportId'], $insight['body']['reportId']); - $missing = $this->getInsight('missing'); + $missing = $this->getInsight($data['reportId'], 'missing'); $this->assertSame(404, $missing['headers']['status-code']); $this->assertSame('insight_not_found', $missing['body']['type']); + // Insight exists but caller used the wrong reportId — still 404. + $wrongReport = $this->getInsight('definitely-missing', $data['insightId']); + $this->assertSame(404, $wrongReport['headers']['status-code']); + $this->assertSame('report_not_found', $wrongReport['body']['type']); + return $data; } @@ -567,12 +584,16 @@ trait InsightsBase */ public function testList(array $data): array { - $list = $this->listInsights(); + $list = $this->listInsights($data['reportId']); $this->assertSame(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $list['body']['total']); $this->assertNotEmpty($list['body']['insights']); + // Every returned insight belongs to the path's report. + foreach ($list['body']['insights'] as $insight) { + $this->assertSame($data['reportId'], $insight['reportId']); + } - $byResourceType = $this->listInsights([ + $byResourceType = $this->listInsights($data['reportId'], [ 'queries' => ['equal("resourceType", "indexes")'], ]); $this->assertSame(200, $byResourceType['headers']['status-code']); @@ -580,7 +601,7 @@ trait InsightsBase $this->assertSame('indexes', $insight['resourceType']); } - $byParentResource = $this->listInsights([ + $byParentResource = $this->listInsights($data['reportId'], [ 'queries' => [ 'equal("parentResourceType", "tables")', 'equal("parentResourceId", "orders")', @@ -592,7 +613,7 @@ trait InsightsBase $this->assertSame('orders', $insight['parentResourceId']); } - $byStatus = $this->listInsights([ + $byStatus = $this->listInsights($data['reportId'], [ 'queries' => ['equal("status", "active")'], ]); $this->assertSame(200, $byStatus['headers']['status-code']); @@ -600,7 +621,7 @@ trait InsightsBase $this->assertSame('active', $insight['status']); } - $byType = $this->listInsights([ + $byType = $this->listInsights($data['reportId'], [ 'queries' => ['equal("type", "tablesDBIndex")'], ]); $this->assertSame(200, $byType['headers']['status-code']); @@ -608,7 +629,7 @@ trait InsightsBase $this->assertSame('tablesDBIndex', $insight['type']); } - $bySeverity = $this->listInsights([ + $bySeverity = $this->listInsights($data['reportId'], [ 'queries' => ['equal("severity", "warning")'], ]); $this->assertSame(200, $bySeverity['headers']['status-code']); @@ -616,14 +637,10 @@ trait InsightsBase $this->assertSame('warning', $insight['severity']); } - $byReport = $this->listInsights([ - 'queries' => ['equal("reportId", "' . $data['reportId'] . '")'], - ]); - $this->assertSame(200, $byReport['headers']['status-code']); - $this->assertGreaterThanOrEqual(1, $byReport['body']['total']); - foreach ($byReport['body']['insights'] as $insight) { - $this->assertSame($data['reportId'], $insight['reportId']); - } + // Listing under a non-existent report is a 404. + $missingReport = $this->listInsights('definitely-missing'); + $this->assertSame(404, $missingReport['headers']['status-code']); + $this->assertSame('report_not_found', $missingReport['body']['type']); return $data; } @@ -633,7 +650,7 @@ trait InsightsBase */ public function testListRejectsInvalidQueryAttribute(array $data): array { - $invalid = $this->listInsights([ + $invalid = $this->listInsights($data['reportId'], [ 'queries' => ['equal("unknownField", "x")'], ]); $this->assertSame(400, $invalid['headers']['status-code']); @@ -646,33 +663,34 @@ trait InsightsBase */ public function testListWithCursor(array $data): array { - // Seed two extra insights so pagination has something to chew through + // Seed two extra insights under the same report so pagination has + // something to chew through. $first = ID::unique(); $second = ID::unique(); - $this->createInsight($this->sampleInsight($first)); - $this->createInsight($this->sampleInsight($second)); + $this->createInsight($data['reportId'], $this->sampleInsight($first)); + $this->createInsight($data['reportId'], $this->sampleInsight($second)); - $page1 = $this->listInsights([ + $page1 = $this->listInsights($data['reportId'], [ 'queries' => ['limit(1)'], ]); $this->assertSame(200, $page1['headers']['status-code']); $this->assertCount(1, $page1['body']['insights']); $cursorId = $page1['body']['insights'][0]['$id']; - $page2 = $this->listInsights([ + $page2 = $this->listInsights($data['reportId'], [ 'queries' => ['limit(1)', 'cursorAfter("' . $cursorId . '")'], ]); $this->assertSame(200, $page2['headers']['status-code']); $this->assertCount(1, $page2['body']['insights']); $this->assertNotSame($cursorId, $page2['body']['insights'][0]['$id']); - $missingCursor = $this->listInsights([ + $missingCursor = $this->listInsights($data['reportId'], [ 'queries' => ['cursorAfter("definitely-missing")'], ]); $this->assertSame(400, $missingCursor['headers']['status-code']); - $this->deleteInsight($first); - $this->deleteInsight($second); + $this->deleteInsight($data['reportId'], $first); + $this->deleteInsight($data['reportId'], $second); return $data; } @@ -682,9 +700,9 @@ trait InsightsBase */ public function testUpdate(array $data): array { - $original = $this->getInsight($data['insightId'])['body']; + $original = $this->getInsight($data['reportId'], $data['insightId'])['body']; - $updated = $this->updateInsight($data['insightId'], [ + $updated = $this->updateInsight($data['reportId'], $data['insightId'], [ 'severity' => 'critical', ]); @@ -713,20 +731,20 @@ trait InsightsBase */ public function testDismissViaUpdate(array $data): array { - $dismissed = $this->updateInsight($data['insightId'], ['status' => 'dismissed']); + $dismissed = $this->updateInsight($data['reportId'], $data['insightId'], ['status' => 'dismissed']); $this->assertSame(200, $dismissed['headers']['status-code']); $this->assertSame('dismissed', $dismissed['body']['status']); $this->assertNotEmpty($dismissed['body']['dismissedAt']); $this->assertNotEmpty($dismissed['body']['dismissedBy']); - $byDismissed = $this->listInsights([ + $byDismissed = $this->listInsights($data['reportId'], [ 'queries' => ['equal("status", "dismissed")'], ]); $this->assertSame(200, $byDismissed['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $byDismissed['body']['total']); - $undismiss = $this->updateInsight($data['insightId'], ['status' => 'active']); + $undismiss = $this->updateInsight($data['reportId'], $data['insightId'], ['status' => 'active']); $this->assertSame(200, $undismiss['headers']['status-code']); $this->assertSame('active', $undismiss['body']['status']); @@ -741,9 +759,15 @@ trait InsightsBase */ public function testUpdateMissing(array $data): array { - $missing = $this->updateInsight('missing', ['severity' => 'critical']); - $this->assertSame(404, $missing['headers']['status-code']); - $this->assertSame('insight_not_found', $missing['body']['type']); + // Real report, missing insight → insight_not_found. + $missingInsight = $this->updateInsight($data['reportId'], 'missing', ['severity' => 'critical']); + $this->assertSame(404, $missingInsight['headers']['status-code']); + $this->assertSame('insight_not_found', $missingInsight['body']['type']); + + // Missing report → report_not_found before insight is even checked. + $missingReport = $this->updateInsight('definitely-missing', $data['insightId'], ['severity' => 'critical']); + $this->assertSame(404, $missingReport['headers']['status-code']); + $this->assertSame('report_not_found', $missingReport['body']['type']); return $data; } @@ -753,10 +777,10 @@ trait InsightsBase */ public function testDelete(array $data): array { - $delete = $this->deleteInsight($data['insightId']); + $delete = $this->deleteInsight($data['reportId'], $data['insightId']); $this->assertSame(204, $delete['headers']['status-code']); - $missing = $this->getInsight($data['insightId']); + $missing = $this->getInsight($data['reportId'], $data['insightId']); $this->assertSame(404, $missing['headers']['status-code']); return $data; @@ -768,7 +792,7 @@ trait InsightsBase public function testDeleteReportCascadesToInsights(array $data): void { $insightId = ID::unique(); - $create = $this->createInsight($this->sampleInsight($insightId, $data['reportId'])); + $create = $this->createInsight($data['reportId'], $this->sampleInsight($insightId)); $this->assertSame(201, $create['headers']['status-code']); $deleteReport = $this->deleteReport($data['reportId']); @@ -777,13 +801,17 @@ trait InsightsBase $missingReport = $this->getReport($data['reportId']); $this->assertSame(404, $missingReport['headers']['status-code']); - $orphaned = $this->getInsight($insightId); + // The insight got cascaded too — both the parent path and the insight + // itself are gone. + $orphaned = $this->getInsight($data['reportId'], $insightId); $this->assertSame(404, $orphaned['headers']['status-code']); } public function testCreateRequiresServerKey(): void { - $unauthorized = $this->createInsight($this->sampleInsight(), [ + // Auth check runs before the report fetch, so any reportId works for + // this assertion. + $unauthorized = $this->createInsight(ID::unique(), $this->sampleInsight(), [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]); @@ -793,15 +821,16 @@ trait InsightsBase public function testCreateRequiresManagerScope(): void { - // A server key with insights.read + insights.write but NOT insights.manager - // must be rejected — Create lives behind /v1/manager/insights and only - // internal Appwrite services hold the manager scope. + // A server key with insights.read + insights.write but NOT + // insights.manager must be rejected — Create lives behind + // /v1/manager/reports/:reportId/insights and only internal Appwrite + // services hold the manager scope. $userKey = $this->getNewKey([ 'insights.read', 'insights.write', ]); - $rejected = $this->createInsight($this->sampleInsight(), [ + $rejected = $this->createInsight(ID::unique(), $this->sampleInsight(), [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $userKey, @@ -810,13 +839,15 @@ trait InsightsBase $this->assertSame(401, $rejected['headers']['status-code']); } - public function testListSurvivesEmptyDatabase(): void + public function testListSurvivesEmptyReport(): void { - $list = $this->listInsights([ - 'queries' => ['equal("type", "siteSeo")'], - ]); + $reportId = $this->createFixtureReport(); + + $list = $this->listInsights($reportId); $this->assertSame(200, $list['headers']['status-code']); $this->assertSame(0, $list['body']['total']); $this->assertEmpty($list['body']['insights']); + + $this->deleteReport($reportId); } } From 4660185a238c5c4760f432bf6534670d649f2c16 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 20:20:54 +1200 Subject: [PATCH 34/70] test(insights): fix CI failures from nesting refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three breakages on the prior CI run: 1. PHPUnit 12 didn't propagate `@depends` data — chained tests got ArgumentCountError because they expected `array $data` but PHPUnit passed nothing. Convert all annotations to `#[Depends]` attributes, matching the modern style already used by the Migrations and VectorsDB suites. 2. `InsightsCustomConsoleTest` extends ProjectConsole, which doesn't set up a project API key. The trait's `serverHeaders()` hardcodes `x-appwrite-key`, so every Console test 401'd. Drop the Console class entirely — the manager Create endpoint is KEY-only by design, the Server class already exercises every code path, and a Console-side variant adds no real coverage. 3. `testCreateRequiresManagerScope` called `getNewKey()`, which lives on `ProjectCustom`. PHPStan flagged the call as undefined when the trait was analyzed against the (no-longer-existing) Console class. Move the test into `InsightsCustomServerTest.php` directly so it's only ever resolved against `ProjectCustom`. Plus PHP 8.4 + match-exhaustive cleanups PHPStan caught while I was in there: - `?array $headers = null` instead of `array $headers = null` on every helper (PHP 8.4 deprecates implicit-nullable params). - `?string $insightId = null` on `sampleInsight()`. - `parentResourceType` match collapsed to `tablesDB => 'tables'; databases/documentsDB/vectorsDB => 'collections'`. The earlier `$type` match already throws on unknown engine, so the fall-through default was unreachable. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/Services/Insights/InsightsBase.php | 100 +++++------------- .../Insights/InsightsCustomConsoleTest.php | 14 --- .../Insights/InsightsCustomServerTest.php | 27 +++++ 3 files changed, 53 insertions(+), 88 deletions(-) delete mode 100644 tests/e2e/Services/Insights/InsightsCustomConsoleTest.php diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 4521e60013..d61ce3b3ae 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -2,6 +2,7 @@ namespace Tests\E2E\Services\Insights; +use PHPUnit\Framework\Attributes\Depends; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; @@ -24,53 +25,53 @@ trait InsightsBase ], $this->getHeaders()); } - protected function createReport(array $body, array $headers = null): array + protected function createReport(array $body, ?array $headers = null): array { return $this->client->call(Client::METHOD_POST, '/reports', $headers ?? $this->serverHeaders(), $body); } - protected function getReport(string $reportId, array $headers = null): array + 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 + protected function listReports(array $params = [], ?array $headers = null): array { return $this->client->call(Client::METHOD_GET, '/reports', $headers ?? $this->serverHeaders(), $params); } - protected function updateReport(string $reportId, array $body, array $headers = null): array + protected function updateReport(string $reportId, array $body, ?array $headers = null): array { return $this->client->call(Client::METHOD_PATCH, '/reports/' . $reportId, $headers ?? $this->serverHeaders(), $body); } - protected function deleteReport(string $reportId, array $headers = null): array + protected function deleteReport(string $reportId, ?array $headers = null): array { return $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId, $headers ?? $this->serverHeaders()); } - protected function createInsight(string $reportId, array $body, array $headers = null): array + protected function createInsight(string $reportId, array $body, ?array $headers = null): array { // Manager-only endpoint — internal Appwrite services ingest here, not user SDKs. return $this->client->call(Client::METHOD_POST, '/manager/reports/' . $reportId . '/insights', $headers ?? $this->serverHeaders(), $body); } - protected function getInsight(string $reportId, string $insightId, array $headers = null): array + 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 + 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); } - protected function updateInsight(string $reportId, string $insightId, array $body, array $headers = null): array + protected function updateInsight(string $reportId, string $insightId, array $body, ?array $headers = null): array { return $this->client->call(Client::METHOD_PATCH, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders(), $body); } - protected function deleteInsight(string $reportId, string $insightId, array $headers = null): array + protected function deleteInsight(string $reportId, string $insightId, ?array $headers = null): array { return $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders()); } @@ -154,7 +155,7 @@ trait InsightsBase }; } - protected function sampleInsight(string $insightId = null, string $engine = 'tablesDB'): array + protected function sampleInsight(?string $insightId = null, string $engine = 'tablesDB'): array { $type = match ($engine) { 'databases' => 'databaseIndex', @@ -165,11 +166,8 @@ trait InsightsBase }; $parentResourceType = match ($engine) { - 'databases' => 'collections', 'tablesDB' => 'tables', - 'documentsDB' => 'collections', - 'vectorsDB' => 'collections', - default => 'collections', + 'databases', 'documentsDB', 'vectorsDB' => 'collections', }; return [ @@ -253,9 +251,7 @@ trait InsightsBase $this->deleteReport($reportId); } - /** - * @depends testCreateReport - */ + #[Depends('testCreateReport')] public function testGetReport(array $data): array { $report = $this->getReport($data['reportId']); @@ -271,9 +267,7 @@ trait InsightsBase return $data; } - /** - * @depends testGetReport - */ + #[Depends('testGetReport')] public function testListReports(array $data): array { $list = $this->listReports(); @@ -307,9 +301,7 @@ trait InsightsBase return $data; } - /** - * @depends testListReports - */ + #[Depends('testListReports')] public function testUpdateReport(array $data): array { $original = $this->getReport($data['reportId']); @@ -334,9 +326,7 @@ trait InsightsBase return $data; } - /** - * @depends testUpdateReport - */ + #[Depends('testUpdateReport')] public function testCreate(array $data): array { $insightId = ID::unique(); @@ -556,9 +546,7 @@ trait InsightsBase $this->deleteReport($reportId); } - /** - * @depends testCreate - */ + #[Depends('testCreate')] public function testGet(array $data): array { $insight = $this->getInsight($data['reportId'], $data['insightId']); @@ -579,9 +567,7 @@ trait InsightsBase return $data; } - /** - * @depends testGet - */ + #[Depends('testGet')] public function testList(array $data): array { $list = $this->listInsights($data['reportId']); @@ -645,9 +631,7 @@ trait InsightsBase return $data; } - /** - * @depends testList - */ + #[Depends('testList')] public function testListRejectsInvalidQueryAttribute(array $data): array { $invalid = $this->listInsights($data['reportId'], [ @@ -658,9 +642,7 @@ trait InsightsBase return $data; } - /** - * @depends testListRejectsInvalidQueryAttribute - */ + #[Depends('testListRejectsInvalidQueryAttribute')] public function testListWithCursor(array $data): array { // Seed two extra insights under the same report so pagination has @@ -695,9 +677,7 @@ trait InsightsBase return $data; } - /** - * @depends testListWithCursor - */ + #[Depends('testListWithCursor')] public function testUpdate(array $data): array { $original = $this->getInsight($data['reportId'], $data['insightId'])['body']; @@ -726,9 +706,7 @@ trait InsightsBase return $data; } - /** - * @depends testUpdate - */ + #[Depends('testUpdate')] public function testDismissViaUpdate(array $data): array { $dismissed = $this->updateInsight($data['reportId'], $data['insightId'], ['status' => 'dismissed']); @@ -754,9 +732,7 @@ trait InsightsBase return $data; } - /** - * @depends testDismissViaUpdate - */ + #[Depends('testDismissViaUpdate')] public function testUpdateMissing(array $data): array { // Real report, missing insight → insight_not_found. @@ -772,9 +748,7 @@ trait InsightsBase return $data; } - /** - * @depends testUpdateMissing - */ + #[Depends('testUpdateMissing')] public function testDelete(array $data): array { $delete = $this->deleteInsight($data['reportId'], $data['insightId']); @@ -786,9 +760,7 @@ trait InsightsBase return $data; } - /** - * @depends testDelete - */ + #[Depends('testDelete')] public function testDeleteReportCascadesToInsights(array $data): void { $insightId = ID::unique(); @@ -819,26 +791,6 @@ trait InsightsBase $this->assertSame(401, $unauthorized['headers']['status-code']); } - public function testCreateRequiresManagerScope(): void - { - // A server key with insights.read + insights.write but NOT - // insights.manager must be rejected — Create lives behind - // /v1/manager/reports/:reportId/insights and only internal Appwrite - // services hold the manager scope. - $userKey = $this->getNewKey([ - 'insights.read', - 'insights.write', - ]); - - $rejected = $this->createInsight(ID::unique(), $this->sampleInsight(), [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $userKey, - ]); - - $this->assertSame(401, $rejected['headers']['status-code']); - } - public function testListSurvivesEmptyReport(): void { $reportId = $this->createFixtureReport(); diff --git a/tests/e2e/Services/Insights/InsightsCustomConsoleTest.php b/tests/e2e/Services/Insights/InsightsCustomConsoleTest.php deleted file mode 100644 index daf0ea819d..0000000000 --- a/tests/e2e/Services/Insights/InsightsCustomConsoleTest.php +++ /dev/null @@ -1,14 +0,0 @@ -getNewKey([ + 'insights.read', + 'insights.write', + ]); + + $rejected = $this->client->call( + Client::METHOD_POST, + '/manager/reports/' . ID::unique() . '/insights', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $userKey, + ], + $this->sampleInsight() + ); + + $this->assertSame(401, $rejected['headers']['status-code']); + } } From 0d65ffbb6c4f264e7b898d7a9c5ef1dcaa1aa6e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 05:13:38 +0000 Subject: [PATCH 35/70] chore(insights): simplify report index lengths and orders Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/2e52811c-bf98-4b39-b3f7-64fabcef4cf6 Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- app/config/collections/platform.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 2d50551a2c..9220226b9e 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2073,21 +2073,21 @@ $platformCollections = [ '$id' => ID::custom('_key_project'), 'type' => Database::INDEX_KEY, 'attributes' => ['projectInternalId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], + 'lengths' => [], + 'orders' => [], ], [ '$id' => ID::custom('_key_project_type'), 'type' => Database::INDEX_KEY, 'attributes' => ['projectInternalId', 'type'], - 'lengths' => [Database::LENGTH_KEY, 64], + 'lengths' => [], 'orders' => [], ], [ '$id' => ID::custom('_key_project_target'), 'type' => Database::INDEX_KEY, 'attributes' => ['projectInternalId', 'targetType', 'target'], - 'lengths' => [Database::LENGTH_KEY, 64, 256], + 'lengths' => [], 'orders' => [], ], ], From 8c57ff161e52102aebe626235ebd41e98abf59e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 05:17:15 +0000 Subject: [PATCH 36/70] fix(insights): use VAR_ID for project internal references Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/688345d8-e680-46c7-9002-82f73193461b Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- app/config/collections/platform.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 9220226b9e..30a8d9b785 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -1964,9 +1964,9 @@ $platformCollections = [ 'attributes' => [ [ '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, + 'type' => Database::VAR_ID, 'format' => '', - 'size' => Database::LENGTH_KEY, + 'size' => 0, 'signed' => true, 'required' => true, 'default' => null, @@ -2100,9 +2100,9 @@ $platformCollections = [ 'attributes' => [ [ '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, + 'type' => Database::VAR_ID, 'format' => '', - 'size' => Database::LENGTH_KEY, + 'size' => 0, 'signed' => true, 'required' => true, 'default' => null, @@ -2397,9 +2397,9 @@ $platformCollections = [ 'attributes' => [ [ '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, + 'type' => Database::VAR_ID, 'format' => '', - 'size' => Database::LENGTH_KEY, + 'size' => 0, 'signed' => true, 'required' => true, 'default' => null, From 0829b2650804ea88bab6ce8b169cb74aae5a724e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 05:44:20 +0000 Subject: [PATCH 37/70] refactor(insights): remove redundant payload field Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/a680e208-34b8-4bae-a7fd-51949112233a Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- app/config/collections/platform.php | 11 ----------- .../Modules/Insights/Http/Insights/Update.php | 2 +- .../Modules/Insights/Http/Manager/Insights/Create.php | 6 +----- src/Appwrite/Utopia/Response/Model/Insight.php | 6 ------ tests/e2e/Services/Insights/InsightsBase.php | 2 -- 5 files changed, 2 insertions(+), 25 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 30a8d9b785..b156e4aefc 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2270,17 +2270,6 @@ $platformCollections = [ 'array' => false, 'filters' => [], ], - [ - '$id' => ID::custom('payload'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 65535, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['json'], - ], [ // Virtual attribute — CTAs live in the `insightCTAs` collection // back-referenced by `insightInternalId`. The subQuery filter diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php index 241537f08e..70d217c078 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php @@ -21,7 +21,7 @@ use Utopia\Validator\WhiteList; * User-facing Update endpoint. * * Limited to user-controlled state: dismissal (status), and severity overrides. - * Analyzer-controlled fields (title, summary, payload, ctas, analyzedAt) flow + * Analyzer-controlled fields (title, summary, ctas, analyzedAt) flow * through the manager-only Create endpoint — analyzers re-ingest by deleting * the stale insight and submitting a fresh one. */ diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index 4bf5c8cea6..0ea9493102 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -18,7 +18,6 @@ use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\JSON; use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -74,7 +73,7 @@ class Create extends Action )) ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform']) ->param('insightId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(INSIGHT_TYPES, true), 'Insight type. Determines the analyzer that owns this insight and the shape of `payload`.') + ->param('type', '', new WhiteList(INSIGHT_TYPES, true), 'Insight type. Determines the analyzer that owns this insight.') ->param('severity', INSIGHT_SEVERITY_INFO, new WhiteList(INSIGHT_SEVERITIES, true), 'Insight severity. One of `info`, `warning`, `critical`.', true) ->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.') ->param('resourceId', '', new Text(36), 'ID of the resource the insight is about.') @@ -84,7 +83,6 @@ class Create extends Action ->param('parentResourceInternalId', '', new Text(36), 'Internal ID of the parent resource.', true) ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) - ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `label`, `service`, `method`, and an optional `params` object.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) ->inject('response') @@ -107,7 +105,6 @@ class Create extends Action string $parentResourceInternalId, string $title, string $summary, - ?array $payload, array $ctas, ?string $analyzedAt, Response $response, @@ -154,7 +151,6 @@ class Create extends Action 'parentResourceInternalId' => $parentResourceInternalId, 'title' => $title, 'summary' => $summary, - 'payload' => $payload, 'analyzedAt' => $analyzedAt, 'dismissedAt' => null, 'dismissedBy' => '', diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php index 151301df41..01e5953a0c 100644 --- a/src/Appwrite/Utopia/Response/Model/Insight.php +++ b/src/Appwrite/Utopia/Response/Model/Insight.php @@ -107,12 +107,6 @@ class Insight extends Model 'default' => '', 'example' => 'Queries against `orders.status` are scanning the full collection.', ]) - ->addRule('payload', [ - 'type' => self::TYPE_JSON, - 'description' => 'Type-specific structured payload for the insight.', - 'default' => new \stdClass(), - 'example' => ['databaseId' => 'main', 'collectionId' => 'orders'], - ]) ->addRule('ctas', [ 'type' => Response::MODEL_INSIGHT_CTA, 'description' => 'List of call-to-action buttons attached to this insight.', diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index d61ce3b3ae..c874212d6e 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -180,7 +180,6 @@ trait InsightsBase 'parentResourceId' => 'orders', 'title' => 'Missing index on collection orders', 'summary' => 'Queries against `orders.status` are scanning the full collection.', - 'payload' => ['databaseId' => 'main', 'engine' => $engine], 'ctas' => [$this->sampleCTA($engine)], ]; } @@ -701,7 +700,6 @@ trait InsightsBase $this->assertSame($original['parentResourceId'], $updated['body']['parentResourceId']); $this->assertSame($original['reportId'], $updated['body']['reportId']); $this->assertSame($original['ctas'], $updated['body']['ctas']); - $this->assertSame($original['payload'], $updated['body']['payload']); return $data; } From 6d0eab258373327b4d2a67ae6efc859aac39a210 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 06:07:23 +0000 Subject: [PATCH 38/70] refactor(advisor): make insights API read-only in CE Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/8d7897b5-ac68-487d-954a-be717380bf66 Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- app/config/collections/platform.php | 14 + app/config/roles.php | 1 - app/config/scopes/project.php | 10 +- app/config/services.php | 8 +- app/init/constants.php | 50 +- app/init/database/filters.php | 16 + .../Insights/Enums/InsightCTAMethod.php | 8 + .../Insights/Enums/InsightCTAService.php | 11 + .../Insights/Enums/InsightSeverity.php | 10 + .../Modules/Insights/Enums/InsightStatus.php | 9 + .../Modules/Insights/Enums/InsightType.php | 16 + .../Modules/Insights/Enums/ReportType.php | 10 + .../Modules/Insights/Http/Insights/Delete.php | 113 --- .../Modules/Insights/Http/Insights/Get.php | 2 +- .../Modules/Insights/Http/Insights/Update.php | 135 --- .../Modules/Insights/Http/Insights/XList.php | 2 +- .../Insights/Http/Manager/Insights/Create.php | 188 ----- .../Modules/Insights/Http/Reports/Create.php | 117 --- .../Modules/Insights/Http/Reports/Delete.php | 109 --- .../Modules/Insights/Http/Reports/Get.php | 2 +- .../Modules/Insights/Http/Reports/Update.php | 115 --- .../Modules/Insights/Http/Reports/XList.php | 2 +- .../Modules/Insights/Services/Http.php | 14 - src/Appwrite/Utopia/Response/Model/Report.php | 7 + tests/e2e/Scopes/ProjectCustom.php | 2 - tests/e2e/Services/Insights/InsightsBase.php | 783 ++---------------- .../Insights/InsightsCustomServerTest.php | 34 +- 27 files changed, 211 insertions(+), 1577 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Insights/Enums/InsightCTAMethod.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Enums/InsightCTAService.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Enums/InsightSeverity.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Enums/InsightStatus.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Enums/InsightType.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Enums/ReportType.php delete mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php delete mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php delete mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php delete mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Reports/Create.php delete mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Reports/Delete.php delete mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Reports/Update.php diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index b156e4aefc..c70a976677 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2056,6 +2056,20 @@ $platformCollections = [ '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_STRING, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['subQueryReportInsights'], + ], [ '$id' => ID::custom('analyzedAt'), 'type' => Database::VAR_DATETIME, diff --git a/app/config/roles.php b/app/config/roles.php index cb4b178a29..db4437216c 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -106,7 +106,6 @@ $admins = [ 'insights.read', 'insights.write', 'reports.read', - 'reports.write', ]; return [ diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 3b8a86e220..264a6fc731 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -368,11 +368,7 @@ return [ 'category' => 'Other', ], 'insights.write' => [ - 'description' => 'Access to update, dismiss, and delete insights.', - 'category' => 'Other', - ], - 'insights.manager' => [ - 'description' => 'Internal-only: ingest insights produced by Appwrite analyzers (edge, executor, …). Not granted to user roles.', + 'description' => 'Access to ingest analyzer reports and insights.', 'category' => 'Other', ], @@ -381,8 +377,4 @@ return [ 'description' => 'Access to read analyzer reports and their insights.', 'category' => 'Other', ], - 'reports.write' => [ - 'description' => 'Access to create, update, and delete analyzer reports.', - 'category' => 'Other', - ], ]; diff --git a/app/config/services.php b/app/config/services.php index a285224b1e..6ce828c4c0 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -309,10 +309,10 @@ return [ 'icon' => '/images/services/messaging.png', 'platforms' => ['client', 'server', 'console'], ], - 'insights' => [ - 'key' => 'insights', - 'name' => 'Insights', - 'subtitle' => 'The Insights service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the 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/insights.md', 'controller' => '', // Uses modules 'sdk' => true, diff --git a/app/init/constants.php b/app/init/constants.php index 299499ddab..aa732383f5 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -1,6 +1,12 @@ value; // legacy databases.createIndex +const INSIGHT_TYPE_TABLES_DB_INDEX = InsightType::TABLES_DB_INDEX->value; // tablesDB.createIndex +const INSIGHT_TYPE_DOCUMENTS_DB_INDEX = InsightType::DOCUMENTS_DB_INDEX->value; // documentsDB.createIndex +const INSIGHT_TYPE_VECTORS_DB_INDEX = InsightType::VECTORS_DB_INDEX->value; // vectorsDB.createIndex +const INSIGHT_TYPE_DATABASE_PERFORMANCE = InsightType::DATABASE_PERFORMANCE->value; +const INSIGHT_TYPE_SITE_PERFORMANCE = InsightType::SITE_PERFORMANCE->value; +const INSIGHT_TYPE_SITE_ACCESSIBILITY = InsightType::SITE_ACCESSIBILITY->value; +const INSIGHT_TYPE_SITE_SEO = InsightType::SITE_SEO->value; +const INSIGHT_TYPE_FUNCTION_PERFORMANCE = InsightType::FUNCTION_PERFORMANCE->value; const INSIGHT_TYPES = [ INSIGHT_TYPE_DATABASE_INDEX, @@ -452,18 +458,18 @@ const INSIGHT_TYPES = [ // 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 INSIGHT_CTA_SERVICE_DATABASES = 'databases'; // legacy -const INSIGHT_CTA_SERVICE_TABLES_DB = 'tablesDB'; -const INSIGHT_CTA_SERVICE_DOCUMENTS_DB = 'documentsDB'; -const INSIGHT_CTA_SERVICE_VECTORS_DB = 'vectorsDB'; +const INSIGHT_CTA_SERVICE_DATABASES = InsightCTAService::DATABASES->value; // legacy +const INSIGHT_CTA_SERVICE_TABLES_DB = InsightCTAService::TABLES_DB->value; +const INSIGHT_CTA_SERVICE_DOCUMENTS_DB = InsightCTAService::DOCUMENTS_DB->value; +const INSIGHT_CTA_SERVICE_VECTORS_DB = InsightCTAService::VECTORS_DB->value; // Public API method names that an insight CTA's `method` can reference for index suggestions. -const INSIGHT_CTA_METHOD_CREATE_INDEX = 'createIndex'; +const INSIGHT_CTA_METHOD_CREATE_INDEX = InsightCTAMethod::CREATE_INDEX->value; // Insight severities -const INSIGHT_SEVERITY_INFO = 'info'; -const INSIGHT_SEVERITY_WARNING = 'warning'; -const INSIGHT_SEVERITY_CRITICAL = 'critical'; +const INSIGHT_SEVERITY_INFO = InsightSeverity::INFO->value; +const INSIGHT_SEVERITY_WARNING = InsightSeverity::WARNING->value; +const INSIGHT_SEVERITY_CRITICAL = InsightSeverity::CRITICAL->value; const INSIGHT_SEVERITIES = [ INSIGHT_SEVERITY_INFO, @@ -472,8 +478,8 @@ const INSIGHT_SEVERITIES = [ ]; // Insight statuses -const INSIGHT_STATUS_ACTIVE = 'active'; -const INSIGHT_STATUS_DISMISSED = 'dismissed'; +const INSIGHT_STATUS_ACTIVE = InsightStatus::ACTIVE->value; +const INSIGHT_STATUS_DISMISSED = InsightStatus::DISMISSED->value; const INSIGHT_STATUSES = [ INSIGHT_STATUS_ACTIVE, @@ -481,9 +487,9 @@ const INSIGHT_STATUSES = [ ]; // Report types -const REPORT_TYPE_LIGHTHOUSE = 'lighthouse'; -const REPORT_TYPE_AUDIT = 'audit'; -const REPORT_TYPE_DATABASE_ANALYZER = 'databaseAnalyzer'; +const REPORT_TYPE_LIGHTHOUSE = ReportType::LIGHTHOUSE->value; +const REPORT_TYPE_AUDIT = ReportType::AUDIT->value; +const REPORT_TYPE_DATABASE_ANALYZER = ReportType::DATABASE_ANALYZER->value; const REPORT_TYPES = [ REPORT_TYPE_LIGHTHOUSE, diff --git a/app/init/database/filters.php b/app/init/database/filters.php index f6afb28304..351b8053f3 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -484,8 +484,24 @@ Database::addFilter( function (mixed $value, Document $document, Database $database) { return $database->getAuthorization()->skip(fn () => $database ->find('insightCTAs', [ + Query::equal('projectInternalId', [$document->getAttribute('projectInternalId')]), Query::equal('insightInternalId', [$document->getSequence()]), Query::limit(APP_LIMIT_SUBQUERY), ])); } ); + +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/src/Appwrite/Platform/Modules/Insights/Enums/InsightCTAMethod.php b/src/Appwrite/Platform/Modules/Insights/Enums/InsightCTAMethod.php new file mode 100644 index 0000000000..c8b84d1330 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Enums/InsightCTAMethod.php @@ -0,0 +1,8 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) - ->setHttpPath('/v1/reports/:reportId/insights/:insightId') - ->desc('Delete insight') - ->groups(['api', 'insights']) - ->label('scope', 'insights.write') - ->label('event', 'reports.[reportId].insights.[insightId].delete') - ->label('resourceType', RESOURCE_TYPE_INSIGHTS) - ->label('audits.event', 'insight.delete') - ->label('audits.resource', 'report/{request.reportId}/insight/{request.insightId}') - ->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: 'insights', - group: 'insights', - name: 'delete', - description: <<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') - ->inject('queueForEvents') - ->callback($this->action(...)); - } - - public function action( - string $reportId, - string $insightId, - Response $response, - Document $project, - Database $dbForPlatform, - Event $queueForEvents - ) { - $report = $dbForPlatform->getDocument('reports', $reportId); - - 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); - } - - // Cascade delete child CTAs first. - $childCTAs = $dbForPlatform->find('insightCTAs', [ - Query::equal('insightInternalId', [$insight->getSequence()]), - Query::limit(APP_LIMIT_COUNT), - ]); - - foreach ($childCTAs as $cta) { - $dbForPlatform->deleteDocument('insightCTAs', $cta->getId()); - } - - if (!$dbForPlatform->deleteDocument('insights', $insight->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove insight from DB'); - } - - $queueForEvents - ->setParam('reportId', $report->getId()) - ->setParam('insightId', $insight->getId()) - ->setPayload($response->output($insight, Response::MODEL_INSIGHT)); - - $response->noContent(); - } -} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php index ea3c88349c..126ea759ae 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php @@ -32,7 +32,7 @@ class Get extends Action ->label('scope', 'insights.read') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) ->label('sdk', new Method( - namespace: 'insights', + namespace: 'advisor', group: 'insights', name: 'get', description: <<setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/reports/:reportId/insights/:insightId') - ->desc('Update insight') - ->groups(['api', 'insights']) - ->label('scope', 'insights.write') - ->label('event', 'reports.[reportId].insights.[insightId].update') - ->label('resourceType', RESOURCE_TYPE_INSIGHTS) - ->label('audits.event', 'insight.update') - ->label('audits.resource', 'report/{request.reportId}/insight/{response.$id}') - ->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: 'insights', - group: 'insights', - name: 'update', - description: <<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']) - ->param('severity', null, new Nullable(new WhiteList(INSIGHT_SEVERITIES, true)), 'Insight severity. One of `info`, `warning`, `critical`.', true) - ->param('status', null, new Nullable(new WhiteList(INSIGHT_STATUSES, true)), 'Insight status. Set to `dismissed` to dismiss the insight, `active` to undo a dismissal.', true) - ->inject('response') - ->inject('user') - ->inject('project') - ->inject('dbForPlatform') - ->inject('queueForEvents') - ->callback($this->action(...)); - } - - public function action( - string $reportId, - string $insightId, - ?string $severity, - ?string $status, - Response $response, - Document $user, - Document $project, - Database $dbForPlatform, - Event $queueForEvents - ) { - $report = $dbForPlatform->getDocument('reports', $reportId); - - 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); - } - - $changes = []; - - if ($severity !== null) { - $changes['severity'] = $severity; - } - if ($status !== null && $status !== $insight->getAttribute('status')) { - $changes['status'] = $status; - if ($status === INSIGHT_STATUS_DISMISSED) { - $changes['dismissedAt'] = DateTime::now(); - $changes['dismissedBy'] = $user->getId(); - } else { - $changes['dismissedAt'] = null; - $changes['dismissedBy'] = ''; - } - } - - if ($changes !== []) { - foreach ($changes as $key => $value) { - $insight->setAttribute($key, $value); - } - $insight = $dbForPlatform->updateDocument('insights', $insight->getId(), $insight); - } - - $queueForEvents - ->setParam('reportId', $report->getId()) - ->setParam('insightId', $insight->getId()); - - $response->dynamic($insight, Response::MODEL_INSIGHT); - } -} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php index dba5b6da7b..111d2f167f 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php @@ -38,7 +38,7 @@ class XList extends Action ->label('scope', 'insights.read') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) ->label('sdk', new Method( - namespace: 'insights', + namespace: 'advisor', group: 'insights', name: 'list', description: <<