Merge remote-tracking branch 'origin/1.9.x' into pr-12288

# Conflicts:
#	composer.lock
This commit is contained in:
Chirag Aggarwal
2026-05-13 16:22:26 +05:30
50 changed files with 2257 additions and 65 deletions
+83
View 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;
}
}
+8
View File
@@ -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`),
+2
View File
@@ -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());
}
}
+1 -1
View File
@@ -493,7 +493,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
->setGitRepo($language['gitUrl'])
->setGitRepoName($language['gitRepoName'])
->setGitUserName($language['gitUserName'])
->setLogo($cover)
->setCoverImage($cover)
->setURL('https://appwrite.io')
->setShareText('Appwrite is a backend as a service for building web or mobile apps')
->setShareURL('http://appwrite.io')
+34
View File
@@ -218,11 +218,25 @@ class Deletes extends Action
$this->deleteExpiredTransactions($project, $getProjectDB);
$this->deleteOldDeployments($publisherForDeletes, $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,
@@ -718,6 +732,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);
}
}
+7
View File
@@ -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;
}
}