From 5a8be8148482549aa5bf40c1d25e93e664a1f2bd Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:40:47 +1200 Subject: [PATCH] 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) --- .../Modules/Insights/Http/Insights/Create.php | 138 ++++++++++++++++++ .../Modules/Insights/Http/Insights/Delete.php | 86 +++++++++++ .../Modules/Insights/Http/Insights/Get.php | 67 +++++++++ .../Modules/Insights/Http/Insights/Update.php | 134 +++++++++++++++++ .../Modules/Insights/Http/Insights/XList.php | 106 ++++++++++++++ 5 files changed, 531 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php new file mode 100644 index 0000000000..001c339e88 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php @@ -0,0 +1,138 @@ +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: <<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); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php new file mode 100644 index 0000000000..ad2cd01818 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Delete.php @@ -0,0 +1,86 @@ +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: <<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(); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php new file mode 100644 index 0000000000..bc4d33f241 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Get.php @@ -0,0 +1,67 @@ +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: <<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); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php new file mode 100644 index 0000000000..47480eb980 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Update.php @@ -0,0 +1,134 @@ +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: <<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); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php new file mode 100644 index 0000000000..9ab6dfffc8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/XList.php @@ -0,0 +1,106 @@ +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: <<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); + } +}