From 236e59419505d3c5fb8f0ad597bb204b4c3fdc27 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:40:53 +1200 Subject: [PATCH] feat(insights): add dismiss and CTA trigger endpoints Dismiss is a convenience that stamps `dismissedAt` and `dismissedBy` so analyzers can see an insight has been acknowledged without losing the record. CTA trigger looks up the action in the runtime registry, validates the params blob, executes the action, and returns the result as an `InsightCtaResult`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Modules/Insights/Http/Cta/Trigger.php | 130 ++++++++++++++++++ .../Insights/Http/Insights/Dismiss.php | 87 ++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php create mode 100644 src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php b/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php new file mode 100644 index 0000000000..160eb2eae2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Cta/Trigger.php @@ -0,0 +1,130 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/insights/:insightId/ctas/:ctaId/trigger') + ->desc('Trigger insight CTA') + ->groups(['api', 'insights']) + ->label('scope', 'insights.write') + ->label('event', 'insights.[insightId].ctas.[ctaId].trigger') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('audits.event', 'insight.cta.trigger') + ->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: 'triggerCta', + description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->param('ctaId', '', new Text(64), 'CTA ID, unique within the parent insight.') + ->inject('response') + ->inject('project') + ->inject('dbForProject') + ->inject('insightCtaRegistry') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $insightId, + string $ctaId, + Response $response, + Document $project, + Database $dbForProject, + InsightCtaRegistry $insightCtaRegistry, + Event $queueForEvents + ) { + $insight = $dbForProject->getDocument('insights', $insightId); + + if ($insight->isEmpty()) { + throw new Exception(Exception::INSIGHT_NOT_FOUND); + } + + $cta = null; + foreach ($insight->getAttribute('ctas', []) as $candidate) { + if (($candidate['id'] ?? null) === $ctaId) { + $cta = $candidate; + break; + } + } + + if ($cta === null) { + throw new Exception(Exception::INSIGHT_CTA_NOT_FOUND); + } + + $actionName = (string) ($cta['action'] ?? ''); + $params = $cta['params'] ?? []; + if (!\is_array($params)) { + $params = []; + } + + $action = $insightCtaRegistry->get($actionName); + $action->validate($params); + + $status = 'succeeded'; + $resultPayload = new \stdClass(); + + try { + $result = $action->execute($params, $insight, $project, $dbForProject); + $resultPayload = $result->getArrayCopy(); + } catch (Exception $e) { + if ($e->getType() === Exception::GENERAL_NOT_IMPLEMENTED) { + throw $e; + } + $status = 'failed'; + $resultPayload = ['error' => $e->getMessage()]; + } + + $queueForEvents + ->setParam('insightId', $insight->getId()) + ->setParam('ctaId', $ctaId); + + $response->dynamic(new Document([ + 'insightId' => $insight->getId(), + 'ctaId' => $ctaId, + 'action' => $actionName, + 'status' => $status, + 'result' => $resultPayload, + ]), Response::MODEL_INSIGHT_CTA_RESULT); + } +} diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php new file mode 100644 index 0000000000..ab2ef38682 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Insights/Http/Insights/Dismiss.php @@ -0,0 +1,87 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/insights/:insightId/dismiss') + ->desc('Dismiss insight') + ->groups(['api', 'insights']) + ->label('scope', 'insights.write') + ->label('event', 'insights.[insightId].dismiss') + ->label('resourceType', RESOURCE_TYPE_INSIGHTS) + ->label('audits.event', 'insight.dismiss') + ->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: 'dismiss', + description: <<param('insightId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForProject']) + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $insightId, + Response $response, + Document $user, + Database $dbForProject, + Event $queueForEvents + ) { + $insight = $dbForProject->getDocument('insights', $insightId); + + if ($insight->isEmpty()) { + throw new Exception(Exception::INSIGHT_NOT_FOUND); + } + + $insight = $dbForProject->updateDocument('insights', $insight->getId(), new Document([ + 'dismissedAt' => DateTime::now(), + 'dismissedBy' => $user->getId(), + ])); + + $queueForEvents->setParam('insightId', $insight->getId()); + + $response->dynamic($insight, Response::MODEL_INSIGHT); + } +}