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