refactor(advisor): make insights API read-only in CE

Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/8d7897b5-ac68-487d-954a-be717380bf66

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-08 06:07:23 +00:00
committed by GitHub
co-authored by abnegate
parent 0829b26508
commit 6d0eab2583
27 changed files with 211 additions and 1577 deletions
+14
View File
@@ -2056,6 +2056,20 @@ $platformCollections = [
'array' => true,
'filters' => [],
],
[
// Virtual attribute — insights live in the `insights` collection
// back-referenced by `reportInternalId`. The subQuery filter joins
// them at read time.
'$id' => ID::custom('insights'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 65535,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['subQueryReportInsights'],
],
[
'$id' => ID::custom('analyzedAt'),
'type' => Database::VAR_DATETIME,
-1
View File
@@ -106,7 +106,6 @@ $admins = [
'insights.read',
'insights.write',
'reports.read',
'reports.write',
];
return [
+1 -9
View File
@@ -368,11 +368,7 @@ return [
'category' => 'Other',
],
'insights.write' => [
'description' => 'Access to update, dismiss, and delete insights.',
'category' => 'Other',
],
'insights.manager' => [
'description' => 'Internal-only: ingest insights produced by Appwrite analyzers (edge, executor, …). Not granted to user roles.',
'description' => 'Access to ingest analyzer reports and insights.',
'category' => 'Other',
],
@@ -381,8 +377,4 @@ return [
'description' => 'Access to read analyzer reports and their insights.',
'category' => 'Other',
],
'reports.write' => [
'description' => 'Access to create, update, and delete analyzer reports.',
'category' => 'Other',
],
];
+4 -4
View File
@@ -309,10 +309,10 @@ return [
'icon' => '/images/services/messaging.png',
'platforms' => ['client', 'server', 'console'],
],
'insights' => [
'key' => 'insights',
'name' => 'Insights',
'subtitle' => 'The Insights service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the console.',
'advisor' => [
'key' => 'advisor',
'name' => 'Advisor',
'subtitle' => 'The Advisor service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the console.',
'description' => '/docs/services/insights.md',
'controller' => '', // Uses modules
'sdk' => true,
+28 -22
View File
@@ -1,6 +1,12 @@
<?php
use Appwrite\Platform\Modules\Compute\Specification;
use Appwrite\Platform\Modules\Insights\Enums\InsightCTAMethod;
use Appwrite\Platform\Modules\Insights\Enums\InsightCTAService;
use Appwrite\Platform\Modules\Insights\Enums\InsightSeverity;
use Appwrite\Platform\Modules\Insights\Enums\InsightStatus;
use Appwrite\Platform\Modules\Insights\Enums\InsightType;
use Appwrite\Platform\Modules\Insights\Enums\ReportType;
use Utopia\System\System;
const APP_NAME = 'Appwrite';
@@ -428,15 +434,15 @@ 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 INSIGHT_TYPE_DATABASE_INDEX = 'databaseIndex'; // legacy databases.createIndex
const INSIGHT_TYPE_TABLES_DB_INDEX = 'tablesDBIndex'; // tablesDB.createIndex
const INSIGHT_TYPE_DOCUMENTS_DB_INDEX = 'documentsDBIndex'; // documentsDB.createIndex
const INSIGHT_TYPE_VECTORS_DB_INDEX = 'vectorsDBIndex'; // vectorsDB.createIndex
const INSIGHT_TYPE_DATABASE_PERFORMANCE = 'databasePerformance';
const INSIGHT_TYPE_SITE_PERFORMANCE = 'sitePerformance';
const INSIGHT_TYPE_SITE_ACCESSIBILITY = 'siteAccessibility';
const INSIGHT_TYPE_SITE_SEO = 'siteSeo';
const INSIGHT_TYPE_FUNCTION_PERFORMANCE = 'functionPerformance';
const INSIGHT_TYPE_DATABASE_INDEX = InsightType::DATABASE_INDEX->value; // legacy databases.createIndex
const INSIGHT_TYPE_TABLES_DB_INDEX = InsightType::TABLES_DB_INDEX->value; // tablesDB.createIndex
const INSIGHT_TYPE_DOCUMENTS_DB_INDEX = InsightType::DOCUMENTS_DB_INDEX->value; // documentsDB.createIndex
const INSIGHT_TYPE_VECTORS_DB_INDEX = InsightType::VECTORS_DB_INDEX->value; // vectorsDB.createIndex
const INSIGHT_TYPE_DATABASE_PERFORMANCE = InsightType::DATABASE_PERFORMANCE->value;
const INSIGHT_TYPE_SITE_PERFORMANCE = InsightType::SITE_PERFORMANCE->value;
const INSIGHT_TYPE_SITE_ACCESSIBILITY = InsightType::SITE_ACCESSIBILITY->value;
const INSIGHT_TYPE_SITE_SEO = InsightType::SITE_SEO->value;
const INSIGHT_TYPE_FUNCTION_PERFORMANCE = InsightType::FUNCTION_PERFORMANCE->value;
const INSIGHT_TYPES = [
INSIGHT_TYPE_DATABASE_INDEX,
@@ -452,18 +458,18 @@ const INSIGHT_TYPES = [
// Public API services (SDK namespaces) that an insight CTA's `service` can reference.
// Analyzers must pick the one matching the engine the resource lives in.
const INSIGHT_CTA_SERVICE_DATABASES = 'databases'; // legacy
const INSIGHT_CTA_SERVICE_TABLES_DB = 'tablesDB';
const INSIGHT_CTA_SERVICE_DOCUMENTS_DB = 'documentsDB';
const INSIGHT_CTA_SERVICE_VECTORS_DB = 'vectorsDB';
const INSIGHT_CTA_SERVICE_DATABASES = InsightCTAService::DATABASES->value; // legacy
const INSIGHT_CTA_SERVICE_TABLES_DB = InsightCTAService::TABLES_DB->value;
const INSIGHT_CTA_SERVICE_DOCUMENTS_DB = InsightCTAService::DOCUMENTS_DB->value;
const INSIGHT_CTA_SERVICE_VECTORS_DB = InsightCTAService::VECTORS_DB->value;
// Public API method names that an insight CTA's `method` can reference for index suggestions.
const INSIGHT_CTA_METHOD_CREATE_INDEX = 'createIndex';
const INSIGHT_CTA_METHOD_CREATE_INDEX = InsightCTAMethod::CREATE_INDEX->value;
// Insight severities
const INSIGHT_SEVERITY_INFO = 'info';
const INSIGHT_SEVERITY_WARNING = 'warning';
const INSIGHT_SEVERITY_CRITICAL = 'critical';
const INSIGHT_SEVERITY_INFO = InsightSeverity::INFO->value;
const INSIGHT_SEVERITY_WARNING = InsightSeverity::WARNING->value;
const INSIGHT_SEVERITY_CRITICAL = InsightSeverity::CRITICAL->value;
const INSIGHT_SEVERITIES = [
INSIGHT_SEVERITY_INFO,
@@ -472,8 +478,8 @@ const INSIGHT_SEVERITIES = [
];
// Insight statuses
const INSIGHT_STATUS_ACTIVE = 'active';
const INSIGHT_STATUS_DISMISSED = 'dismissed';
const INSIGHT_STATUS_ACTIVE = InsightStatus::ACTIVE->value;
const INSIGHT_STATUS_DISMISSED = InsightStatus::DISMISSED->value;
const INSIGHT_STATUSES = [
INSIGHT_STATUS_ACTIVE,
@@ -481,9 +487,9 @@ const INSIGHT_STATUSES = [
];
// Report types
const REPORT_TYPE_LIGHTHOUSE = 'lighthouse';
const REPORT_TYPE_AUDIT = 'audit';
const REPORT_TYPE_DATABASE_ANALYZER = 'databaseAnalyzer';
const REPORT_TYPE_LIGHTHOUSE = ReportType::LIGHTHOUSE->value;
const REPORT_TYPE_AUDIT = ReportType::AUDIT->value;
const REPORT_TYPE_DATABASE_ANALYZER = ReportType::DATABASE_ANALYZER->value;
const REPORT_TYPES = [
REPORT_TYPE_LIGHTHOUSE,
+16
View File
@@ -484,8 +484,24 @@ Database::addFilter(
function (mixed $value, Document $document, Database $database) {
return $database->getAuthorization()->skip(fn () => $database
->find('insightCTAs', [
Query::equal('projectInternalId', [$document->getAttribute('projectInternalId')]),
Query::equal('insightInternalId', [$document->getSequence()]),
Query::limit(APP_LIMIT_SUBQUERY),
]));
}
);
Database::addFilter(
'subQueryReportInsights',
function (mixed $value) {
return;
},
function (mixed $value, Document $document, Database $database) {
return $database->getAuthorization()->skip(fn () => $database
->find('insights', [
Query::equal('projectInternalId', [$document->getAttribute('projectInternalId')]),
Query::equal('reportInternalId', [$document->getSequence()]),
Query::limit(APP_LIMIT_SUBQUERY),
]));
}
);
@@ -0,0 +1,8 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Enums;
enum InsightCTAMethod: string
{
case CREATE_INDEX = 'createIndex';
}
@@ -0,0 +1,11 @@
<?php
namespace Appwrite\Platform\Modules\Insights\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\Insights\Enums;
enum InsightSeverity: string
{
case INFO = 'info';
case WARNING = 'warning';
case CRITICAL = 'critical';
}
@@ -0,0 +1,9 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Enums;
enum InsightStatus: string
{
case ACTIVE = 'active';
case DISMISSED = 'dismissed';
}
@@ -0,0 +1,16 @@
<?php
namespace Appwrite\Platform\Modules\Insights\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\Insights\Enums;
enum ReportType: string
{
case LIGHTHOUSE = 'lighthouse';
case AUDIT = 'audit';
case DATABASE_ANALYZER = 'databaseAnalyzer';
}
@@ -1,113 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Insights;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
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\Query;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteInsight';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/reports/:reportId/insights/:insightId')
->desc('Delete insight')
->groups(['api', 'insights'])
->label('scope', 'insights.write')
->label('event', 'reports.[reportId].insights.[insightId].delete')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('audits.event', 'insight.delete')
->label('audits.resource', 'report/{request.reportId}/insight/{request.insightId}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'insights',
group: 'insights',
name: 'delete',
description: <<<EOT
Delete an insight by its unique ID.
EOT,
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()), 'Parent report ID.', false, ['dbForPlatform'])
->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform'])
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $reportId,
string $insightId,
Response $response,
Document $project,
Database $dbForPlatform,
Event $queueForEvents
) {
$report = $dbForPlatform->getDocument('reports', $reportId);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
$insight = $dbForPlatform->getDocument('insights', $insightId);
if (
$insight->isEmpty()
|| $insight->getAttribute('projectInternalId') !== $project->getSequence()
|| $insight->getAttribute('reportInternalId') !== $report->getSequence()
) {
throw new Exception(Exception::INSIGHT_NOT_FOUND);
}
// Cascade delete child CTAs first.
$childCTAs = $dbForPlatform->find('insightCTAs', [
Query::equal('insightInternalId', [$insight->getSequence()]),
Query::limit(APP_LIMIT_COUNT),
]);
foreach ($childCTAs as $cta) {
$dbForPlatform->deleteDocument('insightCTAs', $cta->getId());
}
if (!$dbForPlatform->deleteDocument('insights', $insight->getId())) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove insight from DB');
}
$queueForEvents
->setParam('reportId', $report->getId())
->setParam('insightId', $insight->getId())
->setPayload($response->output($insight, Response::MODEL_INSIGHT));
$response->noContent();
}
}
@@ -32,7 +32,7 @@ class Get extends Action
->label('scope', 'insights.read')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('sdk', new Method(
namespace: 'insights',
namespace: 'advisor',
group: 'insights',
name: 'get',
description: <<<EOT
@@ -1,135 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Insights;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
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\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Nullable;
use Utopia\Validator\WhiteList;
/**
* User-facing Update endpoint.
*
* Limited to user-controlled state: dismissal (status), and severity overrides.
* Analyzer-controlled fields (title, summary, ctas, analyzedAt) flow
* through the manager-only Create endpoint — analyzers re-ingest by deleting
* the stale insight and submitting a fresh one.
*/
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateInsight';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/reports/:reportId/insights/:insightId')
->desc('Update insight')
->groups(['api', 'insights'])
->label('scope', 'insights.write')
->label('event', 'reports.[reportId].insights.[insightId].update')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('audits.event', 'insight.update')
->label('audits.resource', 'report/{request.reportId}/insight/{response.$id}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'insights',
group: 'insights',
name: 'update',
description: <<<EOT
Update user-controlled state on an insight. Set `status` to `dismissed` to dismiss it (the dismissal timestamp and user are recorded automatically) or back to `active` to undo a dismissal. `severity` lets users escalate or downgrade the analyzer's classification.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
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'])
->param('severity', null, new Nullable(new WhiteList(INSIGHT_SEVERITIES, true)), 'Insight severity. One of `info`, `warning`, `critical`.', true)
->param('status', null, new Nullable(new WhiteList(INSIGHT_STATUSES, true)), 'Insight status. Set to `dismissed` to dismiss the insight, `active` to undo a dismissal.', true)
->inject('response')
->inject('user')
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $reportId,
string $insightId,
?string $severity,
?string $status,
Response $response,
Document $user,
Document $project,
Database $dbForPlatform,
Event $queueForEvents
) {
$report = $dbForPlatform->getDocument('reports', $reportId);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
$insight = $dbForPlatform->getDocument('insights', $insightId);
if (
$insight->isEmpty()
|| $insight->getAttribute('projectInternalId') !== $project->getSequence()
|| $insight->getAttribute('reportInternalId') !== $report->getSequence()
) {
throw new Exception(Exception::INSIGHT_NOT_FOUND);
}
$changes = [];
if ($severity !== null) {
$changes['severity'] = $severity;
}
if ($status !== null && $status !== $insight->getAttribute('status')) {
$changes['status'] = $status;
if ($status === INSIGHT_STATUS_DISMISSED) {
$changes['dismissedAt'] = DateTime::now();
$changes['dismissedBy'] = $user->getId();
} else {
$changes['dismissedAt'] = null;
$changes['dismissedBy'] = '';
}
}
if ($changes !== []) {
foreach ($changes as $key => $value) {
$insight->setAttribute($key, $value);
}
$insight = $dbForPlatform->updateDocument('insights', $insight->getId(), $insight);
}
$queueForEvents
->setParam('reportId', $report->getId())
->setParam('insightId', $insight->getId());
$response->dynamic($insight, Response::MODEL_INSIGHT);
}
}
@@ -38,7 +38,7 @@ class XList extends Action
->label('scope', 'insights.read')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('sdk', new Method(
namespace: 'insights',
namespace: 'advisor',
group: 'insights',
name: 'list',
description: <<<EOT
@@ -1,188 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Manager\Insights;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Insights\Validator\CTAs as CTAsValidator;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
/**
* Manager-only endpoint for analyzer ingestion.
*
* Insights are produced by internal Appwrite services (edge, executor,
* background analyzers) — never by user clients. The endpoint lives under
* /v1/manager/* and is hidden from generated SDKs to keep that contract
* explicit. Internal services call it directly over HTTP using a server
* API key with the `insights.manager` scope.
*/
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createInsight';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/manager/reports/:reportId/insights')
->desc('Create insight')
->groups(['api', 'manager', 'insights'])
->label('scope', 'insights.manager')
->label('event', 'reports.[reportId].insights.[insightId].create')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('audits.event', 'insight.create')
->label('audits.resource', 'report/{request.reportId}/insight/{response.$id}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'manager',
group: 'insights',
name: 'createInsight',
description: <<<EOT
Manager-only: ingest an insight produced by an internal analyzer (edge, executor, background worker, …). Not exposed to user-facing client or server SDKs.
EOT,
auth: [AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_INSIGHT,
),
],
hide: true,
))
->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
->param('insightId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform'])
->param('type', '', new WhiteList(INSIGHT_TYPES, true), 'Insight type. Determines the analyzer that owns this insight.')
->param('severity', INSIGHT_SEVERITY_INFO, new WhiteList(INSIGHT_SEVERITIES, true), 'Insight severity. One of `info`, `warning`, `critical`.', true)
->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.')
->param('resourceId', '', new Text(36), 'ID of the resource the insight is about.')
->param('resourceInternalId', '', new Text(36), 'Internal ID of the resource the insight is about.', true)
->param('parentResourceType', '', new Text(64), 'Plural noun for the parent (containing) resource, e.g. `tables` for an insight about a column index. Optional.', true)
->param('parentResourceId', '', new Text(36), 'ID of the parent resource.', true)
->param('parentResourceInternalId', '', new Text(36), 'Internal ID of the parent resource.', true)
->param('title', '', new Text(256), 'Short, human-readable title.')
->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true)
->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `label`, `service`, `method`, and an optional `params` object.', true)
->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true)
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $reportId,
string $insightId,
string $type,
string $severity,
string $resourceType,
string $resourceId,
string $resourceInternalId,
string $parentResourceType,
string $parentResourceId,
string $parentResourceInternalId,
string $title,
string $summary,
array $ctas,
?string $analyzedAt,
Response $response,
Document $project,
Database $dbForPlatform,
Event $queueForEvents
) {
$insightId = ($insightId === 'unique()') ? ID::unique() : $insightId;
$report = $dbForPlatform->getDocument('reports', $reportId);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
$reportInternalId = $report->getSequence();
$normalizedCTAs = [];
foreach ($ctas as $cta) {
$normalizedCTAs[] = [
'label' => (string) $cta['label'],
'service' => (string) $cta['service'],
'method' => (string) $cta['method'],
'params' => $cta['params'] ?? new \stdClass(),
];
}
try {
$insight = $dbForPlatform->createDocument('insights', new Document([
'$id' => $insightId,
'projectInternalId' => $project->getSequence(),
'projectId' => $project->getId(),
'reportInternalId' => $reportInternalId,
'reportId' => $reportId,
'type' => $type,
'severity' => $severity,
'status' => INSIGHT_STATUS_ACTIVE,
'resourceType' => $resourceType,
'resourceId' => $resourceId,
'resourceInternalId' => $resourceInternalId,
'parentResourceType' => $parentResourceType,
'parentResourceId' => $parentResourceId,
'parentResourceInternalId' => $parentResourceInternalId,
'title' => $title,
'summary' => $summary,
'analyzedAt' => $analyzedAt,
'dismissedAt' => null,
'dismissedBy' => '',
]));
} catch (DuplicateException) {
throw new Exception(Exception::INSIGHT_ALREADY_EXISTS);
}
foreach ($normalizedCTAs as $cta) {
$dbForPlatform->createDocument('insightCTAs', new Document([
'$id' => ID::unique(),
'projectInternalId' => $project->getSequence(),
'projectId' => $project->getId(),
'insightInternalId' => $insight->getSequence(),
'insightId' => $insight->getId(),
'label' => $cta['label'],
'service' => $cta['service'],
'method' => $cta['method'],
'params' => $cta['params'],
]));
}
// Re-fetch so the subQueryInsightCTAs filter embeds the freshly-created
// CTA documents on the response — keeps a single round-trip for callers.
$insight = $dbForPlatform->getDocument('insights', $insight->getId());
$queueForEvents
->setParam('reportId', $report->getId())
->setParam('insightId', $insight->getId());
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($insight, Response::MODEL_INSIGHT);
}
}
@@ -1,117 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Reports;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/reports')
->desc('Create report')
->groups(['api', 'insights'])
->label('scope', 'reports.write')
->label('event', 'reports.[reportId].create')
->label('resourceType', RESOURCE_TYPE_REPORTS)
->label('audits.event', 'report.create')
->label('audits.resource', 'report/{response.$id}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'insights',
group: 'reports',
name: 'createReport',
description: <<<EOT
Create a new analyzer report. A report groups one or more insights produced by a single analyzer run (e.g. a Lighthouse audit of a URL, a database analyzer pass over a project).
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_REPORT,
),
]
))
->param('reportId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform'])
->param('type', '', new WhiteList(REPORT_TYPES, true), 'Analyzer type. One of `lighthouse`, `audit`, `databaseAnalyzer`.')
->param('title', '', new Text(256), 'Short, human-readable title.')
->param('summary', '', new Text(4096, 0), 'Markdown summary describing the report.', true)
->param('targetType', '', new Text(64), 'Plural noun describing what the report analyzes, e.g. `databases`, `sites`, `urls`.')
->param('target', '', new Text(2048), 'Free-form target identifier (URL for lighthouse, resource ID for db).')
->param('categories', [], new ArrayList(new Text(64), 32), 'Categories covered by the report, e.g. `performance`, `accessibility`. Max 32 entries, each 64 chars.', true)
->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the report was analyzed in ISO 8601 format. Defaults to now.', true)
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $reportId,
string $type,
string $title,
string $summary,
string $targetType,
string $target,
array $categories,
?string $analyzedAt,
Response $response,
Document $project,
Database $dbForPlatform,
Event $queueForEvents
) {
$reportId = ($reportId === 'unique()') ? ID::unique() : $reportId;
try {
$report = $dbForPlatform->createDocument('reports', new Document([
'$id' => $reportId,
'projectInternalId' => $project->getSequence(),
'projectId' => $project->getId(),
'type' => $type,
'title' => $title,
'summary' => $summary,
'targetType' => $targetType,
'target' => $target,
'categories' => $categories,
'analyzedAt' => $analyzedAt,
]));
} catch (DuplicateException) {
throw new Exception(Exception::REPORT_ALREADY_EXISTS);
}
$queueForEvents->setParam('reportId', $report->getId());
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($report, Response::MODEL_REPORT);
}
}
@@ -1,109 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Reports;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
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\Query;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/reports/:reportId')
->desc('Delete report')
->groups(['api', 'insights'])
->label('scope', 'reports.write')
->label('event', 'reports.[reportId].delete')
->label('resourceType', RESOURCE_TYPE_REPORTS)
->label('audits.event', 'report.delete')
->label('audits.resource', 'report/{request.reportId}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'insights',
group: 'reports',
name: 'deleteReport',
description: <<<EOT
Delete an analyzer report and all its child insights.
EOT,
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('queueForEvents')
->callback($this->action(...));
}
public function action(
string $reportId,
Response $response,
Document $project,
Database $dbForPlatform,
Event $queueForEvents
) {
$report = $dbForPlatform->getDocument('reports', $reportId);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
$childInsights = $dbForPlatform->find('insights', [
Query::equal('projectInternalId', [$project->getSequence()]),
Query::equal('reportInternalId', [$report->getSequence()]),
Query::limit(APP_LIMIT_COUNT),
]);
foreach ($childInsights as $insight) {
// Cascade through CTAs first.
$childCTAs = $dbForPlatform->find('insightCTAs', [
Query::equal('insightInternalId', [$insight->getSequence()]),
Query::limit(APP_LIMIT_COUNT),
]);
foreach ($childCTAs as $cta) {
$dbForPlatform->deleteDocument('insightCTAs', $cta->getId());
}
$dbForPlatform->deleteDocument('insights', $insight->getId());
}
if (!$dbForPlatform->deleteDocument('reports', $report->getId())) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove report from DB');
}
$queueForEvents
->setParam('reportId', $report->getId())
->setPayload($response->output($report, Response::MODEL_REPORT));
$response->noContent();
}
}
@@ -32,7 +32,7 @@ class Get extends Action
->label('scope', 'reports.read')
->label('resourceType', RESOURCE_TYPE_REPORTS)
->label('sdk', new Method(
namespace: 'insights',
namespace: 'advisor',
group: 'reports',
name: 'getReport',
description: <<<EOT
@@ -1,115 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Reports;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
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\Datetime as DatetimeValidator;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/reports/:reportId')
->desc('Update report')
->groups(['api', 'insights'])
->label('scope', 'reports.write')
->label('event', 'reports.[reportId].update')
->label('resourceType', RESOURCE_TYPE_REPORTS)
->label('audits.event', 'report.update')
->label('audits.resource', 'report/{response.$id}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'insights',
group: 'reports',
name: 'updateReport',
description: <<<EOT
Update an analyzer report. Pass only the attributes you want to change.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
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'])
->param('title', null, new Nullable(new Text(256)), 'Short, human-readable title.', true)
->param('summary', null, new Nullable(new Text(4096, 0)), 'Markdown summary describing the report.', true)
->param('categories', null, new Nullable(new ArrayList(new Text(64), 32)), 'Categories covered by the report.', true)
->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the report was analyzed in ISO 8601 format.', true)
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $reportId,
?string $title,
?string $summary,
?array $categories,
?string $analyzedAt,
Response $response,
Document $project,
Database $dbForPlatform,
Event $queueForEvents
) {
$report = $dbForPlatform->getDocument('reports', $reportId);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
$changes = [];
if ($title !== null) {
$changes['title'] = $title;
}
if ($summary !== null) {
$changes['summary'] = $summary;
}
if ($categories !== null) {
$changes['categories'] = $categories;
}
if ($analyzedAt !== null) {
$changes['analyzedAt'] = $analyzedAt;
}
if ($changes !== []) {
foreach ($changes as $key => $value) {
$report->setAttribute($key, $value);
}
$report = $dbForPlatform->updateDocument('reports', $report->getId(), $report);
}
$queueForEvents->setParam('reportId', $report->getId());
$response->dynamic($report, Response::MODEL_REPORT);
}
}
@@ -37,7 +37,7 @@ class XList extends Action
->label('scope', 'reports.read')
->label('resourceType', RESOURCE_TYPE_REPORTS)
->label('sdk', new Method(
namespace: 'insights',
namespace: 'advisor',
group: 'reports',
name: 'listReports',
description: <<<EOT
@@ -2,15 +2,9 @@
namespace Appwrite\Platform\Modules\Insights\Services;
use Appwrite\Platform\Modules\Insights\Http\Insights\Delete as DeleteInsight;
use Appwrite\Platform\Modules\Insights\Http\Insights\Get as GetInsight;
use Appwrite\Platform\Modules\Insights\Http\Insights\Update as UpdateInsight;
use Appwrite\Platform\Modules\Insights\Http\Insights\XList as ListInsights;
use Appwrite\Platform\Modules\Insights\Http\Manager\Insights\Create as CreateInsight;
use Appwrite\Platform\Modules\Insights\Http\Reports\Create as CreateReport;
use Appwrite\Platform\Modules\Insights\Http\Reports\Delete as DeleteReport;
use Appwrite\Platform\Modules\Insights\Http\Reports\Get as GetReport;
use Appwrite\Platform\Modules\Insights\Http\Reports\Update as UpdateReport;
use Appwrite\Platform\Modules\Insights\Http\Reports\XList as ListReports;
use Utopia\Platform\Service;
@@ -20,18 +14,10 @@ class Http extends Service
{
$this->type = Service::TYPE_HTTP;
$this->addAction(CreateReport::getName(), new CreateReport());
$this->addAction(GetReport::getName(), new GetReport());
$this->addAction(ListReports::getName(), new ListReports());
$this->addAction(UpdateReport::getName(), new UpdateReport());
$this->addAction(DeleteReport::getName(), new DeleteReport());
// Manager-only ingestion (hidden from SDKs, /v1/manager/reports/:reportId/insights).
$this->addAction(CreateInsight::getName(), new CreateInsight());
$this->addAction(GetInsight::getName(), new GetInsight());
$this->addAction(ListInsights::getName(), new ListInsights());
$this->addAction(UpdateInsight::getName(), new UpdateInsight());
$this->addAction(DeleteInsight::getName(), new DeleteInsight());
}
}
@@ -65,6 +65,13 @@ class Report extends Model
'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.',
-2
View File
@@ -179,9 +179,7 @@ trait ProjectCustom
'templates.write',
'insights.read',
'insights.write',
'insights.manager',
'reports.read',
'reports.write',
],
]);
+49 -734
View File
@@ -2,7 +2,6 @@
namespace Tests\E2E\Services\Insights;
use PHPUnit\Framework\Attributes\Depends;
use Tests\E2E\Client;
use Utopia\Database\Helpers\ID;
@@ -17,19 +16,6 @@ trait InsightsBase
];
}
protected function clientHeaders(): array
{
return array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders());
}
protected function createReport(array $body, ?array $headers = null): array
{
return $this->client->call(Client::METHOD_POST, '/reports', $headers ?? $this->serverHeaders(), $body);
}
protected function getReport(string $reportId, ?array $headers = null): array
{
return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId, $headers ?? $this->serverHeaders());
@@ -40,22 +26,6 @@ trait InsightsBase
return $this->client->call(Client::METHOD_GET, '/reports', $headers ?? $this->serverHeaders(), $params);
}
protected function updateReport(string $reportId, array $body, ?array $headers = null): array
{
return $this->client->call(Client::METHOD_PATCH, '/reports/' . $reportId, $headers ?? $this->serverHeaders(), $body);
}
protected function deleteReport(string $reportId, ?array $headers = null): array
{
return $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId, $headers ?? $this->serverHeaders());
}
protected function createInsight(string $reportId, array $body, ?array $headers = null): array
{
// Manager-only endpoint — internal Appwrite services ingest here, not user SDKs.
return $this->client->call(Client::METHOD_POST, '/manager/reports/' . $reportId . '/insights', $headers ?? $this->serverHeaders(), $body);
}
protected function getInsight(string $reportId, string $insightId, ?array $headers = null): array
{
return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders());
@@ -66,738 +36,83 @@ trait InsightsBase
return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights', $headers ?? $this->serverHeaders(), $params);
}
protected function updateInsight(string $reportId, string $insightId, array $body, ?array $headers = null): array
{
return $this->client->call(Client::METHOD_PATCH, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders(), $body);
}
protected function deleteInsight(string $reportId, string $insightId, ?array $headers = null): array
{
return $this->client->call(Client::METHOD_DELETE, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders());
}
/**
* Create a throwaway report so a standalone validation test has a parent
* report to nest under. Caller is responsible for `deleteReport()`.
*/
protected function createFixtureReport(string $type = 'audit'): string
{
$reportId = ID::unique();
$report = $this->createReport([
'reportId' => $reportId,
'type' => $type,
'title' => 'Fixture report',
'targetType' => 'sites',
'target' => 'fixture',
]);
$this->assertSame(201, $report['headers']['status-code']);
return $reportId;
}
/**
* Sample CTA pointing at the engine-specific public API.
*
* The `engine` parameter selects which API the CTA targets:
* - `databases` → service `databases`, method `createIndex` (legacy, params use collectionId/attributes)
* - `tablesDB` → service `tablesDB`, method `createIndex` (params use tableId/columns)
* - `documentsDB` → service `documentsDB`, method `createIndex` (params use collectionId/attributes)
* - `vectorsDB` → service `vectorsDB`, method `createIndex` (params use collectionId/attributes)
*/
protected function sampleCTA(string $engine = 'tablesDB'): array
{
$base = [
'label' => 'Create missing index',
'method' => 'createIndex',
];
return match ($engine) {
'databases' => $base + [
'service' => 'databases',
'params' => [
'databaseId' => 'main',
'collectionId' => 'orders',
'key' => '_idx_status',
'type' => 'key',
'attributes' => ['status'],
],
],
'tablesDB' => $base + [
'service' => 'tablesDB',
'params' => [
'databaseId' => 'main',
'tableId' => 'orders',
'key' => '_idx_status',
'type' => 'key',
'columns' => ['status'],
],
],
'documentsDB' => $base + [
'service' => 'documentsDB',
'params' => [
'databaseId' => 'main',
'collectionId' => 'orders',
'key' => '_idx_status',
'type' => 'key',
'attributes' => ['status'],
],
],
'vectorsDB' => $base + [
'service' => 'vectorsDB',
'params' => [
'databaseId' => 'main',
'collectionId' => 'orders',
'key' => '_idx_status',
'type' => 'key',
'attributes' => ['status'],
],
],
default => throw new \InvalidArgumentException("Unknown engine: {$engine}"),
};
}
protected function sampleInsight(?string $insightId = null, string $engine = 'tablesDB'): array
{
$type = match ($engine) {
'databases' => 'databaseIndex',
'tablesDB' => 'tablesDBIndex',
'documentsDB' => 'documentsDBIndex',
'vectorsDB' => 'vectorsDBIndex',
default => throw new \InvalidArgumentException("Unknown engine: {$engine}"),
};
$parentResourceType = match ($engine) {
'tablesDB' => 'tables',
'databases', 'documentsDB', 'vectorsDB' => 'collections',
};
return [
'insightId' => $insightId ?? ID::unique(),
'type' => $type,
'severity' => 'warning',
'resourceType' => 'indexes',
'resourceId' => '_idx_status',
'parentResourceType' => $parentResourceType,
'parentResourceId' => 'orders',
'title' => 'Missing index on collection orders',
'summary' => 'Queries against `orders.status` are scanning the full collection.',
'ctas' => [$this->sampleCTA($engine)],
];
}
public function testCreateReport(): array
{
$reportId = ID::unique();
$report = $this->createReport([
'reportId' => $reportId,
'type' => 'databaseAnalyzer',
'title' => 'Database analyzer report',
'summary' => 'Daily scan of project DB.',
'targetType' => 'databases',
'target' => 'main',
'categories' => ['performance', 'integrity'],
]);
$this->assertSame(201, $report['headers']['status-code']);
$this->assertSame($reportId, $report['body']['$id']);
$this->assertSame('databaseAnalyzer', $report['body']['type']);
$this->assertSame('Database analyzer report', $report['body']['title']);
$this->assertSame('main', $report['body']['target']);
$this->assertSame('databases', $report['body']['targetType']);
$this->assertSame(['performance', 'integrity'], $report['body']['categories']);
$this->assertArrayHasKey('$createdAt', $report['body']);
$this->assertArrayHasKey('$updatedAt', $report['body']);
return ['reportId' => $reportId];
}
public function testCreateReportRejectsInvalidType(): void
{
$report = $this->createReport([
'reportId' => ID::unique(),
'type' => 'unknownAnalyzer',
'title' => 'Bad type',
'targetType' => 'databases',
'target' => 'main',
]);
$this->assertSame(400, $report['headers']['status-code']);
}
public function testCreateReportRejectsDuplicateId(): void
{
$reportId = ID::unique();
$first = $this->createReport([
'reportId' => $reportId,
'type' => 'audit',
'title' => 'First',
'targetType' => 'sites',
'target' => 'home',
]);
$this->assertSame(201, $first['headers']['status-code']);
$second = $this->createReport([
'reportId' => $reportId,
'type' => 'audit',
'title' => 'Second',
'targetType' => 'sites',
'target' => 'home',
]);
$this->assertSame(409, $second['headers']['status-code']);
$this->assertSame('report_already_exists', $second['body']['type']);
$this->deleteReport($reportId);
}
#[Depends('testCreateReport')]
public function testGetReport(array $data): array
{
$report = $this->getReport($data['reportId']);
$this->assertSame(200, $report['headers']['status-code']);
$this->assertSame($data['reportId'], $report['body']['$id']);
$this->assertSame('databaseAnalyzer', $report['body']['type']);
$missing = $this->getReport('missing');
$this->assertSame(404, $missing['headers']['status-code']);
$this->assertSame('report_not_found', $missing['body']['type']);
return $data;
}
#[Depends('testGetReport')]
public function testListReports(array $data): array
public function testListReports(): void
{
$list = $this->listReports();
$this->assertSame(200, $list['headers']['status-code']);
$this->assertGreaterThanOrEqual(1, $list['body']['total']);
$this->assertNotEmpty($list['body']['reports']);
$byType = $this->listReports([
'queries' => [
'equal("type", "databaseAnalyzer")',
],
]);
$this->assertSame(200, $byType['headers']['status-code']);
foreach ($byType['body']['reports'] as $report) {
$this->assertSame('databaseAnalyzer', $report['type']);
}
$byTarget = $this->listReports([
'queries' => [
'equal("targetType", "databases")',
'equal("target", "main")',
],
]);
$this->assertSame(200, $byTarget['headers']['status-code']);
foreach ($byTarget['body']['reports'] as $report) {
$this->assertSame('databases', $report['targetType']);
$this->assertSame('main', $report['target']);
}
return $data;
$this->assertArrayHasKey('reports', $list['body']);
$this->assertArrayHasKey('total', $list['body']);
$this->assertIsArray($list['body']['reports']);
}
#[Depends('testListReports')]
public function testUpdateReport(array $data): array
public function testGetReportMissing(): void
{
$original = $this->getReport($data['reportId']);
$this->assertSame(200, $original['headers']['status-code']);
$missing = $this->getReport(ID::unique());
$updated = $this->updateReport($data['reportId'], [
'title' => 'Updated database analyzer report',
'summary' => 'Updated summary.',
]);
$this->assertSame(200, $updated['headers']['status-code']);
$this->assertSame('Updated database analyzer report', $updated['body']['title']);
$this->assertSame('Updated summary.', $updated['body']['summary']);
$this->assertSame($original['body']['type'], $updated['body']['type']);
$this->assertSame($original['body']['target'], $updated['body']['target']);
$this->assertSame($original['body']['targetType'], $updated['body']['targetType']);
$missing = $this->updateReport('missing', ['title' => 'x']);
$this->assertSame(404, $missing['headers']['status-code']);
return $data;
$this->assertSame('report_not_found', $missing['body']['type']);
}
#[Depends('testUpdateReport')]
public function testCreate(array $data): array
public function testListInsightsMissingReport(): void
{
$insightId = ID::unique();
$missing = $this->listInsights(ID::unique());
$insight = $this->createInsight($data['reportId'], $this->sampleInsight($insightId, 'tablesDB'));
$this->assertSame(201, $insight['headers']['status-code']);
$this->assertSame($insightId, $insight['body']['$id']);
$this->assertSame($data['reportId'], $insight['body']['reportId']);
$this->assertSame('tablesDBIndex', $insight['body']['type']);
$this->assertSame('warning', $insight['body']['severity']);
$this->assertSame('active', $insight['body']['status']);
$this->assertSame('indexes', $insight['body']['resourceType']);
$this->assertSame('_idx_status', $insight['body']['resourceId']);
$this->assertSame('tables', $insight['body']['parentResourceType']);
$this->assertSame('orders', $insight['body']['parentResourceId']);
$this->assertSame('Missing index on collection orders', $insight['body']['title']);
$this->assertCount(1, $insight['body']['ctas']);
$this->assertSame($insightId, $insight['body']['ctas'][0]['insightId']);
$this->assertSame('Create missing index', $insight['body']['ctas'][0]['label']);
$this->assertSame('tablesDB', $insight['body']['ctas'][0]['service']);
$this->assertSame('createIndex', $insight['body']['ctas'][0]['method']);
$this->assertSame('orders', $insight['body']['ctas'][0]['params']['tableId']);
$this->assertSame(['status'], $insight['body']['ctas'][0]['params']['columns']);
$this->assertArrayHasKey('$id', $insight['body']['ctas'][0]);
$this->assertArrayHasKey('$createdAt', $insight['body']['ctas'][0]);
$this->assertEmpty($insight['body']['dismissedAt']);
$this->assertEmpty($insight['body']['dismissedBy']);
return $data + ['insightId' => $insightId];
}
/**
* @dataProvider engineMatrixProvider
*/
public function testCreateForEachEngine(string $engine, string $expectedType, string $expectedService, string $expectedMethod): void
{
$reportId = $this->createFixtureReport();
$insightId = ID::unique();
$insight = $this->createInsight($reportId, $this->sampleInsight($insightId, $engine));
$this->assertSame(201, $insight['headers']['status-code']);
$this->assertSame($expectedType, $insight['body']['type']);
$this->assertSame($expectedService, $insight['body']['ctas'][0]['service']);
$this->assertSame($expectedMethod, $insight['body']['ctas'][0]['method']);
$this->deleteInsight($reportId, $insightId);
$this->deleteReport($reportId);
}
public static function engineMatrixProvider(): array
{
return [
'legacy databases' => ['databases', 'databaseIndex', 'databases', 'createIndex'],
'tablesDB' => ['tablesDB', 'tablesDBIndex', 'tablesDB', 'createIndex'],
'documentsDB' => ['documentsDB', 'documentsDBIndex', 'documentsDB', 'createIndex'],
'vectorsDB' => ['vectorsDB', 'vectorsDBIndex', 'vectorsDB', 'createIndex'],
];
}
public function testCreateWithoutParentResource(): void
{
// Top-level resource (no parent) — e.g. a project-wide audit finding.
$reportId = $this->createFixtureReport();
$insightId = ID::unique();
$body = $this->sampleInsight($insightId);
unset($body['parentResourceType'], $body['parentResourceId']);
$body['resourceType'] = 'projects';
$body['resourceId'] = $this->getProject()['$id'];
$insight = $this->createInsight($reportId, $body);
$this->assertSame(201, $insight['headers']['status-code']);
$this->assertSame('projects', $insight['body']['resourceType']);
$this->assertEmpty($insight['body']['parentResourceType']);
$this->assertEmpty($insight['body']['parentResourceId']);
$this->assertEmpty($insight['body']['parentResourceInternalId']);
$this->deleteInsight($reportId, $insightId);
$this->deleteReport($reportId);
}
public function testCreateRejectsInvalidType(): void
{
$reportId = $this->createFixtureReport();
$insight = $this->createInsight($reportId, [
'insightId' => ID::unique(),
'type' => 'unknownType',
'resourceType' => 'databases',
'resourceId' => 'main',
'title' => 'Should not be created',
]);
$this->assertSame(400, $insight['headers']['status-code']);
$this->deleteReport($reportId);
}
public function testCreateRejectsInvalidSeverity(): void
{
$reportId = $this->createFixtureReport();
$insight = $this->createInsight($reportId, [
'insightId' => ID::unique(),
'type' => 'databaseIndex',
'severity' => 'catastrophic',
'resourceType' => 'databases',
'resourceId' => 'main',
'title' => 'Should not be created',
]);
$this->assertSame(400, $insight['headers']['status-code']);
$this->deleteReport($reportId);
}
public function testCreateRejectsDuplicateId(): void
{
$reportId = $this->createFixtureReport();
$insightId = ID::unique();
$first = $this->createInsight($reportId, $this->sampleInsight($insightId));
$this->assertSame(201, $first['headers']['status-code']);
$second = $this->createInsight($reportId, $this->sampleInsight($insightId));
$this->assertSame(409, $second['headers']['status-code']);
$this->assertSame('insight_already_exists', $second['body']['type']);
$this->deleteInsight($reportId, $insightId);
$this->deleteReport($reportId);
}
public function testCreateRejectsUnknownReport(): void
{
// Path-level reportId doesn't exist — endpoint 404s before touching any
// insight logic.
$insight = $this->createInsight('definitely-missing', $this->sampleInsight());
$this->assertSame(404, $insight['headers']['status-code']);
$this->assertSame('report_not_found', $insight['body']['type']);
}
public function testCreateRejectsCTAWithEmptyLabel(): void
{
$reportId = $this->createFixtureReport();
$insight = $this->createInsight($reportId, [
'insightId' => ID::unique(),
'type' => 'databaseIndex',
'resourceType' => 'databases',
'resourceId' => 'main',
'title' => 'Should not be created',
'ctas' => [
['label' => '', 'service' => 'databases', 'method' => 'createIndex'],
],
]);
$this->assertSame(400, $insight['headers']['status-code']);
$this->deleteReport($reportId);
}
public function testCreateRejectsCTAWithMissingMethod(): void
{
$reportId = $this->createFixtureReport();
$insight = $this->createInsight($reportId, [
'insightId' => ID::unique(),
'type' => 'databaseIndex',
'resourceType' => 'databases',
'resourceId' => 'main',
'title' => 'Should not be created',
'ctas' => [
['label' => 'Missing method', 'service' => 'tablesDB'],
],
]);
$this->assertSame(400, $insight['headers']['status-code']);
$this->deleteReport($reportId);
}
public function testCreateRejectsCTAWithMissingService(): void
{
$reportId = $this->createFixtureReport();
$insight = $this->createInsight($reportId, [
'insightId' => ID::unique(),
'type' => 'databaseIndex',
'resourceType' => 'databases',
'resourceId' => 'main',
'title' => 'Should not be created',
'ctas' => [
['label' => 'Missing service', 'method' => 'createIndex'],
],
]);
$this->assertSame(400, $insight['headers']['status-code']);
$this->deleteReport($reportId);
}
public function testCreateRejectsTooManyCTAs(): void
{
$reportId = $this->createFixtureReport();
$ctas = [];
for ($i = 0; $i < 17; $i++) {
$ctas[] = [
'label' => 'CTA ' . $i,
'service' => 'databases',
'method' => 'createIndex',
];
}
$insight = $this->createInsight($reportId, [
'insightId' => ID::unique(),
'type' => 'databaseIndex',
'resourceType' => 'databases',
'resourceId' => 'main',
'title' => 'Should not be created',
'ctas' => $ctas,
]);
$this->assertSame(400, $insight['headers']['status-code']);
$this->deleteReport($reportId);
}
#[Depends('testCreate')]
public function testGet(array $data): array
{
$insight = $this->getInsight($data['reportId'], $data['insightId']);
$this->assertSame(200, $insight['headers']['status-code']);
$this->assertSame($data['insightId'], $insight['body']['$id']);
$this->assertSame($data['reportId'], $insight['body']['reportId']);
$missing = $this->getInsight($data['reportId'], 'missing');
$this->assertSame(404, $missing['headers']['status-code']);
$this->assertSame('insight_not_found', $missing['body']['type']);
// Insight exists but caller used the wrong reportId — still 404.
$wrongReport = $this->getInsight('definitely-missing', $data['insightId']);
$this->assertSame(404, $wrongReport['headers']['status-code']);
$this->assertSame('report_not_found', $wrongReport['body']['type']);
return $data;
$this->assertSame('report_not_found', $missing['body']['type']);
}
#[Depends('testGet')]
public function testList(array $data): array
public function testGetInsightMissingReport(): void
{
$list = $this->listInsights($data['reportId']);
$this->assertSame(200, $list['headers']['status-code']);
$this->assertGreaterThanOrEqual(1, $list['body']['total']);
$this->assertNotEmpty($list['body']['insights']);
// Every returned insight belongs to the path's report.
foreach ($list['body']['insights'] as $insight) {
$this->assertSame($data['reportId'], $insight['reportId']);
}
$missing = $this->getInsight(ID::unique(), ID::unique());
$byResourceType = $this->listInsights($data['reportId'], [
'queries' => ['equal("resourceType", "indexes")'],
]);
$this->assertSame(200, $byResourceType['headers']['status-code']);
foreach ($byResourceType['body']['insights'] as $insight) {
$this->assertSame('indexes', $insight['resourceType']);
}
$byParentResource = $this->listInsights($data['reportId'], [
'queries' => [
'equal("parentResourceType", "tables")',
'equal("parentResourceId", "orders")',
],
]);
$this->assertSame(200, $byParentResource['headers']['status-code']);
foreach ($byParentResource['body']['insights'] as $insight) {
$this->assertSame('tables', $insight['parentResourceType']);
$this->assertSame('orders', $insight['parentResourceId']);
}
$byStatus = $this->listInsights($data['reportId'], [
'queries' => ['equal("status", "active")'],
]);
$this->assertSame(200, $byStatus['headers']['status-code']);
foreach ($byStatus['body']['insights'] as $insight) {
$this->assertSame('active', $insight['status']);
}
$byType = $this->listInsights($data['reportId'], [
'queries' => ['equal("type", "tablesDBIndex")'],
]);
$this->assertSame(200, $byType['headers']['status-code']);
foreach ($byType['body']['insights'] as $insight) {
$this->assertSame('tablesDBIndex', $insight['type']);
}
$bySeverity = $this->listInsights($data['reportId'], [
'queries' => ['equal("severity", "warning")'],
]);
$this->assertSame(200, $bySeverity['headers']['status-code']);
foreach ($bySeverity['body']['insights'] as $insight) {
$this->assertSame('warning', $insight['severity']);
}
// Listing under a non-existent report is a 404.
$missingReport = $this->listInsights('definitely-missing');
$this->assertSame(404, $missingReport['headers']['status-code']);
$this->assertSame('report_not_found', $missingReport['body']['type']);
return $data;
}
#[Depends('testList')]
public function testListRejectsInvalidQueryAttribute(array $data): array
{
$invalid = $this->listInsights($data['reportId'], [
'queries' => ['equal("unknownField", "x")'],
]);
$this->assertSame(400, $invalid['headers']['status-code']);
return $data;
}
#[Depends('testListRejectsInvalidQueryAttribute')]
public function testListWithCursor(array $data): array
{
// Seed two extra insights under the same report so pagination has
// something to chew through.
$first = ID::unique();
$second = ID::unique();
$this->createInsight($data['reportId'], $this->sampleInsight($first));
$this->createInsight($data['reportId'], $this->sampleInsight($second));
$page1 = $this->listInsights($data['reportId'], [
'queries' => ['limit(1)'],
]);
$this->assertSame(200, $page1['headers']['status-code']);
$this->assertCount(1, $page1['body']['insights']);
$cursorId = $page1['body']['insights'][0]['$id'];
$page2 = $this->listInsights($data['reportId'], [
'queries' => ['limit(1)', 'cursorAfter("' . $cursorId . '")'],
]);
$this->assertSame(200, $page2['headers']['status-code']);
$this->assertCount(1, $page2['body']['insights']);
$this->assertNotSame($cursorId, $page2['body']['insights'][0]['$id']);
$missingCursor = $this->listInsights($data['reportId'], [
'queries' => ['cursorAfter("definitely-missing")'],
]);
$this->assertSame(400, $missingCursor['headers']['status-code']);
$this->deleteInsight($data['reportId'], $first);
$this->deleteInsight($data['reportId'], $second);
return $data;
}
#[Depends('testListWithCursor')]
public function testUpdate(array $data): array
{
$original = $this->getInsight($data['reportId'], $data['insightId'])['body'];
$updated = $this->updateInsight($data['reportId'], $data['insightId'], [
'severity' => 'critical',
]);
$this->assertSame(200, $updated['headers']['status-code']);
$this->assertSame('critical', $updated['body']['severity']);
// Analyzer-controlled fields preserved (regression for partial-document
// overwrite). User Update only takes `severity` and `status`; everything
// else flows through the manager Create endpoint.
$this->assertSame($original['title'], $updated['body']['title']);
$this->assertSame($original['summary'], $updated['body']['summary']);
$this->assertSame($original['type'], $updated['body']['type']);
$this->assertSame($original['resourceType'], $updated['body']['resourceType']);
$this->assertSame($original['resourceId'], $updated['body']['resourceId']);
$this->assertSame($original['parentResourceType'], $updated['body']['parentResourceType']);
$this->assertSame($original['parentResourceId'], $updated['body']['parentResourceId']);
$this->assertSame($original['reportId'], $updated['body']['reportId']);
$this->assertSame($original['ctas'], $updated['body']['ctas']);
return $data;
}
#[Depends('testUpdate')]
public function testDismissViaUpdate(array $data): array
{
$dismissed = $this->updateInsight($data['reportId'], $data['insightId'], ['status' => 'dismissed']);
$this->assertSame(200, $dismissed['headers']['status-code']);
$this->assertSame('dismissed', $dismissed['body']['status']);
$this->assertNotEmpty($dismissed['body']['dismissedAt']);
$this->assertNotEmpty($dismissed['body']['dismissedBy']);
$byDismissed = $this->listInsights($data['reportId'], [
'queries' => ['equal("status", "dismissed")'],
]);
$this->assertSame(200, $byDismissed['headers']['status-code']);
$this->assertGreaterThanOrEqual(1, $byDismissed['body']['total']);
$undismiss = $this->updateInsight($data['reportId'], $data['insightId'], ['status' => 'active']);
$this->assertSame(200, $undismiss['headers']['status-code']);
$this->assertSame('active', $undismiss['body']['status']);
$this->assertEmpty($undismiss['body']['dismissedAt']);
$this->assertEmpty($undismiss['body']['dismissedBy']);
return $data;
}
#[Depends('testDismissViaUpdate')]
public function testUpdateMissing(array $data): array
{
// Real report, missing insight → insight_not_found.
$missingInsight = $this->updateInsight($data['reportId'], 'missing', ['severity' => 'critical']);
$this->assertSame(404, $missingInsight['headers']['status-code']);
$this->assertSame('insight_not_found', $missingInsight['body']['type']);
// Missing report → report_not_found before insight is even checked.
$missingReport = $this->updateInsight('definitely-missing', $data['insightId'], ['severity' => 'critical']);
$this->assertSame(404, $missingReport['headers']['status-code']);
$this->assertSame('report_not_found', $missingReport['body']['type']);
return $data;
}
#[Depends('testUpdateMissing')]
public function testDelete(array $data): array
{
$delete = $this->deleteInsight($data['reportId'], $data['insightId']);
$this->assertSame(204, $delete['headers']['status-code']);
$missing = $this->getInsight($data['reportId'], $data['insightId']);
$this->assertSame(404, $missing['headers']['status-code']);
return $data;
$this->assertSame('report_not_found', $missing['body']['type']);
}
#[Depends('testDelete')]
public function testDeleteReportCascadesToInsights(array $data): void
public function testReportsAreReadOnly(): void
{
$insightId = ID::unique();
$create = $this->createInsight($data['reportId'], $this->sampleInsight($insightId));
$this->assertSame(201, $create['headers']['status-code']);
$deleteReport = $this->deleteReport($data['reportId']);
$this->assertSame(204, $deleteReport['headers']['status-code']);
$missingReport = $this->getReport($data['reportId']);
$this->assertSame(404, $missingReport['headers']['status-code']);
// The insight got cascaded too — both the parent path and the insight
// itself are gone.
$orphaned = $this->getInsight($data['reportId'], $insightId);
$this->assertSame(404, $orphaned['headers']['status-code']);
}
public function testCreateRequiresServerKey(): void
{
// Auth check runs before the report fetch, so any reportId works for
// this assertion.
$unauthorized = $this->createInsight(ID::unique(), $this->sampleInsight(), [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
$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']);
$this->assertSame(401, $unauthorized['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']);
$delete = $this->client->call(Client::METHOD_DELETE, '/reports/' . ID::unique(), $this->serverHeaders());
$this->assertSame(404, $delete['headers']['status-code']);
}
public function testListSurvivesEmptyReport(): void
public function testInsightsAreReadOnly(): void
{
$reportId = $this->createFixtureReport();
$createManager = $this->client->call(
Client::METHOD_POST,
'/manager/reports/' . ID::unique() . '/insights',
$this->serverHeaders(),
[]
);
$this->assertSame(404, $createManager['headers']['status-code']);
$list = $this->listInsights($reportId);
$this->assertSame(200, $list['headers']['status-code']);
$this->assertSame(0, $list['body']['total']);
$this->assertEmpty($list['body']['insights']);
$update = $this->client->call(
Client::METHOD_PATCH,
'/reports/' . ID::unique() . '/insights/' . ID::unique(),
$this->serverHeaders(),
['status' => 'dismissed']
);
$this->assertSame(404, $update['headers']['status-code']);
$this->deleteReport($reportId);
$delete = $this->client->call(
Client::METHOD_DELETE,
'/reports/' . ID::unique() . '/insights/' . ID::unique(),
$this->serverHeaders()
);
$this->assertSame(404, $delete['headers']['status-code']);
}
}
@@ -14,28 +14,42 @@ class InsightsCustomServerTest extends Scope
use ProjectCustom;
use SideServer;
public function testCreateRequiresManagerScope(): void
public function testReadWithAdvisorScopes(): void
{
// A server key with insights.read + insights.write but NOT
// insights.manager must be rejected — Create lives behind
// /v1/manager/reports/:reportId/insights and only internal Appwrite
// services hold the manager scope.
$userKey = $this->getNewKey([
'insights.read',
'insights.write',
'reports.read',
]);
$rejected = $this->client->call(
$listed = $this->client->call(
Client::METHOD_GET,
'/reports',
[
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $userKey,
]
);
$this->assertSame(200, $listed['headers']['status-code']);
$create = $this->client->call(
Client::METHOD_POST,
'/manager/reports/' . ID::unique() . '/insights',
'/reports',
[
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $userKey,
],
$this->sampleInsight()
[
'reportId' => ID::unique(),
'type' => 'audit',
'title' => 'Read-only check',
'targetType' => 'sites',
'target' => 'home',
]
);
$this->assertSame(401, $rejected['headers']['status-code']);
$this->assertSame(404, $create['headers']['status-code']);
}
}