feat(insights): add CRUD endpoints

Adds the create, get, list, update, and delete endpoints under the
`insights` SDK namespace. Mutating endpoints are admin/key-only because
insights are produced by analyzers; reads are open to sessions and JWTs
so console UIs can surface them. Updates use sparse documents so unset
fields keep their existing value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-05-01 12:40:47 +12:00
co-authored by Claude Opus 4.7
parent 7f7be46547
commit 5a8be81484
5 changed files with 531 additions and 0 deletions
@@ -0,0 +1,138 @@
<?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\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\JSON;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createInsight';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/insights')
->desc('Create insight')
->groups(['api', 'insights'])
->label('scope', 'insights.write')
->label('event', 'insights.[insightId].create')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('audits.event', 'insight.create')
->label('audits.resource', 'insight/{response.$id}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'insights',
group: 'insights',
name: 'create',
description: <<<EOT
Create a new insight. Server-side only: insights are produced by analyzers and surfaced to project members.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_INSIGHT,
),
]
))
->param('insightId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
->param('type', '', new WhiteList(INSIGHT_TYPES, true), 'Insight type. Determines the analyzer that owns this insight and the shape of `payload`.')
->param('severity', INSIGHT_SEVERITY_INFO, new WhiteList(INSIGHT_SEVERITIES, true), 'Insight severity. One of `info`, `warning`, `critical`.', true)
->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.')
->param('resourceId', '', new Text(36), 'ID of the resource the insight is about.')
->param('resourceInternalId', '', new Text(36), 'Internal ID of the resource the insight is about.', true)
->param('title', '', new Text(256), 'Short, human-readable title.')
->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true)
->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true)
->param('ctas', [], new ArrayList(new JSON(), 16), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true)
->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true)
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $insightId,
string $type,
string $severity,
string $resourceType,
string $resourceId,
string $resourceInternalId,
string $title,
string $summary,
?array $payload,
array $ctas,
?string $analyzedAt,
Response $response,
Database $dbForProject,
Event $queueForEvents
) {
$insightId = ($insightId === 'unique()') ? ID::unique() : $insightId;
$normalizedCtas = [];
foreach ($ctas as $cta) {
if (!isset($cta['id'], $cta['label'], $cta['action'])) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Each CTA must define `id`, `label`, and `action`.');
}
$normalizedCtas[] = [
'id' => (string) $cta['id'],
'label' => (string) $cta['label'],
'action' => (string) $cta['action'],
'params' => $cta['params'] ?? new \stdClass(),
];
}
try {
$insight = $dbForProject->createDocument('insights', new Document([
'$id' => $insightId,
'type' => $type,
'severity' => $severity,
'resourceType' => $resourceType,
'resourceId' => $resourceId,
'resourceInternalId' => $resourceInternalId,
'title' => $title,
'summary' => $summary,
'payload' => $payload,
'ctas' => $normalizedCtas,
'analyzedAt' => $analyzedAt,
'dismissedAt' => null,
'dismissedBy' => '',
]));
} catch (DuplicateException) {
throw new Exception(Exception::INSIGHT_ALREADY_EXISTS);
}
$queueForEvents->setParam('insightId', $insight->getId());
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($insight, Response::MODEL_INSIGHT);
}
}
@@ -0,0 +1,86 @@
<?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\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/insights/:insightId')
->desc('Delete insight')
->groups(['api', 'insights'])
->label('scope', 'insights.write')
->label('event', 'insights.[insightId].delete')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('audits.event', 'insight.delete')
->label('audits.resource', 'insight/{request.insightId}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'insights',
group: 'insights',
name: 'delete',
description: <<<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('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $insightId,
Response $response,
Database $dbForProject,
Event $queueForEvents
) {
$insight = $dbForProject->getDocument('insights', $insightId);
if ($insight->isEmpty()) {
throw new Exception(Exception::INSIGHT_NOT_FOUND);
}
if (!$dbForProject->deleteDocument('insights', $insight->getId())) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove insight from DB');
}
$queueForEvents
->setParam('insightId', $insight->getId())
->setPayload($response->output($insight, Response::MODEL_INSIGHT));
$response->noContent();
}
}
@@ -0,0 +1,67 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Insights;
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\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getInsight';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/insights/:insightId')
->desc('Get insight')
->groups(['api', 'insights'])
->label('scope', 'insights.read')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('sdk', new Method(
namespace: 'insights',
group: 'insights',
name: 'get',
description: <<<EOT
Get an insight by its unique ID.
EOT,
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_INSIGHT,
),
]
))
->param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
string $insightId,
Response $response,
Database $dbForProject
) {
$insight = $dbForProject->getDocument('insights', $insightId);
if ($insight->isEmpty()) {
throw new Exception(Exception::INSIGHT_NOT_FOUND);
}
$response->dynamic($insight, Response::MODEL_INSIGHT);
}
}
@@ -0,0 +1,134 @@
<?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\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\JSON;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateInsight';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/insights/:insightId')
->desc('Update insight')
->groups(['api', 'insights'])
->label('scope', 'insights.write')
->label('event', 'insights.[insightId].update')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('audits.event', 'insight.update')
->label('audits.resource', 'insight/{response.$id}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'insights',
group: 'insights',
name: 'update',
description: <<<EOT
Update an insight. 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_INSIGHT,
),
]
))
->param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject'])
->param('severity', null, new Nullable(new WhiteList(INSIGHT_SEVERITIES, true)), 'Insight severity. One of `info`, `warning`, `critical`.', true)
->param('title', null, new Nullable(new Text(256)), 'Short, human-readable title.', true)
->param('summary', null, new Nullable(new Text(4096, 0)), 'Markdown summary describing the insight.', true)
->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true)
->param('ctas', null, new Nullable(new ArrayList(new JSON(), 16)), 'Array of call-to-action descriptors.', true)
->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format.', true)
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $insightId,
?string $severity,
?string $title,
?string $summary,
?array $payload,
?array $ctas,
?string $analyzedAt,
Response $response,
Database $dbForProject,
Event $queueForEvents
) {
$insight = $dbForProject->getDocument('insights', $insightId);
if ($insight->isEmpty()) {
throw new Exception(Exception::INSIGHT_NOT_FOUND);
}
$changes = [];
if ($severity !== null) {
$changes['severity'] = $severity;
}
if ($title !== null) {
$changes['title'] = $title;
}
if ($summary !== null) {
$changes['summary'] = $summary;
}
if ($payload !== null) {
$changes['payload'] = $payload;
}
if ($ctas !== null) {
$normalized = [];
foreach ($ctas as $cta) {
if (!isset($cta['id'], $cta['label'], $cta['action'])) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Each CTA must define `id`, `label`, and `action`.');
}
$normalized[] = [
'id' => (string) $cta['id'],
'label' => (string) $cta['label'],
'action' => (string) $cta['action'],
'params' => $cta['params'] ?? new \stdClass(),
];
}
$changes['ctas'] = $normalized;
}
if ($analyzedAt !== null) {
$changes['analyzedAt'] = $analyzedAt;
}
if ($changes !== []) {
$insight = $dbForProject->updateDocument('insights', $insight->getId(), new Document($changes));
}
$queueForEvents->setParam('insightId', $insight->getId());
$response->dynamic($insight, Response::MODEL_INSIGHT);
}
}
@@ -0,0 +1,106 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Insights;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Insights;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listInsights';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/insights')
->desc('List insights')
->groups(['api', 'insights'])
->label('scope', 'insights.read')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('sdk', new Method(
namespace: 'insights',
group: 'insights',
name: 'list',
description: <<<EOT
Get a list of all the project's insights. You can use the query params to filter your results.
EOT,
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_INSIGHT_LIST,
),
]
))
->param('queries', [], new Insights(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Insights::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
array $queries,
bool $includeTotal,
Response $response,
Database $dbForProject
) {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$insightId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('insights', $insightId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Insight '{$insightId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$insights = $dbForProject->find('insights', $queries);
$total = $includeTotal ? $dbForProject->count('insights', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'insights' => $insights,
'total' => $total,
]), Response::MODEL_INSIGHT_LIST);
}
}