feat(alerts): test message + last test status + deep links (#122)

Implements feature 100 (Alert Targets):

- US1: “Send test message” action (RBAC + confirmation + rate limit + audit + async job)
- US2: Derived “Last test” status badge (Never/Sent/Failed/Pending) on view + edit surfaces
- US3: “View last delivery” deep link + deliveries viewer filters (event_type, destination) incl. tenantless test deliveries

Tests:
- Full suite green (1348 passed, 7 skipped)
- Added focused feature tests for send test, last test resolver/badges, and deep-link filters

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #122
This commit is contained in:
ahmido 2026-02-18 23:12:38 +00:00
parent 3ed275cef3
commit d49d33ac27
34 changed files with 2235 additions and 39 deletions

View File

@ -29,6 +29,7 @@ ## Active Technologies
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -48,8 +49,7 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 100-alert-target-test-actions: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications
- 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface`
- 090-action-surface-contract-compliance: Added PHP 8.4.15
- 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -70,6 +70,7 @@ ### Tenant Isolation is Non-negotiable
- Tenant-owned tables MUST include workspace_id and tenant_id as NOT NULL.
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
### RBAC & UI Enforcement Standards (RBAC-UX)

View File

@ -7,8 +7,11 @@
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -114,7 +117,10 @@ public static function getEloquentQuery(): Builder
)
->when(
$user instanceof User,
fn (Builder $query): Builder => $query->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id')),
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
$q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'))
->orWhereNull('tenant_id');
}),
)
->when(
Filament::getTenant() instanceof Tenant,
@ -136,8 +142,9 @@ public static function infolist(Schema $schema): Schema
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state))
->color(fn (?string $state): string => self::statusColor((string) $state)),
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
TextEntry::make('event_type')
->label('Event')
->badge()
@ -214,8 +221,9 @@ public static function table(Table $table): Table
->placeholder('—'),
TextColumn::make('status')
->badge()
->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state))
->color(fn (?string $state): string => self::statusColor((string) $state)),
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
TextColumn::make('rule.name')
->label('Rule')
->placeholder('—'),
@ -235,6 +243,29 @@ public static function table(Table $table): Table
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
AlertDelivery::STATUS_CANCELED => 'Canceled',
]),
SelectFilter::make('event_type')
->label('Event type')
->options(function (): array {
$options = AlertRuleResource::eventTypeOptions();
$options[AlertDelivery::EVENT_TYPE_TEST] = 'Test';
return $options;
}),
SelectFilter::make('alert_destination_id')
->label('Destination')
->options(function (): array {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return AlertDestination::query()
->where('workspace_id', $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}),
])
->actions([
ViewAction::make()->label('View'),
@ -249,30 +280,4 @@ public static function getPages(): array
'view' => Pages\ViewAlertDelivery::route('/{record}'),
];
}
private static function statusLabel(string $status): string
{
return match ($status) {
AlertDelivery::STATUS_QUEUED => 'Queued',
AlertDelivery::STATUS_DEFERRED => 'Deferred',
AlertDelivery::STATUS_SENT => 'Sent',
AlertDelivery::STATUS_FAILED => 'Failed',
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
AlertDelivery::STATUS_CANCELED => 'Canceled',
default => ucfirst($status),
};
}
private static function statusColor(string $status): string
{
return match ($status) {
AlertDelivery::STATUS_QUEUED => 'gray',
AlertDelivery::STATUS_DEFERRED => 'warning',
AlertDelivery::STATUS_SENT => 'success',
AlertDelivery::STATUS_FAILED => 'danger',
AlertDelivery::STATUS_SUPPRESSED => 'info',
AlertDelivery::STATUS_CANCELED => 'gray',
default => 'gray',
};
}
}

View File

@ -173,7 +173,7 @@ public static function table(Table $table): Table
->defaultSort('name')
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: null)
: static::getUrl('view', ['record' => $record]))
->columns([
TextColumn::make('name')
->searchable(),
@ -266,6 +266,7 @@ public static function getPages(): array
return [
'index' => Pages\ListAlertDestinations::route('/'),
'create' => Pages\CreateAlertDestination::route('/create'),
'view' => Pages\ViewAlertDestination::route('/{record}'),
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
];
}

View File

@ -4,9 +4,15 @@
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Alerts\AlertDestinationLastTestResolver;
use App\Services\Alerts\AlertDestinationTestMessageService;
use App\Support\Alerts\AlertDestinationLastTestStatus;
use App\Support\Audit\AuditActionId;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
@ -14,6 +20,80 @@ class EditAlertDestination extends EditRecord
{
protected static string $resource = AlertDestinationResource::class;
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
public function mount(int|string $record): void
{
parent::mount($record);
$this->resolveLastTestStatus();
}
protected function getHeaderActions(): array
{
$user = auth()->user();
$record = $this->record;
$canManage = $user instanceof User
&& $record instanceof AlertDestination
&& $user->can('update', $record);
return [
Action::make('send_test_message')
->label('Send test message')
->icon('heroicon-o-paper-airplane')
->requiresConfirmation()
->modalHeading('Send test message')
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
->modalSubmitActionLabel('Send')
->visible(fn (): bool => $record instanceof AlertDestination)
->disabled(fn (): bool => ! $canManage)
->action(function () use ($record): void {
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return;
}
$service = app(AlertDestinationTestMessageService::class);
$result = $service->sendTest($record, $user);
if ($result['success']) {
Notification::make()
->title($result['message'])
->success()
->send();
} else {
Notification::make()
->title($result['message'])
->warning()
->send();
}
$this->resolveLastTestStatus();
}),
Action::make('view_last_delivery')
->label('View last delivery')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): ?string => $this->buildDeepLinkUrl())
->openUrlInNewTab()
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
];
}
public function getSubheading(): ?string
{
if ($this->lastTestStatus === null) {
return null;
}
$label = ucfirst($this->lastTestStatus->status->value);
$timestamp = $this->lastTestStatus->timestamp?->diffForHumans();
return $timestamp !== null
? "Last test: {$label} ({$timestamp})"
: "Last test: {$label}";
}
protected function mutateFormDataBeforeSave(array $data): array
{
$record = $this->record;
@ -46,4 +126,34 @@ protected function afterSave(): void
->success()
->send();
}
private function resolveLastTestStatus(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
}
private function buildDeepLinkUrl(): ?string
{
$record = $this->record;
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
return null;
}
$baseUrl = AlertDeliveryResource::getUrl('index');
$params = http_build_query([
'filters' => [
'event_type' => ['value' => 'alerts.test'],
'alert_destination_id' => ['value' => (string) $record->getKey()],
],
]);
return "{$baseUrl}?{$params}";
}
}

View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Alerts\AlertDestinationLastTestResolver;
use App\Services\Alerts\AlertDestinationTestMessageService;
use App\Support\Alerts\AlertDestinationLastTestStatus;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Filament\Actions\Action;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ViewAlertDestination extends ViewRecord
{
protected static string $resource = AlertDestinationResource::class;
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
public function mount(int|string $record): void
{
parent::mount($record);
$this->resolveLastTestStatus();
}
protected function getHeaderActions(): array
{
$user = auth()->user();
$record = $this->record;
$canManage = $user instanceof User
&& $record instanceof AlertDestination
&& $user->can('update', $record);
return [
Action::make('send_test_message')
->label('Send test message')
->icon('heroicon-o-paper-airplane')
->requiresConfirmation()
->modalHeading('Send test message')
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
->modalSubmitActionLabel('Send')
->visible(fn (): bool => $record instanceof AlertDestination)
->disabled(fn (): bool => ! $canManage)
->action(function () use ($record): void {
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return;
}
$service = app(AlertDestinationTestMessageService::class);
$result = $service->sendTest($record, $user);
if ($result['success']) {
Notification::make()
->title($result['message'])
->success()
->send();
} else {
Notification::make()
->title($result['message'])
->warning()
->send();
}
$this->resolveLastTestStatus();
}),
Action::make('view_last_delivery')
->label('View last delivery')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): ?string => $this->buildDeepLinkUrl())
->openUrlInNewTab()
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
];
}
public function infolist(Schema $schema): Schema
{
$lastTest = $this->lastTestStatus ?? AlertDestinationLastTestStatus::never();
return $schema
->schema([
Section::make('Last test')
->schema([
TextEntry::make('last_test_status')
->label('Status')
->badge()
->state($lastTest->status->value)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDestinationLastTestStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDestinationLastTestStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDestinationLastTestStatus)),
TextEntry::make('last_test_timestamp')
->label('Timestamp')
->state($lastTest->timestamp?->toDateTimeString())
->placeholder('—'),
])
->columns(2),
Section::make('Details')
->schema([
TextEntry::make('name'),
TextEntry::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => AlertDestinationResource::typeLabel((string) $state)),
TextEntry::make('is_enabled')
->label('Enabled')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
])
->columns(2),
]);
}
private function resolveLastTestStatus(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
}
private function buildDeepLinkUrl(): ?string
{
$record = $this->record;
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
return null;
}
return AlertDeliveryResource::getUrl(panel: 'admin').'?'.http_build_query([
'filters' => [
'event_type' => ['value' => 'alerts.test'],
'alert_destination_id' => ['value' => (string) $record->getKey()],
],
]);
}
}

View File

@ -4,7 +4,7 @@
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\Concerns\DerivesWorkspaceIdFromTenantWhenPresent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -13,10 +13,12 @@
class AlertDelivery extends Model
{
use DerivesWorkspaceIdFromTenant;
use DerivesWorkspaceIdFromTenantWhenPresent;
use HasFactory;
use Prunable;
public const string EVENT_TYPE_TEST = 'alerts.test';
public const string STATUS_QUEUED = 'queued';
public const string STATUS_DEFERRED = 'deferred';

View File

@ -5,7 +5,6 @@
namespace App\Policies;
use App\Models\AlertDelivery;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
@ -40,8 +39,8 @@ public function view(User $user, AlertDelivery $alertDelivery): bool|Response
$tenant = $alertDelivery->tenant;
if (! $tenant instanceof Tenant) {
return Response::denyAsNotFound();
if ($tenant === null) {
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW);
}
if (! $user->canAccessTenant($tenant)) {

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Support\Alerts\AlertDestinationLastTestStatus;
use App\Support\Alerts\AlertDestinationLastTestStatusEnum;
use Carbon\CarbonImmutable;
class AlertDestinationLastTestResolver
{
/**
* Resolve the derived "last test" status for a given destination.
*
* Uses the most recent `alert_deliveries` row with `event_type = 'alerts.test'`,
* ordered by `created_at` desc then `id` desc (deterministic tie-breaking).
*/
public function resolve(AlertDestination $destination): AlertDestinationLastTestStatus
{
$delivery = AlertDelivery::query()
->where('workspace_id', (int) $destination->workspace_id)
->where('alert_destination_id', (int) $destination->getKey())
->where('event_type', AlertDelivery::EVENT_TYPE_TEST)
->orderByDesc('created_at')
->orderByDesc('id')
->first();
if ($delivery === null) {
return AlertDestinationLastTestStatus::never();
}
$status = $this->mapStatus((string) $delivery->status);
$timestamp = $this->mapTimestamp($delivery, $status);
return new AlertDestinationLastTestStatus(
status: $status,
timestamp: $timestamp,
deliveryId: (int) $delivery->getKey(),
);
}
private function mapStatus(string $deliveryStatus): AlertDestinationLastTestStatusEnum
{
return match ($deliveryStatus) {
AlertDelivery::STATUS_SENT => AlertDestinationLastTestStatusEnum::Sent,
AlertDelivery::STATUS_FAILED => AlertDestinationLastTestStatusEnum::Failed,
AlertDelivery::STATUS_QUEUED,
AlertDelivery::STATUS_DEFERRED => AlertDestinationLastTestStatusEnum::Pending,
default => AlertDestinationLastTestStatusEnum::Failed,
};
}
private function mapTimestamp(AlertDelivery $delivery, AlertDestinationLastTestStatusEnum $status): ?CarbonImmutable
{
return match ($status) {
AlertDestinationLastTestStatusEnum::Sent => $delivery->sent_at
? CarbonImmutable::instance($delivery->sent_at)
: null,
AlertDestinationLastTestStatusEnum::Failed => $delivery->updated_at
? CarbonImmutable::instance($delivery->updated_at)
: null,
AlertDestinationLastTestStatusEnum::Pending => $delivery->send_after
? CarbonImmutable::instance($delivery->send_after)
: ($delivery->created_at ? CarbonImmutable::instance($delivery->created_at) : null),
AlertDestinationLastTestStatusEnum::Never => null,
};
}
}

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use App\Jobs\Alerts\DeliverAlertsJob;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use Illuminate\Auth\Access\AuthorizationException;
class AlertDestinationTestMessageService
{
private const int RATE_LIMIT_SECONDS = 60;
public function __construct(
private WorkspaceAuditLogger $auditLogger,
) {}
/**
* Send a test message for the given alert destination.
*
* @return array{success: bool, message: string, delivery_id: int|null}
*/
public function sendTest(AlertDestination $destination, User $actor): array
{
if (! $actor->can('update', $destination)) {
throw new AuthorizationException('You do not have permission to send test messages for this destination.');
}
if (! $destination->is_enabled) {
return [
'success' => false,
'message' => 'This destination is currently disabled. Enable it before sending a test message.',
'delivery_id' => null,
];
}
if ($this->isRateLimited($destination)) {
return [
'success' => false,
'message' => 'A test message was sent recently. Please wait before trying again.',
'delivery_id' => null,
];
}
$delivery = $this->createTestDelivery($destination);
$this->auditLog($destination, $actor);
DeliverAlertsJob::dispatch((int) $destination->workspace_id);
return [
'success' => true,
'message' => 'Test message queued for delivery.',
'delivery_id' => (int) $delivery->getKey(),
];
}
private function isRateLimited(AlertDestination $destination): bool
{
return AlertDelivery::query()
->where('workspace_id', (int) $destination->workspace_id)
->where('alert_destination_id', (int) $destination->getKey())
->where('event_type', AlertDelivery::EVENT_TYPE_TEST)
->where('created_at', '>=', now()->subSeconds(self::RATE_LIMIT_SECONDS))
->exists();
}
private function createTestDelivery(AlertDestination $destination): AlertDelivery
{
return AlertDelivery::create([
'workspace_id' => (int) $destination->workspace_id,
'tenant_id' => null,
'alert_rule_id' => null,
'alert_destination_id' => (int) $destination->getKey(),
'event_type' => AlertDelivery::EVENT_TYPE_TEST,
'status' => AlertDelivery::STATUS_QUEUED,
'severity' => null,
'fingerprint_hash' => 'test:'.(int) $destination->getKey(),
'attempt_count' => 0,
'payload' => [
'title' => 'Test alert',
'body' => 'This is a test delivery for destination verification.',
],
]);
}
private function auditLog(AlertDestination $destination, User $actor): void
{
$workspace = $destination->workspace;
if ($workspace === null) {
return;
}
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::AlertDestinationTestRequested->value,
context: [
'metadata' => [
'alert_destination_id' => (int) $destination->getKey(),
'name' => (string) $destination->name,
'type' => (string) $destination->type,
],
],
actor: $actor,
resourceType: 'alert_destination',
resourceId: (string) $destination->getKey(),
);
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Alerts;
use Carbon\CarbonImmutable;
final class AlertDestinationLastTestStatus
{
public function __construct(
public readonly AlertDestinationLastTestStatusEnum $status,
public readonly ?CarbonImmutable $timestamp,
public readonly ?int $deliveryId,
) {}
public static function never(): self
{
return new self(
status: AlertDestinationLastTestStatusEnum::Never,
timestamp: null,
deliveryId: null,
);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Alerts;
enum AlertDestinationLastTestStatusEnum: string
{
case Never = 'never';
case Sent = 'sent';
case Failed = 'failed';
case Pending = 'pending';
}

View File

@ -36,6 +36,7 @@ enum AuditActionId: string
case AlertDestinationDeleted = 'alert_destination.deleted';
case AlertDestinationEnabled = 'alert_destination.enabled';
case AlertDestinationDisabled = 'alert_destination.disabled';
case AlertDestinationTestRequested = 'alert_destination.test_requested';
case AlertRuleCreated = 'alert_rule.created';
case AlertRuleUpdated = 'alert_rule.updated';

View File

@ -37,6 +37,8 @@ final class BadgeCatalog
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class,
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
];
/**

View File

@ -29,4 +29,6 @@ enum BadgeDomain: string
case VerificationCheckStatus = 'verification_check_status';
case VerificationCheckSeverity = 'verification_check_severity';
case VerificationReportOverall = 'verification_report_overall';
case AlertDeliveryStatus = 'alert_delivery_status';
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Models\AlertDelivery;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class AlertDeliveryStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
AlertDelivery::STATUS_QUEUED => new BadgeSpec('Queued', 'gray', 'heroicon-m-clock'),
AlertDelivery::STATUS_DEFERRED => new BadgeSpec('Deferred', 'warning', 'heroicon-m-clock'),
AlertDelivery::STATUS_SENT => new BadgeSpec('Sent', 'success', 'heroicon-m-check-circle'),
AlertDelivery::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
AlertDelivery::STATUS_SUPPRESSED => new BadgeSpec('Suppressed', 'info', 'heroicon-m-no-symbol'),
AlertDelivery::STATUS_CANCELED => new BadgeSpec('Canceled', 'gray', 'heroicon-m-stop'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Alerts\AlertDestinationLastTestStatusEnum;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class AlertDestinationLastTestStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
AlertDestinationLastTestStatusEnum::Never->value => new BadgeSpec('Never', 'gray', 'heroicon-m-minus-circle'),
AlertDestinationLastTestStatusEnum::Sent->value => new BadgeSpec('Sent', 'success', 'heroicon-m-check-circle'),
AlertDestinationLastTestStatusEnum::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
AlertDestinationLastTestStatusEnum::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Support\Concerns;
use App\Models\Tenant;
use App\Support\WorkspaceIsolation\WorkspaceIsolationViolation;
use Illuminate\Database\Eloquent\Model;
/**
* Like DerivesWorkspaceIdFromTenant, but allows tenant_id to be null.
*
* When tenant_id IS present the trait enforces the same workspace-derivation
* and immutability rules as DerivesWorkspaceIdFromTenant.
* When tenant_id IS NULL the caller MUST supply workspace_id explicitly.
*/
trait DerivesWorkspaceIdFromTenantWhenPresent
{
public static function bootDerivesWorkspaceIdFromTenantWhenPresent(): void
{
static::creating(static function (Model $model): void {
self::enforceWorkspaceBindingOptional($model);
});
static::updating(static function (Model $model): void {
self::enforceWorkspaceBindingOptional($model);
});
}
private static function enforceWorkspaceBindingOptional(Model $model): void
{
$tenantId = $model->getAttribute('tenant_id');
if ($tenantId === null || $tenantId === '') {
self::requireExplicitWorkspaceId($model);
return;
}
if (! is_numeric($tenantId)) {
throw WorkspaceIsolationViolation::missingTenantId(class_basename($model));
}
$tenantId = (int) $tenantId;
self::ensureTenantIdIsImmutableOptional($model, $tenantId);
$tenantWorkspaceId = self::resolveTenantWorkspaceIdOptional($model, $tenantId);
$workspaceId = $model->getAttribute('workspace_id');
if ($workspaceId === null || $workspaceId === '') {
$model->setAttribute('workspace_id', $tenantWorkspaceId);
return;
}
if (! is_numeric($workspaceId)) {
throw WorkspaceIsolationViolation::workspaceMismatch(
class_basename($model),
$tenantId,
$tenantWorkspaceId,
0,
);
}
$workspaceId = (int) $workspaceId;
if ($workspaceId !== $tenantWorkspaceId) {
throw WorkspaceIsolationViolation::workspaceMismatch(
class_basename($model),
$tenantId,
$tenantWorkspaceId,
$workspaceId,
);
}
}
private static function requireExplicitWorkspaceId(Model $model): void
{
$workspaceId = $model->getAttribute('workspace_id');
if (! is_numeric($workspaceId) || (int) $workspaceId <= 0) {
throw WorkspaceIsolationViolation::missingTenantId(
class_basename($model).' (tenantless record requires explicit workspace_id)',
);
}
}
private static function ensureTenantIdIsImmutableOptional(Model $model, int $tenantId): void
{
if (! $model->exists || ! $model->isDirty('tenant_id')) {
return;
}
$originalTenantId = $model->getOriginal('tenant_id');
if (! is_numeric($originalTenantId)) {
return;
}
$originalTenantId = (int) $originalTenantId;
if ($originalTenantId === $tenantId) {
return;
}
throw WorkspaceIsolationViolation::tenantImmutable(
class_basename($model),
$originalTenantId,
$tenantId,
);
}
private static function resolveTenantWorkspaceIdOptional(Model $model, int $tenantId): int
{
$tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null;
if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) {
$tenant = Tenant::query()->find($tenantId);
}
if (! $tenant instanceof Tenant) {
throw WorkspaceIsolationViolation::tenantNotFound(class_basename($model), $tenantId);
}
if (! is_numeric($tenant->workspace_id)) {
throw WorkspaceIsolationViolation::tenantWorkspaceMissing(class_basename($model), $tenantId);
}
return (int) $tenant->workspace_id;
}
}

View File

@ -19,6 +19,8 @@ class AlertDeliveryFactory extends Factory
public function definition(): array
{
return [
// tenant_id and alert_rule_id are nullable; test() state sets them to null.
// Default definition creates a tenant-scoped delivery.
'tenant_id' => Tenant::factory(),
'workspace_id' => function (array $attributes): int {
$tenantId = $attributes['tenant_id'] ?? null;
@ -75,4 +77,22 @@ public function definition(): array
],
];
}
/**
* A tenantless test delivery for a specific destination.
*/
public function test(): static
{
return $this->state(fn (array $attributes): array => [
'tenant_id' => null,
'alert_rule_id' => null,
'event_type' => AlertDelivery::EVENT_TYPE_TEST,
'severity' => null,
'fingerprint_hash' => 'test:'.($attributes['alert_destination_id'] instanceof \Closure ? 0 : ($attributes['alert_destination_id'] ?? 0)),
'payload' => [
'title' => 'Test alert',
'body' => 'This is a test delivery for destination verification.',
],
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('alert_deliveries', function (Blueprint $table): void {
$table->unsignedBigInteger('tenant_id')->nullable()->change();
$table->unsignedBigInteger('alert_rule_id')->nullable()->change();
$table->index(
['workspace_id', 'alert_destination_id', 'event_type', 'created_at'],
'alert_deliveries_last_test_lookup_index',
);
});
}
public function down(): void
{
Schema::table('alert_deliveries', function (Blueprint $table): void {
$table->dropIndex('alert_deliveries_last_test_lookup_index');
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
$table->unsignedBigInteger('alert_rule_id')->nullable(false)->change();
});
}
};

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Alert Targets Test Actions
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-18
**Feature**: [specs/100-alert-target-test-actions/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,17 @@
# Contract — Alert Event Types (workspace alerts)
## Overview
This add-on introduces a single new event type used for *test deliveries*.
## Event Type
- `alerts.test`
- Purpose: user-triggered “Send test message” action on an alert target.
- Storage: `alert_deliveries.event_type`
- Delivery: sent via the existing alert delivery pipeline (`DeliverAlertsJob` + `AlertSender`).
## Notes
- This is an internal event type string; it is not a Microsoft Graph contract.
- No secrets are stored in payload/error text.

View File

@ -0,0 +1,32 @@
# Contract — Filament deep link into Alert Deliveries
## Goal
Allow a deterministic deep link from an Alert Target (destination) to the Deliveries viewer filtered to the most relevant rows.
## Target
- Resource: `AlertDeliveryResource`
- Page: list/index
## Query-string filter contract (Filament v5)
Use Filaments `filters[...]` query-string state to pre-apply table filters:
- Filter by event type:
- `filters[event_type][value]=alerts.test`
- Filter by destination:
- `filters[alert_destination_id][value]={DESTINATION_ID}`
Combined example:
`/admin/alert-deliveries?filters[event_type][value]=alerts.test&filters[alert_destination_id][value]=123`
## Required table filters
The Deliveries list must define these filters with matching keys:
- `SelectFilter::make('event_type')`
- `SelectFilter::make('alert_destination_id')`
(These are additive to the existing `status` filter.)

View File

@ -0,0 +1,79 @@
# Phase 1 — Data Model (099.1 Add-on)
## Entities
### AlertDestination (existing)
- **Table**: `alert_destinations`
- **Ownership**: workspace-owned
- **Key fields (relevant here)**:
- `id`
- `workspace_id`
- `type` (e.g. Teams webhook, Email)
- `config` (Teams webhook URL or list of recipients)
- `is_enabled`
### AlertDelivery (existing, extended for test sends)
- **Table**: `alert_deliveries`
- **Ownership**:
- v1 deliveries for real alerts remain tenant-associated
- **test deliveries for this add-on are tenantless** (workspace-only)
- **Key fields (relevant here)**:
- `id`
- `workspace_id` (required)
- `tenant_id` (**nullable for test deliveries**)
- `alert_rule_id` (**nullable for test deliveries**)
- `alert_destination_id` (required)
- `event_type` (string, includes `alerts.test`)
- `status` (`queued|deferred|sent|failed|suppressed|canceled`)
- `send_after` (nullable; used for deferral/backoff)
- `sent_at` (nullable)
- `attempt_count`
- `last_error_code`, `last_error_message` (sanitized)
- `payload` (array/json)
- timestamps: `created_at`, `updated_at`
## Relationships
- `AlertDelivery``AlertDestination` (belongsTo via `alert_destination_id`)
- `AlertDelivery``AlertRule` (belongsTo via `alert_rule_id`, nullable)
- `AlertDelivery``Tenant` (belongsTo via `tenant_id`, nullable)
## New derived concepts (no storage)
### LastTestStatus (derived)
Derived from the most recent `alert_deliveries` record where:
- `alert_destination_id = {destination}`
- `event_type = 'alerts.test'`
Mapping:
- no record → `Never`
- `status in (queued, deferred)``Pending`
- `status = sent``Sent`
- `status = failed``Failed`
Associated timestamp (derived):
- Sent → `sent_at`
- Failed → `updated_at`
- Pending → `send_after` (fallback `created_at`)
## Validation / invariants
- Creating a test delivery requires:
- `alert_destination_id` exists and belongs to current workspace
- destination is enabled (if disabled, refuse test request)
- rate limit: no prior test delivery for this destination in last 60 seconds
- Test delivery record must not persist secrets in payload or error message.
## Migration notes
To support tenantless test deliveries:
- Make `alert_deliveries.tenant_id` nullable and adjust the FK behavior.
- Make `alert_deliveries.alert_rule_id` nullable and adjust the FK behavior.
- Add or adjust indexes for efficient status lookup per destination + event type:
- `(workspace_id, alert_destination_id, event_type, created_at)`
(Exact migration steps and DB constraint changes are specified in the implementation plan.)

View File

@ -0,0 +1,221 @@
# Implementation Plan: Alert Targets — Test Actions + Last Test Status
**Branch**: `feat/100-alert-target-test-actions` | **Date**: 2026-02-18 | **Spec**: `specs/100-alert-target-test-actions/spec.md`
**Input**: Feature specification from `specs/100-alert-target-test-actions/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Add a derived “Last test status” indicator to Alert Targets (Filament `AlertDestinationResource` view and edit pages), plus a capability-gated “Send test message” action that enqueues a test delivery (no outbound calls in the request thread). The status is derived solely from the latest `alert_deliveries` row with `event_type = 'alerts.test'` for the destination.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4.15 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Queue, Laravel Notifications
**Storage**: PostgreSQL (Sail locally)
**Testing**: Pest v4 (via `vendor/bin/sail artisan test`)
**Target Platform**: Laravel web app (Sail-first locally; Dokploy containers in staging/prod)
**Project Type**: Web application (monolith)
**Performance Goals**: DB-only page rendering; avoid N+1; derived last-test status computed with a single indexed query
**Constraints**: No outbound network calls in request/response; no “last test” DB field; deterministic mapping (Never/Sent/Failed/Pending)
**Scale/Scope**: Workspace-scoped admin UX; minimal UI surfaces (view + edit pages)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: N/A (no inventory changes)
- Read/write separation: test request is a small DB-only write; includes confirmation + audit + tests
- Graph contract path: no Graph calls
- Deterministic capabilities: uses existing `Capabilities::ALERTS_VIEW` / `Capabilities::ALERTS_MANAGE` registry
- RBAC-UX: existing workspace policy semantics (non-member 404 via policy) + member-without-capability 403 on action execution
- Workspace isolation: enforced via `WorkspaceContext` + `AlertDestinationPolicy`
- Destructive confirmations: “Send test message” is not destructive but still requires confirmation per spec
- Tenant isolation: deliveries viewer remains tenant-safe; tenantless test deliveries are treated as workspace-owned and are safe to reveal
- Run observability: external sends happen via existing `alerts.deliver` operation run created by `DeliverAlertsJob`
- Data minimization: test payload + errors are sanitized (no webhook URL / recipients)
- Badge semantics: new badge domains/mappers added; no ad-hoc mappings
- Filament action surface: edits are confined to edit header actions + read-only status section; action is capability-gated and audited
## Project Structure
### Documentation (this feature)
```text
specs/100-alert-target-test-actions/
├── plan.md
├── spec.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── checklists/
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Filament/
│ └── Resources/
│ ├── AlertDestinationResource.php
│ ├── AlertDeliveryResource.php
│ └── AlertDestinationResource/Pages/EditAlertDestination.php
├── Jobs/Alerts/
│ └── DeliverAlertsJob.php
├── Models/
│ └── AlertDelivery.php
├── Policies/
│ ├── AlertDestinationPolicy.php
│ └── AlertDeliveryPolicy.php
├── Services/Alerts/
│ ├── AlertSender.php
│ └── AlertDispatchService.php
└── Support/
├── Audit/AuditActionId.php
└── Badges/
database/migrations/
tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Laravel monolith; Filament resources under `app/Filament`, domain services/jobs under `app/Services` and `app/Jobs`, tests in `tests/`.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
## Phase 0 — Outline & Research (completed)
Artifacts:
- `specs/100-alert-target-test-actions/research.md`
Key outcomes:
- Deep link uses Filament v5 `filters[...]` query string.
- Timestamp mapping uses existing columns (`sent_at`, `updated_at`, `send_after`).
- Test deliveries must be tenantless to avoid tenant-entitlement divergence.
## Phase 1 — Design & Contracts
### Data model changes
The current `alert_deliveries` table enforces tenant and rule FKs as required. For test sends:
- Make `tenant_id` nullable (test deliveries have no tenant context)
- Make `alert_rule_id` nullable (test deliveries are not tied to a routing rule)
Also add an index to support the derived “last test” lookup:
- `(workspace_id, alert_destination_id, event_type, created_at)`
### Backend design
1. Add a small resolver to compute `LastTestStatus` from `alert_deliveries`:
- Input: `workspace_id`, `alert_destination_id`
- Query: latest delivery with `event_type = 'alerts.test'`
- Output: `{status, timestamp, delivery_id?}`
2. Add a service for creating a test delivery:
- Authorize using existing `AlertDestinationPolicy::update` (manage capability)
- Enforce rate limit (60s) by checking latest test delivery `created_at`
- Create delivery with:
- `workspace_id` = current workspace
- `tenant_id` = null
- `alert_rule_id` = null
- `alert_destination_id` = destination id
- `event_type` = `alerts.test`
- `status` = `queued`
- `fingerprint_hash` = deterministic stable string (no secrets) (e.g. `test:{destination_id}`)
- `payload` = minimal safe text
3. Queue execution:
- Dispatch `DeliverAlertsJob($workspaceId)` so the test is processed without waiting for a scheduler.
### Filament UI design
1. Alert Targets (View page)
- Provide a read-only view for view-only users.
- Render the derived “Last test” status (badge + timestamp).
- Add header actions:
- **Send test message**: visible but disabled when user lacks `ALERTS_MANAGE`.
- **View last delivery**: visible only if at least one test delivery exists.
2. Alert Targets (Edit page)
- Add a read-only “Last test” status at the top of the edit form using a form component that can render as a badge.
- Add header actions:
- **Send test message** (mutating): `Action::make(...)->requiresConfirmation()->action(...)`
- Visible for workspace members with `ALERTS_VIEW`.
- Disabled via UI enforcement when user lacks `ALERTS_MANAGE`.
- **View last delivery** (navigation): `Action::make(...)->url(...)`
- Visible only if at least one test delivery exists.
3. Deliveries viewer (List page)
- Add table filters:
- `event_type` (SelectFilter)
- `alert_destination_id` (SelectFilter)
- Adjust tenant entitlement filter to include tenantless deliveries (`tenant_id IS NULL`).
### Authorization & semantics
- Non-member: existing policies return `denyAsNotFound()`.
- Member without `ALERTS_MANAGE`: action execution must result in 403; UI remains visible but disabled.
### Audit logging
- Add a new `AuditActionId` value: `alert_destination.test_requested`.
- Log when a test is requested with redacted metadata (no webhook URL / recipients).
### Badge semantics
- Add badge domain(s) and tests:
- Alert delivery status badge mapping (to replace the ad-hoc mapping in `AlertDeliveryResource`).
- Alert destination last-test status badge mapping.
### Contracts
- Deep link contract + event type contract live in `specs/100-alert-target-test-actions/contracts/`.
## Post-Design Constitution Re-check
- RBAC-UX: enforced via policies + capability registry; test action server-authorized.
- BADGE-001: new badge domains + tests planned; no ad-hoc mappings.
- OPS/run observability: outbound delivery occurs only in queued job; `alerts.deliver` operation run remains the monitoring source.
- DB-only rendering: derived status and links use indexed DB queries; no external calls.
## Phase 2 — Implementation planning (for tasks.md)
Next steps (to be expanded into `tasks.md`):
1. DB migration: make `alert_deliveries.tenant_id` and `alert_rule_id` nullable + add supporting index.
2. Update `AlertDelivery` model/relationships/casts if needed for tenantless + ruleless deliveries.
3. Update `AlertDeliveryPolicy` + `AlertDeliveryResource` query to allow tenantless deliveries.
4. Add badge domains + mapping tests.
5. Add “Send test message” header action + “Last test” badge section to `EditAlertDestination`.
6. Add feature tests (Pest) for:
- derived status mapping (Never/Sent/Failed/Pending)
- rate limiting
- RBAC (manage vs view)
- deep link visibility

View File

@ -0,0 +1,20 @@
# Quickstart (099.1 Add-on)
## Prereqs
- Laravel Sail is used for local dev.
## Run locally
- Start containers: `vendor/bin/sail up -d`
- Run migrations: `vendor/bin/sail artisan migrate`
## Run tests (focused)
- `vendor/bin/sail artisan test --compact --filter=AlertTargetTest`
(Replace filter/name with the final Pest test names once implemented.)
## Format
- `vendor/bin/sail bin pint --dirty`

View File

@ -0,0 +1,68 @@
# Phase 0 — Research (099.1 Add-on)
This research resolves the open technical questions from the feature spec and anchors decisions to existing code in this repository.
## Decision 1 — Where “Alert Target View/Edit” lives in code
- **Decision**: Implement “Last test status” + “Send test message” on the Alert Target **Edit** page only.
- **Rationale**: The current Filament resource for alert targets is `AlertDestinationResource` and only defines `index/create/edit` pages (no View page).
- **Alternatives considered**:
- Add a new View page for `AlertDestinationResource` (rejected: expands UX surface and is not required for this add-on).
## Decision 2 — Canonical event type string
- **Decision**: Use a single canonical event type string for test sends: `alerts.test`.
- **Rationale**: The add-on spec requires deriving status from `alert_deliveries.event_type`. Keeping this string stable allows deterministic filtering and deep links.
- **Alternatives considered**:
- Use `test` or `destination_test` (rejected: deviates from spec and makes intent less explicit).
## Decision 3 — Timestamp mapping vs actual schema
- **Decision**: Map timestamps using existing columns:
- **Sent**`sent_at`
- **Failed**`updated_at` (no `failed_at` column exists)
- **Pending**`send_after` if set, else `created_at`
- **Rationale**: The `alert_deliveries` schema only has `send_after`, `sent_at`, and `updated_at`. The specs `deliver_at` / `failed_at` naming is treated as conceptual.
- **Alternatives considered**:
- Add `failed_at` / `deliver_at` columns (rejected: violates “no new DB fields”).
## Decision 4 — How to represent test deliveries given current tenant enforcement
- **Decision**: Store test deliveries in `alert_deliveries` as **workspace-scoped, tenantless** records:
- `workspace_id` set
- `tenant_id` nullable
- `alert_rule_id` nullable
- `alert_destination_id` set
- **Rationale**:
- Alert targets (`AlertDestination`) are workspace-owned and accessible without a selected tenant.
- Current enforcement (`DerivesWorkspaceIdFromTenant`) requires a tenant and would make test deliveries invisible/uneven across users due to tenant entitlements.
- Deliver job execution (`DeliverAlertsJob`) does not require a tenant; it only needs the destination + payload.
- **Alternatives considered**:
- Pick an arbitrary tenant ID for test deliveries (rejected: would be invisible to some operators and breaks “single truth” per target).
- Create a synthetic “workspace tenant” record (rejected: adds data-model complexity).
## Decision 5 — Deep link mechanics for the Deliveries viewer
- **Decision**: Deep link into `AlertDeliveryResource` list view using Filament v5 filter query-string binding:
- `?filters[event_type][value]=alerts.test&filters[alert_destination_id][value]={DESTINATION_ID}`
- **Rationale**: Filament v5 `ListRecords` binds `tableFilters` to the URL under the `filters` key; `SelectFilter` uses `value`.
- **Alternatives considered**:
- Custom dedicated “Test deliveries” page (rejected: new surface beyond spec).
## Decision 6 — Badge semantics centralization (BADGE-001)
- **Decision**: Introduce centralized badge domains/mappers for:
- `AlertDeliveryStatus` (queued/deferred/sent/failed/suppressed/canceled)
- `AlertDestinationLastTestStatus` (never/pending/sent/failed)
- **Rationale**: Current `AlertDeliveryResource` implements ad-hoc status label/color helpers. This add-on must comply with BADGE-001 and should not add more local mappings.
- **Alternatives considered**:
- Keep ad-hoc mappings in the new code (rejected: violates BADGE-001).
## Decision 7 — OperationRun usage
- **Decision**: Do **not** create a new per-test `OperationRun`.
- **Rationale**:
- The user-triggered action is DB-only (authorization + rate limit + create delivery + audit), typically <2s.
- The external work runs via the existing `alerts.deliver` operation run (created by `DeliverAlertsJob`).
- **Alternatives considered**:
- Create a dedicated operation run type (rejected: higher complexity for small UX add-on).

View File

@ -0,0 +1,156 @@
# Feature Specification: Alert Targets Test Actions
**Feature Branch**: `feat/100-alert-target-test-actions`
**Created**: 2026-02-18
**Status**: Draft
**Input**: User description: "099.1 — Alert Targets: Send Test Message + Last Test Status (Teams + Email)"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: Alert Target View, Alert Target Edit, Deliveries viewer (filtered deep links)
- **Data Ownership**:
- Workspace-owned: alert targets
- Tenant-owned: alert delivery history (non-test)
- Workspace-scoped: test deliveries (`event_type=alerts.test`) may be tenantless (`tenant_id` nullable)
- **RBAC**:
- Workspace membership is required to access Alert Targets and Deliveries.
- Users with manage capability can request a test send.
- Users with view-only capability can see test status but cannot request a test send.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Not applicable (workspace scope).
- **Explicit entitlement checks preventing cross-tenant leakage**: Targets and deliveries are only visible within the current workspace; non-members must not receive any existence hints.
## Clarifications
### Session 2026-02-18
- Q: Which timestamps should the “Last test … at <timestamp>” subtext use? → A: Sent → `sent_at`; Failed → `updated_at`; Pending → `send_after`.
- Q: What should the test-send rate limit be per target? → A: 60 seconds per target.
- Q: What should “View last delivery” open? → B: Deliveries list viewer filtered to `alert_destination_id` + `event_type=alerts.test`.
- Q: When should “View last delivery” be shown on the Alert Target pages? → B: Show only if at least one test delivery exists.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Send a test message for a target (Priority: P1)
As an admin, I want to send a test alert to a configured Alert Target so I can verify the integration works before relying on it in production.
**Why this priority**: This is the fastest way to detect misconfiguration (wrong destination, blocked network path, invalid credentials) and reduce support/incident time.
**Independent Test**: Can be fully tested by requesting a test send and confirming a new test delivery record exists and can be inspected.
**Acceptance Scenarios**:
1. **Given** I am a workspace member with manage permission, **When** I confirm “Send test message” on an Alert Target, **Then** the system creates exactly one new test delivery record for that target and indicates the request was queued.
2. **Given** I am a workspace member without manage permission, **When** I attempt to execute “Send test message”, **Then** the action is blocked and no delivery record is created.
3. **Given** I have requested a test very recently for the same target, **When** I attempt another test immediately, **Then** the system refuses the request and does not create a new delivery record.
---
### User Story 2 - See the last test status at a glance (Priority: P2)
As an admin, I want to see whether the last test send for an Alert Target succeeded, failed, was never executed, or is still pending so I can assess health without digging through deliveries.
**Why this priority**: Health visibility reduces troubleshooting time and prevents silent failures.
**Independent Test**: Can be fully tested by viewing a target with (a) no test deliveries, (b) a successful test delivery, (c) a failed test delivery and verifying the badge and timestamp.
**Acceptance Scenarios**:
1. **Given** a target has no test delivery records, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Never”.
2. **Given** the most recent test delivery record is successful, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Sent” and an associated timestamp.
3. **Given** the most recent test delivery record is failed, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Failed” and an associated timestamp.
4. **Given** the most recent test delivery record is queued or deferred, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Pending”.
---
### User Story 3 - Jump from target to the relevant delivery details (Priority: P3)
As an admin, I want a quick link from the Alert Target to the most recent test delivery details so I can troubleshoot outcomes efficiently.
**Why this priority**: It reduces clicks and prevents mistakes when searching through delivery history.
**Independent Test**: Can be tested by clicking a “View last delivery” link and verifying the deliveries view is pre-filtered for this target and the test event type.
**Acceptance Scenarios**:
1. **Given** a target has at least one test delivery record, **When** I click “View last delivery” from the target page, **Then** I am taken to the deliveries list viewer scoped to the same workspace and filtered to that target and the test event type.
### Edge Cases
- Target exists but has never been tested.
- Target has multiple test deliveries; only the most recent one is used for status.
- The most recent test delivery is queued/deferred; status must show pending without implying success.
- Users without workspace membership attempt to access targets or deliveries (must be deny-as-not-found).
- Failure details must not expose secrets (destination URLs, recipients).
- Rapid repeated test requests (anti-spam / rate limiting) must not create additional delivery records.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces a user-triggered action that creates a delivery record and schedules work to be executed asynchronously.
The spec requires: confirmation, RBAC enforcement, anti-spam rate limiting, audit logging, tenant/workspace isolation, and tests.
**Constitution alignment (RBAC-UX):**
- Authorization planes involved: Admin UI (workspace-scoped).
- 404 vs 403 semantics:
- Non-member / not entitled to workspace scope → 404 (deny-as-not-found)
- Workspace member but missing manage capability → 403 for executing the test action
- All mutations (test request) require server-side authorization.
**Constitution alignment (BADGE-001):** “Last test: Sent/Failed/Pending/Never” MUST use centralized badge semantics (no ad-hoc mappings).
**Constitution alignment (Filament Action Surfaces):** This feature modifies Filament pages (Alert Target view/edit) and therefore includes a UI Action Matrix.
### Functional Requirements
- **FR-001 (Derived status, no new fields)**: The system MUST display a “Last test status” indicator on Alert Target View and Edit pages derived solely from existing alert delivery records.
- **FR-002 (Deterministic selection)**: The “Last test status” MUST be derived from the single most recent delivery record for the given target where the delivery event type is “alerts.test”, ordered by `created_at` (desc) then `id` (desc).
- **FR-003 (Status mapping)**: The system MUST map the most recent test delivery record to one of: Never (no record), Sent, Failed, or Pending.
- **FR-004 (Timestamp semantics)**: The UI MUST display a timestamp that reflects when the outcome occurred: Sent → `sent_at`; Failed → `updated_at`; Pending → `send_after`.
- **FR-005 (DB-only UI)**: Requesting a test send MUST not perform synchronous external delivery attempts in the users request/response flow.
- **FR-006 (Confirmation)**: The “Send test message” action MUST require explicit confirmation, explaining that it will contact the configured destination.
- **FR-007 (Anti-spam rate limit)**: The system MUST prevent repeated test requests for the same target within 60 seconds.
- **FR-008 (RBAC)**: Only workspace members with manage permission can request a test send; view-only users can see the action but cannot execute it.
- **FR-009 (Deny-as-not-found)**: Users without workspace membership MUST receive deny-as-not-found behavior for targets and deliveries.
- **FR-010 (Auditability)**: The system MUST record an audit event when a test is requested, without including destination secrets.
- **FR-011 (Deep link)**: The system SHOULD provide a “View last delivery” link from the target to the Deliveries list viewer filtered to that target and `event_type=alerts.test`.
- **FR-012 (Deep link visibility)**: The system SHOULD show “View last delivery” only when at least one test delivery exists for the target.
### Assumptions
- Alerts v1 already provides Alert Targets, Alert Deliveries, and a Deliveries viewer.
- A test send is represented as a delivery record with event type “alerts.test”.
### Non-Goals
- No new database fields for storing “last test status”.
- No bulk “test all targets” feature.
- No destination setup wizard.
- No per-row list view badge (avoids performance/N+1 concerns in v1).
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Alert Target (View) | Alert Target view page | Send test message (confirm, manage-only), View last delivery (navigate) | Not changed in this feature | Not changed in this feature | None | None | Same as Header Actions | Not applicable | Yes | View-only users see disabled “Send test message”. |
| Alert Target (Edit) | Alert Target edit page | Send test message (confirm, manage-only), View last delivery (navigate) | Not changed in this feature | Not changed in this feature | None | None | Same as Header Actions | Existing save/cancel | Yes | “Last test status” appears above the form. |
| Deliveries viewer | Deliveries list/details | None (existing) | Filtered via deep link | Existing row actions | Existing | Existing | Existing | Not applicable | Existing | Must remain workspace-scoped. |
### Key Entities *(include if feature involves data)*
- **Alert Target**: A destination configuration for alerts (e.g., Teams webhook or email destination), scoped to a workspace.
- **Alert Delivery**: A delivery attempt record that captures event type (including “alerts.test”), status, timestamps, and safe diagnostic details.
- **Audit Event**: A workspace-scoped audit entry representing a user-triggered test request.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An admin can determine “last test status” for a target within 5 seconds from the target page.
- **SC-002**: An admin can request a test send in under 30 seconds (including confirmation) without leaving the target page.
- **SC-003**: A test request always results in either (a) a new test delivery record being created, or (b) a clear refusal due to rate limiting or missing permissions.
- **SC-004**: Troubleshooting time for “alerts not delivered” issues is reduced because the last test outcome and a direct link to details are immediately available.

View File

@ -0,0 +1,180 @@
---
description: "Task list for 100 — Alert Targets: Send Test Message + Last Test Status (input: 099.1)"
---
# Tasks: Alert Targets — Send Test Message + Last Test Status (Teams + Email)
**Input**: Design documents from `specs/100-alert-target-test-actions/`
**Prerequisites**:
- `specs/100-alert-target-test-actions/spec.md` (user stories + priorities)
- `specs/100-alert-target-test-actions/plan.md` (implementation approach)
- `specs/100-alert-target-test-actions/research.md` (decisions)
- `specs/100-alert-target-test-actions/data-model.md` (tenantless test deliveries)
- `specs/100-alert-target-test-actions/contracts/` (event type + deep link filters)
**Tests**: REQUIRED (Pest) for all runtime behavior changes.
## Format: `- [ ] [TaskID] [P?] [Story?] Description with file path`
- **[P]**: can run in parallel (different files, no dependencies on incomplete tasks)
- **[US1] [US2] [US3]**: user story mapping (required for story phases)
---
## Phase 1: Setup (Shared)
**Purpose**: Ensure local dev/test workflow is concrete for this feature.
- [X] T001 Update test quickstart commands in specs/100-alert-target-test-actions/quickstart.md
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Enable tenantless test deliveries + keep workspace isolation and RBAC correct.
- [X] T002 Create migration to allow tenantless test deliveries + add supporting index in database/migrations/2026_02_18_000001_make_alert_deliveries_tenant_and_rule_nullable_for_test_deliveries.php
- [X] T003 Create tenant-optional workspace isolation trait in app/Support/Concerns/DerivesWorkspaceIdFromTenantWhenPresent.php
- [X] T004 Switch AlertDelivery to tenant-optional isolation in app/Models/AlertDelivery.php
- [X] T005 Add AlertDeliveryFactory support for tenantless deliveries in database/factories/AlertDeliveryFactory.php
- [X] T006 Add audit action id for test request in app/Support/Audit/AuditActionId.php
**Checkpoint**: Tenantless `alert_deliveries` records can be created safely, and audit IDs exist.
---
## Phase 3: User Story 1 — Send a test message for a target (Priority: P1) 🎯 MVP
**Goal**: Capability-gated “Send test message” action creates a test delivery record and queues delivery asynchronously.
**Independent Test**: A manage-capable workspace member can request a test send; a new `alert_deliveries` row exists with `event_type=alerts.test`, and the queue has a `DeliverAlertsJob`.
### Tests (write first)
- [X] T007 [P] [US1] Add send-test action happy-path test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
- [X] T008 [P] [US1] Add rate-limit refusal test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
- [X] T009 [P] [US1] Add authorization (readonly forbidden) test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
- [X] T010 [P] [US1] Add non-member deny-as-not-found (404) regression test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
- [X] T011 [P] [US1] Add audit log assertion test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
- [X] T012 [P] [US1] Add confirmation requirement test for the Filament action in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
### Implementation
- [X] T013 [US1] Add canonical test event type constant in app/Models/AlertDelivery.php
- [X] T014 [US1] Implement test delivery creation service in app/Services/Alerts/AlertDestinationTestMessageService.php
- [X] T015 [US1] Add "Send test message" header action on edit page in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php
**Checkpoint**: US1 passes its feature tests and performs no outbound calls in the request thread.
---
## Phase 4: User Story 2 — See the last test status at a glance (Priority: P2)
**Goal**: Show derived “Last test: Never/Sent/Failed/Pending” badge with deterministic, DB-only semantics.
**Independent Test**: With controlled `alert_deliveries` rows, the target page shows the correct derived status and timestamp without external calls.
### Tests (write first)
- [X] T016 [P] [US2] Add badge semantics tests for delivery + last-test domains in tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php
- [X] T017 [P] [US2] Add derived last-test mapping test (never/sent/failed/pending) in tests/Feature/Alerts/AlertDestinationLastTestStatusTest.php
### Implementation
- [ ] T018 [P] [US2] Add last-test status enum + result DTO in app/Support/Alerts/AlertDestinationLastTestStatus.php
- [ ] T019 [US2] Implement last-test resolver (latest alerts.test row) in app/Services/Alerts/AlertDestinationLastTestResolver.php
- [ ] T020 [US2] Add badge domains for alert delivery + destination last-test in app/Support/Badges/BadgeDomain.php
- [ ] T021 [US2] Register new badge mappers in app/Support/Badges/BadgeCatalog.php
- [ ] T022 [P] [US2] Implement badge mapper for delivery status in app/Support/Badges/Domains/AlertDeliveryStatusBadge.php
- [ ] T023 [P] [US2] Implement badge mapper for destination last-test status in app/Support/Badges/Domains/AlertDestinationLastTestStatusBadge.php
- [ ] T024 [US2] Refactor delivery status badges to use BadgeCatalog in app/Filament/Resources/AlertDeliveryResource.php
- [ ] T025 [US2] Add read-only View page for alert destinations in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php
- [ ] T026 [US2] Register view page and route view-only users from list in app/Filament/Resources/AlertDestinationResource.php
- [ ] T027 [US2] Render “Last test” badge + timestamp on view page in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php
- [ ] T028 [US2] Render “Last test” badge + timestamp on edit page in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php
**Checkpoint**: US2 is fully readable by view-only users (via View page) and meets BADGE-001.
---
## Phase 5: User Story 3 — Jump from target to the relevant delivery details (Priority: P3)
**Goal**: Provide a “View last delivery” deep link into the Deliveries viewer filtered by `alerts.test` and destination.
**Independent Test**: When at least one test delivery exists, the link appears and routes to `/admin/alert-deliveries` with the correct `filters[...]` query string.
### Tests (write first)
- [X] T029 [P] [US3] Add deep-link visibility + URL contract test in tests/Feature/Alerts/AlertDestinationViewLastDeliveryLinkTest.php
- [X] T030 [P] [US3] Add deliveries viewer filter coverage test in tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php
### Implementation
- [X] T031 [US3] Add table filters for event_type + alert_destination_id in app/Filament/Resources/AlertDeliveryResource.php
- [X] T032 [US3] Allow tenantless deliveries in deliveries list query in app/Filament/Resources/AlertDeliveryResource.php
- [X] T033 [US3] Update tenant entitlement policy to allow tenantless delivery records in app/Policies/AlertDeliveryPolicy.php
- [X] T034 [US3] Add "View last delivery" header action (view page) in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php
- [X] T035 [US3] Add "View last delivery" header action (edit page) in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php
**Checkpoint**: US3 deep links match the contract in specs/100-alert-target-test-actions/contracts/filament-deep-link-filters.md.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T036 [P] Run Pint on changed files with vendor/bin/sail bin pint --dirty
- [X] T037 Run focused test suite for this feature with vendor/bin/sail artisan test --compact tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
- [X] T038 Run focused badge tests with vendor/bin/sail artisan test --compact tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php
---
## Dependencies & Execution Order
### Dependency graph (user stories)
```mermaid
graph TD
F[Phase 2: Foundational] --> US1[US1: Send test message]
F --> US2[US2: Last test status]
US2 --> US3[US3: View last delivery deep link]
```
- **Foundational (Phase 2)** blocks all user stories.
- **US1** can be delivered as MVP after Phase 2.
- **US2** adds read-only visibility and centralized badge semantics.
- **US3** depends on US2 resolver/view surface (or duplicate a small existence query if needed).
---
## Parallel execution examples
### US1
- `T007T012` can be written in parallel (single test file, but separate assertions can be split across commits).
- `T013` (model constant) and `T014` (service) can be done before `T015` (Filament action).
### US2
- `T022` and `T023` (badge mappers) can be implemented in parallel.
- `T018` (enum/DTO) and `T019` (resolver) can be done before wiring UI.
### US3
- `T031T033` (deliveries viewer plumbing) can proceed in parallel with `T034T035` (target header actions) once the resolver is available.
---
## Implementation strategy
### MVP first
1. Complete Phase 2 (Foundational)
2. Implement and ship US1 (Send test message)
3. Validate with focused tests
### Incremental delivery
- Add US2 (Last test status + View page) after US1 is stable
- Add US3 (Deep link + deliveries filters) last

View File

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
// ---------------------------------------------------------------------------
// T030 — Deliveries viewer filter coverage (event_type + alert_destination_id)
// ---------------------------------------------------------------------------
it('filters deliveries by event_type', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
]);
$testDelivery = AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
$normalDelivery = AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenant->getKey(),
'alert_rule_id' => $rule->getKey(),
'alert_destination_id' => $destination->getKey(),
'event_type' => 'high_drift',
'status' => AlertDelivery::STATUS_SENT,
]);
Livewire::test(ListAlertDeliveries::class)
->assertCanSeeTableRecords([$testDelivery, $normalDelivery])
->filterTable('event_type', AlertDelivery::EVENT_TYPE_TEST)
->assertCanSeeTableRecords([$testDelivery])
->assertCanNotSeeTableRecords([$normalDelivery]);
});
it('filters deliveries by alert_destination_id', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destinationA = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$destinationB = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
]);
$deliveryA = AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenant->getKey(),
'alert_rule_id' => $rule->getKey(),
'alert_destination_id' => $destinationA->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
$deliveryB = AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenant->getKey(),
'alert_rule_id' => $rule->getKey(),
'alert_destination_id' => $destinationB->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
Livewire::test(ListAlertDeliveries::class)
->assertCanSeeTableRecords([$deliveryA, $deliveryB])
->filterTable('alert_destination_id', (string) $destinationA->getKey())
->assertCanSeeTableRecords([$deliveryA])
->assertCanNotSeeTableRecords([$deliveryB]);
});
it('includes tenantless test deliveries in the list', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$testDelivery = AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
Livewire::test(ListAlertDeliveries::class)
->assertCanSeeTableRecords([$testDelivery]);
});

View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Services\Alerts\AlertDestinationLastTestResolver;
use App\Support\Alerts\AlertDestinationLastTestStatusEnum;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns Never status when no test delivery exists', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
]);
$resolver = new AlertDestinationLastTestResolver;
$result = $resolver->resolve($destination);
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Never);
expect($result->timestamp)->toBeNull();
expect($result->deliveryId)->toBeNull();
});
it('returns Sent status when the latest test delivery was sent', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
]);
$delivery = AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'sent_at' => now()->subMinutes(5),
]);
$resolver = new AlertDestinationLastTestResolver;
$result = $resolver->resolve($destination);
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Sent);
expect($result->deliveryId)->toBe((int) $delivery->getKey());
expect($result->timestamp)->not->toBeNull();
});
it('returns Failed status when the latest test delivery failed', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
]);
$delivery = AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_FAILED,
]);
$resolver = new AlertDestinationLastTestResolver;
$result = $resolver->resolve($destination);
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Failed);
expect($result->deliveryId)->toBe((int) $delivery->getKey());
});
it('returns Pending status when the latest test delivery is queued', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
]);
$delivery = AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_QUEUED,
]);
$resolver = new AlertDestinationLastTestResolver;
$result = $resolver->resolve($destination);
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Pending);
expect($result->deliveryId)->toBe((int) $delivery->getKey());
});
it('returns Pending status when the latest test delivery is deferred', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
]);
$delivery = AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_DEFERRED,
'send_after' => now()->addMinutes(5),
]);
$resolver = new AlertDestinationLastTestResolver;
$result = $resolver->resolve($destination);
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Pending);
expect($result->deliveryId)->toBe((int) $delivery->getKey());
});
it('uses the most recent test delivery when multiple exist', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
]);
AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'sent_at' => now()->subHour(),
'created_at' => now()->subHour(),
]);
$latestDelivery = AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_FAILED,
'created_at' => now()->subMinutes(5),
]);
$resolver = new AlertDestinationLastTestResolver;
$result = $resolver->resolve($destination);
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Failed);
expect($result->deliveryId)->toBe((int) $latestDelivery->getKey());
});
it('ignores non-test deliveries when resolving last test status', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'event_type' => 'high_drift',
'status' => AlertDelivery::STATUS_SENT,
'created_at' => now(),
]);
$resolver = new AlertDestinationLastTestResolver;
$result = $resolver->resolve($destination);
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Never);
});

View File

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\AlertDestinationResource\Pages\EditAlertDestination;
use App\Jobs\Alerts\DeliverAlertsJob;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AuditLog;
use App\Models\Workspace;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
// ---------------------------------------------------------------------------
// T007 — Happy path: owner sends a test message, delivery + job created
// ---------------------------------------------------------------------------
it('creates a test delivery and dispatches a deliver job on send test message', function (): void {
Queue::fake();
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
->callAction('send_test_message');
$delivery = AlertDelivery::query()
->where('alert_destination_id', $destination->getKey())
->where('event_type', AlertDelivery::EVENT_TYPE_TEST)
->first();
expect($delivery)->not->toBeNull();
expect($delivery->workspace_id)->toBe($workspaceId);
expect($delivery->tenant_id)->toBeNull();
expect($delivery->alert_rule_id)->toBeNull();
expect($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED);
Queue::assertPushed(DeliverAlertsJob::class, function (DeliverAlertsJob $job) use ($workspaceId): bool {
return $job->workspaceId === $workspaceId;
});
});
// ---------------------------------------------------------------------------
// T008 — Rate-limit refusal: second request within 60 seconds is rejected
// ---------------------------------------------------------------------------
it('refuses a second test message within 60 seconds', function (): void {
Queue::fake();
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => (int) $destination->getKey(),
'created_at' => now()->subSeconds(30),
]);
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
->callAction('send_test_message')
->assertNotified();
expect(
AlertDelivery::query()
->where('alert_destination_id', $destination->getKey())
->where('event_type', AlertDelivery::EVENT_TYPE_TEST)
->count()
)->toBe(1);
Queue::assertNothingPushed();
});
// ---------------------------------------------------------------------------
// T009 — Authorization: readonly member is forbidden from edit page
// ---------------------------------------------------------------------------
it('forbids readonly members from accessing the edit page for destinations', function (): void {
Queue::fake();
[$user] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$this->get(AlertDestinationResource::getUrl('edit', ['record' => $destination], panel: 'admin'))
->assertForbidden();
Queue::assertNothingPushed();
});
// ---------------------------------------------------------------------------
// T010 — Non-member: denied as not found (404)
// ---------------------------------------------------------------------------
it('returns 404 for non-member trying to access edit page with send test action', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$otherWorkspace = Workspace::factory()->create();
$destination = AlertDestination::factory()->create([
'workspace_id' => (int) $otherWorkspace->getKey(),
'is_enabled' => true,
]);
$this->actingAs($user)
->get(AlertDestinationResource::getUrl('edit', ['record' => $destination], panel: 'admin'))
->assertNotFound();
});
// ---------------------------------------------------------------------------
// T011 — Audit log assertion: test request is audit-logged
// ---------------------------------------------------------------------------
it('creates an audit log entry when a test message is sent', function (): void {
Queue::fake();
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
->callAction('send_test_message');
$auditLog = AuditLog::query()
->where('workspace_id', $workspaceId)
->where('action', AuditActionId::AlertDestinationTestRequested->value)
->where('resource_type', 'alert_destination')
->where('resource_id', (string) $destination->getKey())
->first();
expect($auditLog)->not->toBeNull();
expect($auditLog->actor_id)->toBe((int) $user->getKey());
});
// ---------------------------------------------------------------------------
// T012 — Confirmation requirement: action requires confirmation before execution
// ---------------------------------------------------------------------------
it('requires confirmation before sending a test message', function (): void {
Queue::fake();
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
->assertActionExists('send_test_message')
->mountAction('send_test_message')
->assertActionMounted('send_test_message');
expect(AlertDelivery::query()->count())->toBe(0);
Queue::assertNothingPushed();
});

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDestinationResource\Pages\EditAlertDestination;
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
// ---------------------------------------------------------------------------
// T029 — Deep-link visibility + URL contract
// ---------------------------------------------------------------------------
it('shows view_last_delivery action on view page when a test delivery exists', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()])
->assertActionVisible('view_last_delivery');
});
it('hides view_last_delivery action on view page when no test delivery exists', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()])
->assertActionHidden('view_last_delivery');
});
it('shows view_last_delivery action on edit page when a test delivery exists', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
->assertActionVisible('view_last_delivery');
});
it('hides view_last_delivery action on edit page when no test delivery exists', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
->assertActionHidden('view_last_delivery');
});
it('generates deep link URL matching the contract filters', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
AlertDelivery::factory()->test()->create([
'workspace_id' => $workspaceId,
'alert_destination_id' => $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
$component = Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()]);
$url = $component->instance()->getAction('view_last_delivery')->getUrl();
expect($url)->toContain('alert-deliveries');
expect($url)->toContain('filters%5Bevent_type%5D%5Bvalue%5D=alerts.test');
expect($url)->toContain('filters%5Balert_destination_id%5D%5Bvalue%5D='.$destination->getKey());
});

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps alert delivery status values to canonical badge semantics', function (): void {
$queued = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'queued');
expect($queued->label)->toBe('Queued');
expect($queued->color)->toBe('gray');
$deferred = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'deferred');
expect($deferred->label)->toBe('Deferred');
expect($deferred->color)->toBe('warning');
$sent = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'sent');
expect($sent->label)->toBe('Sent');
expect($sent->color)->toBe('success');
$failed = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
$suppressed = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'suppressed');
expect($suppressed->label)->toBe('Suppressed');
expect($suppressed->color)->toBe('info');
$canceled = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'canceled');
expect($canceled->label)->toBe('Canceled');
expect($canceled->color)->toBe('gray');
});
it('returns unknown badge for invalid alert delivery status', function (): void {
$unknown = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'invalid_status');
expect($unknown->label)->toBe('Unknown');
expect($unknown->color)->toBe('gray');
});
it('maps alert destination last test status values to canonical badge semantics', function (): void {
$never = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'never');
expect($never->label)->toBe('Never');
expect($never->color)->toBe('gray');
$sent = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'sent');
expect($sent->label)->toBe('Sent');
expect($sent->color)->toBe('success');
$failed = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
$pending = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'pending');
expect($pending->label)->toBe('Pending');
expect($pending->color)->toBe('warning');
});
it('returns unknown badge for invalid last test status', function (): void {
$unknown = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'nonexistent');
expect($unknown->label)->toBe('Unknown');
expect($unknown->color)->toBe('gray');
});