refactor(insights): collapse dismissal into a status field on update

Dismissal was a sub-resource (POST /v1/insights/:id/dismissals) but a
dismissal is just a state transition, not a thing the client creates.
Drop the dedicated endpoint and add a `status` enum (`active` |
`dismissed`) to the insights schema, settable via the existing PATCH
update route. The server still derives `dismissedAt` and `dismissedBy`
on transition for audit/sorting, but the client-facing API is just a
single status toggle.

- Schema: add `status` attribute (default `active`)
- Constants: add `INSIGHT_STATUSES`
- Update endpoint: accept `status` param, derive dismissedAt/By on
  active <-> dismissed transitions
- Response model: add `status` rule
- Drop Insights/Dismissal/Create.php, the createInsightDismissal SDK
  method, the `insights.[id].dismissals.create` event, and the
  `insight.dismissal.create` audit
- E2E: replace testCreateDismissal with testDismissViaUpdate covering
  both directions of the toggle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-05-01 14:36:31 +12:00
co-authored by Claude Opus 4.7
parent cd539d972a
commit f779c7aa3b
9 changed files with 56 additions and 100 deletions
+11
View File
@@ -2782,6 +2782,17 @@ return [
'array' => false,
'filters' => [],
],
[
// Possible values: active, dismissed
'$id' => ID::custom('status'),
'type' => Database::VAR_STRING,
'size' => 16,
'signed' => true,
'required' => true,
'default' => 'active',
'array' => false,
'filters' => [],
],
[
// Possible values: databases, collections, sites, functions
'$id' => ID::custom('resourceType'),
-8
View File
@@ -440,14 +440,6 @@ return [
'delete' => [
'$description' => 'This event triggers when an insight is deleted.',
],
'dismissals' => [
'$model' => Response::MODEL_INSIGHT,
'$resource' => true,
'$description' => 'This event triggers on any insight dismissal event.',
'create' => [
'$description' => 'This event triggers when an insight is dismissed.',
],
],
'ctas' => [
'$model' => Response::MODEL_INSIGHT_CTA,
'$resource' => true,
+9
View File
@@ -453,6 +453,15 @@ const INSIGHT_SEVERITIES = [
INSIGHT_SEVERITY_CRITICAL,
];
// Insight statuses
const INSIGHT_STATUS_ACTIVE = 'active';
const INSIGHT_STATUS_DISMISSED = 'dismissed';
const INSIGHT_STATUSES = [
INSIGHT_STATUS_ACTIVE,
INSIGHT_STATUS_DISMISSED,
];
// Insight CTA actions
const INSIGHT_CTA_ACTION_DATABASES_CREATE_INDEX = 'databases.createIndex';
@@ -111,6 +111,7 @@ class Create extends Action
'$id' => $insightId,
'type' => $type,
'severity' => $severity,
'status' => INSIGHT_STATUS_ACTIVE,
'resourceType' => $resourceType,
'resourceId' => $resourceId,
'resourceInternalId' => $resourceInternalId,
@@ -1,87 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Insights\Dismissal;
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;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createInsightDismissal';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/insights/:insightId/dismissals')
->desc('Create insight dismissal')
->groups(['api', 'insights'])
->label('scope', 'insights.write')
->label('event', 'insights.[insightId].dismissals.create')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('audits.event', 'insight.dismissal.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: 'createDismissal',
description: <<<EOT
Dismiss an insight. Stamps the current user and time on the insight without deleting it, so analyzers can see it has been acknowledged.
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('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);
}
}
@@ -9,6 +9,7 @@ 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\Datetime as DatetimeValidator;
use Utopia\Database\Validator\UID;
@@ -61,12 +62,14 @@ class Update extends Action
))
->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('status', null, new Nullable(new WhiteList(INSIGHT_STATUSES, true)), 'Insight status. Set to `dismissed` to dismiss the insight, `active` to undo a dismissal.', 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('user')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
@@ -75,12 +78,14 @@ class Update extends Action
public function action(
string $insightId,
?string $severity,
?string $status,
?string $title,
?string $summary,
?array $payload,
?array $ctas,
?string $analyzedAt,
Response $response,
Document $user,
Database $dbForProject,
Event $queueForEvents
) {
@@ -95,6 +100,16 @@ class Update extends Action
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 ($title !== null) {
$changes['title'] = $title;
}
@@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Insights\Services;
use Appwrite\Platform\Modules\Insights\Http\CTA\Execution\Create as CreateInsightCTAExecution;
use Appwrite\Platform\Modules\Insights\Http\Insights\Create as CreateInsight;
use Appwrite\Platform\Modules\Insights\Http\Insights\Delete as DeleteInsight;
use Appwrite\Platform\Modules\Insights\Http\Insights\Dismissal\Create as CreateInsightDismissal;
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;
@@ -22,7 +21,6 @@ class Http extends Service
$this->addAction(ListInsights::getName(), new ListInsights());
$this->addAction(UpdateInsight::getName(), new UpdateInsight());
$this->addAction(DeleteInsight::getName(), new DeleteInsight());
$this->addAction(CreateInsightDismissal::getName(), new CreateInsightDismissal());
$this->addAction(CreateInsightCTAExecution::getName(), new CreateInsightCTAExecution());
}
}
@@ -47,6 +47,12 @@ class Insight extends Model
'default' => 'info',
'example' => 'warning',
])
->addRule('status', [
'type' => self::TYPE_STRING,
'description' => 'Insight status. One of active, dismissed.',
'default' => 'active',
'example' => 'active',
])
->addRule('resourceType', [
'type' => self::TYPE_STRING,
'description' => 'Type of the resource the insight is about. Plural noun, e.g. databases, sites, functions.',
+14 -3
View File
@@ -129,20 +129,31 @@ trait InsightsBase
/**
* @depends testUpdate
*/
public function testCreateDismissal(array $data): array
public function testDismissViaUpdate(array $data): array
{
$insightId = $data['insightId'];
$response = $this->client->call(Client::METHOD_POST, '/insights/' . $insightId . '/dismissals', $this->serverHeaders());
$response = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [
'status' => 'dismissed',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('dismissed', $response['body']['status']);
$this->assertNotEmpty($response['body']['dismissedAt']);
$undismiss = $this->client->call(Client::METHOD_PATCH, '/insights/' . $insightId, $this->serverHeaders(), [
'status' => 'active',
]);
$this->assertSame(200, $undismiss['headers']['status-code']);
$this->assertSame('active', $undismiss['body']['status']);
$this->assertEmpty($undismiss['body']['dismissedAt']);
return $data;
}
/**
* @depends testCreateDismissal
* @depends testDismissViaUpdate
*/
public function testCreateCTAExecution(array $data): void
{