TenantAtlas/app/Filament/Resources/AlertDestinationResource.php
ahmido d49d33ac27 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
2026-02-18 23:12:38 +00:00

382 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertDestinationResource\Pages;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class AlertDestinationResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertDestination::class;
protected static ?string $slug = 'alert-destinations';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 3;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert targets';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', AlertDestination::class);
}
public static function canCreate(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('create', AlertDestination::class);
}
public static function canEdit(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return false;
}
return $user->can('update', $record);
}
public static function canDelete(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return false;
}
return $user->can('delete', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert destinations in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Select::make('type')
->required()
->options(self::typeOptions())
->native(false)
->live(),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
TextInput::make('teams_webhook_url')
->label('Teams webhook URL')
->placeholder('https://...')
->url()
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_TEAMS_WEBHOOK),
TagsInput::make('email_recipients')
->label('Email recipients')
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_EMAIL)
->placeholder('ops@example.com')
->nestedRecursiveRules(['email']),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: static::getUrl('view', ['record' => $record]))
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
TextColumn::make('is_enabled')
->label('Enabled')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
TextColumn::make('updated_at')
->since(),
])
->actions([
EditAction::make()
->label('Edit')
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
ActionGroup::make([
Action::make('toggle_enabled')
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
->icon(fn (AlertDestination $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
->action(function (AlertDestination $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('update', $record)) {
throw new AuthorizationException;
}
$enabled = ! (bool) $record->is_enabled;
$record->forceFill([
'is_enabled' => $enabled,
])->save();
$actionId = $enabled
? AuditActionId::AlertDestinationEnabled
: AuditActionId::AlertDestinationDisabled;
self::audit($record, $actionId, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
'is_enabled' => $enabled,
]);
Notification::make()
->title($enabled ? 'Destination enabled' : 'Destination disabled')
->success()
->send();
}),
Action::make('delete')
->label('Delete')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (AlertDestination $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('delete', $record)) {
throw new AuthorizationException;
}
self::audit($record, AuditActionId::AlertDestinationDeleted, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
]);
$record->delete();
Notification::make()
->title('Destination deleted')
->success()
->send();
}),
])->label('More'),
])
->bulkActions([
BulkActionGroup::make([])->label('More'),
])
->emptyStateActions([
\Filament\Actions\CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! static::canCreate()),
]);
}
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'),
];
}
/**
* @param array<string, mixed> $data
*/
public static function normalizePayload(array $data, ?AlertDestination $record = null): array
{
$type = trim((string) ($data['type'] ?? $record?->type ?? ''));
$existingConfig = is_array($record?->config ?? null) ? $record->config : [];
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
$webhookUrl = trim((string) ($data['teams_webhook_url'] ?? ''));
if ($webhookUrl === '' && $record instanceof AlertDestination) {
$webhookUrl = trim((string) Arr::get($existingConfig, 'webhook_url', ''));
}
$data['config'] = [
'webhook_url' => $webhookUrl,
];
}
if ($type === AlertDestination::TYPE_EMAIL) {
$recipients = Arr::wrap($data['email_recipients'] ?? []);
$recipients = array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients)));
if ($recipients === [] && $record instanceof AlertDestination) {
$existingRecipients = Arr::get($existingConfig, 'recipients', []);
$recipients = is_array($existingRecipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $existingRecipients))) : [];
}
$data['config'] = [
'recipients' => array_values(array_unique($recipients)),
];
}
unset($data['teams_webhook_url'], $data['email_recipients']);
return $data;
}
/**
* @return array<string, string>
*/
public static function typeOptions(): array
{
return [
AlertDestination::TYPE_TEAMS_WEBHOOK => 'Microsoft Teams webhook',
AlertDestination::TYPE_EMAIL => 'Email',
];
}
public static function typeLabel(string $type): string
{
return self::typeOptions()[$type] ?? ucfirst($type);
}
/**
* @param array<string, mixed> $data
*/
public static function assertValidConfigPayload(array $data): void
{
$type = (string) ($data['type'] ?? '');
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
$webhook = trim((string) Arr::get($config, 'webhook_url', ''));
if ($webhook === '') {
throw ValidationException::withMessages([
'teams_webhook_url' => ['The Teams webhook URL is required.'],
]);
}
}
if ($type === AlertDestination::TYPE_EMAIL) {
$recipients = Arr::get($config, 'recipients', []);
$recipients = is_array($recipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))) : [];
if ($recipients === []) {
throw ValidationException::withMessages([
'email_recipients' => ['At least one recipient is required for email destinations.'],
]);
}
}
}
/**
* @param array<string, mixed> $metadata
*/
public static function audit(AlertDestination $record, AuditActionId $actionId, array $metadata): void
{
$workspace = $record->workspace;
if ($workspace === null) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: $actionId->value,
context: [
'metadata' => $metadata,
],
actor: auth()->user() instanceof User ? auth()->user() : null,
resourceType: 'alert_destination',
resourceId: (string) $record->getKey(),
);
}
}