mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
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:
co-authored by
abnegate
parent
0829b26508
commit
6d0eab2583
@@ -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,
|
||||
|
||||
@@ -106,7 +106,6 @@ $admins = [
|
||||
'insights.read',
|
||||
'insights.write',
|
||||
'reports.read',
|
||||
'reports.write',
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -179,9 +179,7 @@ trait ProjectCustom
|
||||
'templates.write',
|
||||
'insights.read',
|
||||
'insights.write',
|
||||
'insights.manager',
|
||||
'reports.read',
|
||||
'reports.write',
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user